From 7b618a6ed5eea65fa8e3979380eefbb341a0200d Mon Sep 17 00:00:00 2001 From: Chill Date: Sat, 21 Sep 2024 14:11:40 +0530 Subject: [PATCH 01/25] Setup models and routing --- .env | 26 +- .gitignore | 3 + backend/.gitignore | 1 + backend/Dockerfile | 2 + backend/app/alembic/env.py | 10 +- backend/app/alembic/versions/.keep | 0 ...608336_add_cascade_delete_relationships.py | 37 -- ...4c78_add_max_length_for_string_varchar_.py | 69 --- .../ca5d4c361c2e_initial_migration.py | 517 ++++++++++++++++++ ...edit_replace_id_integers_in_all_models_.py | 90 --- .../e2412789c190_initialize_models.py | 54 -- backend/app/api/deps.py | 58 +- backend/app/api/main.py | 8 +- backend/app/api/routes/items.py | 109 ---- backend/app/api/routes/login.py | 248 ++++----- backend/app/api/routes/users.py | 314 ++++------- backend/app/api/routes/utils.py | 26 - backend/app/api/routes/venues.py | 194 +++++++ backend/app/core/config.py | 19 +- backend/app/core/db.py | 76 ++- backend/app/crud.py | 159 ++++-- backend/app/initial_data.py | 3 +- backend/app/models.py | 114 ---- backend/app/models/__init__.py | 32 ++ backend/app/models/club_visit.py | 19 + backend/app/models/event.py | 18 + backend/app/models/event_booking.py | 19 + backend/app/models/event_offering.py | 21 + backend/app/models/group.py | 30 + backend/app/models/group_wallet.py | 13 + backend/app/models/group_wallet_topup.py | 13 + backend/app/models/menu.py | 33 ++ backend/app/models/menu_category.py | 22 + backend/app/models/menu_item.py | 18 + backend/app/models/order.py | 57 ++ backend/app/models/order_item.py | 16 + backend/app/models/payment.py | 43 ++ backend/app/models/pickup_location.py | 16 + backend/app/models/user.py | 87 +++ backend/app/models/venue.py | 93 ++++ backend/app/models_/menu.py | 86 +++ backend/app/models_/venue.py | 228 ++++++++ backend/poetry.lock | 24 +- backend/pyproject.toml | 2 + development.md | 2 +- docker-compose.yml | 2 +- img/dashboard-create.png | Bin 79627 -> 0 bytes img/dashboard-dark.png | Bin 76203 -> 0 bytes img/dashboard-items.png | Bin 65084 -> 0 bytes img/dashboard-user-settings.png | Bin 62823 -> 0 bytes img/dashboard.png | Bin 70654 -> 0 bytes img/docs.png | Bin 98515 -> 0 bytes img/github-social-preview.png | Bin 44746 -> 0 bytes img/github-social-preview.svg | 100 ---- img/login.png | Bin 36530 -> 0 bytes 55 files changed, 2064 insertions(+), 1067 deletions(-) delete mode 100755 backend/app/alembic/versions/.keep delete mode 100644 backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py delete mode 100755 backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py create mode 100644 backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py delete mode 100755 backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py delete mode 100644 backend/app/alembic/versions/e2412789c190_initialize_models.py create mode 100644 backend/app/api/routes/venues.py delete mode 100644 backend/app/models.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/club_visit.py create mode 100644 backend/app/models/event.py create mode 100644 backend/app/models/event_booking.py create mode 100644 backend/app/models/event_offering.py create mode 100644 backend/app/models/group.py create mode 100644 backend/app/models/group_wallet.py create mode 100644 backend/app/models/group_wallet_topup.py create mode 100644 backend/app/models/menu.py create mode 100644 backend/app/models/menu_category.py create mode 100644 backend/app/models/menu_item.py create mode 100644 backend/app/models/order.py create mode 100644 backend/app/models/order_item.py create mode 100644 backend/app/models/payment.py create mode 100644 backend/app/models/pickup_location.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/models/venue.py create mode 100644 backend/app/models_/menu.py create mode 100644 backend/app/models_/venue.py delete mode 100644 img/dashboard-create.png delete mode 100644 img/dashboard-dark.png delete mode 100644 img/dashboard-items.png delete mode 100644 img/dashboard-user-settings.png delete mode 100644 img/dashboard.png delete mode 100644 img/docs.png delete mode 100644 img/github-social-preview.png delete mode 100644 img/github-social-preview.svg delete mode 100644 img/login.png diff --git a/.env b/.env index 98c8196862..eb3cb9c08d 100644 --- a/.env +++ b/.env @@ -5,30 +5,30 @@ DOMAIN=localhost # Environment: local, staging, production ENVIRONMENT=local -PROJECT_NAME="Full Stack FastAPI Project" +PROJECT_NAME="SOCIA" STACK_NAME=full-stack-fastapi-project # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis +SECRET_KEY=PDma3zqc5pq9LZVfVj7qL5bB6mC4OCIN0sKdjSiKOlI +FIRST_SUPERUSER=arpit.singh@example.com +FIRST_SUPERUSER_PASSWORD=SOciaSOcia # Emails -SMTP_HOST= -SMTP_USER= -SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com +SMTP_HOST=arpit.singh@example.com +SMTP_USER=arpit.singh@example.com +SMTP_PASSWORD=SOciaSOcia +EMAILS_FROM_EMAIL=arpit.singh@example.com SMTP_TLS=True SMTP_SSL=False SMTP_PORT=587 # Postgres -POSTGRES_SERVER=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_SERVER=aws-0-ap-south-1.pooler.supabase.com +POSTGRES_PORT=6543 +POSTGRES_USER=postgres.uiwsgdtnmovxahfgxfkj +POSTGRES_PASSWORD=Aa1sociaaicos +POSTGRES_DB=postgres SENTRY_DSN= diff --git a/.gitignore b/.gitignore index a6dd346572..530b37eca8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +.history/ +.DS_Store + diff --git a/backend/.gitignore b/backend/.gitignore index 63f67bcd21..9340d4daf9 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,3 +6,4 @@ app.egg-info htmlcov .cache .venv +poetry.lock diff --git a/backend/Dockerfile b/backend/Dockerfile index c3187aeb28..57fa537edd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,3 +26,5 @@ COPY ./prestart.sh /app/ COPY ./tests-start.sh /app/ COPY ./app /app/app + +RUN poetry run pip list \ No newline at end of file diff --git a/backend/app/alembic/env.py b/backend/app/alembic/env.py index 7f29c04680..ddab70f856 100755 --- a/backend/app/alembic/env.py +++ b/backend/app/alembic/env.py @@ -22,7 +22,7 @@ from app.core.config import settings # noqa target_metadata = SQLModel.metadata - +print("target_metadata in env:", SQLModel.metadata.tables) # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") @@ -78,7 +78,7 @@ def run_migrations_online(): context.run_migrations() -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() +# if context.is_offline_mode(): +# run_migrations_offline() +# else: +run_migrations_online() diff --git a/backend/app/alembic/versions/.keep b/backend/app/alembic/versions/.keep deleted file mode 100755 index e69de29bb2..0000000000 diff --git a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py deleted file mode 100644 index 10e47a1456..0000000000 --- a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Add cascade delete relationships - -Revision ID: 1a31ce608336 -Revises: d98dd8ec85a3 -Create Date: 2024-07-31 22:24:34.447891 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = '1a31ce608336' -down_revision = 'd98dd8ec85a3' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('item', 'owner_id', - existing_type=sa.UUID(), - nullable=False) - op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') - op.create_foreign_key(None, 'item', 'user', ['owner_id'], ['id'], ondelete='CASCADE') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'item', type_='foreignkey') - op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) - op.alter_column('item', 'owner_id', - existing_type=sa.UUID(), - nullable=True) - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py b/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py deleted file mode 100755 index 78a41773b9..0000000000 --- a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Add max length for string(varchar) fields in User and Items models - -Revision ID: 9c0a54914c78 -Revises: e2412789c190 -Create Date: 2024-06-17 14:42:44.639457 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = '9c0a54914c78' -down_revision = 'e2412789c190' -branch_labels = None -depends_on = None - - -def upgrade(): - # Adjust the length of the email field in the User table - op.alter_column('user', 'email', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=False) - - # Adjust the length of the full_name field in the User table - op.alter_column('user', 'full_name', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=True) - - # Adjust the length of the title field in the Item table - op.alter_column('item', 'title', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=False) - - # Adjust the length of the description field in the Item table - op.alter_column('item', 'description', - existing_type=sa.String(), - type_=sa.String(length=255), - existing_nullable=True) - - -def downgrade(): - # Revert the length of the email field in the User table - op.alter_column('user', 'email', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=False) - - # Revert the length of the full_name field in the User table - op.alter_column('user', 'full_name', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=True) - - # Revert the length of the title field in the Item table - op.alter_column('item', 'title', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=False) - - # Revert the length of the description field in the Item table - op.alter_column('item', 'description', - existing_type=sa.String(length=255), - type_=sa.String(), - existing_nullable=True) diff --git a/backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py b/backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py new file mode 100644 index 0000000000..a936f4e3a5 --- /dev/null +++ b/backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py @@ -0,0 +1,517 @@ +"""initial migration + +Revision ID: ca5d4c361c2e +Revises: +Create Date: 2024-09-21 06:23:39.920369 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'ca5d4c361c2e' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('foodcourt', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('capacity', sa.Integer(), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_rating', sa.Float(), nullable=True), + sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('avg_expense_for_two', sa.Float(), nullable=True), + sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_foodcourt_id'), 'foodcourt', ['id'], unique=False) + op.create_table('nightclub', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('capacity', sa.Integer(), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_rating', sa.Float(), nullable=True), + sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('avg_expense_for_two', sa.Float(), nullable=True), + sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_nightclub_id'), 'nightclub', ['id'], unique=False) + op.create_table('restaurant', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('capacity', sa.Integer(), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_rating', sa.Float(), nullable=True), + sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('avg_expense_for_two', sa.Float(), nullable=True), + sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_restaurant_id'), 'restaurant', ['id'], unique=False) + op.create_table('user_business', + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('registration_date', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_business_email'), 'user_business', ['email'], unique=True) + op.create_index(op.f('ix_user_business_id'), 'user_business', ['id'], unique=False) + op.create_index(op.f('ix_user_business_phone_number'), 'user_business', ['phone_number'], unique=True) + op.create_table('user_public', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date_of_birth', sa.DateTime(), nullable=True), + sa.Column('gender', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('registration_date', sa.DateTime(), nullable=False), + sa.Column('profile_picture', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('preferences', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_public_id'), 'user_public', ['id'], unique=False) + op.create_table('event', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('start_time', sa.DateTime(), nullable=False), + sa.Column('end_time', sa.DateTime(), nullable=False), + sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('age_restriction', sa.Integer(), nullable=True), + sa.Column('dress_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_event_id'), 'event', ['id'], unique=False) + op.create_table('foodcourtuserbusinesslink', + sa.Column('foodcourt_id', sa.Integer(), nullable=False), + sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), + sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), + sa.PrimaryKeyConstraint('foodcourt_id', 'user_business_id') + ) + op.create_table('group', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('nightclub_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('admin_user_id', sa.Integer(), nullable=False), + sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['admin_user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_group_id'), 'group', ['id'], unique=False) + op.create_table('nightclubmenu', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_nightclubmenu_id'), 'nightclubmenu', ['id'], unique=False) + op.create_table('nightclubuserbusinesslink', + sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), + sa.PrimaryKeyConstraint('nightclub_id', 'user_business_id') + ) + op.create_table('payment_source_nightclub', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('payment_time', sa.DateTime(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('retry_count', sa.Integer(), nullable=False), + sa.Column('last_attempt_time', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_payment_source_nightclub_id'), 'payment_source_nightclub', ['id'], unique=False) + op.create_table('payment_source_qsr', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('payment_time', sa.DateTime(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('retry_count', sa.Integer(), nullable=False), + sa.Column('last_attempt_time', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_payment_source_qsr_id'), 'payment_source_qsr', ['id'], unique=False) + op.create_table('payment_source_restaurant', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('payment_time', sa.DateTime(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('retry_count', sa.Integer(), nullable=False), + sa.Column('last_attempt_time', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_payment_source_restaurant_id'), 'payment_source_restaurant', ['id'], unique=False) + op.create_table('pickup_location', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_pickup_location_id'), 'pickup_location', ['id'], unique=False) + op.create_table('qsr', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('capacity', sa.Integer(), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_rating', sa.Float(), nullable=True), + sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('avg_expense_for_two', sa.Float(), nullable=True), + sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('foodcourt_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_qsr_id'), 'qsr', ['id'], unique=False) + op.create_table('restaurantmenu', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('restaurant_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_restaurantmenu_id'), 'restaurantmenu', ['id'], unique=False) + op.create_table('restaurantuserbusinesslink', + sa.Column('restaurant_id', sa.Integer(), nullable=False), + sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), + sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), + sa.PrimaryKeyConstraint('restaurant_id', 'user_business_id') + ) + op.create_table('clubvisit', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('entry_time', sa.DateTime(), nullable=False), + sa.Column('exit_time', sa.DateTime(), nullable=True), + sa.Column('cover_charge', sa.Float(), nullable=True), + sa.Column('total_bill', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('event_booking', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('booking_time', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('group_wallet', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('balance', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('group_id') + ) + op.create_index(op.f('ix_group_wallet_id'), 'group_wallet', ['id'], unique=False) + op.create_table('groupmembers', + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('group_id', 'user_id') + ) + op.create_table('nightclub_order', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('order_time', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('taxes_and_charges', sa.Float(), nullable=True), + sa.Column('cover_charge_used', sa.Float(), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('venue_id', sa.Integer(), nullable=False), + sa.Column('payment_id', sa.Integer(), nullable=False), + sa.Column('pickup_location_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['payment_id'], ['payment_source_nightclub.id'], ), + sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_nightclub_order_id'), 'nightclub_order', ['id'], unique=False) + op.create_table('qsr_order', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('pickup_location_id', sa.Integer(), nullable=True), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('order_time', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('taxes_and_charges', sa.Float(), nullable=True), + sa.Column('cover_charge_used', sa.Float(), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('venue_id', sa.Integer(), nullable=False), + sa.Column('payment_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['payment_id'], ['payment_source_qsr.id'], ), + sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['qsr.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_qsr_order_id'), 'qsr_order', ['id'], unique=False) + op.create_table('qsrmenu', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('qsr_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_qsrmenu_id'), 'qsrmenu', ['id'], unique=False) + op.create_table('qsruserbusinesslink', + sa.Column('qsr_id', sa.Integer(), nullable=False), + sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), + sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), + sa.PrimaryKeyConstraint('qsr_id', 'user_business_id') + ) + op.create_table('restaurant_order', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('pickup_location_id', sa.Integer(), nullable=True), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('order_time', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('taxes_and_charges', sa.Float(), nullable=True), + sa.Column('cover_charge_used', sa.Float(), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('venue_id', sa.Integer(), nullable=False), + sa.Column('payment_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['payment_id'], ['payment_source_restaurant.id'], ), + sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['restaurant.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_restaurant_order_id'), 'restaurant_order', ['id'], unique=False) + op.create_table('event_offering', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('event_booking_id', sa.Integer(), nullable=False), + sa.Column('offering_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('total_guests_per_pass', sa.Integer(), nullable=False), + sa.Column('cover_charge', sa.Float(), nullable=True), + sa.Column('additional_charges', sa.Float(), nullable=True), + sa.Column('availability', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['event_booking_id'], ['event_booking.id'], ), + sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_event_offering_id'), 'event_offering', ['id'], unique=False) + op.create_table('group_nightclub_order_link', + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('nightclub_order_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), + sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), + sa.PrimaryKeyConstraint('group_id', 'nightclub_order_id') + ) + op.create_table('groupwallettopup', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('group_wallet_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('topup_time', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['group_wallet_id'], ['group_wallet.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_groupwallettopup_id'), 'groupwallettopup', ['id'], unique=False) + op.create_table('menu_category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('qsr_menu_id', sa.Integer(), nullable=True), + sa.Column('restaurant_menu_id', sa.Integer(), nullable=True), + sa.Column('nightclub_menu_id', sa.Integer(), nullable=True), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint(['nightclub_menu_id'], ['nightclubmenu.id'], ), + sa.ForeignKeyConstraint(['qsr_menu_id'], ['qsrmenu.id'], ), + sa.ForeignKeyConstraint(['restaurant_menu_id'], ['restaurantmenu.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_menu_category_id'), 'menu_category', ['id'], unique=False) + op.create_table('payment_event', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('payment_time', sa.DateTime(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('event_booking_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['event_booking_id'], ['event_booking.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_payment_event_id'), 'payment_event', ['id'], unique=False) + op.create_table('menu_item', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('is_veg', sa.Boolean(), nullable=True), + sa.Column('ingredients', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('abv', sa.Float(), nullable=True), + sa.Column('ibu', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_menu_item_id'), 'menu_item', ['id'], unique=False) + op.create_table('orderitem', + sa.Column('order_item_id', sa.Integer(), nullable=False), + sa.Column('nightclub_order_id', sa.Integer(), nullable=True), + sa.Column('restaurant_order_id', sa.Integer(), nullable=True), + sa.Column('qsr_order_id', sa.Integer(), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['menu_item.id'], ), + sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), + sa.ForeignKeyConstraint(['qsr_order_id'], ['qsr_order.id'], ), + sa.ForeignKeyConstraint(['restaurant_order_id'], ['restaurant_order.id'], ), + sa.PrimaryKeyConstraint('order_item_id') + ) + op.create_index(op.f('ix_orderitem_order_item_id'), 'orderitem', ['order_item_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_orderitem_order_item_id'), table_name='orderitem') + op.drop_table('orderitem') + op.drop_index(op.f('ix_menu_item_id'), table_name='menu_item') + op.drop_table('menu_item') + op.drop_index(op.f('ix_payment_event_id'), table_name='payment_event') + op.drop_table('payment_event') + op.drop_index(op.f('ix_menu_category_id'), table_name='menu_category') + op.drop_table('menu_category') + op.drop_index(op.f('ix_groupwallettopup_id'), table_name='groupwallettopup') + op.drop_table('groupwallettopup') + op.drop_table('group_nightclub_order_link') + op.drop_index(op.f('ix_event_offering_id'), table_name='event_offering') + op.drop_table('event_offering') + op.drop_index(op.f('ix_restaurant_order_id'), table_name='restaurant_order') + op.drop_table('restaurant_order') + op.drop_table('qsruserbusinesslink') + op.drop_index(op.f('ix_qsrmenu_id'), table_name='qsrmenu') + op.drop_table('qsrmenu') + op.drop_index(op.f('ix_qsr_order_id'), table_name='qsr_order') + op.drop_table('qsr_order') + op.drop_index(op.f('ix_nightclub_order_id'), table_name='nightclub_order') + op.drop_table('nightclub_order') + op.drop_table('groupmembers') + op.drop_index(op.f('ix_group_wallet_id'), table_name='group_wallet') + op.drop_table('group_wallet') + op.drop_table('event_booking') + op.drop_table('clubvisit') + op.drop_table('restaurantuserbusinesslink') + op.drop_index(op.f('ix_restaurantmenu_id'), table_name='restaurantmenu') + op.drop_table('restaurantmenu') + op.drop_index(op.f('ix_qsr_id'), table_name='qsr') + op.drop_table('qsr') + op.drop_index(op.f('ix_pickup_location_id'), table_name='pickup_location') + op.drop_table('pickup_location') + op.drop_index(op.f('ix_payment_source_restaurant_id'), table_name='payment_source_restaurant') + op.drop_table('payment_source_restaurant') + op.drop_index(op.f('ix_payment_source_qsr_id'), table_name='payment_source_qsr') + op.drop_table('payment_source_qsr') + op.drop_index(op.f('ix_payment_source_nightclub_id'), table_name='payment_source_nightclub') + op.drop_table('payment_source_nightclub') + op.drop_table('nightclubuserbusinesslink') + op.drop_index(op.f('ix_nightclubmenu_id'), table_name='nightclubmenu') + op.drop_table('nightclubmenu') + op.drop_index(op.f('ix_group_id'), table_name='group') + op.drop_table('group') + op.drop_table('foodcourtuserbusinesslink') + op.drop_index(op.f('ix_event_id'), table_name='event') + op.drop_table('event') + op.drop_index(op.f('ix_user_public_id'), table_name='user_public') + op.drop_table('user_public') + op.drop_index(op.f('ix_user_business_phone_number'), table_name='user_business') + op.drop_index(op.f('ix_user_business_id'), table_name='user_business') + op.drop_index(op.f('ix_user_business_email'), table_name='user_business') + op.drop_table('user_business') + op.drop_index(op.f('ix_restaurant_id'), table_name='restaurant') + op.drop_table('restaurant') + op.drop_index(op.f('ix_nightclub_id'), table_name='nightclub') + op.drop_table('nightclub') + op.drop_index(op.f('ix_foodcourt_id'), table_name='foodcourt') + op.drop_table('foodcourt') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py b/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py deleted file mode 100755 index 37af1fa215..0000000000 --- a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Edit replace id integers in all models to use UUID instead - -Revision ID: d98dd8ec85a3 -Revises: 9c0a54914c78 -Create Date: 2024-07-19 04:08:04.000976 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes -from sqlalchemy.dialects import postgresql - - -# revision identifiers, used by Alembic. -revision = 'd98dd8ec85a3' -down_revision = '9c0a54914c78' -branch_labels = None -depends_on = None - - -def upgrade(): - # Ensure uuid-ossp extension is available - op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') - - # Create a new UUID column with a default UUID value - op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) - op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) - op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True)) - - # Populate the new columns with UUIDs - op.execute('UPDATE "user" SET new_id = uuid_generate_v4()') - op.execute('UPDATE item SET new_id = uuid_generate_v4()') - op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)') - - # Set the new_id as not nullable - op.alter_column('user', 'new_id', nullable=False) - op.alter_column('item', 'new_id', nullable=False) - - # Drop old columns and rename new columns - op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') - op.drop_column('item', 'owner_id') - op.alter_column('item', 'new_owner_id', new_column_name='owner_id') - - op.drop_column('user', 'id') - op.alter_column('user', 'new_id', new_column_name='id') - - op.drop_column('item', 'id') - op.alter_column('item', 'new_id', new_column_name='id') - - # Create primary key constraint - op.create_primary_key('user_pkey', 'user', ['id']) - op.create_primary_key('item_pkey', 'item', ['id']) - - # Recreate foreign key constraint - op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) - -def downgrade(): - # Reverse the upgrade process - op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True)) - op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True)) - op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True)) - - # Populate the old columns with default values - # Generate sequences for the integer IDs if not exist - op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id') - op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id') - - op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)') - op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)') - - op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')') - op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)') - - # Drop new columns and rename old columns back - op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') - op.drop_column('item', 'owner_id') - op.alter_column('item', 'old_owner_id', new_column_name='owner_id') - - op.drop_column('user', 'id') - op.alter_column('user', 'old_id', new_column_name='id') - - op.drop_column('item', 'id') - op.alter_column('item', 'old_id', new_column_name='id') - - # Create primary key constraint - op.create_primary_key('user_pkey', 'user', ['id']) - op.create_primary_key('item_pkey', 'item', ['id']) - - # Recreate foreign key constraint - op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) diff --git a/backend/app/alembic/versions/e2412789c190_initialize_models.py b/backend/app/alembic/versions/e2412789c190_initialize_models.py deleted file mode 100644 index 7529ea91fa..0000000000 --- a/backend/app/alembic/versions/e2412789c190_initialize_models.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Initialize models - -Revision ID: e2412789c190 -Revises: -Create Date: 2023-11-24 22:55:43.195942 - -""" -import sqlalchemy as sa -import sqlmodel.sql.sqltypes -from alembic import op - -# revision identifiers, used by Alembic. -revision = "e2412789c190" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "user", - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) - op.create_table( - "item", - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("owner_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["owner_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("item") - op.drop_index(op.f("ix_user_email"), table_name="user") - op.drop_table("user") - # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c841d..55b219e5dd 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -11,7 +11,7 @@ from app.core import security from app.core.config import settings from app.core.db import engine -from app.models import TokenPayload, User +# from app.models import User reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" @@ -27,31 +27,31 @@ def get_db() -> Generator[Session, None, None]: TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: TokenDep) -> User: - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - token_data = TokenPayload(**payload) - except (InvalidTokenError, ValidationError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Could not validate credentials", - ) - user = session.get(User, token_data.sub) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - return user - - -CurrentUser = Annotated[User, Depends(get_current_user)] - - -def get_current_active_superuser(current_user: CurrentUser) -> User: - if not current_user.is_superuser: - raise HTTPException( - status_code=403, detail="The user doesn't have enough privileges" - ) - return current_user +# def get_current_user(session: SessionDep, token: TokenDep) -> User: +# try: +# payload = jwt.decode( +# token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] +# ) +# token_data = TokenPayload(**payload) +# except (InvalidTokenError, ValidationError): +# raise HTTPException( +# status_code=status.HTTP_403_FORBIDDEN, +# detail="Could not validate credentials", +# ) +# user = session.get(User, token_data.sub) +# if not user: +# raise HTTPException(status_code=404, detail="User not found") +# if not user.is_active: +# raise HTTPException(status_code=400, detail="Inactive user") +# return user + + +# CurrentUser = Annotated[User, Depends(get_current_user)] + + +# def get_current_active_superuser(current_user: CurrentUser) -> User: +# if not current_user.is_superuser: +# raise HTTPException( +# status_code=403, detail="The user doesn't have enough privileges" +# ) +# return current_user diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 09e0663fc3..e11d6fcba5 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,9 +1,7 @@ from fastapi import APIRouter -from app.api.routes import items, login, users, utils +from app.api.routes import venues api_router = APIRouter() -api_router.include_router(login.router, tags=["login"]) -api_router.include_router(users.router, prefix="/users", tags=["users"]) -api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) -api_router.include_router(items.router, prefix="/items", tags=["items"]) +api_router.include_router(venues.router, prefix="/venues", tags=["venues"]) + diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 67196c2366..e69de29bb2 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,109 +0,0 @@ -import uuid -from typing import Any - -from fastapi import APIRouter, HTTPException -from sqlmodel import func, select - -from app.api.deps import CurrentUser, SessionDep -from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message - -router = APIRouter() - - -@router.get("/", response_model=ItemsPublic) -def read_items( - session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 -) -> Any: - """ - Retrieve items. - """ - - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() - statement = select(Item).offset(skip).limit(limit) - items = session.exec(statement).all() - else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() - statement = ( - select(Item) - .where(Item.owner_id == current_user.id) - .offset(skip) - .limit(limit) - ) - items = session.exec(statement).all() - - return ItemsPublic(data=items, count=count) - - -@router.get("/{id}", response_model=ItemPublic) -def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: - """ - Get item by ID. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=400, detail="Not enough permissions") - return item - - -@router.post("/", response_model=ItemPublic) -def create_item( - *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate -) -> Any: - """ - Create new item. - """ - item = Item.model_validate(item_in, update={"owner_id": current_user.id}) - session.add(item) - session.commit() - session.refresh(item) - return item - - -@router.put("/{id}", response_model=ItemPublic) -def update_item( - *, - session: SessionDep, - current_user: CurrentUser, - id: uuid.UUID, - item_in: ItemUpdate, -) -> Any: - """ - Update an item. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=400, detail="Not enough permissions") - update_dict = item_in.model_dump(exclude_unset=True) - item.sqlmodel_update(update_dict) - session.add(item) - session.commit() - session.refresh(item) - return item - - -@router.delete("/{id}") -def delete_item( - session: SessionDep, current_user: CurrentUser, id: uuid.UUID -) -> Message: - """ - Delete an item. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=400, detail="Not enough permissions") - session.delete(item) - session.commit() - return Message(message="Item deleted successfully") diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index fe7e94d5c1..1b352fd6e7 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -1,124 +1,124 @@ -from datetime import timedelta -from typing import Annotated, Any - -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import HTMLResponse -from fastapi.security import OAuth2PasswordRequestForm - -from app import crud -from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser -from app.core import security -from app.core.config import settings -from app.core.security import get_password_hash -from app.models import Message, NewPassword, Token, UserPublic -from app.utils import ( - generate_password_reset_token, - generate_reset_password_email, - send_email, - verify_password_reset_token, -) - -router = APIRouter() - - -@router.post("/login/access-token") -def login_access_token( - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -) -> Token: - """ - OAuth2 compatible token login, get an access token for future requests - """ - user = crud.authenticate( - session=session, email=form_data.username, password=form_data.password - ) - if not user: - raise HTTPException(status_code=400, detail="Incorrect email or password") - elif not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return Token( - access_token=security.create_access_token( - user.id, expires_delta=access_token_expires - ) - ) - - -@router.post("/login/test-token", response_model=UserPublic) -def test_token(current_user: CurrentUser) -> Any: - """ - Test access token - """ - return current_user - - -@router.post("/password-recovery/{email}") -def recover_password(email: str, session: SessionDep) -> Message: - """ - Password Recovery - """ - user = crud.get_user_by_email(session=session, email=email) - - if not user: - raise HTTPException( - status_code=404, - detail="The user with this email does not exist in the system.", - ) - password_reset_token = generate_password_reset_token(email=email) - email_data = generate_reset_password_email( - email_to=user.email, email=email, token=password_reset_token - ) - send_email( - email_to=user.email, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return Message(message="Password recovery email sent") - - -@router.post("/reset-password/") -def reset_password(session: SessionDep, body: NewPassword) -> Message: - """ - Reset password - """ - email = verify_password_reset_token(token=body.token) - if not email: - raise HTTPException(status_code=400, detail="Invalid token") - user = crud.get_user_by_email(session=session, email=email) - if not user: - raise HTTPException( - status_code=404, - detail="The user with this email does not exist in the system.", - ) - elif not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - hashed_password = get_password_hash(password=body.new_password) - user.hashed_password = hashed_password - session.add(user) - session.commit() - return Message(message="Password updated successfully") - - -@router.post( - "/password-recovery-html-content/{email}", - dependencies=[Depends(get_current_active_superuser)], - response_class=HTMLResponse, -) -def recover_password_html_content(email: str, session: SessionDep) -> Any: - """ - HTML Content for Password Recovery - """ - user = crud.get_user_by_email(session=session, email=email) - - if not user: - raise HTTPException( - status_code=404, - detail="The user with this username does not exist in the system.", - ) - password_reset_token = generate_password_reset_token(email=email) - email_data = generate_reset_password_email( - email_to=user.email, email=email, token=password_reset_token - ) - - return HTMLResponse( - content=email_data.html_content, headers={"subject:": email_data.subject} - ) +# from datetime import timedelta +# from typing import Annotated, Any + +# from fastapi import APIRouter, Depends, HTTPException +# from fastapi.responses import HTMLResponse +# from fastapi.security import OAuth2PasswordRequestForm + +# from app import crud +# from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser +# from app.core import security +# from app.core.config import settings +# from app.core.security import get_password_hash +# from app.models import Message, NewPassword, Token, UserPublic +# from app.utils import ( +# generate_password_reset_token, +# generate_reset_password_email, +# send_email, +# verify_password_reset_token, +# ) + +# router = APIRouter() + + +# @router.post("/login/access-token") +# def login_access_token( +# session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +# ) -> Token: +# """ +# OAuth2 compatible token login, get an access token for future requests +# """ +# user = crud.authenticate( +# session=session, email=form_data.username, password=form_data.password +# ) +# if not user: +# raise HTTPException(status_code=400, detail="Incorrect email or password") +# elif not user.is_active: +# raise HTTPException(status_code=400, detail="Inactive user") +# access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) +# return Token( +# access_token=security.create_access_token( +# user.id, expires_delta=access_token_expires +# ) +# ) + + +# @router.post("/login/test-token", response_model=UserPublic) +# def test_token(current_user: CurrentUser) -> Any: +# """ +# Test access token +# """ +# return current_user + + +# @router.post("/password-recovery/{email}") +# def recover_password(email: str, session: SessionDep) -> Message: +# """ +# Password Recovery +# """ +# user = crud.get_user_by_email(session=session, email=email) + +# if not user: +# raise HTTPException( +# status_code=404, +# detail="The user with this email does not exist in the system.", +# ) +# password_reset_token = generate_password_reset_token(email=email) +# email_data = generate_reset_password_email( +# email_to=user.email, email=email, token=password_reset_token +# ) +# send_email( +# email_to=user.email, +# subject=email_data.subject, +# html_content=email_data.html_content, +# ) +# return Message(message="Password recovery email sent") + + +# @router.post("/reset-password/") +# def reset_password(session: SessionDep, body: NewPassword) -> Message: +# """ +# Reset password +# """ +# email = verify_password_reset_token(token=body.token) +# if not email: +# raise HTTPException(status_code=400, detail="Invalid token") +# user = crud.get_user_by_email(session=session, email=email) +# if not user: +# raise HTTPException( +# status_code=404, +# detail="The user with this email does not exist in the system.", +# ) +# elif not user.is_active: +# raise HTTPException(status_code=400, detail="Inactive user") +# hashed_password = get_password_hash(password=body.new_password) +# user.hashed_password = hashed_password +# session.add(user) +# session.commit() +# return Message(message="Password updated successfully") + + +# @router.post( +# "/password-recovery-html-content/{email}", +# dependencies=[Depends(get_current_active_superuser)], +# response_class=HTMLResponse, +# ) +# def recover_password_html_content(email: str, session: SessionDep) -> Any: +# """ +# HTML Content for Password Recovery +# """ +# user = crud.get_user_by_email(session=session, email=email) + +# if not user: +# raise HTTPException( +# status_code=404, +# detail="The user with this username does not exist in the system.", +# ) +# password_reset_token = generate_password_reset_token(email=email) +# email_data = generate_reset_password_email( +# email_to=user.email, email=email, token=password_reset_token +# ) + +# return HTMLResponse( +# content=email_data.html_content, headers={"subject:": email_data.subject} +# ) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index c636b094ee..dbd3d12b3f 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,228 +1,128 @@ -import uuid -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import col, delete, func, select - -from app import crud -from app.api.deps import ( - CurrentUser, - SessionDep, - get_current_active_superuser, -) -from app.core.config import settings -from app.core.security import get_password_hash, verify_password -from app.models import ( - Item, - Message, - UpdatePassword, - User, - UserCreate, - UserPublic, - UserRegister, - UsersPublic, - UserUpdate, - UserUpdateMe, -) -from app.utils import generate_new_account_email, send_email +from typing import List +from fastapi import APIRouter, Query, HTTPException, Depends +from sqlmodel import Session +from app.api.deps import SessionDep +from app.models import UserBusiness, UserPublic +from app.crud import get_all_records, get_record_by_id, create_record, update_record, delete_record router = APIRouter() - -@router.get( - "/", - dependencies=[Depends(get_current_active_superuser)], - response_model=UsersPublic, -) -def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: +@router.get("/user-businesses/", response_model=List[UserBusiness]) +def read_user_businesses( + session: SessionDep, + skip: int = Query(0, alias="page", ge=0), + limit: int = Query(10, le=100) +): """ - Retrieve users. + Retrieve a paginated list of user businesses. + - **skip**: The page number (starting from 0) + - **limit**: The number of items per page """ + return get_all_records(session, UserBusiness, skip=skip, limit=limit) - count_statement = select(func.count()).select_from(User) - count = session.exec(count_statement).one() - - statement = select(User).offset(skip).limit(limit) - users = session.exec(statement).all() - - return UsersPublic(data=users, count=count) - - -@router.post( - "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic -) -def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: - """ - Create new user. - """ - user = crud.get_user_by_email(session=session, email=user_in.email) - if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system.", - ) - - user = crud.create_user(session=session, user_create=user_in) - if settings.emails_enabled and user_in.email: - email_data = generate_new_account_email( - email_to=user_in.email, username=user_in.email, password=user_in.password - ) - send_email( - email_to=user_in.email, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return user - - -@router.patch("/me", response_model=UserPublic) -def update_user_me( - *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser -) -> Any: +@router.get("/user-businesses/{user_business_id}", response_model=UserBusiness) +def read_user_business( + user_business_id: int, + session: SessionDep +): """ - Update own user. + Retrieve a single user business by ID. + - **user_business_id**: The ID of the user business to retrieve """ + return get_record_by_id(session, UserBusiness, user_business_id) - if user_in.email: - existing_user = crud.get_user_by_email(session=session, email=user_in.email) - if existing_user and existing_user.id != current_user.id: - raise HTTPException( - status_code=409, detail="User with this email already exists" - ) - user_data = user_in.model_dump(exclude_unset=True) - current_user.sqlmodel_update(user_data) - session.add(current_user) - session.commit() - session.refresh(current_user) - return current_user - - -@router.patch("/me/password", response_model=Message) -def update_password_me( - *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser -) -> Any: - """ - Update own password. - """ - if not verify_password(body.current_password, current_user.hashed_password): - raise HTTPException(status_code=400, detail="Incorrect password") - if body.current_password == body.new_password: - raise HTTPException( - status_code=400, detail="New password cannot be the same as the current one" - ) - hashed_password = get_password_hash(body.new_password) - current_user.hashed_password = hashed_password - session.add(current_user) - session.commit() - return Message(message="Password updated successfully") - - -@router.get("/me", response_model=UserPublic) -def read_user_me(current_user: CurrentUser) -> Any: +@router.post("/user-businesses/", response_model=UserBusiness) +def create_user_business( + user_business: UserBusiness, + session: SessionDep +): """ - Get current user. + Create a new user business. + - **user_business**: The user business data to create """ - return current_user - + return create_record(session, UserBusiness, user_business) -@router.delete("/me", response_model=Message) -def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: - """ - Delete own user. - """ - if current_user.is_superuser: - raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" - ) - statement = delete(Item).where(col(Item.owner_id) == current_user.id) - session.exec(statement) # type: ignore - session.delete(current_user) - session.commit() - return Message(message="User deleted successfully") - - -@router.post("/signup", response_model=UserPublic) -def register_user(session: SessionDep, user_in: UserRegister) -> Any: - """ - Create new user without the need to be logged in. - """ - user = crud.get_user_by_email(session=session, email=user_in.email) - if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system", - ) - user_create = UserCreate.model_validate(user_in) - user = crud.create_user(session=session, user_create=user_create) - return user - - -@router.get("/{user_id}", response_model=UserPublic) -def read_user_by_id( - user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser -) -> Any: - """ - Get a specific user by id. - """ - user = session.get(User, user_id) - if user == current_user: - return user - if not current_user.is_superuser: - raise HTTPException( - status_code=403, - detail="The user doesn't have enough privileges", - ) - return user +@router.put("/user-businesses/{user_business_id}", response_model=UserBusiness) +def update_user_business( + user_business_id: int, + user_business: UserBusiness, + session: SessionDep +): + """ + Update an existing user business. + - **user_business_id**: The ID of the user business to update + - **user_business**: The updated user business data + """ + return update_record(session, UserBusiness, user_business_id, user_business) +@router.delete("/user-businesses/{user_business_id}", response_model=UserBusiness) +def delete_user_business( + user_business_id: int, + session: SessionDep +): + """ + Delete a user business by ID. + - **user_business_id**: The ID of the user business to delete + """ + delete_record(session, UserBusiness, user_business_id) + return {"message": f"UserBusiness with ID {user_business_id} has been deleted."} -@router.patch( - "/{user_id}", - dependencies=[Depends(get_current_active_superuser)], - response_model=UserPublic, -) -def update_user( - *, +@router.get("/user-publics/", response_model=List[UserPublic]) +def read_user_publics( session: SessionDep, - user_id: uuid.UUID, - user_in: UserUpdate, -) -> Any: + skip: int = Query(0, alias="page", ge=0), + limit: int = Query(10, le=100) +): """ - Update a user. + Retrieve a paginated list of user publics. + - **skip**: The page number (starting from 0) + - **limit**: The number of items per page """ + return get_all_records(session, UserPublic, skip=skip, limit=limit) - db_user = session.get(User, user_id) - if not db_user: - raise HTTPException( - status_code=404, - detail="The user with this id does not exist in the system", - ) - if user_in.email: - existing_user = crud.get_user_by_email(session=session, email=user_in.email) - if existing_user and existing_user.id != user_id: - raise HTTPException( - status_code=409, detail="User with this email already exists" - ) +@router.get("/user-publics/{user_public_id}", response_model=UserPublic) +def read_user_public( + user_public_id: int, + session: SessionDep +): + """ + Retrieve a single user public by ID. + - **user_public_id**: The ID of the user public to retrieve + """ + return get_record_by_id(session, UserPublic, user_public_id) - db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in) - return db_user +@router.post("/user-publics/", response_model=UserPublic) +def create_user_public( + user_public: UserPublic, + session: SessionDep +): + """ + Create a new user public. + - **user_public**: The user public data to create + """ + return create_record(session, UserPublic, user_public) +@router.put("/user-publics/{user_public_id}", response_model=UserPublic) +def update_user_public( + user_public_id: int, + user_public: UserPublic, + session: SessionDep +): + """ + Update an existing user public. + - **user_public_id**: The ID of the user public to update + - **user_public**: The updated user public data + """ + return update_record(session, UserPublic, user_public_id, user_public) -@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) -def delete_user( - session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID -) -> Message: - """ - Delete a user. - """ - user = session.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if user == current_user: - raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" - ) - statement = delete(Item).where(col(Item.owner_id) == user_id) - session.exec(statement) # type: ignore - session.delete(user) - session.commit() - return Message(message="User deleted successfully") +@router.delete("/user-publics/{user_public_id}", response_model=UserPublic) +def delete_user_public( + user_public_id: int, + session: SessionDep +): + """ + Delete a user public by ID. + - **user_public_id**: The ID of the user public to delete + """ + delete_record(session, UserPublic, user_public_id) + return {"message": f"UserPublic with ID {user_public_id} has been deleted."} \ No newline at end of file diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index 82f6d2b821..e69de29bb2 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -1,26 +0,0 @@ -from fastapi import APIRouter, Depends -from pydantic.networks import EmailStr - -from app.api.deps import get_current_active_superuser -from app.models import Message -from app.utils import generate_test_email, send_email - -router = APIRouter() - - -@router.post( - "/test-email/", - dependencies=[Depends(get_current_active_superuser)], - status_code=201, -) -def test_email(email_to: EmailStr) -> Message: - """ - Test emails. - """ - email_data = generate_test_email(email_to=email_to) - send_email( - email_to=email_to, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return Message(message="Test email sent") diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py new file mode 100644 index 0000000000..3c29ac4988 --- /dev/null +++ b/backend/app/api/routes/venues.py @@ -0,0 +1,194 @@ +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlmodel import Session, select +from typing import List + +from app.models.venue import Nightclub, Restaurant, QSR, Foodcourt +from app.api.deps import SessionDep +from app.crud import ( + get_all_records, + get_record_by_id, + create_record, + update_record, + delete_record +) + +router = APIRouter() + +# CRUD operations for Nightclubs + +@router.get("/nightclubs/", response_model=List[Nightclub]) +def read_nightclubs( + session: SessionDep, + skip: int = Query(0, alias="page", ge=0), + limit: int = Query(10, le=100) +): + """ + Retrieve a paginated list of nightclubs. + - **skip**: The page number (starting from 0) + - **limit**: The number of items per page + """ + return get_all_records(session, Nightclub, skip=skip, limit=limit) + + +@router.get("/nightclubs/{venue_id}", response_model=Nightclub) +def read_nightclub(venue_id: int, session: SessionDep ): + nightclub = get_record_by_id(session, Nightclub, venue_id) + if not nightclub: + raise HTTPException(status_code=404, detail="Nightclub not found") + return nightclub + +@router.post("/nightclubs/", response_model=Nightclub) +def create_nightclub( + nightclub: Nightclub, + session: SessionDep +): + return create_record(session, Nightclub, nightclub) + +@router.put("/nightclubs/{venue_id}", response_model=Nightclub) +def update_nightclub( + venue_id: int, + updated_nightclub: Nightclub, + session: SessionDep + +): + return update_record(session, Nightclub, venue_id, updated_nightclub) + +@router.delete("/nightclubs/{venue_id}", response_model=None) +def delete_nightclub( + venue_id: int, + session: SessionDep + +): + return delete_record(session, Nightclub, venue_id) + +# # CRUD operations for Restaurants + +# @router.get("/restaurants/", response_model=List[Restaurant]) +# def read_restaurants(session: SessionDep ): +# return get_all_venues(session, Restaurant) + +# @router.get("/restaurants/{venue_id}", response_model=Restaurant) +# def read_restaurant(venue_id: int, session: SessionDep ): +# restaurant = get_venue_by_id(session, Restaurant, venue_id) +# if not restaurant: +# raise HTTPException(status_code=404, detail="Restaurant not found") +# return restaurant + +# @router.post("/restaurants/", response_model=Restaurant) +# def create_restaurant( +# restaurant: Restaurant, +# session: SessionDep + +# ): +# return create_venue(session, restaurant) + +# @router.put("/restaurants/{venue_id}", response_model=Restaurant) +# def update_restaurant( +# venue_id: int, +# updated_restaurant: Restaurant, +# session: SessionDep + +# ): +# existing_restaurant = get_venue_by_id(session, Restaurant, venue_id) +# if not existing_restaurant: +# raise HTTPException(status_code=404, detail="Restaurant not found") +# return update_venue(session, venue_id, updated_restaurant) + +# @router.delete("/restaurants/{venue_id}", response_model=Restaurant) +# def delete_restaurant( +# venue_id: int, +# session: SessionDep + +# ): +# existing_restaurant = get_venue_by_id(session, Restaurant, venue_id) +# if not existing_restaurant: +# raise HTTPException(status_code=404, detail="Restaurant not found") +# return delete_venue(session, Restaurant, venue_id) + +# # CRUD operations for QSRs + +# @router.get("/qsrs/", response_model=List[QSR]) +# def read_qsrs(session: SessionDep ): +# return get_all_venues(session, QSR) + +# @router.get("/qsrs/{venue_id}", response_model=QSR) +# def read_qsr(venue_id: int, session: SessionDep ): +# qsr = get_venue_by_id(session, QSR, venue_id) +# if not qsr: +# raise HTTPException(status_code=404, detail="QSR not found") +# return qsr + +# @router.post("/qsrs/", response_model=QSR) +# def create_qsr( +# qsr: QSR, +# session: SessionDep + +# ): +# return create_venue(session, qsr) + +# @router.put("/qsrs/{venue_id}", response_model=QSR) +# def update_qsr( +# venue_id: int, +# updated_qsr: QSR, +# session: SessionDep + +# ): +# existing_qsr = get_venue_by_id(session, QSR, venue_id) +# if not existing_qsr: +# raise HTTPException(status_code=404, detail="QSR not found") +# return update_venue(session, venue_id, updated_qsr) + +# @router.delete("/qsrs/{venue_id}", response_model=QSR) +# def delete_qsr( +# venue_id: int, +# session: SessionDep + +# ): +# existing_qsr = get_venue_by_id(session, QSR, venue_id) +# if not existing_qsr: +# raise HTTPException(status_code=404, detail="QSR not found") +# return delete_venue(session, QSR, venue_id) + +# # CRUD operations for FoodCourts + +# @router.get("/foodcourts/", response_model=List[Foodcourt]) +# def read_foodcourts(session: SessionDep ): +# return get_all_venues(session, Foodcourt) + +# @router.get("/foodcourts/{venue_id}", response_model=Foodcourt) +# def read_foodcourt(venue_id: int, session: SessionDep ): +# foodcourt = get_venue_by_id(session, Foodcourt, venue_id) +# if not foodcourt: +# raise HTTPException(status_code=404, detail="Foodcourt not found") +# return foodcourt + +# @router.post("/foodcourts/", response_model=Foodcourt) +# def create_foodcourt( +# foodcourt: Foodcourt, +# session: SessionDep + +# ): +# return create_venue(session, foodcourt) + +# @router.put("/foodcourts/{venue_id}", response_model=Foodcourt) +# def update_foodcourt( +# venue_id: int, +# updated_foodcourt: Foodcourt, +# session: SessionDep + +# ): +# existing_foodcourt = get_venue_by_id(session, Foodcourt, venue_id) +# if not existing_foodcourt: +# raise HTTPException(status_code=404, detail="Foodcourt not found") +# return update_venue(session, venue_id, updated_foodcourt) + +# @router.delete("/foodcourts/{venue_id}", response_model=Foodcourt) +# def delete_foodcourt( +# venue_id: int, +# session: SessionDep + +# ): +# existing_foodcourt = get_venue_by_id(session, Foodcourt, venue_id) +# if not existing_foodcourt: +# raise HTTPException(status_code=404, detail="Foodcourt not found") +# return delete_venue(session, Foodcourt, venue_id) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1e3a440c1c..2fa45de608 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,7 +1,7 @@ import secrets import warnings from typing import Annotated, Any, Literal - +import os from pydantic import ( AnyUrl, BeforeValidator, @@ -56,15 +56,8 @@ def server_host(self) -> str: @computed_field # type: ignore[prop-decorator] @property - def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: - return MultiHostUrl.build( - scheme="postgresql+psycopg", - username=self.POSTGRES_USER, - password=self.POSTGRES_PASSWORD, - host=self.POSTGRES_SERVER, - port=self.POSTGRES_PORT, - path=self.POSTGRES_DB, - ) + def SQLALCHEMY_DATABASE_URI(self) -> str: + return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" SMTP_TLS: bool = True SMTP_SSL: bool = False @@ -113,8 +106,10 @@ def _enforce_non_default_secrets(self) -> Self: self._check_default_secret( "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD ) + print(f"FIRST_SUPERUSER_PASSWORD: {self.FIRST_SUPERUSER_PASSWORD}") return self - -settings = Settings() # type: ignore +print("About to initialize Settings...") +settings = Settings() +print("Settings initialized.") diff --git a/backend/app/core/db.py b/backend/app/core/db.py index d260a856d2..7032ecb698 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,34 +1,50 @@ +from datetime import datetime from sqlmodel import Session, create_engine, select - -from app import crud +from app.models.user import UserBusiness from app.core.config import settings -from app.models import User, UserCreate +print("SQLALCHEMY_DATABASE_URI : ", str(settings.SQLALCHEMY_DATABASE_URI)) engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) - - -# make sure all SQLModel models are imported (app.models) before initializing DB -# otherwise, SQLModel might fail to initialize relationships properly -# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 - - -def init_db(session: Session) -> None: - # Tables should be created with Alembic migrations - # But if you don't want to use migrations, create - # the tables un-commenting the next lines - # from sqlmodel import SQLModel - - # from app.core.engine import engine - # This works because the models are already imported and registered from app.models - # SQLModel.metadata.create_all(engine) - - user = session.exec( - select(User).where(User.email == settings.FIRST_SUPERUSER) - ).first() - if not user: - user_in = UserCreate( - email=settings.FIRST_SUPERUSER, - password=settings.FIRST_SUPERUSER_PASSWORD, - is_superuser=True, - ) - user = crud.create_user(session=session, user_create=user_in) +print("engine created : ") + +connection = engine.connect() +print("Connection successful!") +connection.close() + +def init_db() -> None: + """ + Initialize the database with the necessary default data. + Assumes that database schema is up-to-date due to Alembic migrations. + """ + # Example: Create the superuser if it does not exist + with Session(engine) as session: + print("here") + # Check for existing superuser + # superuser = session.exec( + # select(UserBusiness).where(UserBusiness.email == settings.FIRST_SUPERUSER) + # ).first() + # print("heree") + + # if not superuser: + # print("here1") + # user_in = UserBusiness( + # email=settings.FIRST_SUPERUSER, + # phone_number=None, + # is_active=True, + # is_superuser=True, + # full_name="Superuser", + # registration_date=datetime.utcnow() + # ) + # print("here2") + # # Create superuser in the database + # session.add(user_in) + # print("here3") + # session.commit() + # print("here4") + # session.refresh(user_in) + + # Other initial setup tasks can go here + # Example: Create default Nightclub, Foodcourt, etc. + # ... + + print("Database initialization complete.") \ No newline at end of file diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..cb3a37b2e5 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,54 +1,137 @@ +from http.client import HTTPException import uuid from typing import Any +from app.models.venue import QSR, Foodcourt, Nightclub, Restaurant +# from app.models.user import UserBusiness, UserPublic + from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate -def create_user(*, session: Session, user_create: UserCreate) -> User: - db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} - ) - session.add(db_obj) +from typing import Type, List +from sqlmodel import select, Session, SQLModel + +# Generic CRUD function to get all records with pagination +def get_all_records( + session: Session, model: Type[SQLModel], skip: int = 0, limit: int = 10 +) -> List[SQLModel]: + """ + Retrieve a paginated list of records. + - **session**: Database session + - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) + - **skip**: Number of records to skip + - **limit**: Number of records to return + """ + statement = select(model).offset(skip).limit(limit) + result = session.exec(statement) + return result.all() + +# Function to get a single record by ID +def get_record_by_id( + session: Session, model: Type[SQLModel], record_id: int +) -> SQLModel: + """ + Retrieve a single record by ID. + - **session**: Database session + - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) + - **record_id**: ID of the record to retrieve + """ + statement = select(model).where(model.id == record_id) + result = session.exec(statement).first() + if not result: + raise ValueError(f"{model.__name__} with ID {record_id} not found.") + return result + +# Function to create a new record +def create_record( + session: Session, model: Type[SQLModel], obj_in: SQLModel +) -> SQLModel: + """ + Create a new record. + - **session**: Database session + - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) + - **obj_in**: Data to create the new record + """ + obj = model(**obj_in.dict()) + session.add(obj) + session.commit() + session.refresh(obj) + return obj + +# Function to update an existing record +def update_record( + session: Session, model: Type[SQLModel], record_id: int, obj_in: SQLModel +) -> SQLModel: + """ + Update an existing record. + - **session**: Database session + - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) + - **record_id**: ID of the record to update + - **obj_in**: Data to update the record + """ + obj = get_record_by_id(session, model, record_id) + if not obj: + raise HTTPException(status_code=404, detail="Record not found") + obj_data = obj_in.dict(exclude_unset=True) + for field, value in obj_data.items(): + setattr(obj, field, value) + session.add(obj) session.commit() - session.refresh(db_obj) - return db_obj - - -def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: - user_data = user_in.model_dump(exclude_unset=True) - extra_data = {} - if "password" in user_data: - password = user_data["password"] - hashed_password = get_password_hash(password) - extra_data["hashed_password"] = hashed_password - db_user.sqlmodel_update(user_data, update=extra_data) - session.add(db_user) + session.refresh(obj) + return obj + +# Function to delete a record +def delete_record( + session: Session, model: Type[SQLModel], record_id: int +) -> None: + """ + Delete a record by ID. + - **session**: Database session + - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) + - **record_id**: ID of the record to delete + """ + obj = get_record_by_id(session, model, record_id) + session.delete(obj) session.commit() - session.refresh(db_user) - return db_user +# Example functions specific to Nightclub, Restaurant, QSR, and Foodcourt -def get_user_by_email(*, session: Session, email: str) -> User | None: - statement = select(User).where(User.email == email) - session_user = session.exec(statement).first() - return session_user +def get_all_nightclubs( + session: Session, skip: int = 0, limit: int = 10 +) -> List[SQLModel]: + return get_all_records(session, Nightclub, skip, limit) +def get_all_restaurants( + session: Session, skip: int = 0, limit: int = 10 +) -> List[SQLModel]: + return get_all_records(session, Restaurant, skip, limit) -def authenticate(*, session: Session, email: str, password: str) -> User | None: - db_user = get_user_by_email(session=session, email=email) - if not db_user: - return None - if not verify_password(password, db_user.hashed_password): - return None - return db_user +def get_all_qsrs( + session: Session, skip: int = 0, limit: int = 10 +) -> List[SQLModel]: + return get_all_records(session, QSR, skip, limit) +def get_all_foodcourts( + session: Session, skip: int = 0, limit: int = 10 +) -> List[SQLModel]: + return get_all_records(session, Foodcourt, skip, limit) -def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: - db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) - session.add(db_item) - session.commit() - session.refresh(db_item) - return db_item + + +# def authenticate(*, session: Session, email: str, password: str) -> User | None: +# db_user = get_user_by_email(session=session, email=email) +# if not db_user: +# return None +# if not verify_password(password, db_user.hashed_password): +# return None +# return db_user + + +# def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: +# db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) +# session.add(db_item) +# session.commit() +# session.refresh(db_item) +# return db_item diff --git a/backend/app/initial_data.py b/backend/app/initial_data.py index d806c3d381..e72da8788f 100644 --- a/backend/app/initial_data.py +++ b/backend/app/initial_data.py @@ -9,8 +9,7 @@ def init() -> None: - with Session(engine) as session: - init_db(session) + init_db() def main() -> None: diff --git a/backend/app/models.py b/backend/app/models.py deleted file mode 100644 index 90ef5559e3..0000000000 --- a/backend/app/models.py +++ /dev/null @@ -1,114 +0,0 @@ -import uuid - -from pydantic import EmailStr -from sqlmodel import Field, Relationship, SQLModel - - -# Shared properties -class UserBase(SQLModel): - email: EmailStr = Field(unique=True, index=True, max_length=255) - is_active: bool = True - is_superuser: bool = False - full_name: str | None = Field(default=None, max_length=255) - - -# Properties to receive via API on creation -class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=40) - - -class UserRegister(SQLModel): - email: EmailStr = Field(max_length=255) - password: str = Field(min_length=8, max_length=40) - full_name: str | None = Field(default=None, max_length=255) - - -# Properties to receive via API on update, all are optional -class UserUpdate(UserBase): - email: EmailStr | None = Field(default=None, max_length=255) # type: ignore - password: str | None = Field(default=None, min_length=8, max_length=40) - - -class UserUpdateMe(SQLModel): - full_name: str | None = Field(default=None, max_length=255) - email: EmailStr | None = Field(default=None, max_length=255) - - -class UpdatePassword(SQLModel): - current_password: str = Field(min_length=8, max_length=40) - new_password: str = Field(min_length=8, max_length=40) - - -# Database model, database table inferred from class name -class User(UserBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - hashed_password: str - items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) - - -# Properties to return via API, id is always required -class UserPublic(UserBase): - id: uuid.UUID - - -class UsersPublic(SQLModel): - data: list[UserPublic] - count: int - - -# Shared properties -class ItemBase(SQLModel): - title: str = Field(min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) - - -# Properties to receive on item creation -class ItemCreate(ItemBase): - pass - - -# Properties to receive on item update -class ItemUpdate(ItemBase): - title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore - - -# Database model, database table inferred from class name -class Item(ItemBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - title: str = Field(max_length=255) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" - ) - owner: User | None = Relationship(back_populates="items") - - -# Properties to return via API, id is always required -class ItemPublic(ItemBase): - id: uuid.UUID - owner_id: uuid.UUID - - -class ItemsPublic(SQLModel): - data: list[ItemPublic] - count: int - - -# Generic message -class Message(SQLModel): - message: str - - -# JSON payload containing access token -class Token(SQLModel): - access_token: str - token_type: str = "bearer" - - -# Contents of JWT token -class TokenPayload(SQLModel): - sub: str | None = None - - -class NewPassword(SQLModel): - token: str - new_password: str = Field(min_length=8, max_length=40) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000000..4e4eab4cac --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,32 @@ +from sqlmodel import SQLModel + +# Import all your models here +from .club_visit import ClubVisit +from .event import Event +from .event_booking import EventBooking +from .event_offering import EventOffering +from .group import Group +from .group_wallet import GroupWallet +from .group_wallet_topup import GroupWalletTopup +from .menu import RestaurantMenu, NightclubMenu, QSRMenu +from .menu_category import MenuCategory +from .menu_item import MenuItem +from .order import NightclubOrder, RestaurantOrder, QSROrder +from .order_item import OrderItem +from .pickup_location import PickupLocation +from .user import UserBusiness, UserPublic +from .venue import Nightclub, QSR, Restaurant, Foodcourt +from .payment import PaymentOrderNightclub, PaymentOrderRestaurant, PaymentOrderQSR, PaymentEvent + +# Make all models accessible when importing app.models +__all__ = [ + "SQLModel", "ClubVisit", "Event", "EventBooking", "EventOffering", "Group", "GroupWallet", + "GroupWalletTopup", "RestaurantMenu", "NightclubMenu", "QSRMenu", "MenuCategory", + "MenuItem", "NightclubOrder", "RestaurantOrder", "QSROrder", "OrderItem", + "PickupLocation", "UserBusiness", "UserPublic", "Nightclub", "QSR", "Restaurant", + "Foodcourt", "PaymentOrderNightclub", "PaymentOrderRestaurant", "PaymentOrderQSR", + "PaymentEvent" +] + + +print("target_metadata in init:", SQLModel.metadata.tables) \ No newline at end of file diff --git a/backend/app/models/club_visit.py b/backend/app/models/club_visit.py new file mode 100644 index 0000000000..4ba5df98d5 --- /dev/null +++ b/backend/app/models/club_visit.py @@ -0,0 +1,19 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from datetime import datetime + +class ClubVisit(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user_public.id", nullable=False) + group_id: Optional[int] = Field(foreign_key="group.id", nullable=True) + nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + entry_time: datetime = Field(nullable=False) + exit_time: Optional[datetime] = Field(nullable=True) + cover_charge: Optional[float] = Field(nullable=True) + total_bill: Optional[float] = Field(nullable=True) + + # Relationships + user: Optional["UserPublic"] = Relationship(back_populates="club_visits") + group: Optional["Group"] = Relationship(back_populates="club_visits") + nightclub: Optional["Nightclub"] = Relationship(back_populates="club_visits") + diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000000..eb7af2e2c5 --- /dev/null +++ b/backend/app/models/event.py @@ -0,0 +1,18 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +from datetime import datetime + +class Event(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + title: str = Field(nullable=False) + start_time: datetime = Field(nullable=False) + end_time: datetime = Field(nullable=False) + image_url: Optional[str] = Field(nullable=True) + age_restriction: Optional[int] = Field(nullable=True) + dress_code: Optional[str] = Field(nullable=True) + + # Relationships + nightclub: Optional["Nightclub"] = Relationship(back_populates="events") + offerings: List["EventOffering"] = Relationship(back_populates="event") + event_bookings: List["EventBooking"] = Relationship(back_populates="event") \ No newline at end of file diff --git a/backend/app/models/event_booking.py b/backend/app/models/event_booking.py new file mode 100644 index 0000000000..2b596fbe46 --- /dev/null +++ b/backend/app/models/event_booking.py @@ -0,0 +1,19 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +from datetime import datetime + +class EventBooking(SQLModel, table=True): + __tablename__ = "event_booking" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user_public.id", nullable=False) + event_id: int = Field(foreign_key="event.id", nullable=False) + booking_time: datetime = Field(nullable=False) + total_amount: float = Field(nullable=False) + status: str = Field(nullable=False) + + # Relationships + user: Optional["UserPublic"] = Relationship(back_populates="event_bookings") + event: Optional["Event"] = Relationship(back_populates="event_bookings") + payment: Optional["PaymentEvent"] = Relationship(back_populates="event_booking", sa_relationship_kwargs={"uselist": False}) + event_offerings: List["EventOffering"] = Relationship(back_populates="event_booking") \ No newline at end of file diff --git a/backend/app/models/event_offering.py b/backend/app/models/event_offering.py new file mode 100644 index 0000000000..251737db37 --- /dev/null +++ b/backend/app/models/event_offering.py @@ -0,0 +1,21 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from typing import List + +# Stag, couple etc +class EventOffering(SQLModel, table=True): + __tablename__ = "event_offering" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + event_id: int = Field(foreign_key="event.id", nullable=False) + event_booking_id: int = Field(foreign_key="event_booking.id", nullable=False) + offering_type: str = Field(nullable=False) + description: str = Field(nullable=False) + price: float = Field(nullable=False) + total_guests_per_pass: int = Field(nullable=False) + cover_charge: Optional[float] = Field(nullable=True) + additional_charges: Optional[float] = Field(nullable=True) + availability: int = Field(nullable=False) + + # Relationships + event: Optional["Event"] = Relationship(back_populates="offerings") + event_booking: Optional["EventBooking"] = Relationship(back_populates="event_offerings") \ No newline at end of file diff --git a/backend/app/models/group.py b/backend/app/models/group.py new file mode 100644 index 0000000000..0f66b3c45b --- /dev/null +++ b/backend/app/models/group.py @@ -0,0 +1,30 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +from datetime import datetime + +class GroupMembers(SQLModel, table=True): + group_id: int = Field(foreign_key="group.id", primary_key=True) + user_id: int = Field(foreign_key="user_public.id", primary_key=True) + +class GroupNightclubOrderLink(SQLModel, table=True): + __tablename__ = "group_nightclub_order_link" + + group_id: Optional[int] = Field(default=None, foreign_key="group.id", primary_key=True) + nightclub_order_id: Optional[int] = Field(default=None, foreign_key="nightclub_order.id", primary_key=True) + +class Group(SQLModel, table=True): + __tablename__ = "group" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + nightclub_id: Optional[int] = Field(default=None, foreign_key="nightclub.id") + created_at: datetime = Field(default=datetime.utcnow) + admin_user_id: int = Field(foreign_key="user_public.id", nullable=False) + table_number: Optional[str] = Field(default=None) + + # Relationships + admin_user: Optional["UserPublic"] = Relationship(back_populates="managed_groups") + wallet: Optional["GroupWallet"] = Relationship(back_populates="group") + members: List["UserPublic"] = Relationship(back_populates="groups", link_model=GroupMembers) + club_visits: List["ClubVisit"] = Relationship(back_populates="group") + nightclub_orders: List["NightclubOrder"] = Relationship(back_populates="groups", link_model=GroupNightclubOrderLink) + nightclubs: List["Nightclub"] = Relationship(back_populates="group") \ No newline at end of file diff --git a/backend/app/models/group_wallet.py b/backend/app/models/group_wallet.py new file mode 100644 index 0000000000..10d74c0c95 --- /dev/null +++ b/backend/app/models/group_wallet.py @@ -0,0 +1,13 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List + +# (TODO) Added another model : Group wallet transactions +class GroupWallet(SQLModel, table=True): + __tablename__ = "group_wallet" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + group_id: int = Field(foreign_key="group.id", nullable=False, unique=True) + balance: float = Field(default=0.0, nullable=False) + + # Relationships + group: Optional["Group"] = Relationship(back_populates="wallet") + topups: List["GroupWalletTopup"] = Relationship(back_populates="group_wallet") diff --git a/backend/app/models/group_wallet_topup.py b/backend/app/models/group_wallet_topup.py new file mode 100644 index 0000000000..f609c3374d --- /dev/null +++ b/backend/app/models/group_wallet_topup.py @@ -0,0 +1,13 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from datetime import datetime + + +class GroupWalletTopup(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + group_wallet_id: int = Field(foreign_key="group_wallet.id", nullable=False) + amount: float = Field(nullable=False) + topup_time: datetime = Field(nullable=False) + + # Relationships + group_wallet: Optional["GroupWallet"] = Relationship(back_populates="topups") \ No newline at end of file diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py new file mode 100644 index 0000000000..e43685f895 --- /dev/null +++ b/backend/app/models/menu.py @@ -0,0 +1,33 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List + +class MenuBase(SQLModel): + name: str = Field(nullable=False) + description: Optional[str] = Field(default=None) + menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") + +class QSRMenu(MenuBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + qsr_id: int = Field(foreign_key="qsr.id", nullable=False) + + # Relationships + qsr: "QSR" = Relationship(back_populates="menu") + categories: List["MenuCategory"] = Relationship(back_populates="qsr_menu") + + +class RestaurantMenu(MenuBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + restaurant_id: int = Field(foreign_key="restaurant.id", nullable=False) + + # Relationships + restaurant: "Restaurant" = Relationship(back_populates="menu") + categories: List["MenuCategory"] = Relationship(back_populates="restaurant_menu") + + +class NightclubMenu(MenuBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + + # Relationships + nightclub: "Nightclub" = Relationship(back_populates="menu") + categories: List["MenuCategory"] = Relationship(back_populates="nightclub_menu") diff --git a/backend/app/models/menu_category.py b/backend/app/models/menu_category.py new file mode 100644 index 0000000000..072e9dd00e --- /dev/null +++ b/backend/app/models/menu_category.py @@ -0,0 +1,22 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List + + +class MenuCategory(SQLModel, table=True): + __tablename__ = "menu_category" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + + # Foreign keys for different menus + qsr_menu_id: Optional[int] = Field(default=None, foreign_key="qsrmenu.id") + restaurant_menu_id: Optional[int] = Field(default=None, foreign_key="restaurantmenu.id") + nightclub_menu_id: Optional[int] = Field(default=None, foreign_key="nightclubmenu.id") + + name: str = Field(nullable=False) + + # Relationships + menu_items: List["MenuItem"] = Relationship(back_populates="category") + + # Relationships with specific menu types + qsr_menu: Optional["QSRMenu"] = Relationship(back_populates="categories") + restaurant_menu: Optional["RestaurantMenu"] = Relationship(back_populates="categories") + nightclub_menu: Optional["NightclubMenu"] = Relationship(back_populates="categories") \ No newline at end of file diff --git a/backend/app/models/menu_item.py b/backend/app/models/menu_item.py new file mode 100644 index 0000000000..302d07ce30 --- /dev/null +++ b/backend/app/models/menu_item.py @@ -0,0 +1,18 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional + +class MenuItem(SQLModel, table=True): + __tablename__="menu_item" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + category_id: int = Field(foreign_key="menu_category.id", nullable=False) + name: str = Field(nullable=False) + price: float = Field(nullable=False) + description: Optional[str] = Field(default=None) + image_url: Optional[str] = Field(default=None) + is_veg: Optional[bool] = Field(default=None) + ingredients: Optional[str] = Field(default=None) + abv: Optional[float] = Field(default=None) + ibu: Optional[int] = Field(default=None) + + # Relationships + category: Optional["MenuCategory"] = Relationship(back_populates="menu_items") \ No newline at end of file diff --git a/backend/app/models/order.py b/backend/app/models/order.py new file mode 100644 index 0000000000..146dc0e7ae --- /dev/null +++ b/backend/app/models/order.py @@ -0,0 +1,57 @@ +from app.models.group import GroupNightclubOrderLink +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +from datetime import datetime + + +class OrderBase(SQLModel): + user_id: Optional[int] = Field(default=None, foreign_key="user_public.id") + pickup_location_id: Optional[int] = Field(default=None, foreign_key="pickup_location.id") + note: Optional[str] = Field(nullable=True) + order_time: datetime = Field(nullable=False) + total_amount: float = Field(nullable=False) + taxes_and_charges: Optional[float] = Field(default=None) + cover_charge_used: Optional[float] = Field(default=None) + status: str = Field(nullable=False) + service_type: Optional[str] = Field(default=None) + +class NightclubOrder(OrderBase, table=True): + __tablename__ = "nightclub_order" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + venue_id: int = Field(default=None, foreign_key="nightclub.id") + payment_id: int = Field(default=None, foreign_key="payment_source_nightclub.id") + pickup_location_id: int = Field(default=None, foreign_key="pickup_location.id") + # Relationships + user: Optional["UserPublic"] = Relationship(back_populates="nightclub_orders") + nightclub: Optional["Nightclub"] = Relationship(back_populates="orders") + pickup_location: Optional["PickupLocation"] = Relationship(back_populates="orders") + payment: Optional["PaymentOrderNightclub"] = Relationship(back_populates="order", sa_relationship_kwargs={"uselist": False}) + groups: List["Group"] = Relationship(back_populates="nightclub_orders", link_model=GroupNightclubOrderLink) # Many-to-many + order_items: List["OrderItem"] = Relationship(back_populates="nightclub_order") + +class RestaurantOrder(OrderBase, table=True): + __tablename__ = "restaurant_order" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + venue_id: int = Field(default=None, foreign_key="restaurant.id") + payment_id: int = Field(default=None, foreign_key="payment_source_restaurant.id") + + # Relationships + user: Optional["UserPublic"] = Relationship(back_populates="restaurant_orders") + restaurant: Optional["Restaurant"] = Relationship(back_populates="orders") + payment: Optional["PaymentOrderRestaurant"] = Relationship(back_populates="order", sa_relationship_kwargs={"uselist": False}) + order_items: List["OrderItem"] = Relationship(back_populates="restaurant_order") + +class QSROrder(OrderBase, table=True): + __tablename__ = "qsr_order" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + venue_id: int = Field(default=None, foreign_key="qsr.id") + payment_id: int = Field(default=None, foreign_key="payment_source_qsr.id") + + # Relationships + user: Optional["UserPublic"] = Relationship(back_populates="qsr_orders") + qsr: Optional["QSR"] = Relationship(back_populates="orders") + payment: Optional["PaymentOrderQSR"] = Relationship(back_populates="order", sa_relationship_kwargs={"uselist": False}) + order_items: List["OrderItem"] = Relationship(back_populates="qsr_order") \ No newline at end of file diff --git a/backend/app/models/order_item.py b/backend/app/models/order_item.py new file mode 100644 index 0000000000..9fa027b0e3 --- /dev/null +++ b/backend/app/models/order_item.py @@ -0,0 +1,16 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional + + +class OrderItem(SQLModel, table=True): + order_item_id: Optional[int] = Field(default=None, primary_key=True, index=True) + nightclub_order_id: Optional[int] = Field(default=None, foreign_key="nightclub_order.id") + restaurant_order_id: Optional[int] = Field(default=None, foreign_key="restaurant_order.id") + qsr_order_id: Optional[int] = Field(default=None, foreign_key="qsr_order.id") + item_id: int = Field(foreign_key="menu_item.id", nullable=False) + quantity: int = Field(nullable=False) + + # Relationships + nightclub_order: Optional["NightclubOrder"] = Relationship(back_populates="order_items") + restaurant_order: Optional["RestaurantOrder"] = Relationship(back_populates="order_items") + qsr_order: Optional["QSROrder"] = Relationship(back_populates="order_items") \ No newline at end of file diff --git a/backend/app/models/payment.py b/backend/app/models/payment.py new file mode 100644 index 0000000000..ef6db00299 --- /dev/null +++ b/backend/app/models/payment.py @@ -0,0 +1,43 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from datetime import datetime + +class PaymentBase(SQLModel): + user_id: int = Field(foreign_key="user_public.id", nullable=False) + source_type: str = Field(nullable=False) # Changed to str + gateway_transaction_id: Optional[int] = Field(default=None) + payment_time: datetime = Field(nullable=False) + amount: float = Field(nullable=False) + status: str = Field(nullable=False) # e.g., Paid, Pending, Failed + source_type: str = Field(nullable=False) + +class PaymentOrderNightclub(PaymentBase, table=True): + __tablename__ = "payment_source_nightclub" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + retry_count: int = Field(default=0) + last_attempt_time: Optional[datetime] = Field(default=None) + order: "NightclubOrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + user: Optional["UserPublic"] = Relationship(back_populates="nightclub_payments") + +class PaymentOrderQSR(PaymentBase, table=True): + __tablename__ = "payment_source_qsr" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + retry_count: int = Field(default=0) + last_attempt_time: Optional[datetime] = Field(default=None) + order: "QSROrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + user: Optional["UserPublic"] = Relationship(back_populates="qsr_payments") + +class PaymentOrderRestaurant(PaymentBase, table=True): + __tablename__ = "payment_source_restaurant" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + retry_count: int = Field(default=0) + last_attempt_time: Optional[datetime] = Field(default=None) + order: "RestaurantOrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + user: Optional["UserPublic"] = Relationship(back_populates="restaurant_payments") + +class PaymentEvent(PaymentBase, table=True): + __tablename__ = "payment_event" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + event_booking_id: Optional[int] = Field(default=None, foreign_key="event_booking.id") + event_booking: Optional["EventBooking"] = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + user: Optional["UserPublic"] = Relationship(back_populates="event_payments") diff --git a/backend/app/models/pickup_location.py b/backend/app/models/pickup_location.py new file mode 100644 index 0000000000..0c04c29009 --- /dev/null +++ b/backend/app/models/pickup_location.py @@ -0,0 +1,16 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List + +class PickupLocation(SQLModel, table=True): + __tablename__ = "pickup_location" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + name: str = Field(nullable=False) + description: Optional[str] = Field(default=None) + + # Relationships + orders: List["NightclubOrder"] = Relationship(back_populates="pickup_location") + + # Optionally, if you have a specific type of venue for PickupLocation + nightclub: Optional["Nightclub"] = Relationship(back_populates="pickup_locations") \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000000..2e94164461 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,87 @@ +from app.models.group import GroupMembers +from app.models.venue import FoodcourtUserBusinessLink, NightclubUserBusinessLink, QSRUserBusinessLink, RestaurantUserBusinessLink +from sqlmodel import SQLModel, Field, Relationship +from typing import TYPE_CHECKING, Optional, List +from datetime import datetime +import uuid +from pydantic import EmailStr + +# if TYPE_CHECKING: +# from .venue import QSR, Foodcourt, Restaurant, Nightclub + + +# Shared properties +class UserBase(SQLModel): + email: EmailStr = Field(unique=True, nullable=True, index=True, max_length=255) + phone_number: Optional[str] = Field(unique=True, index=True,default=None) + is_active: bool = True + is_superuser: bool = False + full_name: str | None = Field(default=None, max_length=255) + + +class UserPublic(SQLModel, table=True): + __tablename__ = "user_public" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + date_of_birth: Optional[datetime] = Field(default=None) + gender: Optional[str] = Field(default=None) + registration_date: datetime = Field(nullable=False) + profile_picture: Optional[str] = Field(default=None) + preferences: Optional[str] = Field(default=None) + + # Relationships + nightclub_orders: List["NightclubOrder"] = Relationship(back_populates="user") + restaurant_orders: List["RestaurantOrder"] = Relationship(back_populates="user") + qsr_orders: List["QSROrder"] = Relationship(back_populates="user") + + club_visits: List["ClubVisit"] = Relationship(back_populates="user") + event_bookings: List["EventBooking"] = Relationship(back_populates="user") + groups: List["Group"] = Relationship(back_populates="members", link_model=GroupMembers) + managed_groups: List["Group"] = Relationship(back_populates="admin_user") + nightclub_payments: List["PaymentOrderNightclub"] = Relationship(back_populates="user") + qsr_payments: List["PaymentOrderQSR"] = Relationship(back_populates="user") + restaurant_payments: List["PaymentOrderRestaurant"] = Relationship(back_populates="user") + event_payments: List["PaymentEvent"] = Relationship(back_populates="user") + + +class UserBusiness(UserBase, table=True): + __tablename__ = "user_business" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + registration_date: datetime = Field(nullable=False) + + # Relationships + managed_foodcourts: List["Foodcourt"] = Relationship( + back_populates="managing_users", + link_model=FoodcourtUserBusinessLink + ) + managed_qsrs: List["QSR"] = Relationship( + back_populates="managing_users", + link_model=QSRUserBusinessLink + ) + managed_restaurants: List["Restaurant"] = Relationship( + back_populates="managing_users", + link_model=RestaurantUserBusinessLink + ) + managed_nightclubs: List["Nightclub"] = Relationship( + back_populates="managing_users", + link_model=NightclubUserBusinessLink + ) + +# JSON payload containing access token +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +# Contents of JWT token +class TokenPayload(SQLModel): + sub: str | None = None + + +class NewPassword(SQLModel): + token: str + new_password: str = Field(min_length=8, max_length=40) + +# Generic message +class Message(SQLModel): + message: str diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py new file mode 100644 index 0000000000..9690cab74b --- /dev/null +++ b/backend/app/models/venue.py @@ -0,0 +1,93 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List, TYPE_CHECKING + +# if TYPE_CHECKING: +# from .user import UserBusiness +print("Venue models imported") + +class VenueBase(SQLModel): + name: str = Field(nullable=False) + address: Optional[str] = Field(default=None) + latitude: Optional[float] = Field(default=None) + longitude: Optional[float] = Field(default=None) + capacity: Optional[int] = Field(default=None) + description: Optional[str] = Field(default=None) + google_rating: Optional[float] = Field(default=None) + instagram_handle: Optional[str] = Field(default=None) + instagram_token: Optional[str] = Field(default=None) + google_map_link: Optional[str] = Field(default=None) + mobile_number: Optional[str] = Field(default=None) + email: Optional[str] = Field(default=None) + opening_time: Optional[str] = Field(default=None) + closing_time: Optional[str] = Field(default=None) + avg_expense_for_two: Optional[float] = Field(default=None) + qr_url: Optional[str] = Field(default=None) + +class NightclubUserBusinessLink(SQLModel, table=True): + nightclub_id: int = Field(foreign_key="nightclub.id", primary_key=True) + user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + +class Nightclub(VenueBase, table=True): + __tablename__ = "nightclub" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + # Relationships + events: List["Event"] = Relationship(back_populates="nightclub") + club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") + menu: List["NightclubMenu"] = Relationship(back_populates="nightclub") + orders: List["NightclubOrder"] = Relationship(back_populates="nightclub") + pickup_locations: List["PickupLocation"] = Relationship(back_populates="nightclub") + group : List["Group"] = Relationship(back_populates="nightclubs") + managing_users: List["UserBusiness"] = Relationship( + back_populates="managed_nightclubs", + link_model=NightclubUserBusinessLink + ) + +class RestaurantUserBusinessLink(SQLModel, table=True): + restaurant_id: int = Field(foreign_key="restaurant.id", primary_key=True) + user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + +class Restaurant(VenueBase, table=True): + __tablename__ = "restaurant" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + # Relationships + menu: List["RestaurantMenu"] = Relationship(back_populates="restaurant") + orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") + managing_users: List["UserBusiness"] = Relationship( + back_populates="managed_restaurants", + link_model=RestaurantUserBusinessLink + ) + +class QSRUserBusinessLink(SQLModel, table=True): + qsr_id: int = Field(foreign_key="qsr.id", primary_key=True) + user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + +class QSR(VenueBase, table=True): + __tablename__ = "qsr" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + foodcourt_id: Optional[int] = Field(default=None, foreign_key="foodcourt.id") + # Relationships + foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") + menu: List["QSRMenu"] = Relationship(back_populates="qsr") + orders: List["QSROrder"] = Relationship(back_populates="qsr") + managing_users: List["UserBusiness"] = Relationship( + back_populates="managed_qsrs", + link_model=QSRUserBusinessLink + ) + +class FoodcourtUserBusinessLink(SQLModel, table=True): + foodcourt_id: int = Field(foreign_key="foodcourt.id", primary_key=True) + user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + +class Foodcourt(VenueBase, table=True): + __tablename__ = "foodcourt" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + # Relationships + qsrs: List["QSR"] = Relationship(back_populates="foodcourt") + managing_users: List["UserBusiness"] = Relationship( + back_populates="managed_foodcourts", + link_model=FoodcourtUserBusinessLink + ) \ No newline at end of file diff --git a/backend/app/models_/menu.py b/backend/app/models_/menu.py new file mode 100644 index 0000000000..6844ab62c8 --- /dev/null +++ b/backend/app/models_/menu.py @@ -0,0 +1,86 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List + +from app.models.menu_item import MenuItem +from app.models.venue import QSR, Nightclub, Restaurant + + +# Base class for Menu +class MenuBase(SQLModel): + name: str = Field(nullable=True) + description: Optional[str] = Field(default=None) + +# Schema for reading a Menu +class MenuRead(SQLModel): + id: int + name: str + description: Optional[str] + +# Schema for creating a Menu (excluding the ID which is auto-generated) +class MenuCreate(SQLModel): + name: str + description: Optional[str] + menu_type: Optional[str] + + +# QSR Menu model +class QSRMenu(MenuBase, table=True): + __tablename__ = "qsr_menu" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + qsr_id: int = Field(foreign_key="qsr.id", nullable=False) + + # Relationships + qsr: "QSR" = Relationship(back_populates="menu") + categories: List["menu_category"] = Relationship(back_populates="qsr_menu") + + +# Schema for reading a QSR Menu +class QSRMenuRead(MenuRead): + qsr_id: int + + +# Schema for creating a QSR Menu +class QSRMenuCreate(MenuCreate): + qsr_id: int + + +# Restaurant Menu model +class RestaurantMenu(MenuBase, table=True): + __tablename__ = "restaurant_menu" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + restaurant_id: int = Field(foreign_key="restaurant.id", nullable=False) + + # Relationships + restaurant: "Restaurant" = Relationship(back_populates="menu") + categories: List["menu_category"] = Relationship(back_populates="restaurant_menu") + + +# Schema for reading a Restaurant Menu +class RestaurantMenuRead(MenuRead): + restaurant_id: int + + +# Schema for creating a Restaurant Menu +class RestaurantMenuCreate(MenuCreate): + restaurant_id: int + + +# Nightclub Menu model +class NightclubMenu(MenuBase, table=True): + __tablename__ = "nightclub_menu" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + + # Relationships + nightclub: "Nightclub" = Relationship(back_populates="menu") + categories: List["menu_category"] = Relationship(back_populates="nightclub_menu") + + +# Schema for reading a Nightclub Menu +class NightclubMenuRead(MenuRead): + nightclub_id: int + + +# Schema for creating a Nightclub Menu +class NightclubMenuCreate(MenuCreate): + nightclub_id: int \ No newline at end of file diff --git a/backend/app/models_/venue.py b/backend/app/models_/venue.py new file mode 100644 index 0000000000..5c90e879ac --- /dev/null +++ b/backend/app/models_/venue.py @@ -0,0 +1,228 @@ +from sqlmodel import Field, SQLModel, Relationship +from typing import List, Optional + + +# Base model shared by all venues +class VenueBase(SQLModel): + name: str = Field(nullable=False) + address: Optional[str] = Field(default=None) + latitude: Optional[float] = Field(default=None) + longitude: Optional[float] = Field(default=None) + capacity: Optional[int] = Field(default=None) + description: Optional[str] = Field(default=None) + google_rating: Optional[float] = Field(default=None) + instagram_handle: Optional[str] = Field(default=None) + instagram_token: Optional[str] = Field(default=None) + google_map_link: Optional[str] = Field(default=None) + mobile_number: Optional[str] = Field(default=None) + email: Optional[str] = Field(default=None) + opening_time: Optional[str] = Field(default=None) + closing_time: Optional[str] = Field(default=None) + avg_expense_for_two: Optional[float] = Field(default=None) + qr_url: Optional[str] = Field(default=None) + + +# --------- Nightclub Models --------- +class Nightclub(VenueBase, table=True): + __tablename__ = "nightclub" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + + # Relationships + events: List["Event"] = Relationship(back_populates="nightclub") + club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") + menu: List["NightclubMenu"] = Relationship(back_populates="nightclub") + orders: List["NightclubOrder"] = Relationship(back_populates="nightclub") + pickup_locations: List["PickupLocation"] = Relationship(back_populates="nightclub") + group: List["Group"] = Relationship(back_populates="nightclubs") + + +# --------- Restaurant Models --------- +class Restaurant(VenueBase, table=True): + __tablename__ = "restaurant" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + foodcourt_id: Optional[int] = Field(default=None, foreign_key="foodcourt.id") + + # Relationships + foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") + menu: List["RestaurantMenu"] = Relationship(back_populates="restaurant") + orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") + + +# --------- QSR Models --------- +class QSR(VenueBase, table=True): + __tablename__ = "qsr" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + foodcourt_id: Optional[int] = Field(default=None, foreign_key="foodcourt.id") + + # Relationships + foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") + menu: List["QSRMenu"] = Relationship(back_populates="qsr") + orders: List["QSROrder"] = Relationship(back_populates="qsr") + + +# --------- Foodcourt Models --------- +class Foodcourt(VenueBase, table=True): + __tablename__ = "foodcourt" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + + # Relationships + qsrs: List["QSR"] = Relationship(back_populates="foodcourt") + +# --------- PickupLocation Models --------- +class PickupLocation(SQLModel, table=True): + __tablename__ = "pickup_location" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + venue_id: int = Field(foreign_key="venue.id", nullable=False) + name: str = Field(nullable=False) + description: Optional[str] = Field(default=None) + + # Relationships + orders: List["NightclubOrder"] = Relationship(back_populates="pickup_location") + + # Optionally, if you have a specific type of venue for PickupLocation + nightclub: Optional["Nightclub"] = Relationship(back_populates="pickup_locations") + +# ------------------ SCHEMAS ------------------ + +# --------- Nightclub Schemas --------- +class NightclubCreate(VenueBase): + pass + + +class NightclubRead(VenueBase): + id: int + + +class NightclubUpdate(VenueBase): + name: Optional[str] = None + address: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + capacity: Optional[int] = None + google_rating: Optional[float] = None + instagram_handle: Optional[str] = None + google_map_link: Optional[str] = None + mobile_number: Optional[str] = None + email: Optional[str] = None + opening_time: Optional[str] = None + closing_time: Optional[str] = None + avg_expense_for_two: Optional[float] = None + qr_url: Optional[str] = None + +class NightclubDelete(SQLModel): + id: int + +# --------- Restaurant Schemas --------- +class RestaurantCreate(VenueBase): + pass + + +class RestaurantRead(VenueBase): + id: int + + +class RestaurantUpdate(VenueBase): + name: Optional[str] = None + address: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + capacity: Optional[int] = None + google_rating: Optional[float] = None + instagram_handle: Optional[str] = None + google_map_link: Optional[str] = None + mobile_number: Optional[str] = None + email: Optional[str] = None + opening_time: Optional[str] = None + closing_time: Optional[str] = None + avg_expense_for_two: Optional[float] = None + qr_url: Optional[str] = None + +class RestaurantDelete(SQLModel): + id: int + + +# --------- QSR Schemas --------- +class QSRCreate(VenueBase): + pass + + +class QSRRead(VenueBase): + id: int + + +class QSRUpdate(VenueBase): + name: Optional[str] = None + address: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + capacity: Optional[int] = None + google_rating: Optional[float] = None + instagram_handle: Optional[str] = None + google_map_link: Optional[str] = None + mobile_number: Optional[str] = None + email: Optional[str] = None + opening_time: Optional[str] = None + closing_time: Optional[str] = None + avg_expense_for_two: Optional[float] = None + qr_url: Optional[str] = None + +class QSRDelete(SQLModel): + id: int + +# --------- Foodcourt Schemas --------- +class FoodCourtCreate(VenueBase): + pass + + +class FoodCourtRead(VenueBase): + id: int + + +class FoodCourtUpdate(VenueBase): + name: Optional[str] = None + address: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + capacity: Optional[int] = None + google_rating: Optional[float] = None + instagram_handle: Optional[str] = None + google_map_link: Optional[str] = None + mobile_number: Optional[str] = None + email: Optional[str] = None + opening_time: Optional[str] = None + closing_time: Optional[str] = None + avg_expense_for_two: Optional[float] = None + qr_url: Optional[str] = None + + +class FoodCourtDelete(SQLModel): + id: int + + +# --------- PickupLocation Schemas --------- +class PickupLocationCreate(SQLModel): + venue_id: int + name: str + description: Optional[str] = None + + +class PickupLocationRead(SQLModel): + id: int + venue_id: int + name: str + description: Optional[str] = None + + +class PickupLocationUpdate(SQLModel): + venue_id: Optional[int] = None + name: Optional[str] = None + description: Optional[str] = None + + +class PickupLocationDelete(SQLModel): + id: int \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock index f56ce480f0..277d1e2c1b 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1224,6 +1224,28 @@ files = [ {file = "psycopg_binary-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:921f0c7f39590763d64a619de84d1b142587acc70fd11cbb5ba8fa39786f3073"}, ] +[[package]] +name = "psycopg2" +version = "2.9.9" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg2-2.9.9-cp310-cp310-win32.whl", hash = "sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516"}, + {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, + {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, + {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, + {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, + {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, + {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, + {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, + {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, + {file = "psycopg2-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e"}, + {file = "psycopg2-2.9.9-cp39-cp39-win32.whl", hash = "sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59"}, + {file = "psycopg2-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913"}, + {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, +] + [[package]] name = "pydantic" version = "2.8.2" @@ -2081,4 +2103,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "7ec220bee66b5bc207f9a8b2f4ca9100da0213bb9d0a407b51cac3dc8201e97c" +content-hash = "24f7dbbb95269a10076da119e82d56137ea116600ce3c448296bef29fae8ef0e" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 671a864645..400f0760e6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,8 @@ passlib = {extras = ["bcrypt"], version = "^1.7.4"} tenacity = "^8.2.3" pydantic = ">2.0" emails = "^0.6" +psycopg2 = "^2.9.6" + gunicorn = "^22.0.0" jinja2 = "^3.1.4" diff --git a/development.md b/development.md index 857a4e0a38..60f814751a 100644 --- a/development.md +++ b/development.md @@ -152,7 +152,7 @@ Backend: http://localhost/api/ Automatic Interactive Docs (Swagger UI): http://localhost/docs -Automatic Alternative Docs (ReDoc): http://localhost/redoc +Automatic Alternative Docs (ReDoc): http://localhost/redoc Adminer: http://localhost:8080 diff --git a/docker-compose.yml b/docker-compose.yml index d614942cbd..09de10ddb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: - SMTP_USER=${SMTP_USER} - SMTP_PASSWORD=${SMTP_PASSWORD} - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} - - POSTGRES_SERVER=db + - POSTGRES_SERVER=${POSTGRES_SERVER} - POSTGRES_PORT=${POSTGRES_PORT} - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_USER=${POSTGRES_USER?Variable not set} diff --git a/img/dashboard-create.png b/img/dashboard-create.png deleted file mode 100644 index a394141f7bac86ee4fac8e55a4e8ca8e2d480b72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 79627 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ⅇ27Vk@4wfiIf0@ZRhe`yQH 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

pKDOcJ5e9)?1uJlYhoU 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 I;?lSO2H^O-)c^nh diff --git a/img/dashboard-dark.png b/img/dashboard-dark.png deleted file mode 100644 index 51040a157b19a0aa4b1d74e227bc5be685b067dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76203 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ÙMi)0rzk&>~)*<+0%?Wbm9$tlOC z4iqG83c?@uu+Y&jihCp8F1E;VPED742vheV`B1yv9%rq{&7}loW0cpc!tBx>Mya{y zvxBF6j%`ABXylA1(p*xA8sZO5=jG{Pf*Q=`w9O)v+fx*L(F?D=Lc=Cq+sci&`}vhK z$YF2MG-f502)ltR$^D+Uc61NrhT15L#T*KW29?*^?6o|fyg$Q@aU9;6yzk?{=Nwnz zV9ersUBfwuZ@M4cE4e@N1SL&W?y{!K-#=_f@XM=Fj3imF@vY_&l_iY(<}##Sv8wr9 z1lUguL1JRVdRDhk@{7ssl%m&QyHNaYBmwh+unbg(wNEOTL^L@gge#v!Pe%(=AZq3Z zQ?AM&x8n8A=2RP2GA^EV_`lhFD(NYPSc;Fc?il8jZG20r33mTi`l;!&IQt_9tTZUA zxaxFbGY7tQ$H-sQY(*j_oBS!@L5}79sBoq)fgzvG0t4>GL$RUQtiz9(2A`$Q`~Fpp zCCgzXg?_L!^-Q}uhret7pd%4xHr+MIsRM_82n-56#wxon_1%v-dRw|IiBb|PEE+eY z5qqV+C|WW2>z*8=fFU7M2fboexr{dEX?%Gk{B$K%BSX1Q8HZTP>;A+@WM?y>FY4u}#28K= zVmfx|QK6=PTk%@o!Rh7;87*;3E%mS*ujpT|_XbKyxh3vo)6E@f=P@fOH`@ufQr|sC zGaGHEYiyF({;?N=0N=?TAmh|YQZ60Dlyyj)adbk+QVJ)){3r4 z9ZV)5ZgL60fWW8=Y$@J)e2&t1J`%=iy>a9Mr!0R{G@Lgn3?tojW@vRQYn< zk~sFF&f|e-9mkpFGjXN&^DV(9+a)Vc;Jw9dCOc$#qU#@flbr_x5t}VlHmT zbo{~oNaho*_OMEf&ds;T_6=eTQp~QW^$T7izA0&NcW;%)_u?sEs$dG^c%&AjDcemL zpH$T+mQTu}tU=~81oH}#4YA*L+J#9Z_rZ1>RwY+|W2Ak|%#-_#7&L&ENe+_SUb9WE zyYaWo3cu^9VwM@CEQtXoXPko_qwY!SwEyAi&5sbuq8PGp{PMct9!A*4ijeM*lz@i) zYnx7C-n)oeqsN~pLjr;>cbv7V@i_?xlG8)JTsG3Z>@E$a#Qcxt>iS~wN-(_`pGT^` ziE{}JC9WAke9h<(s!Lern(1%N!R&A;P@KnDS)~IPy&^SN!Svh>PKrV?uq3{pNFfuF zgFltJ9GFqknxY1wti_)@KC2qeZw&BB1!!Qu zR^+dt%E+u7x{p3_7|`HY%MtqyS;2rtrKv~seAQXZC#1grjh&{yRjGKGwPUl)*#Y4+ z7U7@zD*AI-F6qvzypE~hQfY6adC7zr@a0D)bXyqpbf$$G9s-i8?A7wOtP7!}@aJ1t z+oRtUddz@R`<+G?k_u~1Ymd&sa2reFG&ZpCM`;V?^nZ>P6~63vFCCCXR+RO@cRC>~ zJ3Bq%O#&e~G%8L&?|!am6d{s5{wY&~X60r;NNCRp$-RmP(_k8N?FSt0NDLgN27qdv zvv5x9eF|mk)s;t82!>-wGK59*$Ja534PZ%}ymLup<~Bq==Jo!{Alx@FG|tgejv4)& zKMBT+6dIC&G>mfsXhyoj5O$rapW1SIQ;a&|Y=a-?<>wBiRE0xMUcb~Mxkivctz*BpZT#OIQo ziHzANRaU^^`0qB!00AYLU(`1=R2P$gjEpQ#TW{@&XXn4`5{VHh0wSNZAjLrwzMG?b zDIYNKnUm79v9-R~8w{@*eBR~~LA0Jef9X|`)*&E6*V?pIL?<<%*g*eQa zW9=)0rr*`r^FHR1cmM$>8g7R~tNyXdxp7`CeuGv~(t@J@ZEwM+{n_2z{?{Ffq!7mt zyJ-VKIJJdfT6FSjV*Uz@v>%*K~9_W={S z?J_ht2Q#`d$g5wtx!mwptlyeBB14fW{lPk76$Vg{MrncO&-k=m_PYs=@sxArHzD_y zD@OnI6qGjhI4dq|XNUA&Crl>7CWfd2RVQ&5s3b>pXZr6Spbi%QKmRB5} z9DR@3i?8sXv^3dxIFr&1S^%t#Lino~UUMYgyzU_vz;tU)Ld}gA_!Q4Ku`_5LiJa9m z_WRxyRCVEuaDO3-A!rP%e#~{i`>7W&Q8(P@^hNHrcMX^04Y2l4V^0q|Mv!6Nt`9KU zXMU`ff^)+DBFF+P<~dUHAoGQRiTg*)`zwgh=BQ+unEf{Z!A-6PmrtN=gi}0Lwo>bI zZF#VY;VVg`m4;|5eS=Fv;L|kk3B5yK7`)8X+;z%Cd^N(rC2DSET)2+-0@$mE2uBR} zAG9w3@&b4!RD43^xzW+^uCtXj%+?0r1CqjL!axCw7)N!^zte@wWptlXlaJ3_1IwZo zJ@w5z;%5II2fuKo*W`qF)b>BcgpvS{umx*hW7C(OwKgqS@Wugr0_YLi{3&i=6W%=C zY=MlwIyk4WspRx75`p=})p8=|Tb`QUOdkLttcxm7FI_tiem#{OFAEEGljJ+T2M~V6 zh1!Qu1ZGP+w%y9PXJ*;eApC1{-921G>nS31#@FrkhX6ylB}o{!E4M8f=lt!e4PEAe zxY4zehu7KMndkZbB>g#o4-_U({TF;jKj(n);|SA zs}vE_LC|L3kZe`zCY|c_-ZR|qT%tVy^6;y=YA1tyNYCv57<=o%UjXiHMxx=W|2s7fwP{n|Ut+SdR~d0|batZ#f9QdZ zdi$n#p(_nXCkWwMrsh4VcL0ZM7&b_*@6yvdLQJd@gl+a7M=8vPB?j*ldAN*}Ol=F=~wL`@ECx+2Gl@V^mIBf7G3K0?G}4AdvS|rtAIyWA0$Ym&mND)lP1itvT@v9 zO#gSm*&DXxaxVga|LVKJ3i4V7=ylwtmlcn_abP^xpn!pPw1@A3$ffh~)vl19*Nd;q zo#&b5;?C3o70C*cusWy&j(P?<(B98+^DrE5IFc$I0<90 zq1{KPx-)YLvA4eIBd44qjx4EZS}Cm8Dr^lv7}MuCa&wuT^KC)hU5*zgV~6~&`q=%G z@DyTd{b560z*uj$Q2FB$IWf0EfFVu;SrI?cp2YSwOz;nY9je=womvg>I5VY*YAW>P z?)*^gkmmCG>hgS&qeTpQ-MC?A3ZAx%ex*>BEM;MM_<2CfZ>-8VNGHzg5hA@2^9;zE z{buWnyByVVgX+69J&ykZVrl-(LOvN#h!LNN1O58Csnlw16o}C_i1EzuoJi;dHu$PF zAn@K`wx|>5v)FnFVs5uE-*9{01qf-~_X&oTxg zCX5Z7Qe5R~0;q&#-IzLSz9z~u0=qR7Z_8%==pF&dqZ4DuH}LPAFk?>uVR zTF-bs=okJaa?bG(9JKUp^6=o;%$}CKfrq_noCDqkzHa2-CC)7|=wU?YukOb_kCbKH zzCJv+ge`53VgQQ%rKrn6b3uir3P<~Dc2dHar(o3WOQKaga3eyTf$D$~@Z_@D4IEzT zC{9JCHDHwhxy?WQ8?A`;cfA%)Fm<&XV1OWAz)H#kbG?7!OSxeEkm*DfuAK@51$oOZ zV%D}0`dby#^OZfYLM6H2MP+AorUI$m`l@!4#Q%;@y*G@-ylHS~_Iv!yDJjjD0{a%R zI*qUA@dXJLp9DC(=ptd1j@h$KE zqr9x4_oMI&LMss-Yr08Yo=iQ$Qz+mMU%PiJc@V#wOscSacJi2Ib?JXsO-1#TFJC*S z&mgVosRN4IIw!43fX5qQUC$Fzj%U%w!{DDT;~3ztl=G{uWU8WyxXtXf`WUh8vw@WF zDqFnLqI)L(frY9_7d~yk@7~~>fqKF~^z7V6v3|>yo&M5x?>U{;QO1=7(sU>GuL4!J z{vcavZIsqd)-@nxK7!Ylk$G&$0tFw92ny5aLn`&hIpQZU8WtgRiPv6X{v*=`?v!)k z(ouWmXWT$-0@4EhBlbzXFx+T|Ond&64VsBH&2F(WC6Sc#yO==kTh8lBh?rn`Ktglx zA0;78EhaUl+|2{)T6W#rbZK&pPw2weXJbbae0(DQbrLKLqzdgwgOKw#1b3M_74@W` zKN{4+EU8qi(FNaxOY2!#*~}bgv{5GB&PquAf!RyvrlF+eKN2XU<=RPG(tb;muEv@Xv<-~uq;ANeX~>D(U9trf zCve&VRjCf)7tLh>%EF6?0Ko6QKN=oAJ?f<$UYu)*_YHwg0pfBS;i;F16I0K|u`%vt zeJ4e9sf)n7dG+bgymQ(e_qXU}YRGNF?2)NIrX>RUg#3WLXd*$_VZnQ!jI_&D4>Z29 z9I@hgn`Q-J+dTqgyv8MFJ#lxk%yXE>)e0#4WdH2Hy}Xk_=_fL()NVPS&nAsL$@Z<3 z1AObmj32uzrKTQGiGx*WFP2>Ds>KOok@N8h;$sG<)gW9&iD&)6HRi&@(P_F!ccm1aMy0;B{;us~QRhGXk-ur)ldy zV12$+TwmESfcj4Q?%xTMi1P%h`|PwiDbaCr29T2ozuhxLQ49mGvd3#NFoSn4$SR^B zB9eWvV>yb*vJY1w(1$bCla|ZA#%unbHv6`Xf+t3olD<70Y^e3GuDc!xC-UuEEQ6XkyjF^Uawrj976)Dy}I6$J1`Nd-sPS&{6eI?Otu& zrK9s;q@O(@C3nU?A*Wjb>YRWxV%XIRqN3NOPGQYoW9z`I}7-FO(tV1Qg+zS$<< z8KNmJ{i4%yESsf=gIj8$JXDtti^@xXJ@@(mnih{}9Jko=ly>^fvBub99N*Tkxh0 zi^`NfywG{@dr{1MlL|EXCT$+;{LEkBS2M_XF@*o$JAe2=6v-y{?Qis*0HFB*TkwJUNp7~4vaT$squNEH?f{HWf);Y*y z`%}gd(&N8xR$)+O`Jt~ghcZ$n3zU=c)ERgqcsJ`mLG1A~8i5Rk_n79-eD_Vr216RZ6ZB$$wvj7=!?X z4Ol$j;os0+#X7_R^@}GAPG*vdEP2*L`B>e)6|F#4j<rZ{#u?V9ue_=qKy9;58Jc zJg81SI4xhy5wia^`459y16mC~t3R#8rwtF4E786y=%U96?AIoM?Y2CBgg3qCaRr-5W{*klpsRM0Sw& zEe(=U<`B=nTjP487qudgwn}RC`^%+>Afflu_{47$7bnW#q!ND5s`aB$yz;bJetyXs zaO8h~D-4dP<$=P}aRWW>By`oq$$0%8@;%+xvh(^h-0Svq4WWMpOj~5DKNTpA-s&rv zL-A2X2?+-6auK(F;$ArOdc3=!)V1E1!O2uyecWIi zuGUmPSzVC$h3}JMvLf>-d~0WI!H)I!M_z}v8al9O7Sg?II|z;dZg;Aw)8%&8`B73B zgHY$tu1RsMytOzCsOcmD@a C+D!%k diff --git a/img/dashboard-user-settings.png b/img/dashboard-user-settings.png deleted file mode 100644 index 8da2e21df70422019e0f75cdd41f6623323ad4b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 62823 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�};kdJLo;Qr3nv$O0FHyb@l^}R`_nCJ7! z*y)^|jTwVfbC~R3OvApVjXBSzT~4T`95FIcg zemD5ii-v2pAFPcG_b*7HtPGo;W3iPZ=;s*4U~R#d1Y$G*1jpDJv+;oL?&68$rP8|A ztzZ!UF=|j50nfzvt|l+X26`1g7$7%K;Jxy|Q9AVm&IT)61ZVE*=Q&7UxhJQlRmOS1H-06mjVA((W14g+1dX*etwUB_WxXjm;}4R*4CaU- zv}k}x#Wt-NcU__x2|T1my9NlRW0<_=EXwbgZxjFR7>jt7J7y%XqQI~5b;Cc=Mk!|h zum!KAZnC6MG-{K#3Zh%?k-|#>G#^xtB>+@T^#}2JQwa7vUX@{cphM-cKA_5iqMX3T zG}ryos2w*D%eLJX`~>;R9=F?GXyC_qY=0^szSb6~>NcTfZMV$1B8M2CmJ`ZYHf;Gs zR~vd7cZg;*C%%%cD6DIdq!mw$_hv^N^b4=N$g*SNeOJh;Ik04|+N{5>_Ml+!PF7C! z)4h8s>8Pl{S~*#+?EP%{ZKFp=^y&YS_A0Km_l7bi2=|e*wdDkx8coaQA~KpSK>)h_ z&DVo{>aVBzT)+G-Lyj1n$?qPoGV=->KS!?wfRV3>PiPYb?JKZ&6Gnj-DE(dAP-W!E zr(i&l5&&9_U`r&_7GzpHG~lf62RxNs@$bI%<(vIyM0PHc3F70<;&dJ%rt zRf_u_o47d5Nd|C2l=R!FMpZyN>1oZ5_}4ObB?YmI4!P9ev|irk+3X-hw=d^TecUOq z`IOU=HCAN{xz$6M+4Kn*wzYz-j@v^SyUy%1bw|rHd&49_+mP~SeYi1kBDIP6>gWLM zrOAkq2lVIPPL*%b>oa0BL`xRFQ|7o1Y2C`)k>&ba0*5^k_HK5rDY!t!E;giUunK8l z4@=ZP1d9zEc(&h<6A3bDUXZw?Lv3}8ylbZ`~`dKT@NHFmUh=}^wz zRAjoRJKCR7f6sCJ?G+Gne71!*<_yVHFTV!`a1vAh0hL@5qw#pzv9OU*-lweCK3FpD z+UV?iGm-%E!u4=J`+yja-ae_Tt1FVFmZ9Hy$acK}gbPB^+e*B8g}sD60##K``F$Ay z5`1Gj-?ZVsXWtPstM*-7?#!PB$NDp|b! zX|CvVZTUs~dd{6ilTi&h#;I+|_ybA5huRv-gnvWXznZUW_qSdzt!3r?*8g_Z_sS)o zL%j2LKB5SzJ9s=!_JL3XTdL>QL>h)VJkS{#|Ix?ou0Pd z*zalr_BE1T-#?`5w)GPH$x2o3Nw4uYr_GMykE6!k6ensM=*6C_`u7IX@$}HwEF*qM jJ@`M4YWUB#Ow=Zqr9Nw)1bS;}`=E;f-!xwM_J@B1QKK@0 diff --git a/img/dashboard.png b/img/dashboard.png deleted file mode 100644 index 0f034d691b05ef361ced9ea8261c89ee431390c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70654 zcmb??XIN8R(=LkT5h)fx=_(-7LXnPuij;tK0zxPPQbOpxg9T7fO6a}U5Reigp{amK z?;xRfNC-#?y`0VS>id1?&-r!MWg^*o)tOndX6BytK~r6cih_}XjEsy5to%%yjO>yV z8QFz%|6Br|FurOI1pd3=`V_4D4^aI6dG#Ln&ETf+!cE7~%FWZv#gfe0!O`B5-_^p! z($c}z#?ft^yipdY#C=vt-o?_)4d&=@M;B&qNv7y>M^NI9+PuphVL@RL;7dwaLP|vN zj=X|y#3%11GO{~l;Ac;Cy;4^vJiTHelRq{NlfYKm;HT&Bp1WZfReY!A{EN@wyn4?x z^)Xo?$AD_RnVkvy=efrNc^Bev5FYX0^ zHq6r{7sOw`o3B|)wRoo3l5`%J z%xH#aE$7ASOt(YK&F5%YSa_QRlZ!!Q5J5GaeQ(M3j;?qZ>xRy)Kh>RRrOksZFr!0a zS@m{ezByZ5oXHy-10n!{u7saXG|sj@jUmRrguI^cwJKW zdd2&lDf42*m^>}(XmR)P^MP3E(s3a-L9Zf>J@ebb)F%iIo%_6R{tR~~sF=#>>C>ll zEH}qu(PV{hmlI!%60Utt4n`<}0`7 zEK-F5NPE0yhO7Uj<#2Hf2n=pDf%3>^%NPu zsgn~?P9w!!fd#qqcSeG5ihaMEqlbO24Ty9&j?=fcJ`m>ww|o%Vd>1=mMD2m4jgCeR z7x~ggC+Qc_1RY$VDBjWe!m!~@^`{3ax~SU55VN0-Lb1-a0t=~891kBTZ+g9gjGC=9 z{1&9!4%0T2pgMLsI`#DR~+==!)M2W9FR*#T9dF2onnKCxvIhp@G`<~k2NarHx zqRkzhcYj47S;rP1Z-`a}OJ->;YAD+j6zP}_vUrZ?rKYY@^t7~MhExUAhUVLLVN(sq zLlIg;dRw$*a{iHCxJSD9i_v5CD8B%MR0oFqdC%H;*Vv!y52BMYxJ;d+D>PL4y0E zbiDe6iNLPjQezkuYv4$!h$Zt~5;7bYP-0j8@>9J>EC~Di5hs^>)CgfPnY^ z<|sw#X=+AUbtYy}v*6&Z5iCA?GfEK>if_qN5n_GqD@lAgy>|y}l|%0C``KDn!WSjorbf z#8z87zWounR~C7>upd`GMm*~WKUR>i=y$+&3_p0juciZLk$!OhJ{Amzsmz^7=#`@^ zw!aUK@EDyA)W^ieq!hpgM%8Kl&>=!mQgRf#m@JemWP!zc5Q}N7y0kT&KHs>3;}&&K zDXl%Zw6HkO?ELZ4WeG_cgry-$xK6fBuwCz-L>jhu1iUcQl{%<~Jv=On)r#T`4pGhL z9=e(cVh1Ate`&ovb4z5A4U>aY#nzew}(*0X#uH zEIfQDMx#EH?bflbeJ#|Hp#b=Bfs+q52?x6$N3pM(gRyhfDYQ&uhMb(#$4eb$B)r^Y z{0M=6-E)QW)=K`y2yd2goy*oHX?AdAyX$D3_oF@9kc3z5;T989oO7(v;pF6uTNL2o z8BRtMIKW7~vY{T;_?59TxdBdg&>+F_LCJ#p_!TpvA z5xr`2r8<6oP$I5gNuYic8*pN2u>Y&_)x;$)8g=|(&~=%D8N?WJ_r_--lJ!l&|2tEBBCWlv|3n{lddFH>nQse>sfC=g?F zeK<4$4KNFb4%a_yzOa8BAjzE9-rc6<;9&ArukaU9>=VmF6-2vys@#M$*rN4gKXm=M zw6^{q5s2dW)Npd&ezfWdQlGDAb3f*CTK<}h4{YafUCi0?XSZx7yo?1oT4`ORngBxN z4x)+}eJ4om9UaK>KwwYIdI%v#pdMB=W_vm65bbH%X2lC=YE~^Zd(BQkK>2uJq>0zO0n%A~Fo)y?A2j$g6>FMh7 z<~dAhnHe<+Zex^K&nSd7J8I@>N-2(wms;38q%+5;BD!T=6J1=lw#ToQ{j^2nzrH4? zv-R@C9Uiz(D5kmpxK_Wjxs~j;drbE6gb4Y}wCF>OjIv9OrIEQ`p7H9H#&fr6 z%|_|cClbfePzMauBSlS1OUp&d7=5x;)SmTqL-Pw~(W5%aM;|ZzhnYcm4o=VEsD~j0 znIyyd>{*%N6LO8dFIQr!tarPuim9pkrpa2F%wxocJKwQ382AK566V-w0r|mlu4T*mqvWjV-7QN=~Q`2bs43`g~fAFf?$bQUZL%_?y#3Tg)?L;Zmu|`Xv@gRTEZ-))s zk0qsS&%N=M^4v>0SltqU`x!(0OQ=6YL}(L+g>avpb(;IH;KAskR@hUMclmgST(~ z*?zinY&scz^Op;je?adS4bJo~ezKGnCoK=@i%$xJ4U!H?_;3wT*SOY##KhZO$)Y;O zShv2(w@MkgZW?`^Ne@LlR^3HQzr&n@bh-uGt7C*qlD+yL?>}$EDz^T#K5!p964Zc=it7)=xLI(>Pr>6ZY|AZ!h=%JmJeo2FwnK<~13b)|3T2I8PN39V^KX^&XQ$<@@`Tg0W zwfe(|O@bKo^NpJ~ZAsXOWtl%PJ@+u#|TXqby>3K_>J_h(iL@Arja>wI*dX zoKo9MnoOiAkv|sbA71{co-P0HjwM4g!Fp8_B&8r%PgEKaZ zJdtwc<<7~XgyV|eR*qOjQ(qJZY-e>;1-z0E=tkJ~vXBy`Gb8i#g*cyiYD@=D+qz3p z;M2>-spD!PX3H&*#^vaiskRko)w_55v$mc-*U2~2`aGaR@MfkC6?4EDEx4MLY3#g( zhF%OCBlf*nzh(Pv_bMq=6*M>(DOQq!!;}R0e-n{wFXsToE z(9bq2bzA$<;r9;1au@gDC5o%zw8pG{y2n^sseAY38Xqetw5Y5Mqx)U&deoXjTYs9D z557=BlWL79sCl3(3p=r&g#3Tl*^gH#&OXO-PvO3k%)0=S;YV{OL5z^~LZp1XqR1_>Z*@-|@GramolVQ0# zdq$7~kBZu8jXK-oyV>Wu5M?a<(tlswNnv z7o{w%tiTAxNLJ$po~4z7jDCBJT8Rgxs(kqSUm5HdFDIZHHoQr{Gqc(saQC-lboFL5 zq;M77HFzo?SR3Pb+j95d?OWRNvV)*(5H<5py%A7aK8eyp>xwasRJlHVFdVuKypvwo z+TT92NjNxian;nJ@+o5WeSlSqrf}qdwRTUqNT|5ur(e^R(y)p=9`jr+sX9iEaZn#K zJUkird%~wfVnWQ!sOzu4_w`9q(&6?G)t;Yo&K*RT6+mZc=`A)af(Tzk7y7sjtL>6X zS&pGz>~RwXtPib9;z7pwt*pMQ?=3{UE~>9CPPD78+!-KhO*Kcj_|!}J4@={85(QLk z-W}z0H};rTTO@NN1%N{+Gc?LmJjLC7!2N-Z2APLkQ$=I^WYXJ$vs=FIrPGWoOB& z4^70dl~hCm``CBD8ea|l(MEKEuWZIqP*fCKgzh8@5poz!^&xCX(|Eoz!PYY$fc*0I zi0^t`eqc``N)$fQA)-bic$%XKg;bcaoF3VX0#$(;<-EK+!$z|hz~9!@jSV>P2BVL| z!omQjQmRU05McGbpR55&&zoj!r>g{3q(x^0!dP`5C<{FIM;+m^rfQ8^yLasVoIA-s zO4G*N*PEGAYAL~=8Y}YidNR_}v&t>X$~J2NAu{$f9_nE!{dP{>%j31VI8o2zbWKXr zD4Woj#Jlfb5fa?7Byd$A;Hu+u23ehxLMOBVT484NB2}6RV!N+3gIlUNuC!rOc*13X z4Wd2Uo8JXRTWk1ur(hLU-%_RKz*pJRB)78={ueO`e zV@*9X>37SejGjEnDs9}*`_#B0l@>VZG~v~B8xSx{OO{?hhqoKxOCT)hjkeUDvrb&Q zW##47$hMs|?XwM^AY0v3HuZc)}_7E#vJI^oEr~+C9`CvVW(m zkugqN5=fXK4H1m!MM_Wo4ql(>3lrbtG$59N{w(9Jlk@TLw>-^H;?8n#qlSI##^1=z z5d1dSpV;oQ57sn7ou>5>Y~(~vnfis`!eXCZ}!-_AwodNd8s#z-AinDj~a z$E6^0XWtEPzQb{V#X_Oi4|j`5!y*c=7UOnj?mKpDZie?Lhhzmd>^kzF6dmxl{H4g_ zxnhs2==pALz!6ezAkT~jB#AhgWN0C+lTRtC_7WHC`e#93%iFalZaO4XaudijY0xK+ zL_75AMa2gAQvRGbWW9nwL<@ADIkN>xT!IbQ>;D`+i+i4xYySYR6oU6R50BLht_Df{ zK0M~(>qP2g{4fe0pyl=ZPc5=(FCg4C2ZStATr;`h=QJ$Z7e@>Kg=`> zhb3R$Rfo67oyAJeijy5u>}z!HnxcjEtPLU!#jAO+*s*C!c6Xa}&LvMtPOo?I1qI3N<2k&sh2PEKk!{!^1oDU^{s zRXE>nM-v?u7SI0+lrnK2DO$LJB)@YOr0n3wwB~((<5y#xhxkY5522=C=yi}SUEAsn zBukFc+BgdZpOqjBBbJ1|+o30a4+OHu?BybV1CBt2!uMpC|E&ppWd>gfgAY{K!2doV z`)c;$^?zIbKVzz{x5%dV7B{?rBX42LdtuARGTF*C;Xm$8gewrsW2EH(ob#wC&5M^W zi+h5}BIJRfY;rIx*+n93;eqo{!Hnv9!j8%;rYP*K@tOM(Wlbl#XZZ$-X)_-O~vq(wEX4Dj`i{QIG1Pb8S)-jb&L6Z5^Z4*X0UT@(IoPZr@?(E7{Gap^sKL))MrrP>;EsTsOVk8gDQ<-Y6r=ScGxsm&! zjj(FNz)b&KdRFB@@lq6?$zb2)y)*7&#pG`;Z?1IxnS=GQ=*Y76~%zC+bW#Z>xJ3hpVG}QahKZH0Wou3 z-4}%!xBSaY=>wwYt|O)R@tSZc zkJSemQw7`gE4XrgNUi5ObuXpA$RbHe#OA~pyE{-q_Q@c{TgtXs#uKKA-%1uP6IwzU zK355J-&h-LPumw2mu;pmlYKh}`GVKalVmItJEPU(iFcu)u=%`%vv4NAal_NA^nN28 zTRV}zm#6&0W0`G>o9)}L-{3S2cztwNuV|O7Z4Oag7T3xjjE)$VTlJ>6B3i{WRV-H; zIp|P;)*=v?tojPY#6~G}wqxK4I9#B(>9g4R-L_7ZvO$bCbXsBv>z>+OyR01?5Fc6{%BLICFZk$e@{o^%f4ESYvNB3;H(-j#8(`7U{}UJ!Ei3RRzIPU~`Wz%h@}df+A* zxW6J#l{Cu5)5ujk~BI;YM{lCl0WJFsXQ z;BI;Y^?^~)E$Uu^{cyV!)r0{T3EaPkZY=JOu0iK?v-mcc4|n&q!U@+H|5c#2A~W$R zLr7~H{=GF{W8bbJ#LCE<^#Ge~?{zQ;;!-u=F1(Pu_vYTIQ^EM5N1J&iH%EEQa~+>R=&hRQ&xH>SJhmIRT2!?;$sRNPP?Xkwb`#fRb|5 zka?e|MJpk^M|N=W(>!+P7%gsc`+3^fq1MiAK>G{bt$$Kevnl~n0Rj&4y( zOa_N4Etj_K@_mNWHD1HcXm$SX+r%xV*oc=qdiiDf3m_7skN;>UKbWtUDlEJMTPTJkOYdH#qq`;U z=}q@c0o}iJy02~8eW;R`r%uHvkqJJZislsi#OH*ZAJ?L&^{Y@x2;_%@vsV4pb)r@h zrbKAX_ZEJA)>ZUFtYRAOwN}SfIGC22vV0TO=q1yyq8`rtdB;+!gEhlkL%SMYHoF>c zNa}HM<~V-`48VQ+-ZckiCxLLg!$Y!GE6nARhk zV-sXi{zBy7ASE_-Tw!3+8*;fqm|W5i^(8e= zWui`N!gpJ5B$gGIj;#SN);l#XsAx>(L|Utr(M~cuj3N~9QL!bi-Hp!I&D{=^7NW-_b;4^RZY?nj45t2D(lGJLF2nm^t{*8K^^4BZZLR)Y)_S_im{lyPri;#9$}yJdRXPiTIh_s!sO$GihsO4HmVT}&EvxG`4)-p%iXWJHWjSEoMSoig;q*EVN*{SHpOw4*BfJxEBa(t?_ zL`(({*8z1iP)Itbgk+WRErzP7^(Q zWyQJ#A&4M*<44d)^?+wv)I=t$^?V_X#>_T-YE94D*d#INZmMVFBHb_1?Y^31v4+Um zoZO=-Qu1zR8rFJP3{tU{ZbwFVk+_2nPAv9c);jbeU>oP=2h)U@oqIG=J4ndOnKQb5r@#?Q3Ov9&M1s zB2;+80F87wb@eAmhHLlWG>AwG3Z;HkOUvrEJV6nScRulE?3GnSeJ4_EN>$0Z$BEV4 z7%EWpZKJ^^g!2X^-A?8DGFZ2f)ir z-m<#q)@j}$^dLhAqB#5CT!0ek_2iNmjkU6G8aj2@YU0Fn1+IjkEyNN0js5pw|=Zm2$%0U({ z_$+7C5@@>;ck*}8L65U@b6*re#kd#VrQ?(P<;SWW5N0CX+^k@5+Jc)D+5*~jdZ!fw zH=S`_a+tW{6lA--I)rQ|B$#?b(EdK8HI}br1#yaXvc`_-DS4g+<3;-y!Pw1jhoZ!r zwWYT`8tH2dVa)_r(qfH4@_uKUkBC~BBcXrzJlKWX{u|WCO(Cm4)q})xn#=0DagjeG zFlmyv(5L#1d&Wv1j2CiE^chCZ2Q!(1HSurlhCHEi=VNng%%4ojM(NyNPN%yVreceH zQ#ECty`ZCn@7Xd!P{exdy%r^6i^64~yaVmk8m_sg#R{X;abN6gUHdhuq<5d^X`E^o zt!d^NIVE*R^b9AnHS{{@C*pEbOTH1Qsc7@Xh&V9ps)(a&H0=bmDTHe1b6wYgcb|Cs z4q7{-vU{**DWCValE3&!_U#mjMzudEs{qz)u_gOgwL@b6Z7$FpBgL$;6B%_D(8Xw zX+G^Q{ZEf+u>SoM?VWtSw_g6J^FMGg8Awy*2T#%ea<_e6$`@GwxJ9E!|5^VPmlLe! zuziQ+hmXS=^7GKOQ=6H-8nKClB>)mC$ zY78!Nd|erF%mh64-j6Ux9{Z7q$CM1$I9n6|rLMruEdwB$iZwN#Tv;`A-9EZhRN5N( zvxB8Ej&Xn`1^77LkJ58Cr#Ui<-i1K?gJxzGu3Fv>387@sTd84Bi_@_N=eQrfuQuH7 zwZwWF8uUsz8uRj-xaF+XZ(iXyd1Q!*eBYlP3hHl$#l8Ji)#55j(Ci{e;fqbz`!gFn z>M>u6+fr4akD!%{_A%S&>ipEwI1A9Q=AltnxSR`%MXEjB0$(rIdkW@^HzWA2TFxyN zl#Yba#r1sspaDk69O@G#zWn?VU857)@W%S&vkLc|hR&gwwynu6k}9HFV9zjIyCc3i zm#(EyDIvE>ajQKoEx&48n;Vd}TDM__QeD(kjgl|k6*QqdX0nj3Q<2IJ2u#R7;9YQ!Eh zRV2+dQOe5YHqtD;*8iGZ&GVAGJN?q7Hf15&q)L6xKpBm^9#E`k{-@{R3f=l#?c3Oe zNa7t;<;2vK1m5LxRoX>?xB+V$1GBG@EAbpM>9pB`@KJnM&mWdxi=Z z-O@k*c0zy9DAdU)IcDH-9}HkZ2l=^+Fu^QU?;Lrss*#rZMRbb>vJw-28KS#7y7~wL zGRVYV5^=P*xR)O5b)^N(#g0JT9~kPs(6!HaTv}CGK&iVMrQ2oX_d+6OCoSH30_A!+ zzYt8Ks6JFTt!zN$9fkw- zDd`BqGYyE!q4A$^0XNIi!Dj6ywK|q;YDjbKkmZt?n8AEV;=FqY<10usMEiN&y|qST;d5t4)SZW@@m)7N-%HhM- z(Ac$#OLgfDX7t8mi)4h|2>JM7FAu&SQ$JH2Tk8<%WgZo|v1yh${R3 z=18&VqD1^F%e6AsjN?PXWPzb*Fu#)5r0jNCvKYzSvVP$Kld8?y{)eol%wsAGvXta3QM zRKF3azkN(|m9)X-+K$nL+lIi3n;n6T^ci%;xr2$36KR3cy&BL@(6NG_ zgvA+_d({i4L3(szlJQPp1g|Qul}xr!6KF68(7f%=O>~?IsioI~pFvDm88Z;MpX&xU zi34`dLbeMkBkiUhJP)N!M-M9@E;i?MP4YME`czaXP>}jF9y#$Dd|Bo~8Ii`ij&pM( zfYh5GpHf7Y26~c9;(>s@+7}}A@zg-i%HkCap{1zIrax`BT`+gV)Yrgs%02YP%^PkT z3b&TOT|UWm?UAen3BREVUU=2+g?FKQewtzwE*o!uv_*m4^N?JuFZvD( zj7w)jy^Pcdp*)Ua;^Y48yYw-Psz3AR)FdP^V2izCEbx%wkr+t5?`CMM9IM@W;ZD5X zxe~I+t~j&>G9*>V$U4o}EZDT%9ydH*6MW^@r5KY)rH3b^c!w`5)W;k7W1_?~{kR$d zcFm>{u(C}eV5w`visS(^i>3OL;T(_~d3tPHs`#l!i^10e-h>q$sz!&tD^`k}G_18! z$Qiyc7{6cZ7=5tAbO&sRv07NiiPo>Kk{j0gpw;nHjo(R?AsPGym7DZt9q}NDiG#^8 zmm6JS=ACEU?4XfeZ%3Fj&Zu5u9po=)iOt8;b@472@~+pLP7GKPzDWin;MFFkf=>qW zpN}wUITAYDFH(TRv=<91(T_yclC*BYk7E0H5)+F3eAyI2l`lfNVhrSsMTh!4tJYYS z2eE^Ek~s%9nttyo`YGn!P$Qctbz|YRfKM8Sh8OL}+G}277xP+>z6pX0D7h;C6mbPF^-BTWoqnN-(6MrI= zzu=+8Csy}36aNK;g~dhKH_s{fwrI{g$%oVw8P1p&FMpKJA#a$<#+T6%%Q>F?Iq&K` zZ)co4UO4z-!Z8`lgN<*l`-GxnLg3+d%~E+oh}-WPvig0Tm9obD)9M2&`^W*2<)f>- z=-T;VEuYg$bj2Fq7^k)FFL1Kq_!l-VGPOB^fZPy6lYnkP_XLj-%~dL7zETHW@0cSi zMrpnAFvOv50T7Z!X%Qky-|Y@EUpnTsCCwGcE^Ga~i&Pxo*gYSZ;@EO9!YIlK9GK`w z)@WUk1NFW+^*2~y`)%TXxH2nel=bF~pZ$wQLlc3^(wPf33BODdJ~HQt*>_M|KU%1I zxZN@y8Cz549T{~x-$WDltNbml8O_~jg$e!XQ1(UWg`y8u_S8+C+!|WsX?pN{N!GuS zRm!?r@p(so-Ix-gx~PYc#qCJAU#kk$e?HReo6&2Yz+nmN-s`K-@Ykl9IIR1j2;PUu zt*p>hqP;d^u%5P1nTLzs{!&8ET<9M>^aL zS&Uncg=q&TY}lfQFn(*j|KY>3IN_0%`oYoUC44>zwTTXBHG@i8fkpyvDd1OQdIspo z-*A+va(a*AW}-MS{q%mN-VqN)_vW|U6uhG9K%o=^I&rNDLT3u4^<3jr8I4t7F7VN{ z7^7kmvjHr4HBE@Z=;){qkt9wxa$lWSucSX=s{z=PIzT4K`0}~#EKUiiY6))bbFpu( z#@YkqJ&#hoHhX{Fqn`!BTRR;Gt7r<|E(Gu3z1o&C%iwSAy9OKsNA?UF_NIjw9 z7{8@5(mE;eBp`5l^UYa`+D)1UhKB0l5~F-kWc(1ezOM#a7SvcS%ORpW=TM__JsP#W z#-tOMl5%sIyZ9Am%>8MP?55Wv9M=1-UqgOlr57%$(ycp~eV%j>+hC2^v0Q~1rBvgi z2Eo^_N8{?8(-!^q8Xm3TST9kFq|bvUM0{2y>|bCM$u$_VyO>5`lS>)O2*rq;kfNAo}G=-!<*swTbPv)Eev}o)-X8T}1-F?YgsCFP7|zdUTS| z4vvZW&zpMloIzd7`W*Y5kyDzVJ7Oy#@Aym-?DAN>?LywXeNN{~XM-na5cj{B7ShVFk%cKrQ=B^WAnj>`SV@2w%^ z?uP@P$#KT9h+0eS;8wuuiK7qLReOwAR$0py5?Al7Fr{x>FEhUMvi#CYtWN04@$O{r zq)hF3Z6(4l3Bc$bDbg@|(y-}L0iS5lI?r6RN}248nasx^_Z(@(* zwCTuq-xQt0$1f)iOO;QNaeA)ZN1|8%Sp&=_!%EvE;hlU@sPWfzPIr9v2GGOzBR_vq z3$y*?i;d1C*7^h6=ATGfAO#0y2sl5*v7=AS4+P{b9g9gV`6W< zlJq*QzaNmH0mKya6c75`*~_2A!SA_l&{T-WSSa1G; znUj+$z4Gok>hpIT-v5YD?~fQ|TTZ}*9{(#%bI-wiWgJJkJO>fIs3{`BF7I(Y&O(v0$p13Tv(m}>C0Q#|^IK$L*)jd37($xL~+a{;C zh%Ct6?o6G+p>AnB&}(SzHBMo#DwcSGIL|sM!rAosff%T?ri09h`OX>SM6tOgi9EYc zLPo}ne)HBoEUa{_<`$VITl^x3?CVAgBlF-m=fAJX2AOun$VBLX<*M;;`!C7_bO7SxGaAmM z-|LFF+Yo&Q0|r?n!QzisFAu&IeW09WX4YkVDQNDWbJRgr*I8I{qCN{I@$(1@alBoh zKa1Z_CtBXV_w9*sQhrhIXH5mARuNBbvWSQLzxjWYMO+RsdvNd3Q;!s5b?YKO*$e13=He<^OM! zD-_Ll0tNuEGiHpCFy+|pnEd)Sy+rW~N_qP5yU-|w>X%5qkiK!wzjKMu4uK8J*>9xe zGlR;;SxxW2&qrJVDt%vlazXO%LZpzML35jXy0hCCu&mRGH(77+`F=(N;`OI6E8yv- zB_>QHjV6B-UY58YJo=W_{OZq$;J+QrJx*JHQ2R(0xB?Raj6>PC#{Gal8z}Iu247a(eW!g!BZFPhSc2e@2r)L^hn6PoEwt*%I)@LfwWI9CgfB{QDHd9(>XkD z`DdiB&NCHdazjhC+9|I}qtmoScC&fU3&-bGQyrfevc-{4|FdWG=K@tweqZd&Cl3Dx zUPyxfG3@b=V*enW+F&U%+pkN1FF%PijNJ+t$eroBo3XAkM7-Jv`z-;?_s?KtWD%Fv z9S+caJ#~@@aj}x&+LQqM-rN6k-D)~9+5q3{FBdp6Zn@l$(Wqlplrwn@fXjbpD>PTW z5DuC45cOo>ajHEdC~3_x(TYet|C8Tk*!Kn*9Xf&%w)S^A(}|U+I~v@Tlck=geG@*m z+EX#^Pu)s5V5$KTCQyt@CC0vZH?hd#ej!x%dD`K{Az#J6Lj}>#0}Lljc(!L|`M6C- zQH;#u+*?TYk{5y3q}3wpp&B-5;a>&>S$r=U{6xa<_U*It28)OKx=mm6 zcRTrcAN2vq75G!83o$lRCPaQvv1=pu+YmgyRAzf)rwf;ukTaF>$l`X0PfPRJLlH|& z^)&t2Ren%^w!g0^UzU8@!5_pP=qV^?5)f|pHCH`z1S^Zgv)V8U5qwGyCs>1$HNO!A z)h!}+6K-rTf4zHfS4K^4(7BZDVLzUE`g1Vj--$@zQ9p5}>kUBDL2hA{f$oMEk(3g~ z0_n0wEcfp5qG9d0|CP`Y;REa<2!@)oT7j1!8*Be>)AzF;N6N zryahEld8Ha4rO6CTtUp24prB3)F7o4s}LM?-s5W8O#h?#B=AFAFGdp+P_k?dNc)T4 z2eY$C5zAgp()$Shzy@G6H3kt~QzO4PU=y}^%^9E`Y3>qx@j5AH$@vB;AvFr!QZ&?= zwkUS_{g;BpwU2GD+V+0@yF4=f$AI>r7%!FDmFBKS z{m9+c)_%pSHdd#ufjUq~gDQX#9&~gzPGisfohI{CczFf^fiO&oft2C!VgIAHnMr2~ zD8DXMid2IFltCcQAWY)RQ!6)2IxZ>&#hJu_$kmy*c$6tFUQ+66 z@2>Da7Z)RZ$j7P&fZYq92zZ?0;WPJfi`RI4LaOwsjq8{9FHT)JEU^hdJbdfv8Pz8OjI)KIGa!DqZW0>x`#{dY;8|W9%+Knzn%0J5m6lINTq1|-Y(_^}z0~TXH2Nu%LuO7j?SYy9Imf;V&BUj3 z4PlA6R6fgCTDJ18*oAPCs+CVLoPo3D;8kn9V$q6I2rSvko^g?0k|266sNGO0M8xyo zR2*QrPS3IaGva58cM&9y5CK$geZY@>6261tzbC3wrNw!NvjLquWw)iVI$7>VukZ$$*&+NT%q3CJ8VTyE|4UJR*<8yvb)V{)D*8&KSpK3VR(rPGOBvo< zzO8OSmG^ub7uSFrP-_!K^%2>OV>IFGh0$%?j6->$cJ%Q2ML*XqM!Mk{nnq)rFAmBp zI%PG6h>HxhRl?Fb2$5_HeNw~+B&5+l!kqY^U0)IQ!Gm@Y76|)Lp~AA6l8^Wf<>s!4 zLp0^M*^WJH34X`wT+&zuy`KT&`T5z#W$Fo4fZC@I|D|@aiB*w&B)@41aDq_(^t$Q0 zJCgG@ghy6{@6H^{S|XK`)%veOjCk45kE2zoU6wI4=Ki(>nCn@~aLxLMjQWCYL~>KM zyt?g#-juPQecIBMclRPyBTI~OKY5R;M11U*3pLR#a~==KIOyCja51tBR0`qZAk4Xf zkdP@1rle24Be0}AZNheFp>4ulsV8O#ne0?JQKG-FacGG#jB_UD;u?dj9{=5>M{>Tq ztPsnj-M}OMJ4{cg@7;MH_9oNoD|um(wxR?B?#X@L4pyRmPTWvu>`?5GNVkZLn4u0X zR!t@+CfIW3IcE%e@&^?j*2fnwo|C+Co|R0Y+GX$f8_ZEXJNufy2^>8_t5`nF~STU2z3(k&&#$R~^a3maL-O z@~;IJ3`1tBLdvt>h{C`nVAa`rroeB zE+%ziapFu*9P93WGe_1rwP9*xk9CHxpXgUDW}wJb6Gi;#xamZD_?3R+^M^XOZ1?TC zJJuGeF^EEYN}Jo8v`oW2ExU%L=z7Srt@+-mic&N2W>z!58?}j1@h1jMo)u30IVGKN zr*W~5ja+>9SKF}SO`ZwY$kk`w<-9M||dJUGVrg75t)EJmM|G6?kTMiJ_C)2ninGILZq9l0nqfvaG<0aL_t(nJ8Oa!; zLyD00AB>0~77ow%s)0`10dt9yxO|R1>#X2ail1voV)WF1KZ? z-lD(ou?3aF@5olt5W>bmoYQzMj{Y*qQ`yR2U>ZnS_W`{UTJyr7Eo*>_n^&OqNAZdN z7i6%4+WW1WDt+@@`R~-2Jk07u6BqBL$}ou`yp2aVw9CA7I8wsKeChsjt)E^>EvWKT zSN60`y?*`+SLkIi3dc>Ap3UEj52_cfL>WzAc%+jIl%OorMxP1-KQ11Qf3z}XTU-5J zAU^e>z<91W-gV(tVy!*-lx0B@%c-Nf&&1r+#qqDM=jQO~DIsdLS6ypd=kz}dBNN42x_M)#@A{w3@ zMT)M~3}FAW#(qh+WHRg456qk08F1&1{`&AmDi)Z>YRvrGN0_-oEY*kPO=Ax!AQ~Ey zekn!F0PgCynzkn^P7Xzue(|hqYRP9B&EsU2^Q14Ne!hm=qk|U6yhp`(KiF)JI86=~ zpFovtc;dm6`<1WKc+b6zRV@=UUZ09wE%SXeC3mf`)CpUgAz;cX{j^Pi-87$VChK(* zZZ$Rj#h?tR;g{3f8!-Jmn4Jj|_|-OunzilzmhKfrN(qTCy*;fNS7Hio1Oz^4Vm3_7 z$&QkVzW(f4%QeqJD{5YHhqbp?DAGH(L%(0yf@kw*2qkkVC9WC_u%~KxoW714x1n#W z{wO-u|5MyYSYT7ygvEWItxNEgU=w0))Zzj~^$j{W$1CldSJb~NijB}$FdSXk1e^QL z=2?j&7;4ksqx*Xgp!+L1f|PWU zcBcDx*`7IYg3#C4ceXbfz(0*2CW%WY26TA@)rO%5)uNjgBjx7V2%a|*lI5Li&S7NV zCOJVRB4y~i1w*~))skDnaD-P3otyQ67Ct(z>FW1*%&7?3yRL$wp*LF;KcfpWdX(SX zSF`ADGNXhSGg-NgXu9T7ebW@Q$f_3u3gj8DVVh=~}(5r|+nwqU@%T?HIdU~bJ z-mgFLt<*Dq6}vR=3e%GRoDe@X6br66?WA|s&nKEvF}(I4!PA@XFZFEv+&wjORo|i$ zKnwTKJ5RgG#*CK{^?Eg^spicZXFeImr%41xv`zV|ch}tL@1fstU({OL-6`{xS$TeU z%1Te4ZC|?PcQ+O0#`3I#SnF;L!^c%vKpu*=z6JyI1$~ zboXj9)TzG=<`?P^(Q(`4xSY0_DO#*@crf*h(N?wPxks3b3qg_4a~#YXKO5P4vboih2l0|otUARF z&$Ldg`k7sTrJD9&7c&mikCmY=Y*pxOF73y(DH>G~s1|{>ncGqLtDU?mVTOpKuhFGx zkGN0wGL1JGTyPm#vEQvQb0kzglu5Z=Y|_M9kWk+&NQ;+!Hk=O9VbX!}Ot+D+N{Ii@ z5Wo(T!M-v7#HTT_aWG4eaPQ&v5};uk=I)`zwH+V7yfvAaDs;=UkT9w|wh%+0S25FP zd>5a*>I+II?XbA&5IyGXs9TyFYae<5dN|i?g4kpCGoF50w)gZu#@;wbJ!P(*O0Hve zGE?YYRN5G9UXEO-cyQ!x6CxyW+Z8TYQm3yFP37F8Jit+gahi2%kVwVhf~2(EjGU+- zYLFl=7D6t0S|lXT9kLz~P{QlKIe^3AgXHghcn}adeby@Y#9L}Me^mK8#niM-p8(8v z9d2~{z?tm0(|8*i75Xvz@d9bwM`vFDnI-h)uhzgEOSQ^sX84AL97H2ptnxdu8?EdR zOFm}_+ymaN-@@cP_B8U$8SR0f3gND2)1AF*AJ=lf#0SNTGD5Jg?qMvj@eDxIg6|(YJ1D?$4(t;N?7`) zX5EwYp8msGy(n~|Lav7tv3*l4nUgUsIkwfl1oOT8K+Sd4YIXJci4xwN$qH!&-1Gxq zWV=IG%&uBtcaVy7_K5h3GBfRM#b~kKdnt7@TriQxH6D*}8b9a9qGduoyV7+bWn$!F)#q(_Uz3tl<)Q7ihUcaByVx09d&DPhN&KKgy5^;Y+ z%MrG>0*c`L&)7>6kV76TLf_HydI0+e*zW<+->)8Hl_q-;0^(IvS{+|UO0;O*3%bke znXN?VPkWmfT3!X4N2aSBz=D#e23n5oo2Btzyia7gq!V_4yqfDNC<+F!scSl%_;p3- zW^&pQjh}#@bq+{vXuKlw>f2dNN$n#=t2$&Ttf=;|-@0bZP*_xbyyX_3*)BkNmU*2t zGakX#6m3Q02s&vz>D%2W9m_lOs-z0yx1Ck3jH|qfnA0%D1>B6erLuxC`pT}}>vEw3 z?-PDl`F)&is|ruf^40%l{osj_uY!qRB>tH5(=dSU4QD=w{V&8cuBo}3VUe6kvtH{d zx$AcP@vay9i-mG*);S48I;$d}sniMS^0X)`gQ@UOBI;_C*ZO-yt-ht(Gm9268|h3h zd}~<_Ie?|>0fp}O-p%IoRusb|Q4-bHpU~QDyhGJ|gyUbk4ldS*inbCMs18-{gnhaa zs`Bexw46n;WbgML43&ed2cKN@-9IQ8TrWPu|8C{8i3ag)jTHvwl!7Y4*fToX$-26l zE3^r_!P=lz)88kqDr7Bz#Ml+Z6iThJeZC=7_W^9Nt4;lL+Z9s!-OCN`m+R4dTQ=J( zJCCyB77s8F$nBha%ttc1F_S$;PGVqcPku*hXLp6%$FlIt(p7lTW^6^~eO*P3naslu z4@NR*@dq-}EK7fqX>XD)lmo!3W-(J3nT`|^GFR3^GgA7Gg(rG)hinkL9 z!^xI*)n^wBAy&HMQFNMEnk>QQ$YX8|V09<+_CfPccr!e8FGj{x9ki{Q zzrtZ7CuVo<1o)w%WpH^n{MU%SB8geK#nOU_He%oUv{s znWToG|7-OD-K}jt^OcO}vi98?Ph~suYh=iZOz^&uvc9*m5I8h)<1J3lDf7AmtJdG*M&z1c;Xq{HbQoon7jD) z=^vX6S=zaIk5=+KdZqfgY<%oZy(OfMeXzDZ>Sxw#$t-v9&elaC{u-A}XvYb%JwmQ_ zdb73N;Qr>3g&-w?mHRnixR!`fy0ocGPa%J+Aubre7w#KM*iGebrsHg<*!nhR@Q@91 zk!7Cuc0Q09l0zs~;>)|z8AhVRj-X zgI&F3M0zb5T(jOl&99puuQ?TFf_VWbw!n72*dNT?p1+lI&nm`U&Qp2&xxv_|ez@mo zz@g#3(b5|sn{rN#>e9e1wpec)hN&Nd#>N>6ODd|N|ATry4NRFoBQq7_9hWRki4G7Vyc0HM)is;BFS8Z}YWm~B(aOB5^#iD`yBaFgVu`m(IliIYbkiJk zWtUZ1+B{rX{6TZua5^H-NGz#iv*W#U-w50!p*h&^RCVPffmbVLEX^XYu?Oaa+s6kw zhS(!#XJro#dejCY?WWZGd6T8kI)$HZKc@Rxpcm_j{o=u$JfK( zC(p6bJ6rT&-^SPzm&29ija=($qhNn2d2-g%AT4jcD=BA;K>|BBZ)JqlYCYw#ReSZw zQGLO+oYr?vp+UFx#_bJ39HXHLh-8^xAi^`} z^yFkEOXD-T8JvBI7TabD*R2cA>vHh+YTeI0-04ye7Jcy@Hn_f!x@li(PVo`hf@jBI z^+iZLx3^H5`$Qv+SA^4MndzGLOD@s2=dEg;pGZzbfUnICKVckLK-iB>?~ZXH7v{%1 zM<3@$wLsq_dXlPS?S8zT&gB?_jiFj}r;^kgp%ELsrYiQSySeFmFXtEA5Gxl#yUFbR zoNT4n)#u3`$(_kaLGHUM zdERAc&oVrea?LLQ-8g1;o?*BD?k?1PB--k1##<599s&n6^+TF-`E)bF;AG!4otq}P zNKVy+IpN2h$pASNzWk$Ao5Ss)UR{VQvnP=ik^adbW_cScBS!4O054aZ!3pimQHY^z zgyH1nNl3-iBE?HplP5()MuZb*4*fm<4V?-h% z9((P8&%a6t`M6)LMyaW&rEFQhwP=V&!^8YZ(sR4$tb6;Z|R>vb??T?y~AtM)<`M(lKnBqpeVao$Zk(YKRKEEyG{Wexc^gi zR3%!ncUqy6jbSHL#5Wd0cyGq|oqj-8-aXV}PSGZ}@b`rucO(`_+t-vZ#r9ZT_QTg2 z*80L`l00IRyZ*VpsrS4!mWC0jVw+*cW_#WEg!T{z0*#W^r2FX!r|w-pt?~RZ^UnN9 zgSUKt{AaobocqD`G{lov5FUBp6l~Y;PWy3ZGKhwFy9DMQY5B` zXLyM1unthdkOTq}*f*eZ6iYklqtc?$@NZ>By(4mPl-i0()h zqE(YxL&`R<{ldUW~D@lv4`dZ92(gUCOpSJE%=IWQm^?w>Eh$QiSk2w$XO4RY*bUp}S zI);_oaQ+4rTdvHsm^ZKoFKX|(86q=3 z-qm&&BEwvSjT&xV>Sm3s#LN780+-AqfF`J6wXU;Ez;EJGub_^UT@Ju>y7B|Fo1Vs+IZGg1dND0_4%y2zjkY*`?KEIHP2f` zkvd}WalIS2C&L+=9uIH)U0_BjOm5$ui%yy$>vlm8L z^5p!`rNn$vg?Os5|HcB{nc~7+F6tf8!M?v5Y=8b&<)P5(4zSi?W(u<0cE(xqnciS} zj-9K`Q8^;~6O|#zb^%7Z;TzIsg3*XcEHz)+)Orh9y{7U&y`v_4xnaRU<4I^gU;%-i zakYSKN9DK8q>eH0*^*yYqzvFom+IALG%jA&*L!PzAVEc!#dgExs#?!rmsE^2*1MzC zfXhcl;ntmt$hUFNBK=5eT8T1?d}`H^J^ZTD>KuqybY}>v2iG)RS$;8YxP^Sc2Xf_FX-OrLF8fWdyK`KzO z>s9n@O2o=`3R^ghakl$X^?l)-yNk2tG%o~dsD9DQQJ>mwwg);=l4Fn!|4z0{0BW8o zaf2yzm`*n5+b#-*r+|!r%+uj%Magq)v`Me0k#;Uvvg_lxQhMB8f0L+SHaM$RDb>d7 zgY@I3GTXHvB5)g!%^-XfyfY`zYbRF+yL*R zSD&E9*>I-D{cLclI{r{IGw43m(sH{mE?zGRFXYeat(!2{ES&M^G<_#Q$Z|T{l^&edHc1`njJVvvWKf_&KLrrrlr2<>vl!Q<`-cCJn|@MO)rYgt=~j>PQ(6`W=ua#?(+ zKXVfpKU@xvh{x|q(}vSl`>PG)$XA(gp7D6Rf@#x6JlivK*am$-3>hwN-+MgH5{Bn+ zy63ux3k{!!o-q{C>^$DU6E^nQK;JBOmP-}xGW&7AuGYj_LO)y1C`G6BXK*B0=F?G& z@7Ya}I#h{C=}=kzEc<7n+Odh^R@pLz0Ir@IVqC&c_jf^0rK{4Pi*7I;@%xz6A9t;b3uG|Oh|6%cpVKZ^>&>wejHDj!@W-rXRqsU z_S#RD4ZbG#U72%mGDx}rOt)pLf7L2gBk4(HEjPRttRYr~)+x1~h!M2c7yE#)x|pJ{ z-kw^N_u!uaZxOrhFZ*sk*T>CNRGVF_x^b80SC1x%{m%3m->AE^e&)SX4!q5^tRxHI z2Da42AJZ`u|E%0g1dDz8V|N5Hy`DB3C>&hQ$j7ms^6eFTPysb>$4$Dk!r_gbW?zeuPZKw_VoHH-%TueS;6XGfmI#gB1)D6VA z{jLZ&HlJL;%p5r<+-!m=2+Cc(VUKBI4aL99*7PdrDQseTGAeYx;}T(Xa< zD-}+RC(|2F{m1pagvr>{bvlzWLH^YIuTpD4Bs`W_4YP8_3WLybcHt}J*_r!WlA0V1 zyCvpQqOFM3tiZTlu5-ELJ6LM_&mCLqm$*6#2(f5qni8SrQl0<2Ubb**;}~Hay}4jb zR8gn3lF#{%gAcSG)DZ_oj8m#uE<5JZalQxS1&hY4Ck zgx*l)hYUUvN4}rxW>c!5jGu&r94mFnH|6XN#OQ7Y4?YAoB~3p4T&iQ4AL9~5+sl7u zric&k-JYRjF0_g~k$68fU3JY7*TcK>=KORXmo>Q4V&O}G89-8^l6GdMkp94-sc5HR z0P330$r}tOJBZ#uC^oj0H)pU35s%ACs=P*iN6$T)Yh9-13>ux z!f%2r=DQuxG@mY%)2?by)%8BI9g%on`C)F|`%qJFW;$+AtEVVCl*Os(Vl9&yu|2AF zfN)I4{VEFQkl%aHRe!HLFnRp!Hq2lq{uSm$>O+TD+!JZWy_T2UJ7XGA?u`52`Ls23 zcC%CoqN3eio}MtagmQ)ey2sJx-zk7!*>LS}gEA0eV5C0cj;ucKYb9QDR-Kl#?i=8D z4rZzFP7yX3ZyUYPxy-^Rk`4(gziWN4xkL2zk;DHj;_#7)RnQA zn_v4g;En|897Ul!!!vao=E;+?*<5u29P>Oe@`CU(ftA(OOky*m#eD*pJII2@x7s?G z^}A=hXX+{w^nu_Lh2CnKSOROw^BTv&24(!nKXzY1t^m^x@J)O9ALg!m@}?JJT2sIl zb*+S)_D)zz!-OA8r;K>2Cw^U6yRi!tI|0@uVyZZfK}tR}b$sigPTB}^1T?r~HI~9| z_%1pJvIg)oUEjM(L9j>`4iD7snoyivjo-TtNDjb}+or)1kM+LI;Y=j2y{{t1wY+ag zg>5S6s$veW&V6o^JTu4r~w@WpKw#=Dkr*%~Ko2i0wS z^|7K9U=`&qDNj5y=(^kKg>H(WU0o}wzb2wDtVeh(tm_D1UlP0?knvPYUmNzYU>Oq{ z_Gajch&@mC5QRblrz-UEeXZ?0NJ$O|uW}cI|bieq>@rCPyrB?0l@;|9c+8Ew}#sA5n8FqFp;H~;a2g;p2?UZ!T1^z?VDi? z@h$N>k7#b0u65$l%IT@hQACJv-gu12An9)Ei~aEhTLt+_YHL4!3_2S!z2T%z{kF+( z48i{r$ZG(Lvi!Zz!s#NKOnbLVgo4?xwjS{pqEWxF6Bpmw&nw?jbM+bQYPReD&Nni{ zl)kRfu=@4PUxn%ZHQ3mfz_UAFUNXJy&&9+^l+d}OU49W*Ox?VyKCTjUcMuC%_{y7! zgK=;IxH0d23;AND=)KAI__qDgIG+K2jsF=bzW`FX`fAZV^{A#M} zUFVM`KAM{8_yJ%<>5}2O#zD+2XkY zU;qEh|ND0S?+QODQ`|3B*CFU4hV!Tm7>~*OwsWFDDa@kz8!+RwgCmU%?%06X643UpXqjSVXlqf z5S&PvKV~?aVB2W^P~au7ClB{C6_2^I6he+Y@7ix{cq#J;Ijw&nev$t;Oz@Z%H&$Mg z7w23B{8=Ij4WM<1QUaMUvaXE?v$rNDk3@%U&pSB-tvV)|UXX?A9mO9#GwBO;+?a;bogVr_0Z)Gp zM$~=}jLn5#>_~G#a`*+;E>AoOrod>scCu13q67XE2_wmu`k2c3(0et#SpExnTFNNM z<+lu`X2CR#m0@WSK(3DO;jU33ecgq#wAoWeXFg}srA`0q=kN+X)`HU&9c(;7b0(hxSNm01i*&h$%z4-4 zs2u;u{MU7>rcHzTWv&$68?B@oI&x}&>b&~<{>c?9XUuizP;6t*mk0ixBk8{U)mQbh zo3z%Z}$a{yYU_59WWzevLL zKUHj0Q$YgC$nwgn3|Vb=(8s_fO)}HU+N}q)hObUtgS^S~%SYRumvjx4JtG8g=t8l! z?Lob|!cHLNxs>vm7!`s|(Rp5`(VG?kxO0dZe-*H42E26X!|AlaI1#vb6OaKHQUWJ% z{uKmP-u~N%I;cv(kr1t5IEyE1%pim@H_p-J3!VSN9doyh@J^hvMU_ZPm63%BR>e3ZUVN1N=>806D7;2D7Fb&ths zOUdgs{c^+E>LLB0<%!84cV)rrs5_p= z)J9KW15EL+wa<-kDg+e7Fj$rDCtgV08o+~&W<0=1Tfq85iEAahx`yJ+Y*?kA4Ip8g zjK4lPPpak$TY9~_4Kf1NmFMcrTKJT9M^wQ>E*pM zQul84qwYUXDJK1uTJ^}jcD$j92L{B&XgeVs>Xh`Pl0=qMTx-1R+>?E*_ATg6mA6s& zK;gU4p>*{7?_&vsV9k6i%fejvC!(qXyZ z@U#h_{-UajML6=V_!+4sDHNtY7fFx!`G(_4uQkTBN4N_+`tkjxM+fDGRrR<$w-n>F z`!GENYO`kYcQJL%sWq!}JlM@-&rusv6V`>}zbO}?f7DYfUYR{hjq;I|55q6mXtu$- zPY%=Q5r-$EfogsQ5iw8PbVm`w6C&Yr8P6(dRHVU;uD-s_9mLz#e+rbKN7J~|Xtw38 zF(SWVH#W;T&DGc@WE=4s9(ni7s?_RN31+0u+UR$V&U&BG@B8fXvpioi`udmAkuXvR zy*Aat+OI?3kgemLu_fbofv{)YRq1k?2N71nwbV7@;pIMswUpF+SZs z+m}woHnQ5=(T}Lg^)rsWgzW4@+YuimMxK0>knm*vP>%SJ_HT7f6qb&^BQp_WsPy2- z2GVB4Anw2}*7U$W6f`q>v9+xLn; zq2}%jmkfjB&E_tke_qRgX=o!Se46j-IoFaBB!)@hbh6yd1NnMG#r<2yiKw`4L)Pdn z^4e(l2SoLDN7QjT{!_({3_F(Z4OjVwPQ>F(yZ~%TzRvUC!o3$*#uI%^kL|n^0WFL{ zqe?WHO`Yn&`SMnMa`2Gtuu$)Tr;s|=Abu!T&BdX&npBOpZYYwVhFCg>9ks0V- zL^l^iuA~tkh$L_$cTo^x(YK=P` zUwC$-dV-Cx`1yAS3>4JP}_E zR5H+b6L@wq%-ouC&AbWI0sQ0qB6py_CfyL$M(;_#@mCws!MtPra?(+2B<0tmSTtU3 z+pfpUt#-5abGiG7Wu~h0SBLM!W+%redLNs9>+jBIj?*deasI1HaGaaXqN1Ww)-2~+ zytQ}|dcJW?Iq%33W$SB?4KI!oBCE+@abjOWEV#P)KKvrzMqTD-7^-b}(@jc+TrM;J z(vr5v7t;On$BhFI5pz+MsE!G+%*#LCGMZ;1i^0x^U;kx^688Gpfjb$NB0Lc#@oFMREErsm9+QS~xWd!zVQD&8Ay_@H7zP zuL{vx5WL2M2zs22_@Ic$@T6eWcKeqR#&)|f_KUzbF_AbNSsodwYMIy8p@@QqSs+`|t7?R0w+nYl$yL z1yCyKl>ItKI+%_L8D+{Y;<11m(9;;4xirE&eebZ{4K+M;CE!iWHF} zf7A|RWOiXVyO`ULS{l)&2#Y!u|EwztP9;A%IqGpkUlV7D?%U2SD2Ng9;zIPmrW&P< z9v=pjJ|`6_6d>ZS3#)Wqt*Ll+FEwU(;?{CI#3bx)_r;kn#^;*U=3XS8bN*~FPVa2e zkMZHRTqSf%l8&)4Y50J&ecgz9VkY*+=>GJbpq6mIJrcSXxj&W2)$7cCV?whu1foLpAK{TI?rEP>f}goPtI)*Ctov^~;YZRxT=uA^E2^k`1{)P4`9roI zzrF-EX`y9KLfRm)Rpp+haQUdZq{h+R$e8`2lSqKSjSgU#-@4jcK6&yJaV^=}?|SBg zsk$>uTIv445R0A5@IZ>Jx z!L-{yHml#k^i#4XtwT47i&vwp*|P}1R1W4_IXq?a4wxLgVE{0C5ba|x5n&`2w$szW zyolGHuXu74Gtwj@y+`!8>Ly>?;GeRL+%d5+**6umb3LQ#osRt};v+O)L1$n@%lS0_ zIYGA&ErP+p&Gtaog=>C}_C(x0jPYMP@{jP!&J8vmB?m~3-Jy%e)@7-qa2tSFyi|}! zsowL`n)ouFJLrwq4H!XKO$KiFJTBc0Bg3R#PYx+XMM;>siR{&+zQjBy3z;*nL340g0Gx?C(s{J_+ ziAJ-c+BYBKqW^uC=uBaFRK($d|N3ks10W%ux1*n6N~CnN-xz(0aB2NJJ0|MqF1asE zN{78M-4%I_oh-ljn%a~_bHi8(im#C}Jko+&0-Gs19Q&P>Lu||cgH%{?!M;9?;z~D= zA2E+ud^=H0Lr6wZ3L3AcknzyRgkqfGMH+SiAA$<7%t!p|?dpA3bf?vp;V_?e(1VD0?4U&qTo{-h5ra!l*;i{(z^_lyAr zcbHmiCAPZe19yvb0-szNGt=J<>sPhOkNl~r)U2%c4Vqhh z8`~1!{5@l8ukhLb(^0MHOax1rkt^MA+Nihue_fCh1>83JpacoF)WdMM1SZD+`q5nI z7eDHAPP^64uKz3aZ&rqbpE}@LQB1XETY_XeNOl`~Ar7x8L9nakvFm`n%)h#9%D%z9 z^68G-q9{#aj~LqLqB$B|07IHHId?}?;8d^Wcqje%ZYNZ~jFZJbN|@ zzGV&`Pb(I5xgMp5Sm`JjEZo%dOPdgkSKNieJ}&|Vq9YHXL#{Zx`(W_MPs`WvxyI>M zXTT%>K~lyuV05#w@^9JzxaWD!7`C6bXK3>FIFs4PwCjwqyqn#TC2EV~wD~q!TtJ9Y zif-dL8Kif0vIWfv=C>>MgEvDyYGZ2pIGd0FuQ%Fm_YZKl*=oGf{Wm6ATLq_YN6G1_ z*IT=97_=H_n3|p7J8Q(;;SsYk65H92Wv^2vCxZtI-9ZzAaThwr!9y`ddj*uAhF5?@__7EdlNi5E2)czFMtVQ8sNGPlDEbWgI)8A{kGC|ME11<}o6 z`4yx^3-PC~7iz_!Xm5YEVrXUmNEK_1WYDRs?b1lcm-E;;3g&29N)Y9sOB_(C7l^RZ7T3{``35CPury^sM? z68I{kY5hYC*~qoaz|JW6!4_8=<@6AQ4UJsqqO`sFrgqiO$!~?>(=R5};7mz}5_nz_ zLc!+{xf0y5b5pju?jprkbGUf=@lPe^KTJYFHyF(>lvNwB21W=#C@k+<7|_vehaej1 zhlfe6oWXCq(DTHkstm-TXI%}Et*?2e-IPpm7rqw#^l-5t*cCg8)AjM-rdn$VwE`vgakv>Nv&-$Q@vu#4sFj^K(Zsny_C9*<<}Er;oCP_zp* zf4|$v=^6{r=&@OkYA-K*{$JZ7OW(;pGlN2hj(Xg5#zNt2bDfRfcIfA*#^=h54MdhV zQhogj-}DTWMWnm(z#JKgT{CgCpTPXrR#8!-1eMG7 zR*bfG=p@?@sW$5ck|%OolUG0O%agWR#e_P}@bY_mV}0{>=mmO9v^uu;dYy1ThYqpQ z>l+ygvB!^vJ6BncDA4@({yN87zNZ{~p|F=(zx0kK8zdfYo|r^JeTY$+)lzSUp(muV z2A5+k|2Cz{M2%egESOGw8(7U@hVCmu23on3q3?dd8r*(=-rwSu|FwkYk5hvmZ<-F^ z2__ZiJp;Sq+`|+WnlsrqU8_FBzuQIBHJsiQTrk-qyDx8ZPgpH?a7J?U&!mIvSLiEB z4ZT$v*rzFsf5eV(>tue?a;R@GKY9DFYxsmZYoJ+(Y42dLEjL|RUyIbdW^CO9@TB_{ zw&06+NN!(UkI<}dZ9X)dnos{Pu>D7fdGMwE<38>G@E@8}1`Pj4k|j6g|IJC2!6(y` zvCZP*tEZ@rK*Zn=?_c6ey#Ijo?1Ra>_n4T>!E8mcCxkUqkUb!Di&^GQ6=K~QuXbl~ zBQw%VMXjVfHGzcxLHXrd{2>I~zE@bW9ijkaaS6xQ-lCM)l-*#}< zXUHF+**QT#qjdnXGNzpd>fo`eSzI8CPn5hgF`13UW!KEb(>e9=s>V&?txvn`TYg!s zOYU`ak?iTjlS%XAfj~N1Hui>>)8B0l*DP**Z}e!7TPE=TA_};r{;uD+Om23VTx$%3 z7_V8GfR1#$`M-FC2j71y%1=QEacnwC>|+{UjVvk-?XuAwZStAlK0eoTb>f6Q#EBQ{ zyj1ueh-YDnyp1Zo6g|9Ew^~_t>zlM=v^qM>pm729O-Xl4B@wOx+)T|;J(E+T|MQ-b1LA$$B%d_yH=Nb z$f~bsrzfPj#A)IF>_#eT4QT0_>F{_@GY+uyZq%p{QpmsKS+_t5xY-qABp5$pui+nfT zc2!8uGOT|dgz5Dpg%+%}ur>`&B>Z^CQOT3xq<=7XvAofHp??kEsM@1rsE!A5`K*+u z63_KeP`O^;Zq!)zn7xmi0HlSE+1?l@d<$?EM7g;#iP4iHD|TpnD(Ul5!YqAAswefn zuGD%HWhL^wQ%(;!(EwZVI*5^Y>Py`dT+^j(Qm)?JQD{1Gdlj~F#lg;*_E=}%xI42MllDv$Lc!WNV+2_D?J0@8Ho(1gBeH&6 zY*gM24=nwUbgAeUQvb2BO#-!r{C0~ZWkd@zQGq&5bP0Kft35fE3Fo^hdAWzHj)v~t z$J#-S?uJH{jqkjNMzwp5JTaCD_+z+xPJs4}to^I8M5!5iN^74`V5|zXLoYiox?F33 z69Aj?F~h@iW7QeC93!A#Xq#6f`NnM@$5O}MS5}Jj<~2v5%n-(yq-4E8|KjP(;uw_! z0ydsIg>H}jYT&h*2&{q5akF7LQ|TE*q}A8)6(UGq|8!-mPn=WW6s!HnCTemv)7IAe zXGO}VJ|Gw3G-Z)2y~Ac%7WVm_HMRS2zTc4LT#SZ}5^yP>ve7m;q`j-El-L6uy8+zA zxpzZ`5)Q}*3Bfzv>R8{M9Zj?>uw4-7+Oe$n#D^t2Ufe%n{YRcLxqzXnK)e^6F{jb*j^_Iwh#+ylIa)MJ+RhDm#W+*|6Wz@}rR4V0QV znFj(78BjatUlcM?Pwm~t=6~X@52UylKJOSR?4N)FnTwv>fRCmHMNGaX`7@VKTs~ zr2b853AX>$mD{}QR{c*cb+Vbn5B(k2JO5dNO~bWGfU&j?rhFMo)b>Nl9x_V<} z`#{e1aV-@jy&QLn3_E#OM6cYMC^y3s=xjFktHOX>z|h&rR<3eeh}e25;}a&Ter;`U zD2MFK`^yjY9*Q=e5)~%y%XZt(ich{9|X1mW3o^L_20G&`c>95drD+%{$)IB1@jr zQG1zZg2?Q9i_dR06YU#uWl>|#?l^AZZteM0D=-mNL7;OWFeEq(dsEws2sJ74vHy1_ zUUI$9TizB-BXx>?b_d< zGBW-ug%Adi#-c>z-X&am)d(jGa>R7jQ`;jvxPw}J*8U4u$MXA->@xQ3nQe{B*P8=b z-HJ?+6p#WrhNq*F^3Z>1Gy@8yi>|EJ$^c%i-06w46}_vI$ve*~EG5x-oGLl=1+=@{ zEzDNU4x{%5KU^7z=^ZqS8Fuzi$2ACjbT8Q_O1>d=rxT8-vLw59ey>Kx)HN^it#8iV zV5abGPLr^td?1g!hHvOFu8Ye2n!(EHxIyjFu+p3xo1-)P(jyX6shJUQ+!_b|pL>2c z;v$k;`A?5!LJ5=Ra!mxaHBi#X9Gn_UOAYuBB72dssac4y61Sia5EWTjt-yeumDc%3 zstcKadBoO>8AHXwJ~t;L5Xh*p!1Fa)iz9-3t^(A|I!em&4$u5IpnbJ1JzB?Gga5*p z2y#3VPJj{UeE6eh^2p2aSM_@FJ}zYEc*C!b6mjWUTx@ct*y7~kC4>jbC1kD7=IEWE z_F%aM=`-@9_1&+s_3};)6?dizsj8}tX*T7l`N}6I=h#nDPjRv?+*KZ{(YV8!3sYbc zJ1Z&d8lyv&hT^$=yp;%)aLKzxcoywh03O9`<=q73r6d8l(KfgnuOl^btHx}6XqEhM zCt+J&k&Eom##Mhr5+7*>`6lghotM+;zPpIE!}tyu!AlsduS4Y z6q+@W%cQ$~-?&SNqLsUQR)n5gStD=1-8x(um0Egnb{f78v;>* zlBYzL%HqWkKGRf3>#vVC z4up2&y);+66bQ4&9$?AWV9gv zr5?bBT6ZmW(x#x^vv#sM$6VIulv6d)|73p7&9o4@7lk-0E__F;fXd68nf*ydbvTBX z_EgscucX!RdMl!nbu>T-@q?dc}0nbJqy+Tjk(auf#Gop+*pY_XF~UDY90GInNouLb)(dD zUIZ57PT{s2fJ2$iU&wUz6OZfSEEDr4{o=Vi5P0CuE2;;u!Pd`+pNxMVeP7|lDXNG& zq=cNWW?GD`JD^)Gw|d}tj+0|J)Vx00L~E_it{q}-llOv^!apXunMj}|v^ShDGw#02^D;vzVtxJ@@p|_yF_B zXM2F8Rs%%W$DgDAneI9+w?WIpRwKsXg;Q=N8OE;e44?G7Qx&_HM+l=UKMuf=@B);kyomrWl+oko$FJ4xL#L4Co2~_>W zp;R5)XtK$UWz^XNj?khG;#`k&vUSL-zHH0T5+mcYZ>qk`?dc;So|WAeOyIDUd!-P} z%RizNkzSO0R5LI)7jyXA$tB#Wr^LPk^hQ)T?0(Xw5n+2Fb+FK^xmhU^k)W|@pNd6| z$dQoB2EC`^QKlsV8xwpEk{Oy$!fq2n7?dB+y5>4TyHlDTsyU|_q>rlQc8^27{VlUd z4^IUrA?rS%;%&cBR;l!LB#}t~8CA!VW=32heO7-sTawk)2+UDqMfu+F-J8Gkll0nm zYv0r1ExwOOV>x945SHt`k8d5mlFOx!^e&j!9&;zzH#qlJv!!2OzXE&CQB*V-+I86Q zZE8n-n9+^tkTweT22=$I*>g2?4s~2bT1#M+Ov~6oVOwkUPKu8|fa+SE{lTeH1255D z*~-(3v|Vhu+pGt*@N?lb3-5?zoVO{8%lqoiGK}kV##X$Iwmtr6)?p^Zj1_x!AlZKF zD$+OJ+PS$h?(;~%Ys2{@m64hBo@XpjQgYK=>+@BL@`mU=uVAE5Ph1uYX6T7tLT8M@ zlhs6pz#qLWFC*XGoJuy3fgrL~m#{vKea`Wf!WLMV=b#t+SgzP9e;K)z+;~=YEU8UL=51Ab zA317tSl4LsU@A(cTgsj?T(9^mMLE;SRWjU?PpG+}Y&JG5hvT+!!*NuKsyhZaZrH9a z0{yWgQ}deZVm~)B;B||dD9h!fvQ{gF$z9|N@TMtKE)b<~x=6v;8=<`CBb{Gcq4$?{ z*!U`JOzS!nsj=OJGTCTtGus%KnOV#@O*B}K3FBYW_C%?I4B7$0X9pYOSj+Ytr~|rl zb7_ex8i$`g83o5;_egS$&VI?Li>{?+!Dyxqz31)Fm$1O;K@kinBmK(Gy?}oeEAQnY zkpper{dVzHT;6#RitDGSI`AaxQ-@x~+R@3ky2$#yXWHF%aoq0Ij&tmParf3iaV=fn z=l}tdV8I=NyUXAt!CeQ}V8LAmCj__P79hAwaCdjt!QI{M&UxPVJm=i+?^|`NzOI_8 zp4od(uho04)vH(kdi82A#ig{=iz(d#_9(K4IXT+H+|2&i(wHpGed-%EiDe1VW!EJa z=6d1*GH*jv|K5xTgUnHnhG{KT=w6J&m%56wZ!Fe~rDv{Arkfpbo+)XOu@fny+%C=7 z1U>@}$+>A9kC!JTv`@^yOZ;}TM7;;$h+ah%0LdY+buZ{|yy7La)KGgyP}iny zixcAEh>x7oFdCBhoNha#u@XGbn75zZ(FwLh6vreXDPCw<-^2BRiV(U`$y30^^Lh&x zAsvaQMPuI3oJ4f-i00@}(X#PTfvSbjlX~X9r{bnP$_IMivMi_WyQDosc!Bf`P~*B3npe?BTk?#>+VRKyO_i6ps0?u z*5%jo6eWt;9h~x-WOD3Fl1vIl1vOAF2|cvSHu>#znDxlm}$1a zzhoy}mLIar2W?&&65@5yu10u1jVM=k!OAmLaKy)bv3zQ*+y3_M&biK1O2vGd(d+GT z`e5HaFVXdqOfTu^4}``Fs{3hr1zuDa=z-mPUzycoiik{w?%6pGnfTa9Rny9Z-Yn$& zOwsKA7{+)};#pr~NjDHtw3v}1&z&CbSpIu{5Jfm2BUcF%J-Uk4Y@7-$LTH6 z@TTh>;B5EL`*wo--p@FMh*nH~*?o7IDd0472#yBAYM1(0-~k_%1G?r-qd$=3kLga9 zY7FjHg)xl5;g{)t4>!+>rG>G!P$ISb#7VOGh}j@|yTcO%{j`*_{mL7qEeprl%L_ao z5_lcrZ(bb)g1sG!e1uUy?VYL-P#w>{wGiFU;JBfXe(4?@3x1_eBMjn#i2rZTPtD7v z2`)TFNie$tyCETlnSYRxS|G&H3BznWfk6**9x)Ru5eN(MMFhEWMXpAh{(YW@fZq(x|+g=hKE~zIAk~J~0j@~%}|9o90-CBC&C}XfP&l*sP z^bg>eqjjTDCvXG?3i2dgS}w>USDxMZ z3_t97u@DQBc`cnyO-^q1_49Zz8}cQ4h3&S0X`U7a-&fYpHJ6LkwN)@Te_j>o6m6Ni zje_Wy5s$3Dp~PhvY18ny?;}L?i<$Xpw`qfOS4-`xgO7$eafanS-e0oVp?Ui8>x?x! zgH7qpgMQafz`3AIaiwJ!Nz(&{4_08HNwmevxXpxFtnDfhQHgg9uz6x<%XX( z_QGR%kCqw|v+JkdIHMjL916APT906U0O9`8e-XjMu1~qe@V4PWlLwJNZHk7w#HCw_ znOxi8Wzwyoa;*6@kq7)5uLs?^jPxHAqVNo{Y)(bz&JpqBEouXLbd<*phee zZ@#~3OAFYIEVs^2()w1}QqY0dTttXm1LqB3RF9sLUE3CNI4z(<0|Ek(mq^GSyc3h5 z?v6l$XL5Iil)pU(qJ*$Q^RQ#TM)VhNU#$Dsv6klCoj7V=BTQ~M%U_&k~V4t zT!_Djm_D*(G_R6Pm*$NgS>MMQMaXC$B8@|>iX6wNjHB!KM(rwEt_Z3Ie{co!_Ch8n zweTwn;^T4`VfGlCihl$D=hj(9>4pBRVY0ub2_2Eynon_BZ(K6pIP*Gl)TJU|UQapU zFlDWL`s5G|x+T)ELc>bQpV-CMXU&M>TBsHHX z5D)v*hjrh!6sr3>E^@O1+AbK`&&bHf3AlkJral(*3p|*F#8RTm?{>8ZVortlWe94d zFMOV(?;fTUYYuxV>~ICqnaCY#%yB!?o@3oE`f@#AEGb#U7yXw$^^kg z(DVrQT&szQ4*hpDind@-97qxMa1d|4#8!s08(l3qB$&cFdL_y`f= z*Y>nR=|Ftdv zaoc&9A2<-~0H=Q8@8#EzrzbJPD(7sBg_%X~=T7}OGGCa7pK7@ywEXvCkjPqe?mYD+ zzC1JiF4@nEG<_Z3T|h~64n9`}NR%;x9yOjpU%JjYabS4FxRBSzdo$j$^ndgnR~eo9 z%Up@1N`7ga*0wqn3C7Kt$yKtfgu_14h;EO32!lOtP=X;sk1qZR-%4Ra?zBy$<`XaJ zGEeKtRPwIn)LhU0J`l`Msc~gZH{Y;^>&&3AiY}+UtN=wsGIdT&d40F(|lO5VxWS}_dDUTo_Z?*bS0BJMx&_(DUM2L6ArEzlq|%3m7#Pnqnb{r~to9@N4I5Mgi6yh6hlYsf5rLe?WN#&oE=w!pNQiV)g7MdV_yCV-1hgl zLa(M=ioc9T`osSGr>7mHbSj~+!_B_GI8z-7(ZPaJN)Yg`mw}tMuFR#XS-2LVgAZ-= zPsLyK^Uxqh#p#|f|HHA)%&()8(ZZ)(q%~CrUkdfW!2=7%I}dof(d&rM|B%UCE|d7B zRLeYnYw-76<-Z=cN@Vo)-$99H2+$k)7kSaTjp#gyMP$)ovgIcK^jgC_^YYtYj&T}4 z`+hhNC0E$+hQNbq68cV8dS51ueE!@xct2fpKsL?n<|21yLEVT_86T4M>|RFHaL*hz zXu{`}Jzt#vX+5|fyIc&xZUxeufH=#XIj$<5Sr`Yct&faY!rZy5qbkYlUOcBC@2-9V zldO^eUS|_>wsvED{2sj2Od}y1t3i)^3wlL3|ApQ|5!AK?J@9tk{0_lU4wLt*de3*{ z4z&8BsM|gs*PGeDoc_Af;AOibui&G%csB&jucRLyqv{#<&-ZYfd{JZ;egS`WT}QTh zd-K-7j&Mk3=J&tIeL}&9$}_=|mcs{MCEX`^M#7n%sNG5R%9^c@y2p!l8Ll5lZI}83 zg>TRyv)e@4Je{4okM?^!Kl@R|5wuPI*r}u6-tz-#LCW=hiR_hk=VJ~>)_km;K!8!p zbpU@W6?s0&DdN(lb+32=X>spLZ$w|`118vwi^Y0*^nu;Z@nSc(!VW=0DfWx?`K_^Y zgMYvrPkIT(a58o>p(G$;0>L+Y1CAzP=2@ri zq)r$O=C8%k6BMGw!A=ZI1P?a@PDjHZsT50>jte@v<+5OAmQp#Y9(vyexRbS|3INfC z%6;vTF}e|hBkk(QBma79fk%If`A3Soh&dnK{=)#~W@5qgl0Bg}PstsJ3vy$rj#-hv zC?HrjXq8`23~PgUg>B?bZ>R{d|Bdiqk_$sx`@z>Vp6_KwBB$M(*k-*wv|4*1X@TLj z=t@1k5CDCV4QAJ0VVA12TV@)@sH%8{6OW;;zg^M&@@iMAZ>0_n4k(>grP7suh=I6) zp#q40<5FLpVM9aC?J54$0Uy`jeqa_R2#GrOI@D2gJrtyn1m zF+i-3Lk`wP4b&AP)#73+%=jq;J=v1DXke8`FLaw|&8Jeqg3I;=ZABNZD|#({wzml! z^IaGWj*bO30v5eLDpUS3hr#{1<6nEdpJ-^(f@YbwQo@l#OndK4fgc-ogx_pApfOvR{mPv!cxa0BK^Ys9;R+qUQx4q zNBGd%$4dBch-LodUnsNSUut3aejGlos4Z!HXRi;)U9$!g#>v_7*iU0DVRxqLsA!K@ zehU04M%~Fa_U}S4P(9hiZ zboDq|HSPT)py_>kAt6u$sN30^4U9h8TzdLiaj|_pzVai^)ZXU;_C%u&KURXYw>GfS zVjDWS{cnj5E1o&ujG#{CjqsNk9M#)HjwInovv2WuqVe=R{uJ2gp7sZl^_^It+J&yk zi1gcqYqU(qZ0}uSt!rgf<0rxY{t50a!`%w=DEEoncm zP6z)=>%TkCp#J1>47?q(X`)Rd)Hhij;sHSiMjrN$I&v`M1lO|4uiYUdeedDDy9+9Ysc*G?W0S_OyF(J9qmk(O zMxNNegUHfS26-p36p7r-m=#zIj-Yp&LdK?SyZCiD-Ugdy_)$k&4<~6jxnj5wcL&DWcwK6rux0Ef#Mh+*2A;>aF zaNM&A&|6Y2cXW~yD<6j5un(rF)W?MndlEgv+7-9m^&=$Y_@}0FY5T~Vy!PxD20zL1 z{)jTU82a<(mx5J`F+&vz$!NqFjp2b{D4iq5d{RSXspZb){iTTA>N#EA>fMJ78+P3@ z?=UC^%J}Q^(q{d0L=B#;dpO#z_lUwfj$}460yVGpBqbF|UYCYPT5_fkbC7^A$ra^q zM-pMrx@!o-{0$7-go>vA?FXg4ZzMj?=7H`y<2IrF;62DfyrqbTqpk*P8h?`>6y>F3 z$Mlsw19S48tAzVzwXae_HG+-*9{PBH89muKQ45-c{7rIG-v2|T_N7vh9J{h^M( z=9cLW1)Acj&WLa9?yi#ngV0D(_!^#4K7MKJNXqBXXzLL*4nDtf*4Z!mq+c#}KxDYk znM?HEC$#YMCEn#VT1m#5#2N1|3aZq)80~$qgI_kOi1@wkU0G;9F9+W6`NaE!|uE%tsn zzxmd%NEtbVGc2U!UIow5$mhvI<>xYw>3byCX~G6&(5DTC&5-x+JEq!%*`mhs3 zr}fbKs=P<5x_<=yr8d*cQ=0lZ?H522@*>yc+A*5SrW^A<1iI;>S#~;sAJH5f8ZS2@ z5Ii;QzNA9N)wzn&>#6k3*x9IyRbb$nJj_tOpwd*HkIz@??${cu_vHC#& z3Ol%*^FlyF`rDOxQsGsSyK9osBz25|(xqixM|^y&&i;k#S~~fS!ZQ-lmP#B<-$LJR zC>43k*4n43c=y9Pl?RanAzqq8or#-o(V#rMQj52?hQb9{-e38xUtOSeRNYFFBv-{Z z=DCiAcX1r)GwR&H+=@%AEkISg)xNo&HEJr7f?j7RA9KSZvfTE~I@{RAK8u*ybL#GB zMrV?{wE&lyWy`HBrsicHD|O=y*Tf^WxR6jUCs)Up6&Da^I*KBO;`d^LRMTbMTt=O~qY|Ax(w%*_RfOkhXT)Y`&Lx46M& zx*6>zG<36Y0iWiuRBI^AkSsj8Ar!j(y>rY}z%eX!N~nS%1lSivDNsO-4WV<+IOh8i z(v4_QR!BFs@%9#FJvyh&!Bb2t5)uPTZQ|K+wvCD3>Pa5U-ukgFXYGh^R=qhiK*~;J z_!D`MD5l~a+_@!z&mzyWSsKK=>pZn0G)A{7WPjE_ngfX-M{&sAgD1Tu!vvfi?bDRs zx4!@S0V&M#PT!GisJE>c&kzd>-C)w8s_Hklrj0+#9R$|TbG$+}xdiSi zLVAKeE~JsUa$>wtp|;}=)zqK>4%>#8(sxJ=GTVJFE>eoA6VT}d76^a4=N zwgKbaHos9T)Z525f`%Rf*niZp3jY)8WK0x%jRtgLpC->$$QUl zDzMg*2PD~A2Rj2}N?B7<`(NF;={_s4nY|z(*VxJ*!VkM2@a_;F4sA7rNh!Rfppl+^ zog#cSo>bs^be=u@(3>*B&3l)pYz?z8_hxcY4a6}&*riOMXNO=ffB_ltw~ z(As*Tb4I$#huwCx^QW&zGFH{ZZYWf>x9Av~Due>84tZmID`;cuhtSiK_@j0Xr)NCT z=UBx=+sD5(%cw=qf237yj|$^p6-U`b=-c_#NAA#iar-oSv8At({)7Z|gZ>KJG_3s< zdPExPj8)1OS)X$gTGg>n`9fuE{PY>|6>wliyLj$MaDVK9v8{XAq~6Bb{32;0cC&wA z9*WXYhn6Sf%pH<3vemThV?c^7;-laz?P9YaAJD)*C)Qx^a1i8}JEtEGyjBU^9n zTu5$aHnxeRDd9%E;*>3pu(8YANSGw*vTKECQDV8>*F|E!`uIwJ63sQtWTPPELOhBr z{KT?veMEafx9O$5Qq=oA%H#)blJ~uDU5qZ+<)JuyZ#%ptn;|-CRYnqoej?~TNm6lo zAQj{fe^d@jYj6@SdJ`l(7=)SiZtp16HRF#gSSY13!xX(OKM&=!iTYfj9MpWRhJjcs zyE}8w`fx1~Qqlt66}%CCYCM9Tf^pup)P;R|HX&e_&G0x^Qi@_XO?!wlBpg)#l@qu@ zj420@V7+b)>-+Mn0}{EmKH;WrL*?qx0;gzV-2NPevH$grqZv7AYewb`xck-4!rOJK z2>i5)V|LO|jsba8fIg)X&Nb~RWDfUqbhQ39)MVpQWWRzcy}%R9^X+YIcEcPQ16q66;_>PgO~dUNVQ*EZgF`!JA>GKMs{$^E~|73Y;P>kw0Tp zS!k(qrcOVrA0em<{i2IjHGj+iV2wju+wP&d-SoWXz2o zB91-UM^>X^S8XG%-WI)j98Zp>*W)WBu+<_ox@~x8{SXw_W_<=fJYk$HKNt993=Xc4 z$WqHXXZvv`Zih;UAaYNvDxgcPVrw1sWa4WuwH*O4U?n5Ux^9(}g?Z;V}R7Y7ZE zK6ts$G1#6++1Z-in3?{1>&~U$Uap%_2PHzce!qLS)0NF0|Vky7R zyepn{G!#%DwUh$(|HK$uH%`nw3!TKcof~<1dY3VLT|by%IxCOLxDxH(j00u zN{4_5-|$vYM2Xnc0M{bp?dIPc=A-mEYIUW^fmqkp(B-dcJs7h+6m|=_Mg_PlZCkiQ zHSUJ}We4{Ls!qN7bPdb!Ml3IK`?@m>^$)zce{Fxm3+I{VEXbw)U`o$V<*VXQ|7Pls zXzwFUx^hIJE;?dSd(%1QW=&uF;11_AYrK7ZCAXbgMg%r0!o04-1@i&;I3u_pA8+ZN zZOug(0fK7x0|LfuxXj&eX7!*I7F;~BO3j#FEL*?s{}wO-Fp-d1n>R!SVV~_6kPKT? zC%;p89|n`3vU+UIG-i)>gm-r1*@~JnS^{6?X1Jp&ZKZ?%;T9`4Bc%6Pr%Z&~h3A*i zA-Q^E3B2%65a`pa@Rz&l2|lAIWz_KC+~)pl(3nQ5$J=v|12TG#m@Zd( zl1o5W{T(kS4TE+5gle=h!l|sP zTTUj-Fmn+zW|ou%mtw0S%=?rW-mRVeY2%jY0CVJ6{DbEl@=A!{wfJul{#{JrM8-$oTc8{J$C96#DyEqHwpMbi z-PO@1#gQM*&Qu_z-WMSiU5GOBv6V+epcIRn{cb|*cX`4v62&Su@bmohMow2o7tIpy z%&+uJIp?P`3vcTdjTAA1?S-6M{&@e+TX~2{X^-@85wuHwxgmE5U{|W#ee2|6gZHx! z^;9W}{UMBUWn{EVbP9$@MP&grIH;i42Z)?O$hSW3=1B|W(?Z_bQH)gAqF z8?gj*rFJ+%5{>kN3BA-Hd8Lb8@s5;O^p@+K-_Z{eE#vI4ZqZbkzoPVlR>t%`gAmR` zz;D8zfv1qgvqDdmUZ>tR5`~R)jS~ykgfYI63Bi<1p{Wr4gpMj3ZApl6-_M|~(fiAs z;9nWg2o>CK6^X>EIIR0erT1Lu4U&v+8<_&%PA2`KZCknURIZXfM?PEc_D!K6)QHgw z(a)g$-V8Y_dL0vvDFF0qH27^GjWay9vWYg})83t|HUq6mLN0MxPiuhp7F}#d<%IG+ zw!a!2#**VtY3e# z^$V`I%*oO3X+--Fo>ta4*QS9LCd~0`kcXMsMcpop%a0%Kl|BoJlqYP4i-{eO-3*F} zc}!Ok{Ja?Q+1Fj(l~uwaVIzw+Ny6v14ZV^tD-`t};0@i|avA>cUYF|fCP$6RP@w+3 zpUbA6NpH2pe$^(N-miG|ro-)|q61j`n$TE{`V#6I4S&MG-E0fXu4%%)i}`A#=9b2E zjCT9e^&k-`7^&Hk)cWSxJ zhCv~)S-?*lv0f$l8A+y1`}59cL|9v&AAO|niou~%%@lAMp1x#r zHMyK#@FA~A%jFvI9k+tK=nuJG563lOu7M`kd{Y;5{&$dtzppC3EkJ*+M3~%4-(VI z537}$91PhjdbB* z|M6iL&)GeXc&oB&S7$uBQPv-=YZ!$WnvfDEZA~E$^4OiN39<^B zn|k(*lW!$4tGfXUmJ7NX-6vOWdY+qzgPpzXjdS|*K=f$pc{C^hx}0uf3bFTW8u6S_ zKlZblJHimI{BB`=$S{}gkZ!g=fCAxzxlSFc&?)8Ec$dg*D(w%O375w^j*q$Cxbj=U2rly#>tq18V_vAZXUKu@d`9vULeDg7o) zDJ)AoM6E>rHy>gfAENx-tgf7I((jtQoOjY;h z7BXe`_jrCdlDj*qnYHv<$%xQ;1vk!?1*`itV{tMo;Ug`_SKn<~N+&IQiQhR<3E`9v zbFDAdvFq5U-q4?O~$+4rb+($)DJUn4V z)Jg745ak}nZq!m)sxH-f+YeoRT~{?}QT~12v=R3-WWpDllKmst6D%a8HKq3uFeyUs zeEzMGeKS$gBc`o`kf>~nx0T+DS8qrjC?kwl)?L#!{#0h8Ru6CSGRnk0yY)?FgJk6K z%f&wF@8AXPYBx2Hq|`3#_m+k@pd83S$8%#Jneb-9)sfu{JvZo^DO7k>6Esn?!~ z9uFp$ox`OSKTy4V+4mV8&!bad7&-$fb~~9gpHYAqOA`4Yn{0tS`vLj#bPPfg*csl1 z5gz+S&&7ZxhPRZ81cWlo=?vkCywr-g^VpJm+8N`KUVWs%*!`>yD zoqJ=GC``np{yMztlU2D|nQ><_kCZmG1=)+@HW6WECz0_E3al4zfsFW=Hcs*CJsxgv zXOi;85Bv~G6klI}NUnai8HlY92gT(!COu=RZT5a>0Be$)dY3^PNa5?NvXKJtbIeuS zBczwI0dUgdNF^;NtKQ?w)4~c=o1pMCsh5f=4CEl)mkiMjfIl7ZIuUwyU#!dDQWx=# zdD&f*xOzBe6yp%a1wdd_ZW;_zb~W;%TzWzu3jhFs9Tjx*7yxkF_7XkFTszpxp$;xo zsosMmvOh@g;N~Hpep=}4!fvw9pk8VfTt|UE0RYsup|1e|zNdjgYGV1ubbMkl3yeV` zF+Cy)=}Ae5|G8z2nl=;R3LstJoKg33x@zKmJw{^ff8Or@a4rH&YX6^`0DzNrf5Bw{ z;Eo!$bxyq(PH-+BA5EaZ_w@Ss&nDb|{s>Sn%m$0N2lF2)|MQC{u{gB#|1I@+(3E?B z7hI9Q&#eal*1o>A`}=o*4l)et-(P&g$o^vZe~KO38=k-G0Omlz-*f@mdp@lF_rm{0 zjU?&))!(kGyCY13%WwXd!#jD8`L5^MS(v0HITekz<3KUBJxe@lT?zMII=#$^}x znAI{^RLET(-8Sk1PCO#D7V1pDnI0){eoc~_?cj!wOwTE-QfD}|YZ*?r(Rbxf&MxQ} zqpixyCxitE_E%e)eyX6}nRaQYH*}3D@sCvPAKsB|r*h02`3@gvtwVh54T2Z<_cfC4+X0s5YTF_6)3XzeS z9-l&i2LL`ru@50#H;t9PKhQ6JwpCkwK^@)t_SLvn=$rN9EgJcCXE6(%sX-k#W;=0k z3ag%GF*NUGA_Eqep{D{6anGg2y%7d5&ZI@vaP33^fPjc!MhTB(IGuY|v72f%F0Dz@ z)GqPyvi9)FLeufes_&>$>XwUIs!DEKTcCn#2Qxy?j2 zBh2@rQ<^eFJWBHjPw%c}a0|q4xAxd0bM5JS)>Z(*)m!5E!L6-uSc0m_rIfO*D^XF^ z*7l^NHS`KnWYpE|f6=OHby7yWeVgDs&k>rEmR;$no(SBc86arXa(T3nBQl?=kB`57 z@dB(WIWKLRpbRK#S_a{cZCf6!vA^7Or9Lwh?3BOMBVGh$B0;wv8w84)SUE zm^=qAOI1dW{(L@j83}jSg34M%u-5P9*pVVStYi(sv=3^V#|Ir3HGCnDEkRcj2&bRs`t05)IfRKyzdO#ANk~zwkO~ zQhX_{qDluWC9GudDdv4^o<733mj%)FUsNh`6Qs3NxBo+_?Z!aXdcVaGJs4cN^oaSKKJ{1{ zZF|#-Z~&_VSa;uZ0)<3Oyo|2h&tZrc6AP1A-p6CDt_K#84cN;ry_f5;UdYv_fxntw zg0V|;-_C6<5lrl?DO&Rt5~x-$A0x@ZwA54G`Mf*g86>DHtgTTHrewaT?(nH6)o5F@ zQB+u4XVOJv#4fUyw?zV+(M{WAkcbn+cl**ocwa=2T11se!Y(|l6Oad8BXD)H zv&)*89Z}QE72AXiGfEO&y9MI$fa`G!~8!|%Gkc9Mv71NMtC-6|*o z#b_3m>?2(jS9R6!oXS0~kL#7DP(GjJ;!9gTJ`c#TGXoEV>*9uN?}fZHz@N&4xam*h z@k3Wt%lAqVRiE=)+}9CF=@_a$ak<|4U;sQFm(SC-n7c-%7h3N5Fk*_99~0<$Cu2|F z-Psh_Pczhzt4k0n91Quq)U`hC`W$}Sl*(1xT2Tw0s%TRTim>YucbD}4ip5{4dDD5z zKN3H$_*9w4?4yuwuSs8fIvGi63o1#<6cdxX$Y|2`$bTH%Bh80SMf93By!elWX{nW8 zA{`d^1HN28Ec;30Z>EDp+qC!10FXjB+ju(YaM42}R1U3PJ30E=Nch67k#K+<8};4? zt3ou2pB^3ueIQPyt1a5)G(15$>c@j~=yl(i^!arSMLf5GoI00tBg*slU$$#>zcm%t zISpj4-I;_Up~U(4WYer-`9^A4f-e6Q!WbEGdKZ*?K4@ceTPJ(BoN`e`UHw!o|4!`U zg_DvshbvBB&{qD`CqH+P8*Raxl;2q!{qdxQ@=`@#tUOp{KKRqUCS{G9MceYULIzS| zraN}YsQPBN?6Ks#L?2V>(jg0C8(YBl;L+MP9v-k)sC z!W?gu{9&;LfoewD3j6(!$Jui3GKowC8JxP=4^kH-*-7@T?1U}YEv#DJUX;+xcUc+i zs;(Et`C#|q&$01BOo}+b0X(eYo_BtY8V}m>F80no4A0v&!y+D6z2?QT$OeYq*Iqatz zS^byGwsMc>zhv$Gi+eH9{1Bvi7u`yVU0fj)vn60=PRDd)X9FtFYR0G$&9S%+w}VG{ zr#gQnn@bZFy}NtOj)Ddzh=I5dJsYCo&*`}f;TI--9^q4-B*WrU&zjsM2@QUixUKex z4{*)N&CMOk@Oy-ZLG|?*9xv#hxL_PpdqUzJtWqx;CDTL*r2Y0gD2FfdjfGgxO$?x^-#RsT(nxo@nQ8#kW;lcfHQ@AIel_@^2)=Q#7;l|xe({t~p-}{3(!(0Vn<#-CkS_7%w)!64Bi7zG=^?WyP zm=dW_@XJ1iQ6{sGtf-ql7xOEXhKUFFMjllJq=K@=-Juc0_##VN8POs&v6;gf&2ufV z+s}lO@YvP(ROi4KCC!3r^U+DeR)sf1KuQspDb=tAvckRXVlrat+L|ji~ z&4s$U>5E)GJ=41Y7&yWoCX#UL8RN4JAVn`f@Akm8I|v(=Rt*<|9O zW0>}q>4;USC{#k`Hjfts-_tU*7w=8BX8pR7SdK@K8$?DpjYmhkI7e-O=0Gz7)DSAY zMB-w8i1;J|>wFzID6*-)Sa5_-*0_(Iul?Km!RogVziL@%tz&wlzin|RM%lbQN!`pC zpx#RfvzNirIdJybAs~x7{B(Z@uw16D00gvqZFt0J$<;_UmSuHs*J4iAre=6gaUYC$ z-w!TV>8Ik@s4tqJ!HZGTdu=lYfKwfG;)XHt350mEFTJ&>P;6QsR8?5e8dO?g?LLzb zwAp0b1X`)D_-vUkj9T$N+*x>f7O62s#;sBXF{ex-5>gK|W?m+k7IVH_nif7QH$(*q z@RyCx7X*5@x>qOWw7r1LOh7t)AHR$|tnJZ(QDqX^C~Le+8mm?a&gva~BEwLV6OW_` zOxwz4@?2vHw5WW5yB=>nY`&8)(T|&y&O z>W(vmAlRh0w*Q$NKqso>C<0qXY{oA=;LEAqlvLN91xJgYiJfHvHpcxd771!%Ba`%1 zQlQ*`#cFm5DVdh5lStBm4LW=y6;LzzQ{$9IcDZdnl8d|}4DLkH=z2hEVrL!;bhW}{ zGC(UHdxzd}c_`(!Uk_WMu7B#MIMvDcgU0Eh%l7`U^XBGNOb$z$aMNufr z;^nfE$#+`V>&R*rE6hrAt8b4r2$)`PP1U=+US3d|Y zzWg2ozQ{L@yqkh?3R8-@JN#xeM-eI^xZ!h2T&-3#u2=kfpRnddpMOl-NXV#B}t$Z zylm^ev6JyVk$A@iSZ@Dm4YAXFsnriS)xTskwrqLtnpnJ!D6HJAC}p7rG7k~rS#2#$ z{GMfIZvW-?@KnB2?Tk!^aJ94 z`vr+zrADt`W2rXjU0t2zy$m$s>pxo|T>@n1+j%h3$ zUAp9Rxfzg4I%fXKu)Ya_8p?^!af<7#{sW5FmvnvA1G~OQGE&@cdPhXt>2XhAV7Kmj z=$_AI`$`W$_RV^|6##gSQ#jxB9d1>_OcD#)=h7qGGR;|yEMVy2Hiyf1tH6^JR}9}K z`je3JgXgu|GLM#9|5X(TCXgl8I03h4Z|YNz0B+&$wRZW}h!l+CV4-+=Mwc774h3m0 zpC?K6d?HTJjYTsxGm8@L)CZT2qKsY*korq{WL!c~*6JIvpxU&si7|ldj9=X#R>ocP z24=>bU!9G!H{;rpKLGJl-C1(8c zN*r;Z&t3+<^Y4+7ey(mC(bJd3(+xc3h-x-5Y_E75tqSBeZ#!~b#(yE^RVfd2tSPG8 zE!tC1KI{J^)f)N?{sB2&(5kM|;o416b=^t2!0EMwKsVvtII9n=7-6w(QD9@w$#{|1 zxE*uii*axYP)c*SriN;#7UJebs%_>K5@2S~a2OWX$~HZ8=b{VA36p9S&*=@SO$1$A z@aUHpZCFu>DkwjVYVAsAV9`1{mi98ss#MoyDzPxCWNBwk&JxFp#Ju)@0CTtI18+dZ z>z<{NTP#Y88F1LoNlrIoGZuE9Ro;V_I?ACGYuT9ut*3vH(uR2vD+N|cX=#Z+x3L%52@k8kurO7IKYSEoFZuU7u>w)*zBto$rR(X8uN<#?_DAi23~rgg#3u;_ z#fNlF>FOFZq+8I}QE^_i9gC@O!6N%4_6JRqFr6G?nd~&&#plgxle)~3ntN!1Ud1jG zw0jid?0PwEuGQYRM8EqYBxj3wYoxXLL4FJ#Lo8dGEfr1)qw<=i2}3c>*)`r~+3Z!d zmMdGxTuFhR-XDHfY(+>%x$0SIXK`Go4)JO=QR!mNaSm`cZwd2>A6}|(zF1i3MM z|Ayoi$Kf_Ag={L<6bsZ`(w}QPbLz3PB(s;5nw;Pi-1FD!C2 zPHbqZNnO*sQAuETDLdI|X}8Uoh3n~%l&(^l?bQ@`Nk_R$9b)RMEe;kwWLP$1VX?^+ z&vg$%luC1skvM1Ovo?JBU)>O8lXLe1Jx9VHB%Skuo+y-Sj7*H@ItHMVJ={Z(^Ap9r zzQt1S+v%962={Mdk=!(LGiIeE7o>#6#Na{#6G`RY2}h8EAhn%uCJ+*@n}Dmm*!7fJ zll+A6>%AgufZZud_)H{um;-RD(C&C>u+d;}!y0r}k9C|TtZR6jjnlNrUzjSc0bB3& zf}@`sL)Iu8%)a)Lq=~4zdBrtn+t{_&f2$XxqdK$X z$tUU0GlgL*_EoXcnu5h;x$^JBELlSzw!Y=HNnSNt&%KqzF+wYsc$x6#F#okV_L}Xw z{iim~nIjZgHa}{Q323AxG(`^iqz0N58UGDI#zV8`Iik51re8$Qj z1{FFO&Ul*&pQdN2t;|&~ncD}o2fv-0JreWa&wM?LU(!imW(M!CZ8rS>{jBYh2+{#zvdPcHJmD|P=)m{SB*CT}%yZ_t|0(ZIHw*M_67rSfFR2^VOb&>0lwCQ#s~ zS2pKfm`x;;9@M^>=7El7qx>C$!3_s@1%eg!fLkG;2b}#7MTfKeXQand{bS1?Qmfg`Mer#1YNwm9i=d95& zoxX8#4EOj}(H6Abc-|vKSzAt|0yNXZ!z2Zx>vVfAo~#ryC3~uFccG)Yv%bO1C}z=Q zRNKJt<_!a4@7eBfRg1nB4IL(|d*mM6E~qUcUiNn~w5K=8oci`fvm=K_!ky#qcX;LWX`G zORz)Phprn9a_97wWrz%30bb^oc?V>FIAkLs$F8zAZ{zyf|4nAW4)IwTheLH+T33M| zGn)as_s4soU{P`Sn`x&q`xUW;Du1hpXyW3KeFu2rZQQu^C4hi z4$R_xj%nnFeZl#m##e>yOPCy{_B5{w@D%Bck^9`dNaV#JEeDBc=|wJ$w)Teu73$(^mI;*bo|4__NDHI)>X!wl4rKO?)g~OMtHw=vZUu)C)xz01j;U9&yt3B z-D@lJX?AmadM;5dv&}tAMEdjeVhB=kyH1e_6nuP}Ti#;@MOS9&M?Q|M z>ee95qJ*7FbWbUfQ0UEz$4@Xp$8Y3H_A%l#sVj@>V-~MUUyGv7MFr%MyE-?g=DNH~ zi+i89oF&-Jw`-yD6DBJ8zfH#rcq6ZBCcGmNe4Kqvm!BLD*6XRvZ&XC9Ipm}Lb%#_> z3|&%m4Rni=1=>Fvb(a-fbTC%$;H9x9r{`Vd)6VV@g@)#b;hoz=wD8S}f*?<)J&B>h z+ZWY`+EWV_%2+%$+le7m=Et{!>!T$Ty^SSf2PBOA_t}hwPuAwT$3; zKr%NxiXymbX^gAvMiXWpeK2O{{Sp$gHBTLQ?eVNFtVMbDtO4Ja2i@^$>WzCDcA(}X z?9fOQWbOjp(G@rplykWpsQ9nwf)@ToGxVKF*80WBw5i-j?~=KFJY(8ULMO5Ij=8bF z!Y+E6C zSM0h})r+_Htq7WBwr&n@B#{#++JTD=V81F?8!bF5L2GGsS^$h(Ri_RXZ zb7Jd4sM|3yN@rb%GpsW0*L#~)f7RHFG|t`~6Ft4FUCrTi9qRjY2JpCq_!ox&u5`}Q zm7dg2IfAe2J9}A)FYn)Ph5VG@XxRzO+UG6^t(oj>ffYsd1;CnrG219w_t=? zd?Q14SWYe=Z@*l@|EKW-iPPwx;|IzwhR`{LdhHtN5-0a{#(|~bMOg<_nY5@iwXLhbCeqOtOu!=TU=;b|Z4HUG z?u{rD?32D*7)1E_;u2Uz>MIj>>Ya&OHu>VH?L5$Rh5i9fE&+qh>C|UT_F?knSBP3H zFGIlRU@0!rf~&sMyuz_#Cw-2)M<22MOMUnTYG5{hHmGBLL4fP%^Go!Y)C#1KQux-b zDXCi;%>tEIxz@w99zM%9Tm!$^c+;lmyi{Br>toAgCQdzMlMH*j_=5w1_oxH=&}SzT za`y}++33VAzeVh4Ba*bw?;GCAaJ_hhrgVQ{w0VMZPcJqw=X*usoO4#e?e=(9@@;|t+S?;@g4*A5OpULrl0c?qNStl65kLGjB z_{2U7k;RVBYPR!PyfF6((#5xtyZ5vIMq^{fv+NkN3v;5417I$Wuy8BHSvK0{#s}w7 zo|NpL2h1pF`j;M&^O5R;io=Q)$TYu@MYS*E>dk zteTLuI6qOjSsQyio@QSi8rj>@Vc#SnfA?S%(Q2P0FMd2IGFm^RO-w+O#8?+L;;n(D zIxF*Mzk^!>8umXt*ck8hw06l1YS;g(79ba1C23(&5&l18ju%;BGt4MH_^ioW4JIQ1 zuZT(=?M=$=)eyM*T+>Ue(Y7Z$+G=<4h0RcX6*E6$E%MM;8mbk1dXLH9%9)FbKaI;3 zw#xe5RbSM_rO+?s=UC{YRPN0T#gUM2nB%CD-wFS-T=YEd^;HyKb1@W}Go@`=b!qK^ z%l$?gOj+9cE!v~N7=o*MR}(?5NUK2hRP=A3I(}|`b^MFG4HRhP5s))Y&hjXNzD(xs zbQJ|1XWKyOr99I0Ra#~m-P>~W4J+xIA?F~hXU6y?89R+iW?oWo^`Cmy9(9d8i`Fp! z@6kv>+a*p!m6Eh7w6LOq$Km(#NO=u06h+bQnLTB9);vBbniyjsBY2vN?g0NY;I}_j(yHP;s`4*`?Jb+Ux_>+k+7o4~x*PL5$RBhq8t&c^YD>2 z_3w~mYr*-3Rc`k**O{0s`K5mJ`$8V&a<+E~gxrmvPs>RJ>4Gu~=Sb;i2qL1bM$;8& znU&}&k!0uStjiHv!>QDwK9D~QMoFLi+IU!dXy@Brt$c)$x7wI zqG&P=^xXG@;O*_S-??8pKWUP6{vZ}Gg!{CO^Wne~tb&N7p4Ha?;b+-bWw5y=cL9k| zO1Ji%j^Yye%MYgK3>1gvGsXaB8O0HjfU=f=2YUh4>x)eC6y-nSR0|$JS7-O}lIyRJ;!z(lsEHsv5qNZtzep6NSr^Mi~La_*Xx)kC*z!;KlU=AUDnvQvvwWJyd$ zTeHzvW%JAEI<4YOssx__cpXL;lxD`P9_L5zcRn{h_rXOE!iz}ey^$~_z`m3lQ|D-G zIp@`8?yh&BevVKQ@8)OmzLg#7@Nto8#8tI!%0EbsgeeRwZrw4aQDrvijg^C+-dtnP zZ|eQ}@{$dd)E)M^1y%lOR5mJ1xL8AS_p#zJE?p5Z#Z0J666H>@o}m|Sb62`lRziBM zzP#oMPmhok0_BU)BuyiO)jQiPC+du}gjb}Sz83uQk@fxYn1dIY6UNkBc`J$Mk6*sR z@ci^~Wb%IjTIK`x7a_2*iqy)^qrs_+wc?bKq}TD8X4W0sO8ZL*faSgw)#XCH+b>^* z!;JQG)ISWF<+Pnzi!YnuI0{+;2rF)C^C=h>biQs(cfEM215Q`8{qQRMT91NQQo9Bn zAZx9>NaD5lD8pK5l)3YHhMmb=n1A+mwH>M8V_IqFuTnnI$&9B+H;B-6PlwdSkQO83 z7+mY`FEaQD*OyVIr=_kor-nL7hzbjuYp@e?l|Z=o)5P_yzLt%Ux~Y|G$LnQV{hy(J%Z%O@FXGqUbo)+#Rp>BMKnl=&5as~=?xK<0Z~3XF}mEZFJ5)BIf5n@x(; zqTUi!%)`8xki!Bw-_C0=`sqbP!g_8?^|h(&W^-JnyD?q6RUlUZ)<&TTIoUW@HeP?$ zq{-wx=-s>aYlC)@p5d_72ukZgCOu+JvPSFxhG6?d=l~)wI4Re?P8lG8J2LB*0QAz z!f2jBnLNY=>x&qb?Ar_g&X~MypVP6a;-zG(OHLNfTwi!X;O1VS)+T$lsE7pVCVsIy zIFV((pQkol@O%GpW|fv>c+s19*=*o(oOSk?Eg@HAvBfM|fLJFo^saPDzgJX3zvyIH1v#;uJ!0F}+1WWo{z!jy!JefeE=#0$hXh-%QxDMin9`p|S3D#R@f5nIF2S+{} z&diU864!oCBp+->&zmI}6+ewE@ST3vB*s^gb^VVl)Jv%IUA%Eyj?v&?&S{GQbNFTQ zd{9QQhgXX|!6J5&FQ2N!gTfFjH?BdlYo#YQUb6%VLJeV+QrC;_LgwYr?j>H@71s*v z5Oyz`13bceXR>Vf&#fFJ znaKa-z9QFlBRpn)K+Itp78J%zDA=nR|Ye^kxf%q3$88xaQ z0(|Xc1s-Ux%gv{J9nO#zO1UQ8D~<}xtQ4DX)C&$R3)SKrM6&dxObV28oj zBs0k^j%zE&UqbDlJe30^ zFO~Xbc|FEc2Sd4F`9-Gq$Lt)0`^4A=ueFb?t0Nr$An&QN%0xo`o}!}gQJq((JkTL3 z!@d=#SX@%1j0m0u7rf*z-ICL2zNkr}5XG4ViP-t56}y0^(FYrwwpH+(R4tk`+FMfd z;l(rTr89gP-kw5-bY@uYI}%z}4}LYh_@%bv_+Y8Hrkc4)K6zN#x(6h$yYTSosmqV+ z9;RWP+wAQhxglg_2}8+UfGy}O8~rk5#1Wt1=;)N-xYQu#{4V>~3s*!!kAJ%RIOOHK zS(c+x@?+xOPXd0qvLl$&kY3r*IKd15ut9U9tdM8ql4t1Ehw=zI|7WkzX@RlBJ(uqN zIYh6A|JhUY;FlwZ-9hhe{&d((bo=SC!``EppML(ci|EG>{gBbO0rX=W{g@)(#=#FY z@0nzxCX+j0euZ{`P(6N1uht)ADqQytWCR3Vc~jboZbn?RnTnvxZdX zAR>JL>4Xi4%Od)AW=H7Ge8L*|dKs~jYlP(5as@Rj@XkGh9(=axiuPvH61*v##;Pe% z@ULOzZia~IR1cL>$po)0>nDW>m@i~nyy`}A<2jZ0&xw6tYMOa5ks zw3YUV;@4~?Q!^0OzYg(RqLS)3X0~vxb{>;doIF`U-!vxsNDno5RE1zkJe zAK=-KioJ?+B5&1|O-~aG^p`#-=Mfoz;-^D-!>&DXWl0nUlHGLU)2iS2man9*huf2l z*FG*ib#{#P{JA_2NZova_1BpVRQYqnfy%(nm)E9c83JXXf!OmGXQo3b_B^M}{{D}r z;SP=}m1S*@M?q>AZ{=Lgv5*wnQ0t8yo$;P_*~RZcs_d5by)gE!jM`O?dXtUYs=hkkTkeJkuDoAQ;{Q$nx(1$GPmYT~A!VC^aC;28cYmW?! zBqBsr;db`oK&RpTR;8*d(<59dBvn;G!c{KWTgXO1dPrpD1STIHAWN!o(FTJaX`9*G zG5W(x*!Z=eAcR4LbUbkC)0vRuI}0&lyL2`3l1Zr`&>lHsQ(GAs{(`K}`Te1;H~Zj! z%EBDf`8Q|ZvQt^W<#R@9mGS{W?!WpU1=7b$i&?GWx4!o9Y9j&z&nD(Vep|rI^)^K4 zI*^i6*)-WQ7@u!f0Cq@@z9cz)#lib&84_)08q-x5^M;}dvew6myzq?VXs9FFST`dB zk;vQn1kqwnR$0^X$}>+{+S~1vSCHOrXg<>g7ili!!URxz_g$!R>r1*9rDy_tb8!kn zeCY1csoURg_T@&c&jlBR_kBKfGsNUww|W2J3PB(#&Vtz^0I5}44DIb-XYEV$15mcl z$lxcUPZqmAv2s2b9@ohgQm9s7JBZ5C2jIZ_M&8!6yyQjyuQU&DXmfl`fQVA8|1CL> zYBc+u?-#n=+>R9Q&gJr7(`b%XpHo}FF~xnAGood7UZdRhuB1!JN^5N&tkZXYlf%Al zA;;!de_WEnu>2R~L5gNvSVKCY6a6}b8!}QA{hY3zf)!ZEsUAfKUW7z$@z0aSaPFZ% zxAg~G+h@U_TCpMHx`^7NxXkT{$K5&;F9cA#{W?iR`M%LoWx3W|@OJ64G`vIQyZV2* zq33!ei?$oRxxHOA2TH?O_zet~0N}DbWq_6^hWi5xo8T z532XPMT;k1Ji>g528#Qx4`-?<^g2%Wk4E@7lcB$2O1isbmFGsvgc|j(MZC5&Eu*2j z&8)~pX>F4|RG%^PfRuAY{l>I=P*z^J3L%>NESpFD5$jOalU{X-!WgE5(rz3c{vFYLh58!D*3lyb6n-uEyE4 z{ZWKjdlNMG!p11_8gNi@rb6F_p_WhfW(+;giNHi-r^(HM0fR`3o11(FXt=C)Z+?65 zUQH}Y2yq^*LmDOzzis~tl#~i9@D;`a7>#~=m~_u^c2j0?(AI%FybbFzDCxeozi35& zuuLrbrjQFvz09vURjN4{kD$+&iIMU3Sp0Iw%47mgM)Gk^7Cp@bGB6%#$b11+i6Gfi zw^=hFOF$r0+x`29`bhMw%`wOt?ZGu}^83@}RARTC3NibB9lG&K8>@0AnQjv)GlMxy zefXppHoc=AHHoXCpdFSEsytYggzBc$9XFfKkg10z=z9N2aMC@7)b>mQ1%7h~#?9r( zYE9|VxA7*$=p?1kz)?ng7CJn^Jk%M6ci+-wFkDjt!^ZM+)h@FV3kSk33#Ol;+Ey@#^=JH~rQb z!@|ORjd1NLc^Cda)4@A7syEI5d;^l-ga1^HH*UV1|0Avdv&`5dCz+$v;MP)kIsmal=}cp;?O(2h>>Er~u01%dpTk&FHS@}Zg9 ziI87$tHWuUgT?o%bN~;1^-W}~EK=CnR&?m=LA1|KayEVrGC-+_WXn02mp}i`Xtfy8 z-|kvf+0fw+R5piroFUkoHR=NQn!G8v6TY(G`Z@{7ZX3zHPDV<Gec*}u5K$ghHYF+q9f3GB{e5-4tQ&kMe?K3qOvOZhv z+eyi@*|Z7k@>y>s;PUAAjCuE7aP%aTr&U7S7VnxX#*QYM9$nZ-OR0>n(@ZFTv8-F} z(-G4>TH@5d;4{6lOd4%ZkfcokW@yQQygYf3`A-QOa|6%yC{+^XfFZ>0xWsg5QO{;g zp==a%w&frYY=5z!T_~$MCd8ZE1Q`Bsb7>peb|xayqZc8~ma$NT8Yn3iI{F&bJ)|TB zo%6(~6vWpiGbeGtQfoTJJXzynE^rsW6u2@u7v|hz+2@_^RieZL@Da0=;A<(WH8T_P zn2uj4(4U#T8jcX zA2pnSioh1z_HPVqJ_B*L*$+ZMXqzxa$?+xVWt<~iej#~DZ)L64P$E8#>r4SjNLBV4 z${S`U1j8?V1x(Lbswl+52D2nu2{pdENlCNKrJ5#?je=BK-=LCPPv?S^uLV_we5h+g2+J58iml>8GsE7Z1RnfjL^UZC2|w*T>?~Zp{QxD}dgtQchhL z7+9T9vEx&_MHr0HaaM{sTJ?9&sH@nK2Aaff?>{X_a=Xc6qKp_3LbUWVM^&g+MXw6F zP6*i$N${yC6VQ(JNRPeerM&eZS?QLj9GoVxo)xf8YXyuZ$vDW)^piFHbLB=x2x1iz z%YJsWEUDcds%a5k35jDoTi$KqEPk@n?l@dB(^@=0b==ag5)W7n*`Cy9JavvI=eGn~ zww0-d^X|>H}3X7+JLZS*iUA?44T;}(54YnAEqN>wHIywrBt26hH*OiTAA)-_WY z9u0sOVxoO3%}==J;5&fzw9z-^UbK{{Z_g8}s~iWQKQZ!3*nwcjjki4^_$iS^~j9h+ldVCl}yw!%YhQ@(a# zQlxJX4A9oQ!$nNbOeZd`gX=zsp%mcn)Kb~XuTt`Bj^}zzIKOh-L0@nr-@!ogqyTzq z{A~I{PJWK1t2>7dKey(>CI+)=PACB>UIU7wp0-IR0QDBN`r5%@i{f7=7Jz2+7>}M$ zI!?aVaSBatuJ5@@eS^6vlB1J|iG(3aZ1z8tO7F5}tZj6eL%{9OzK{Z5C5hr| z()hByj56h*Ko5qs$-UNHLvR!yEHb}~12X!#dU1b_1P!nis_3HrCS@=~CZ|XDbl+=h zdSxRw70)chyf`vYgvHF6NVKQJJZ%ro6ts5-%(b_xsIh6TxE^AM)yogRaM-tPuE4MG zFy|Wiwv-+41PIVH_IdhCQJQ~qQ)xFvzD8WYoFcjZl_1rK?J>NOZS%RXD@7tZ)9sFw z0>=A+uK%Rzo(JLV$o34@cbAndeMuk0#4I+}FHKU|-bvkLkaj#GnI0ssk`+m# z4#}$$b8zwK9DGcocuAZhJ7+5Zl*zG*Ki=mx4Fx<#(G759%OnW6Z+0a4oZ4Wbe~6WS zu^_NGy0%q*;RYx8F1>YS)iAfGdx0|v^H8QaN6J0BBvNaEr@SEcJXMSP=uxg#jDs6q zB4jsSp>o@tCZS*L(~6pR;L;hbj2P^w-b}G4Z5EHJI}L|>Tm!DOZ>CgSH@KHXjTA}i zGy}9A90Y>w{uI#Dhk769e6FbZr7Jzh5miX~jM0{AnQgEJWo{yC^lQ9#_^le}wzI6V z207OhsIU|I#%^DOAd&o|)q;Bq2KemQNn7tN*=sfjvsdXrvxdnQvcJNahJ$gU-0ogy zo?+Lb8X^!Z`93~%Y0_9Zmfq#uoL}aVPKZCd?01u5v2g%iU$*j+nLrOG4bAj0s0PTZ zuNBsKxKDkbE`E>Mi-XK2deB{Jl>$;qjfqxM9q6Mcd9qAEh6Xj+$-GOXk31%unPrTn zny>2%Cc6HggSg}s-x@Z|Afo*Ts<0A3sgQsQ5gvGKHyj&3{{&RQPf46k^ucr^Cg0z( z%io+DOD&dTahQC}Fiknhj9%%rI#p}!7DfY!GWy$R+sYVIyz;7wXv?Jfe-(z)zU#HNCr~y}H zAorYAne3j>jrR9;=;_c5_fEg%5TE5Ih_BRciCNM?m3nG6BZ%PR27TnfI@yogeSK5Y zd%Ao{{rqfyn7$1OOVC-0+lfWa&wcRII^kVFZs0tLkal0Jr<$(GQJO0_Lw;*58sRAh z*R^j7H5W#D=;6TIT?@r~_XQ#ZMra-Jnt1w?M|`s4QduS&pA7V^DYp^K$eyv2v&c7! z!65SwDf~-NW*h^E)3Hp{KiJyd*x9a*&2n(UC~sGY5uJ66{TOeQS6{=HD{bszEoo1B z^LFo*Cgo+(@%0!bdWPe^T>rg%$)$M@)uJm4ULMoI?Z?aK&YT(b@ast8Gxey9Zdf&I z&lAr9&v%l+%@sWZE{s0>`d!I!gdJuN!_%ptY`18Za84qU{ECvI;L-Mby{YtsxhbtF z(__C;)U8dZq8L_JwoauI!jnsH_Q9tO1`cWh-g*WQMF>h1ZmZFH-fQhG>B%VC5MvM- z*e&(7B>`_cKLld@5j7H%aFV~hqKDGdcM{Rv2;P0qyamnRcBjg9Kd;3^gy|VbIw2!A zGNa6r-n=7v@x&Lr$V&I-^Hr?qF`( zksi=oskG9x*z2y+SvFKQJ(?$9Aq^_GnNBFvnMmYPS^pG3zxsWqs+NSB1{zef^wY|z z?iY)lx(o((UQ-p6zueCygxm6^oB|{vZM@rBcXrG91;3xMdgs@%N=~Y_ly7rBrfSlH z?XYBM=hxyO?!=>|hh5}jXgh_zKl{nY+WyZ}S$t$bS4L46g9BMY3nn>_G8j3{$IcqZ zr{A~XS-cbI4vTXtUE>2X@7eILQjqMwmx6HpRSF{cS1E`M$H~M*X3ONqp+fEN&)I6q z?}CqNFFa2C{(v77>yKnXM@mna|EupSz+{Cnr~X61Oy|LlHikf6=ul)v4@doz-dtj0 zq&l&Wca>xDFhbJKJ1wt}A1@HT->>;3 z)lf@#dw*4VbbP{L7{>oXAxm9o^zek1kt0G^o{0#mlKVb{mx)7Op}#N^joqENzkltZ za>)g*oAKGEvVKajUK$n0&(PlScGHS>53O$N@Q!dfjFv`ZPyLy-bQDta{eC73%fW>| j5s!$#*x{~!SAZ0>65b@B>g9uP#{xC(>9Dk2oNN=yA#}kTX2`)?hb=H10+C@1a~I{2*KUmWpH;J+y-~~dESuc zf4_shukUCd?Y>yoG;6KyReg7Lb=BP!rmQIS9+3bM3JU7IjI_846cj816x3UucW^Iv z3d?k4UVh%Xh{~wFdnv$oW}z?tqq$0GxvDx?xOx~nn?tEuxw*QUJDUcKy)-0)k`WhC z^ISMw_V83w-{?JsWW>s#2H{AHhK%t~}D_D^&J6qRCE6gR41&D2t; zh(lwGdqb$9W6`7ogD%W0*g4vS->{KGV@SWDg6w#CCN^xPyNqV=-=BDo2*OFoX9~=N zUHQ=em6(0dp6l7N$0|OpFrhLU$_xrh@79&$;Vv5%*7wlYqGsscM6B5glLYFCrItfR z`ebB;wzro;B!H*j?2N&jBqvBs@mf?AQid&9k@sg1;M(!{-HU1K6$L3MbEKt1HBrM} zyX&X!+j0iG?`X=-)5O76&AZCXn&QzQcd2RliTKhi*>ex4a`nb0B}s__-wX`M+;RQC z4F`@!Xg@NhmyeAhGI3K<1`7JPn{xT6+X#k45v}*H*UJ{s3JGNn4arYTPF83!((XIr z9@jzfoMoh%F#Z!Usk(ejc>@fVk(V!yJ`)ucwVL|#IbjMDhhQ+K%PGMC^^>HeMFsjd zC>p4)u_S5n*TU?Jjr}L1q*$&d^UD_~0TPceUmRbze^13nq87J$w+8!;uF_9=;s)C7 zYK{2JsSLdiocS`Kl1nTYw}mNNfto_hmL1{7MbkRJG9@Dj&~ZKfh^HVTNc`(&Bo34? zdQ`VMQtxCs;<=R0>ypA|3ilfmLLz{xm>d()o>0XggZ+{V% zlk6=Xw>|RYZw2A9#vu)kmx|?5JsYD=^-tCh0{Cce15yKz*94 zZ(@D~88d4QF4@%>onQ54rpXy7M)FDi9YyZP$zUGfzz1?(5Ejs)(9Ty|=HN~_a1N+T%qEZ=PMBMz&bxtbP5)wSwV zB$2T!@(ML&V)`Qw;@)HkJZ;{-;>`K!1%@jDp925Rt}goANL8557bwuPl9hQbJ;#5+ z)~7R{>6;slRWDH@D9}_L*Y;EQ+X;nO)=@~p;JioVEL8prY~jOd7fz<{b_2T-w~!`H zT*r%gj5e|ky!^1Gj6g{Hne%_VXb8-Q2I+x;9{JdUl^`9k&H%k;NsebV~) zLBLB-JE2V?_~{#qGabA9mS@u^b464QG6j%#T$RkUWiXF7vYrt{ zp(2KtB%Sh#ghWtKkeZD4bP0)E-YPx|2Z=Bwz_IB<5d}B%Q|t+W?T$KwVN2E`orw@WQ)r z-}@(`sHoBxRHs>Y3f{S>Xw!y|dIV8)w4_}ru=53;tnDm#jIH%B#oZ|HscUL+BQQ9u zKl&5ul}C>y!4Wr&vpaAX{ZhG%ml^&%a&jEgm)Qh0nSP2F&phaA0q85FV<7o3HFhCi#k znMbjhjTKb=^3;496uy`FAF@yq2jIX#hMa-yaTE*daSJEUNcQq=t%lz9QWdhn85ZF$ zl~JAGt~Pijd7qS1-nP?SeTRy9^@}|F*6PPT+UDsp5-#Kuo_0PKW1PZTnjy-6{Wn3By zD_f{3NUL4=3nPmuqyQ6LR91`W%C%S_DVcGok9ZAJuUsg=xKn*Kt>3-F=u=LznjJqF zq#mMq!P0zoT1Lcx0o8z+GeSoufZM96KgyYCr?P)#F%hlI#Ul8yquBL5%byi`ye-oz z2O9#1j*Q3sopyCkd>Nj^a)vj!)2k!U^SGuDFSRwrD+|~c33hKLlyuZ?u~Rw!{LUrJ zXLB7sEHcvkDFC*o){x%@YdDp2y`_NkPu#1?-E3Buk+w2hM9?Kv7_7-4@Bb05*fFIO z6KZ@4-EPVyeL#flXVDMJe`>Vq2eD`p{~n?|oNLbO{=f!7A3fh=TDHbIjNssi*>zhz zJ?2LW8@z^U4MJ(l>a#yo*opp>o)SiMw=PJMT9Xo2GuyPTfU~T8a@|!$vQ?|?BRh3; zW&;A4Qu4I!a;_lz#20|ea=K!p=DcKezt=et%heK5-c*I)3;q%`eCFRzzvF6=fN#9t zD&LBjy>(RB4lD65f7@O@aX7C%WLKAxPt!J_lLTK^K#lp#k&sr`rpGhL-m=^R6gL0h zG`ci+PFOWEVfh7Jd=kOqe5~J~8UdkwF?0Adh^KZ480TLqq_S-)AgRx{;*NTadMrDM z=CH)(qs8ZfNLm)zXZ&{jcqQNSrvpQ3CXeuOh-QQ3!f$#cKNh(AEAfdYC6+IpKTj4u z=j_hZAlc3qPqHNca~|HwsjHhs$p4yuGW)go&9U4KEd#~OC;|l6KF{+d8RzTT*F+zT z3L*h_MK@1vCM~7#bdGdIqA!o_WkYvHjsz|)T!{SF(1oIkB*aIVt9cuf{e5rXrQQnz zRVi%MKD|3RpP-&=2Dlz9irmFmw`nllKq=&PJ!+iVv6#Z)>%&77QE5w~VWQY_Bs zq%LM559NLA*-7$yj7GS?=`R-o0W%VTApPTwbH3%lYKb}=KduIkv!`9!lqL^sIVJn0 z22`a{(2eHe%!_*EF4Q1&icIxh+AVkyeNpX0*v=;*=R3PtkMH{fN=9#Col!8h{{EwV z3U+A4g;OaUUpfOI93y%<9%P__gu^1dA6%DzusMKSky~MRX^?LD&azm;kkQXz4ytRN zil4TrTu>y$Z!_<@)9do-{?_PvQOeaA7=*80W9Pz&szPqS0$+|wCciHZd$C7^afb~8ys%xj*o;WSo2@b8a z4)ZR}Md>iTar8>znHj?s7&AU656=yEpIjb8!u3{GptGhQ!raA0FEKwfeeXN z;B-j0{u?}LYbwJ5<91rSu%@LDXTK})BB;!vZFuT^U&lK>h4&f+f*cvvk2bh^Njqd5 z4!)tgwm?q-J$g_nUa-oiS4T=tA|W`)Q{V=`_ZIy! zyHuFpXBs9Bn&uB-)>iL@{!T+%z*0(zq6jrwBg!NtigQGN8q8iEWU(VgCZF9cZTM>qm?w!nCev|$x5oj z_vWq>v*?tKe)1zvb{VOvK{RPTncEHXr`n%JoP=F*T`{Jmv9k~X*-e(J(1$JCCyov4 zR^TR!K&2Z}6jQ`b{!xX1hXRZDElIXIA&1Y3Vf&l5Y#`8!HvN{kVaYk@o9%|u#r$Qn zN3c+lmSSzowLh@D$&<`#@Oz(#H2#Q3^g2{s8!0qik0e0n;epLDcCYz@bRrHyw)C=^ zKdn!JZ*h%2uKPfm)lC)$(1ZLNc1oR}NPdn7y*f&`2y*J0Rz#z}!FO8j$hJsdX;*rZ zAT+K=BH=^Lh$Re-UPwCHQ76;5=h_*`s9J21KJ}w*yPtV;i?#dnZE-lxN4Rfq?`-i| zeen$~_{#YOZtm|hYJvGB9NgDP)O&s0o{#zUo$aHhTGk(Ly$lGPIhR|PK--vA`Ep3? zAPNR=+_W5!cXga;8W)E7MV=G*?h^&CTvCDQhyz2Yx%1>pC`ka{Et{6=1nS(mZ2R6)F5 zZk*sh@$nmS%-DTy3_l!ydtRX+p4Y&<2Y<0&6pRBGB$1Ejaj*a@mD#efD)(ni@io4X ztkesY1@K3X{QAy%gKT;G1AoeE`w+L=uOG`H#vONJ-?l6-8e%(Y zBFkNm5#5RCh0FlpUv2^qKo&`lOVjh;g9o6CkvLBeHNchKl7gKM4s+X`E5197;F2}t zqSi@iTYLQ_&}U7<0MDPgbjzWVYgD1V7rWSV6CzKPJlav z!Y^Paqw3bllJED(?AS77)U2Sm4_I--Dkdq=nKpvfx{4*2smUa|-!yl(jQ9(LrN+y9 za%56_+44lo%t1SLNy=F6Gc3P)st$V>G#&yK3qK~ z(PqhjGx>X+hQ)mAJNBuV+))|wh5GK1t_{vmcYRb>S={cw>D0gahvJ2_rZ*?+nZUY` z-9YoSQ3ADm#hxiUwiv!jObs6)=pPDY%~KC0u($sQaOQ&0qxD3+5DY3 z{(5%q*n+gE!~?tr>4fu^pdBof{pFB<<^db@t#~OuHHY7E;FP)?1SNTmkfTWK|1wxA zDS+9+c@{f&k9wr(<}Y*Q2J*}|qOZ322MbEQ(PV@~?qGsfV}wg7`!v5D(l{kM3!ug>sNaKva zk#vnOgpZY}q~&=A{9kWWh6DwFo!%}X!E6Zf$^dcLIUeUA3QCON{*y~1MNE{G)Ir{& zB~E!W)^BZ?rrFnb^k_3qBx@&LINi9ZQ{>-vBATKi{2z)Vs?1w1m!SOi#)voT`UgS& z1Mi~y-*?a(b~8Ass=e;lUPy&Zi~`ArH!u}m_kSo~r}XU?jIJ>G;IGCNMqDYUmGi8+ zHNPsPYfRZsuFiWlw!TdozwlG*?pt6dpDFHF#5tA2-m;SMXthwNsQN(_<1pT#LjQZD z=q)Q-F{%^xPh-S$gH@&fO*e%~7abMWt6=B_>sT27rPZb!bt-gvVXoVXFX8?5>yg_`t#+@ zIMf%6O?qF5C%j<3KNugVii6M2_3P7b>zy=h1cUnm7olJT)Bg$m4IY&S)w7Vj_}ufQ zLnq#4tzwHI90?t41yjzV6krlk{^aL9XycBkfd6VwYH$0R=&-gcxOd%kW6)zyQjPUn z&~zxn>bEZYvQ9(T|4umP1nkJ$Llpj7X4r4oGUUdb>!YLOz-=~BV)~ICF=}@*UM|kZPXWclL8(E?@QA3ASr`pjOA!iEZ^hfBfhXZ@(YgD zRx8LqdPL3CuzF4n?po?{PHXjxGD>S3ljcH8_ZUO;DCh|MNprGjFXwMGj z37d*F@PXVj=w4k$d9KP5HPi15A;4yuzO_XP)%$9<;}1He=lPL`)oLj*_p)b~S4a?Z zUC|PR3>o>I(cxT$cy;7Y^&$-@aS4eE7H87c(F{3BNy&ym7ea;lhk|>&Y8%9|rx-PB zUmqJmk{ps{hvt~p@zs-#g-!Zr``hY$IiG7GE)r?F6-8aj1%sJ)eZdOiJ9}+5uah7D zTWU1ZQ-DOH%)rf__iJAu=%~}AbS~8Pj0v+qwT^ku+8XNZbfJWejZH#GP@Q6C_ohr- zGUt8U(1VFa-i*5SWW*1+Aepc*frr^X<~dyZ%ha09t!*5*Z!267*&^GMcIf7(QO$SA zh#N&^t=qwAG|oL^MGYpq(@Tz%;;pw=x(|;JUR!y%4ST(WBTYi1Vbi{YIhLcKWFcR; zIQvX#cY72U|76e->2_>JT32owExYL*viJ3|ekr?ttx@B}p5&sDOWq1d^}xw%Iv+9@ zqlZJH)xmi?QQrr37nJ!V)eAt#U;KIeu`YqACk1yd+&*JLW%T#n>Z=~*b6Aa$!J(1> z4a|8m6a_NB6aJhmG?&|-DnY^u3Z#Yq3JVWEnXP|Mf0V^GYoL45Hmk3@=cYa=-FbJp z&#`sHjR?~jLgE`9KQ)DP^$j#IY`j%Ct+!Iwiu#8HZ^ z_M=Vk>21^X@pc$(7UR+rXoDI`kJs)4yy-8mjRD$}yA`abs~b$a)y53)Uf(51Js_pA zByBybIpU8%U8C4Pq3=-fr0wjG;oy=Ch1QnDME{Jz$|pWP@q?ISMO6Q1Z5pa-YG4y! z7!*UV%OntET^%fOK$%F&p!I9%XgU-`3JmutI}~SN zAQG+&;)eK#Y&BRAET7|$M%c><`Uj@pe9(B@Q82jYB(N;dUy}f*Jr6ATh`l>CbF!KQ zuJ{}Y7b|*X)ew{9;FLi8Vs|93bGO18x1S$nR=&faay2&J2o@Qn7zH8CB52zY`uAd^VFkRW&CgT%9JI zSjq@A%>hjZM{R6<<*CyjO=Wg@8XMkVUc;l_t)6YCy0JdC{mNNqfK<3g5ja2@^ z%=|vscx?wF^~IkI`swK{+*=iXo@5kazLTw_=N>b>B}cG=9@{43#Kh#l&=45|$B&nz zkjl!cL9T@QH@^wt;o)6C-D&So9e};y*f@+7HWwTz)kyWCd$nxx^?@&FKSiY9t@=G~ zX^)0Qh82{nN4=QgPs+;5DN)y_mw8I7LX5h##iIKTb3ctj55K~|%Pl|8U*H3|d?mlF ze5%VoH2%EW`Z!nN0v{&RgB#P}JU52XM?*zZ?lOyVcXi|uNuJG8@&v(N*-Pm`C2j7F zPxroPJ4Z3_0`=y2v<&wGNp%DSzj=6DRO||uoyx?f@ztD%4WE8lr}8)=5}J?cG7d?A zGbP<@$R{z~36{DY{Ok?zy7vQ5-ttQ$?DpT>J8aan^|GFQ>oLpBBSzv=+&s|LaXNfV zx&>vr0!0IyXm)TCoBZ!gn`>#11m=kZ_vx21m&-YiO8VyfScy;a*40h%=E)LD+J?YC zyMDC03+@?uR^!%$$Q#*J3Dhz@$0UqErCuJ7hC1PF18dUX)+H>6(Kz~60o_X{^*smPjfI4SKu%-VZzgpxb(OjHj-;GV;E?a`>AQ=va8&h9sWRcb7=cae7S|hePsL zkk!#`rZl!R`HTJKG$G%6bS$i(X&YEFUfz@_p@%Qk7uATvQi@v;$mn~_UN-w7ExVOk z_Vn=-^1u*Q%j0X{?KHR9&Ze5qs`^$Z^~)0DBZ_p+0z2!^p?}715WgB2TVPEoEPk|) z3aK;@0hhCK*)J)Z_>AxMN=Qf^Sq>#D?o5?l1Zz4gJ>MOA9^e~1-=aaem;$X%=il0s zvc?%+Uu>;B)y$pFU^cs&_>1gdKa8FpG2HBR|DLY6#Jjvaa~o#?zyd>F zqO+0JobfnsPxO^(Z?ao_ND1lo(WnDeOi_b1Bb5ujeQ)-=UkMCPvnxuQ6izXL?@6*q zhVI=M1CaBXf99nvGR7To5Vs}L%Ag)qNsB+=Y{CBT#ww94oG98TfsK0)K zx56M9*%#KRG+NK9V%2Y-Du6(<+HAV%Ic;Hg_x79)vE&9Y1^~Duvh1R zFCe${$Lhz=b~Jz_U?1)(lhJSthm!C$-h1oS?xboacxiZN+u58%-Z)~JysRWbB2mF} zrz@gcm#>(%76Kv8cCIK$G_(57&oVV8wUz60T5$5|@^h_Bb_>-%y0mZq`I@rM&dxU{ zkJt4FBTWq(gA6C<*IQ{0t<1>R@-&#UhW;=uKG)>Vt3pC)S6MCu;qI--T|ccSUkm_M zOj?qzT~<^bD*1atG)zq0>>|1>8Fgkom29z<*eIIw6GPG+tFzApK|v=^@g9FLiGbrk za;5k07`)eG6KoOR^dVsOW?at6aIbco3;K*zh&7!zecgKZh=GA=J>Kk<66G-7%!?2f zwll+8#sP6pH})5uUGzG45c^ZL&+&BH-nI0+52OHbtX1ts^=mH}4=)T1-P5={POVDs zIw3)Z9>Q^brdgH@7s$Ye(KFRo3Y6@HM)g%rCai}IG{>2kO2*dXSpx~(GY2^+T9@q{>ue8h?s3~pORkjhvTcla`%QPf1=;J%y`@nIsph#lAN^gZ*NgeMGo(`Ypo|e1$MEB+z z?AKeXqiZjUkEq;t$eXVq!WC_sVq11gL1Pz3z{jkf;?=sOW&RxLS|8bx9lMbk6n-O~ z37+a3OoRG!Ap;)ekn9|v&q(kMC@jry9%SS3muh)yR4L}u^rKPka%r5W)YE7b9^?RB z1CQC+{>L70AAeSqWq=V1Zbt}sl8jSeZ2t(RkEji(LC`sK?a#5q8Dp`MLL9YYmoJ9Y^jQkdVsvmYlqm149=S!1hm#v*$FdPOz#^(mCM3 z=e6hH>zsq15MZmF%A3v{g+f->Ba7RAOg70>3}J&{w0X1_nuQ;sI?Zew`X=}zQt)P)6*t*OXM^j z9R_Vl0|mgn;dw4QBZpDp8=g7@hC`V>xK&8OC0q^*;X9c=i6}=4=L>|-CXm(^bNa5M&bKIgrojH z2feIM3*;Bp$9>r~Af9RmTz*ET`i<0zB>`h(Fg#ZmKV4!;8>5Aovq^>bcF)b2&mpU* z2b0P1alo}92|y^iP5*c}^_iJ^BEzTGeY1wNBMxfSM|0qP*d;=qe*UrR(L@**xxri< zICQ}!dJ2f!Pyru?Y9+dJEVxI49yvNL_;7ZPxN%6*E}?iKvH{a+y_mfk^cj7$==e7tst06x7u-O7m)rs3mDeep)u z9Jet^2nGXR!Y*@=zvXJHZ^DWp%hMyT9Ty=}Dku2B;aM}=e9`3@xMOmpBZAizf_lB# zBq&ZA4oCJ!v4zsVskGi9LV`gtn=2>q9a(0L(3K0iG@g_H6Jhlak>`REY5 zxMguaLTooz9=64xu_n+5DRhJa0-zPIQv-#%|`{Z)3MfzM{F}U}?{Ga{A`?^n-JzNF`n6&igXZw;) z!F^-J+8j?>6|t*{EM>7zjc#XD|A`ZeU)r8rWBks~#d>Oa;r$XwF=V$~wsycDEO+tn z@cQT2Ohc`RMT%xhphLfhn;C8jwPx`rg@+@%9nuL%!ZfbBb7Xk<4~M%U?4!MMa?bn9 z`Ka^S^Dm4?%hV$=zkBEGXq21nYL zCCk5bgdt?9k;$4kx%cZIGq)FX|5N&TwVJkv)>rpByC&K2X<&n}#apx#v|5Vij@;eE zsm}x(zn87*=0(1Kzd_n_zIZ<<*o&TojUxmUYWqnPFB_vH8(ON zJgpL(AsUY0?2o_LUwFAv)M8_!3atvQ;(C31J$qdHB*GUCFO}wBiW)9k7b`0=Ox!^D zfLW*AjM?1a@91(k))a;0A)2v9npg6JLIts>{9m1dGyNZ(i;f;cph%wm7s}93>Gzr& z89`ntd;8e`SjZTHTyg1o(>|2T|8kN263l{>;Pla>`6|td(Nz0?qWpiQFaKBKnhnpl ze}pi_|0#=&#R}^Apj-3*63=GS?2}gJPv!q5B&U-NtJci_dCRf!Q#qqZw!|{+zfn5x zHQ8Ud?Ep(9|2K>JerwK!=kBHV@^9hTs4?vR% z6!bW*G&d)ghT3J9k0rhhtyHglzOiboP*eR+pMa}$dW3gM35l5l)$=YqcrG{LeLX~m z1Gs5fr59hy#qZB5Z%xi8yNL`7SpKbNDBN-B&X(uqq^2ZNL5MTPyKD zzEjupqjd9UU{#tCT+1`tvz=){_5F5*)*tmIlFP0%wDBrZ%CciQAO9S^tsP{aNChQV z{-YOa-hG=xxPfwD;Kd@NPpVfASIaap7b|ipW!1cOYC7)#Rd@`l&K7m3PIY89jRIREa=zgZt zEQ*TxR3GPXb>KA4*V4X|^U=a7#hmtFR-kjVVI<(*!glbd`{;O)##^D^0?zDyro*is zS_$i9Jcq`;9|>jt;F{$PZR{*Ro7Z*0wm&SFIXaX%e7w|pwZ|W0PfTL!O!2FPAJb7^ zE^A?_7+q^l-nl9}v9K@YYoO@CvmPJ%OTLH);6xM+T8$SIlCW2rPib2Sf7^nJO}~37 z7hutC0X@j={?gLX2u^OBg?6w`Fz1fDSI5l1)t|vW*%80y{4g&Q!WOX0Ud&w*3E5c8 zq8PAU%t8mqM&(XUaLiW&Gz;!)d;Qhz-e!NkCb75n=eVr0ta9xOd;l^=tkFQ6ZL4~} z$+YrYu`-pw;SpQw)Ljj5AXqGw{TNN)IFn71xG zQd`$REUc{hvyMO@3)=@x@0-_wi<7VSkcX&eDS_jjhrtIgSAyYH>}PHD`Rhuf;fl%Q z5#5&X!t@{;=ig|&kLJCPU`rE*KSRs+D2c6IC;47h`g-F{bLBp$45;QCxC_Z}5{q$J zov9AbeZ_*-c{uA=rAkF`G_fA`7PD6+c5-zSKfNAJL`=08Dp|ZFVw5a&I7#FjM+E$q zZiTsM984!y+SpjAliDR`CpyjEAQN!mi8`*J+Q|>+`L&Fsha&)Hbk;`5=ng)d(%B~0 zJ<+eswFcPT^R+(u;*lOd2JRMjZ8n^j^E;1@p}cNs1qE0b2FQ^BqrH)(!UEe#8x6~P zrKUw%H;(h#Ej3@#;>;1>$t!i~&tWooO8!^LzV9vq1)7K*C4w7+e@?9y1XM!i#Gbs5XvF#rhxaiB&_sJH1N8( zWt4f+f(@7atlceXoPCI$bb-i;Eb}7vI|e&TQS-i#^?Uw>q&a(@O5RK~&|Q!4SyyA1 z8IE;`h)`c!*083j;pXp!oh1F8dE)$KOTpn)(4Z+D&!|@>U;s4Aa@FutpiCvo?afF# zgS9P)>}k%=xe^C+_RMPm?!JPaocOvT1=#rH!8b)L|AWuhdftwn7{+h|T+$XD9&$0a zlbpa7Kwk3PJ2$2eM8ox8`0vn$T|d91#(wfm<+M{Cb`T-3O*-r`RufV8;@7HgxDmI2 zfFxZ=`?RZ0q}RDdd)14b4SEwa!08a(Nt*S%W7~e86z^l4!lkvCi>$jS+sR%fx~wvi+HN}*g{UH-yqG^ zDNJ|R|IU1E)I_66+kP10KJ-EsDZ=>De^>8whn`w$1M2=!-d8L(1_F#M1y1z;0X5`)i;#zM``H{3S9B4C$ka(rPYOww? z38qvMLH}EgTecxuJbw%GeaQ6n-=oapgJA{2{3x1|gqV zd#+rhWuoq$n~Fy8xSJOD9@SULW|kj+oILPLH-IO=!AP2zR0U;Z=gVS}f!jj+DeA4w zv$J#g2Ii9hH#}-ijX7rTd;p z7CuOV_KH?>VPJR*Cio%N6C z3^)23V#LKMNN#$BTVHARa(@}BX3;_;RCuC0dJVDA3!ur3QFNbg8I_pjK1{|N$hi}> zwVenkwcK$%@8heTLHS^j3f{0AQqO@dP(%WO6G=)tiMfoep7(HkS}e-S$rTZK`SP4s z7ewORMbOp8R@Z!tT2DI^ghGT#=ek#0;U`7SS9i1Yx3w}wT}~@o?vSNS^@FQ zEIJ4)$Wp=5Yn;!emi;lI1ZqxeG{3BvC2z_qQ&77%hWOz+kk}F+rK}9K%wNaiW|$<& zU2#07H*rXtPwykGl-_aJjh_dk^*6pNYr}%%4?%8kW+6(zg`p@y`}IDReHp{ zO&da+dEnl}F3lt2t0yIWiHbm6)VB6b^^Is<4GJI_#nQD84S0BJr^gdTnp25*ohEHQ zFjbb!`e@*tjW;i zB`wy<;^XKFfw7Oca-9|yqpP2RY%y@l^;?aR-*WbB>Q00pe*K0O>FT8$suB0^ZCp%p zf8y%HdE|{$Qhz&o$%z7a5KiE;{n5xmvlE9i~2N1?=cKHH65M4sPs3A{Xws20lG4!hRt ztBVpCzK1_UtRv29ihj>6B(FqPey?nQh$2q-Am9&ACo!Ucus0NeDLMZ0&Ao9?Y(`JO zMDv=W-eN<)*~YQ&9^s8#1zn$Rk^>E342_0cCzfs_Db+?ePBpDJ0CT z&B2bxz1dP`A>925FgLp}J+8dOg3tbcgh+8FD&O(&;}?ogvq_^b2_pf%YlAp6kEyB} zy=K#iGY9RHVvsEMhWPuZm0eq^OFJ3(LX7tHWdt7PYfVIqKf~Rck*t6UyL_}SyBLRt zeVndyt~n|IMhS*~3*WI1d8k*<4bE7Cndug9E&8Bab=|_#BxlOvujXx~SkGcYJX;MZ z1tb0q6<3#k`rd2J-&y;2+FwA9Xs#92(7n=PyqPMM zvihNM0r9}j39im*P0FVI{Eq;8)A&cMGu?Q!VlT|a(2)Xg%(&gX_(Orx{Z7L1;762; z|4w5Ms=))FW^+U9T+{tXcN17$;AvZ3Ak!-daKKe%(WQ8G7-6y#Q>_AGikOe=X>-RCg_3FsfVi}+|fSuitqX(Yu2gS zK}=S|&T&f5o8Db&e>DCp$<*DHf23W3J0s_w$$RGkM!;k2u3>TmbxXHJQ_c-Pq6=kR zxwAjf0K7?PxSWOm7}|=Ld%yMS^v-k%E&BFWcaz|jO}>%_m)DL@MGS*h<{CZMG*5{a zY4Eputk#gz>eMSEE%5x|k9!w9vywpDWL)mAAzj1XHvfXhLVVTBmTEGBjI(3mK)o3j z12LT#z;W_c^TS2bp<@CgIX`DgE=88AUq+7{WMmV33%Uh>>`fB=2M&rNGizbz88j-E zFD*aqc8H$iS&7%i=41`SA3!d<>&s2w;A zGkq%55@h?Q$J5G}#4=VNf0?EkNi&jcXeYLfzDHodn^?+P61&%KJAan~H)rdyR@iv2 zjefa<*DATVeY)Y%A&+=X^G98C!H_?lr&g!LZT7Q&q>faH#6e+6lls!1cN?O?Up9woN?q{K1J1(u)oxZ}YJHQkH|CMdGIIU55)8|eJGqzop=gr?jA*B?S^bXm?;pCx8{5avoFC}kkd3ceZ9+O8$?9!)yh&1K1oq`7eIQdvCn-RD;ZvQVQR*VgtVr7k~y70AB4x4O5=~r8m(3YfL&>oNL%HFx$z=btd z2PMUZ+vl5uwZyD*8<)tNo|uc98Rt3zA$(!Cb2GZM+$SSeAHb-6z%fy)(gA>9uKv3W z3(JW<<$pdHJJtX3E=G#SH#UZzE~q;m+gd1@%|M3Vss@r zVH;&5iu?KdMbi^#v%z*w8JAkHj7iYv{YEHC+SwG=0+Bn;H41OcwIT5+2m@zT%U)e%Sz9Tv$v^qCm@^ zPkq$nZqX(JAn@y)&N>+icUxpc9N1{jDmYU7mkZF*QrqONk9sV%c*;WJeFBw=!?d@Oh0R}U9 zE`5@IO%?w$3$eLnX3`)22OcVF9x>6sHM$Ob4B5YZAdEyS|ElW`kN8tZp~#;;^6X}JK2u*)GIS*5rsItoxoRp zTasUNR=ff4xmilHDKN&U@)0z{`|})Ru{9(0<<1P~80r3)1H8gGCaU5}F`QL9m((UkGNnuBEX3}!l=@j5u-IF+Rj|Q> zHdX@ZedN4ouB*H}+kmxgxEW?YGgU?pV|SI7E8RY=G6Ia(+#xE0?$S1 zFw7U3sys{wfHBZl?VC7@_6@1tNG&aB)%`n>1O*vJ_1gU#)YH`m@sjk@4?d!75-evYRL6g`ZRwV{DLHOyE^rn27D zlR8ZPlM7Urfz;N_kRKb$m0yLM4F;a1+>IXgGH#{nqJ>*kx(UcqzHq{FeiY6nHdQ+A zW&+qW__@QPoptEidR1Gy^hWG;=8|ql-`S%-FQPe|c=@-|llbJPyNA^5?Fz2%_dk+LqD&K9BR~lfi)91eCUn^}Dc*toT`$+tc zJ{62FAc%JAtZ1Vl&nucnK~Oz zhywa8INhv|=BzyW`?BxHZzr{o4-PB^{wSu5oZrCw)2sFwnV5MmK4wj;=Bs{`5c@Yd zX>-PHeD=%8c-yVb4H#f@WG=amreZqrL*xRCrZ`l?bsDt@;jB2Lb*L{7K(H__rDG=z(y z7z(N|h5H-DMUmqbKa)+5mp?j5OG`w{XYJobmy1z_F>xQmrTH@$R(#c6pDk+s!hDJ%47n|+TqEs=E$T2?Yr%!0L zAL|O!mldbVoSKb*R`3ov6?W<^4m{musC`O9UAj-gSsaG)YaL<;l+{|MLtFNFY*l0X z3mtgnip2WghJMx{j!h-VmiT|zd+VS$yI^k+Cj<=y*93QWhv4q+4uQelH3SXrBzSPQ z!QBb&5Zv7vxvr^Ti_%)K9uahzK1|LTV1ofa~z%9dNbU} znmeOqbz@%EM7z4`&wAnZGY%2=$$e*q4Q0?~poEik;q%JpD2#ifOB^~cqD)DMaV@j6 z-+svg=mspw!o=v>7sr=FCBZh3_P2JXsL}mX0Ywewi;4u{f*O`g`S@vK1bS>S3=xEv zQkw_ciS~}7fzHj94X)RgBWd+1cEXCSYfbwqH1wM{L6pS@aV?~`GE(ayK)~;_-t&fJ zUssZj?*Up&Jg9yKw48RHAF>H4ARxGomi<$KBpLwh!l{$9FNz81K&wbqS6B>cqasU*piilw$AxjPSf?wo^)i@u6qI&x_o-CcmWY@$wV_{#@|eKP6H=)gw%-ZC~zD9*QW24G;doZ?F&>0b|Cpb#Si~4 zK$8Ez$^SWc_ur>wn9b;b0hT7?E3ETCg=)XHPlE{wG{CYsf5GP@yTEr)?OVPlpf_)E z-=o^Rg)yqY>hJH@eSwuEyWFZ;T2dsa;4Pr<NQ1F(DfI$KCk1SiEs@{6Njk%$re0WA!Z@$;$_ zk7`Aaul$O-U~xh()ii@~JG?ZFZX61+GQPx%`{68+>YwV$Gs0VB3Sp}&t2SZ<XN=?N$+4tTlXFSEAQ7tHO+(GLr7bw~gPAI0qDA%MK9~#rJ4N`;Xs1&yu*Td?brdgZw#!_Rij=_mrP)_U=Wsl2 zO#P|?@LxJU4$ej|QgrCJrHWy%@<_5g%RxEpXTg~^m&XswdTeRe0vY-09gWMHSU55A z^1J(s4XP=j6y*}B4rXr_ak{pLNiUQyLU_Ucb(yM_8q78w->l!00pY{0?-~ERwl^oE zh?>l%JTP{+^*$>d%kA9>Ks&)i5-^AN#(#V&fy~JzlrY0oy26OpoW5X_`TnYJzn6B(;zDkjwGbDH zW1o2tf_uqj;FW|H9`VELt0^e{Bn3J>#3nTdnaP#-+yzFWr&MpWJdq=0Orn|u4^~ws z5*c1eH7^T4GrUb2b8B6+bYnavwy0a5hRY0$c`Jagw9VV+oy&Tk)r)mBBI#Ei-P-!Z zVK%dQmf4J)x06lIkxvHomcz7Xg|l+Pf(J;M3%53;eTs)!raLkyh{JEe8P2`nl!Fb1 zKBUbRiaU*YqOOBT!oFU80IogugM10%`w6N4eUPJ#8;Va!O1s_WT+iJ-sSjbTECfJ9pqZEXOrKZi(=hbFx|{S!|5B-(1gZz z;O6ro838a8!u(O-S2#R3-q3N?cx|yfU;25N!AjonTz+ZVQ*p}B3iX-*J|R7}*b{4* z8%-|nb@fV=m6GM{ObVRisNN?y0mZ;00q-&3eM2oa=C)(bQvUG-A`@rRWy^ilWy|7% zrHkxWFp>#2PV@rPy)`?$T5sC^xd7LS34!2Anl*BG-ObHe7Z6WpFifBpQ2#j+f4#2j zU%tkTZ0^pZFaaI(t67Pwn10w`N5xIDnV&~1I}h#-*a(8LZ2C6oBFHRR#_7%mQ@ap$ zvjQKYwW`L6XjTu;?7z^i>+R17D_Rjehsb2s5wztlA+N2yk!Oib+!uQD@e=fA-odCW zyP@TL`QmYC;+|=D#U0FO9(kkBoO8lR;GED_=KOtu&OiX^X7pn6AlE^AJMEplNfDcT z>L|ZdVbfJa(yJ0E223$}EEz5ng(_(|lA-S;PlQ7L+&t%IxA@@Htd#01C>$h7rQA+_Emj&xP*hY==e8=*N@6C@f}~)xqRn2NPR5B+#9rA0UtVMi%$5 zI4|D%&U0m9m+Z#Q1?!IaqJGyPnM3;Zh4Xct{VBMYq);D+O=4%fKzt)LNV}@}`GLBr z(VRf;JzWlonH-misjwXX!IO10w!^v{SmWx*tRK%4X zv}qic`eq7rkYw?#Y-rNP9OrR)Gs-%etF=T$r^B21wdq4zYzoxm)QU6ShP`7E9Uj)? z*;A$UU$n$gQ%^r-S{ejOznb2_SIzT{2$Ew9}1MtC{ry693aiq1@Mf!r6=2w}B zDeo(X1-$1>zjx1q{}s*uiw(rXvqn)xFm?5sWy2kds-3cui84F3oY2zHhG6_ zcdUL&7gqF@=4;EhKW4vTO$Y2s!deyuD&jFMeM$YF5gd;D6a5ZfY=*vnLMbW~BGNJvTT1Y(hzem&~T4)ta0kj!=L;95LG7BOtMKNpeA z$Nh40azqK=|BEGJ{E3JVgkxf2#3dvQ%vH@`a%+eQqMk7X%`+0o$?5Cyj+eH#XOfVR zNCjydnY4yG82q6~2iFI_*y@C@Bf^5L`>X;4z7n%A6b$MMcHFzP_7f|Bl1A zaM@!N*x1-Pd3mV8Kh&gONO+fEjZ=&nA?i{ZZL4JT@!OkcKEk|iYmd87yz#g8Zn0Wm zjj`S5fAu=N>YN)FB3eg9U;DXxB+-C)c~4pKKebK%{|$EiSB{jnW1{6@juywpJo`_$ z?_^GTrCvvdNL9Qq>=xQ{g)BecG_HoB~iwFB!TLum%D&O!X1DtYrRPB ziZZ9@f23iYZg?T$?{UM-jwtGMNm6&cnC{x<^>&4$3?uGdmN5%JVj-g$%We`5uL^)W z&Tj@B#~yXq7N%!3ImMY)2{f)b?&c#;?+EH#$#-IuFZo@`_(AB)NVR?z7aMm5 zBW`SNdX*f`lGN1 zD3040ZUs=}PeRG7RYdx`WNe-}0M>qH)$=O+Gd2rq_C;IB2hg?I zNE+ut^Wk&e*qxa&LiT0X?4Zxzd(cp2TNBT1Jk#&hAM_^N35Jp}_4;HIGl%D39X~J^ zBO|akdBSeCN-NbdnNXw<6xTA=Iw!SXdD~S!dQ%kQ1s0g zs1J9SpmA%{k+l9V?}-15GdEE;OC$^fqtXwjSG>dNnAyS`9qfaGGrxD&mT$Q(46jc; ze>ywT8)-^VU?N{B@(zZymh^CsZAH@*Us@$45!821*o<<1+77^UQ%c?gC#af0D(><(ZVN7q%UWThCV;bOgdx+bpH5 zNAYJoH_2qT_9>G8(N(j~X84(c<`a1lL3b(7Q>dS!Z3oY#(bc?~b2>{buP^n^jizaI z9s*}FU$7jM?wl)1JV|y7-X31@q^^1~M$Tz$*7FSB_pbI14xEoS>wjMT!^5mZ_ZTdZ z(d8n9NGm1Yn$f;oTaU-2Th>)V&`M7r7;3g)uXxS}&RR7nUnGy?+S}TdaFq!+bN6o5 z1@!YRpZxN__S7Q(*AHP>U5`W7=MJ75-mIC?Wqxk_6K=N#6fO4YxxxMR+IBxOk=zC7puZ=4R>YPxO!#W;N`l?;iY_@n z%+ztzcI0(0z>T+{b+~?g)O%Df7;p(TSS)ydGz8w{v(GXO9j)E+MXhJKhk6qB>yjLQ z+B9-=n-*iH)XP3kbrl2Ny|W1JME?e!8^QH8;Xu7u#%lcTEe$1|DWt-Mc|q4@>g(+i68ft_FN}#+z!T8=cHt?ML|s_YVqdl+HLWY-gow z@B2pF+34E0CK3jo#C~m5R9WMX$+=(caYF>RCTQjDc#MCaC*0Neazt(sLPbdntv_9? zfw-IpSZM)6z^h-`GST63`K(P0*P4KMEA46rbDltNCZf=%+sczTrWNFiek^Nr)U*9= znr&D7cv|90;I(h6;6TdUA=is{8A~Xzzo+~;R?8y4>p2Vbr@`H?`^}}2)Ar->S)K=y zzlgn}k%4&9e8|e9Hx!}v1H>)k7dnsFR-L;oGu5Ic5c%TeIj=x&kMFsGL~r=6uXToz;NjSz{SC0Zx5Qp1 zGCv0`SUx;DJ?O&9?MPyP72IXWxVN{*_K3Ye#`7AV;LMgP)JF8?izxA@kMFX^D87js zC*sT03k!9~5dYa;W@Tk9E~Q+btAys!YD$8eBE4dH9pnX)gmsU?FwjTF-1{h)sLtmWV6YWTEqguGJG-iYi@J-C+&3oBNBC zrWDpOmEw|;^Q2;EN9A)7>=7iMwV%O3rnRf#(+`MmUUhG9CoW+ay9C5cQAE4WHbjmQ zc32tpYvIF()x1Cb8U7id!7y;P=(Q6mocUtr(Zmfv2Ea%S;jPP#cn1SQvb@gPn^#12 zSou6A<{yp5iHL6uVE=-%7-yt0e76&NDb=t&s$0<4pH140F>;0~x3=YMPN1oHLAvC8TJO!_yL|hOhjW)gXXv<-tM1l~f-&|}A-8&+1yyOcj0!c~Za4D# za5T}3fr{+Mt|y%GC^mM$-9JCM^d( zwX&r8nR=zhl0Nu0;Y4(h_zkzy!y4O^XU80&^zUPXbqYHHXaFBuvdGV7j`dlamEDSt zNz;S5R(gZzkt5D_HN8hSG*?e&()NmMtT~d|QlpZz3o3d!>cvO8>z_tF%nM`9Vrl~v zCR0s9Y)~u5mvhT0tjed%a_5>BXjcyp^79~x^B`3j&q%XnN;5YDGw-tiB#eKUb5X)X z^0~k@{EntHO>Vlx*cwkUCUvaJ%_`WD$3NtTuz999s9INg&y7Ji8=SSKn}$kP%ETVt z-R$!BVzDe<{f5tl7LY)EYm!dP&cT_i1|7Po!jrc9n<>K3en&JLF_snxs(DcLbZ?&H zrvOg_G>Cnati}{yN$P6>Ow68YB@+@v^d``(6^-oSh@9y4;*nAA<$S6z(9uwTo_z5s>iH0(Wtp+)3a@{&E`o$IZP;4u zAt?0p`19)n{mn(BCgVQxFs zttb6%b@qq8J2SH_(TP3|oYy7C14%%i(H}C-%g77WZc(R2=NU$G+boO;i;>w!O_7X; z#F-C^I;T{vPnXIt{I_DFq_8Ve3qn~WtN@WHBcGvT(d!?Z?6cQHjr!hQ5+GjOcP%>_wMNputm#LR*t-*l%k;_3q^nxe6|T ze3&zQ%B!lsNWD#kP?s z=ry01D*N{55<7jI--~Jv1jI*t!g}))pLzTctpBw5r zBzp0xhBOA4PLAXqwTLxqv=CJM{)=w#Q zS)k}|X^&O3HNH60EjK82JZEfSl8XJiK2T=WNj-@4G^SoN&uODa&~~0v>6re!rMF)$ zBQ*6^k$edJwLUwR#@@8B<^5j1y%IC?x1U)aW7+q>!Z;dDm!*J!QI4Q>cN6Q< zxy2B|t;a;u3Ri3B3Np^>rEMbbiz=fV!6MojBJDGRCmfI-*{GI;f{3{LIgO^%Qtaw> zq(N8DB?OiF{YEFx$q);lhL&Jbnyjj>$2iN}mqB#^~=Du7oGZ#cOVIEEnpcKdWRwsid#(T*#__N~W|ccj|c z;CB^3WW#Sf??q;>5TC+8K}>JQ8N{ws?`EGp#cjE*AKDI0oo9(l5*GTqIoRtfs$ylS#GhzhgSv7ZSH_|92>KzP!KA(`99baB_Qs- zxRl>U#0UNP9(s2{)QaM8QMIW!^jJ5~Jubrlxz!UnXdEeWncopnh5eRuDY`mLUrI*I zmOWw{s9S@#t~0y+v?9Fw?{740G-v%w4^tHw%c7^~mpcg+?cH{UmUAE~FiLhNv#A?c zbBl)neY8MU&-)#C&!ovn!l>f!*;R<2k?6^u#7YAN?_b0%3N=%}y`^g&Y z)SN4sHTV6HTL_4pu*D>-&5TLi=};pFrCa)($2D2kbQ#%GbF#D!{t6zdYKtv7<_d^!iZa=8(E(XgUSjpk zQ#ZLX$>CY#@rm=tEmm^jm+aS+=||1nOvGu3LkJF2z<<$|-6lluOtspr>MiF|_=<~S z?6U4_TpcNC$F_#U12_{6GU383DLvN!+Et81c+SJ5_ohYyNik1v6VspQO?!#Vm%q|s zJH4lS`a)T!JJ{QuX#ccxv~WP_v*`14u^AFSHLkqix7ShYro>h2_N2A{3gGIemf&OO zwJqqTI}ZGG1J6BzGwd986N2@9I0)9q%aJZurm_#)7Rzg!zhhr)v5Oyxa;-vdCW@q~ z;*f-n13(5!n~wkI2G?ZlO*bKuEEc1bcQ^6gbWDNueHfK@wgrvCq!`>oKy7X z?3qZnT=G5gT0xuCSZpBj`kjyB3weAu9EiQY%#iR@RD_%ZGM@P}b2gY^NK8??T~7lW zT&8h4;*1UPVeEUL$(cF0L(Nz5jzFzGr_=K|{wbPk_%LS^{24vJ54hg;EcNO=v4ie|IG1fDrb1fU#HPV?m;1zBE2}-d zV^0675#F>68A!_&j|{D2&md?gc_sL{fXHos2al?auc>bCDwGn5kA5cn!0@7Aw(C}k zKgRLHYGk1o)M5?qp3}gkgL0^l1e3p3U<- z+b-RJ+5D*wiz|C5Hf2=CHk*=U7~XA5q#`upNkY@*Cu51p*nnQU{EO_ld|hkS`~3Ue zgU5q-rKc%oO|G!k3~zX}nNidG11wk@)-2kD$;fq^2eka!s0z!2Ob(Al#ewCdK49zK za5PGx$K*;SE^T@bD}O_Pg&w1eu>C6c{dS2E)fI18c3q@R8`*1$Gzzjc=4c z_`$mb1~XAXFW0J3K0R)t+bknC4%=a$^op`#5`^*HJ0QQ^xq)?o*YS$B>#l3U$PJ-U z{*$j2Y+H5jioOaM?mY7PR%^zHZ%p0o1gFvMMOXKNQdKmC^=MOgDw0)yB{$t#9erue zRV?&-C=Z`J0w+LIA3QZASDFKfSLb2tmSFwY#NL*XO0Ys8!g`^V&w%d%iQ#?%lePQq z*7uJMC-?-erF=xv{1-Gf9A-ZAxlqb~ z>kTW~Bh7=Rw%0|2Sk8vzF=`{54;>s8&xYKkGZU+s@qKuic3b5n?Fa40i=asudDaWF zMb}3#MfRXNc(&*o{23+$O^07oYIZ0LpcFm3OYY5LJX=&b0IPdBpRLYNEV677@|%#f z?sQD#ESXhl8_w;rrWSWD7v7d=1GA&rVK&^1hA@5}5I8C2A(rQ}mgmV$ZwVY7dSse9 zBUlEfb$dPkkQ61=lif`B38pB+Gg2**JF2tpZYKK*wsj*o3^w}wSt{)>jSZp0lA8Hs zX8;!OwU#z~9J_9U4u?i0#Tpll+k5Zj?wj)U*6TG{s2)AwT8ycb+0bNNgfN!|JY zhcdS>SIB81+rZ=BjZ+}oht|k^7kg5v=+kS6~cs$^Z*u=}xa6)U-Z1MJ#y3F)*CQVil z^KQq zZdFMdYsvwbv49gM#|lXx!Rl|U&sCJarh(sa2e=c>*pWm7#r2QPO;J9!S_hJRi;O!jX!ZH=mK1; zrP^l;Xjjh3g0S`>8cIb8>*VgU?BITTJLlmn(2e_QU^xtYu9?GP^Bm#%xhE7s1N&8$ zM_xPhcu>S@Nandnna=S@aWY2+Xp7FoOM|sWxw4Ut-$w@F*V7_o@X_2%!ZcSJ(l z0oOk|C!{nKtHbYA?1HQ3AKMJah)0>3GkU*d9)OZhn4DI;=D6<&lxBuZdMdFzYP5&A z&x^jmx?K?7U6Lo#}3S&Aae z#zpX~o6qOaEZ9RkUYW*s8y#g!R}ALee9e0SbxlYOZIX%uQj`xDzz^EpR&vrt)?wh< zoIKZ7I_Eo$4bpnp#L|-aE7<&oZFySlT8_p~opqKI#mF}WNR~<-j9Wu@z9>Y^6T0EE zSkcAl4~&Bl;&0hc#&p;n=JI^2S>V1@oCSCm${l=S$w$N=hs02&U9DKr>FhYpn63gctX@v>DOhkMO!)2@!<6D>u7V&Ad}C(C;3)EiPE%BDEIn^#XpL zahO+3@azBu*{w;qArLgv)A}_odf%JdTIu@Okrr)x57|2#O*jfjtl})Y+8{!q5W3-D zUiHuK&2KQTcizDzq|kTtFKmZ-0rH;mBQ)N9s%K+9y=Dm!2aZI^c-r2IdOC8IIyiYmXW-9N4~h zNK9MIFc9XRs-eJ%@CFf+Gox^HrBrlWnc8kGG{xJe&iz|VQx$l%P-A4rx*Q2 z%}GQOU_*|OeO#8$RC14k^m!?qjJ~ydf5QHzeOYJcVqMRH6Gat3M4tU6S+2RCFZ-@S zEac&4$_Zqx_E2alvwn!-;jrGrHnn+Wq`YE0;%3H|%6;S!Wxw0J&#^NfhBZbNN~GPh zpf|pp9jZ8Hi5z@gSmt5fTk42pR#{1bgjj!B=l=MKz@sS8uXQ;fkJ{{Xt56$>yVl#$ z7;D46ua98y;syZ&Ml3cqy(_pMa3Tt7zu27$Ho$zaSr5I{dybjR`SVa(XEqfM8`9Ir z^o0<7bRg;gy!i2VR1});6klgipx>7pK?1AQ6e`%eVEnUuDMWrl?tUi{;rgU0gy?sX zF>Et(-5dPNpR9=rDg)GLUopqFJv79b>XpPvoE3f2G+=l><~okR?gnjB$&7r`*?#D` z=_sW878YK5kAr%A#DanCIEVf*nBE!^0*pPRDh7UnksQh`DXAqnR05Z1MMl4&NPQ)X z)rpEVYi2bdyafuwo`b=Q&Wh)UW7FXovk;@!8VsN3nvS?w+rX}8=B|G z9#B5{UTct;#Ck0_FG1&-h_TtIn^cqn-Wgr7^p>1~{|D^5ks44%%vE+F60aBc2-TX(hn zt(lN5=;B8dNsanHX&Y>Gtp~fgD1B*-r$}>NUU$dyaqU_B(Z3k`-sLPnsN_ z0q!HuMc4j46)fcGO&-ZN@A-w~S=%I=GfY_{%c#V!q9QnM8ph}Zs<$D|73;tHq5TZD zqK~+0P`x5s^E(q;pQwJ)RVwP-JZ*0b(%dMHtPn91rxqW4)&}>6O6k3({?dmc#rWYv zQZORu)XE;m8PAMU6!H8`*w$eOerte-&HY7J!VOk&pXCs%H+GJx2HXjMMIE%?BNR9` zrA9r(qf#~RZz3Wv1m=LrVo#)2{y1+VIX<)aY~4Hw`7$xr;Zw8;GOxDl>`Ggf$h@a) zmvO!WCYa{s%sC|tCW9R)B(6@2*&_NVZM~wdr_gAfdKq-N`4uo9?&sE*Mas2-H&;tb z#y8c!6Mn7FOx>R!LZ5KlzoA-n8`+z0S97`AE?VFAwSPC+ybc#Erq));9Ew03?dyw{ zKDkOLEuhL}A{k{9g04ByhC*h zrY9-84;|rCFfFp>Sn{^MglG;s%0E$giE)|PozYg@z-U-XT%oUFx!P(H2^AfPa#(RK zS@I0pZD4w6j67H0k08WI&@lWo(zM0Wz9?#*A7^K>egq%KUFr9_965;8px!=pPy~mL z%bsJFJ9Pz$`ybkw(QMgj>)Z24z(A$yOYjMOWv|WZ;4JoZbG*`4-3lJBRRbA{IVD3) zGzlYyC*kAi)}-?bxkMZK0>aqLq%$9Y139)nwqOm>W?LRU{GmPtgwALObGvxZSzw*g zu~F>((op;3w+SB1{e?F-wt`);po!_OdpeRcT`WL5TR6^Kd`E++Tj|9@Aolti@ht zwmU>xEVtMWi{|}fu#XN35_&|OtN8*)hbi)1l@}~`!o$!7sa?)cdKacn%Gu}U*6T?t z7^qcbtQ-f5?!aFiwmafDV4OcT<=`BoA%23!s}{MgokYu4hHs)&e_0r_po35BMz!Kg zM@4)cZR)JA(%C-L<7VePLAHyIkMSD!;v_LkhJw0V1#s9Jo5mUlh)ig~!YV%1<+i3T zBrq;aWh9ziQ*$LCR%(F89XxNXKB~FEk#zAV;{S9LAyP!ZfEMuK=C>Kg@>Wp?7CKw#Z^qFghdYm<3@P&={i7dTv>dN6V)e&^xw_%)BEcYpfWp)6Rcj?PObVsRs7xS+*$5I< zth3*3Y^3#~o-jQo1?rmJvGak182w7Llvbd66LLM98Np)(Qd|n`dd{B{MZ1v`y3%3N z%{0VYtVtEL`uU{_Glo2uRjv~hE(GW6PrdOLY^u9QeCkbGvXO?cnE!}3@h025-sM8) zzUlU$kG+Dt)LH}(Qs)ulLHM%&j(iwVEf z;EV0Ow-|sgiS8eDm?Kgad3UMvM$?rqn5}xwyJ0A0f$RuK;xe@Jby<%p%>)tD>g7pd z8oW-3y1SWNTMO1fNDjBSD8Tn|aLzd74IYju#)QLgR zo^L@tPhPSPtXY27Fb7Y5I_nTSw-ZU!sKL2rN=IBBsaGn)yrJiwXkecdzn+Iu`TohX`QbKiYR z?~`Ql@dR61Z`lmu1TjSPRoNU@v^IF17qR3hZGQ63sOaUcWTelCcW_yo$%2lsuVb`3 zvGS~%@j%clQvTuGC$X+Fj2CI53n6^;Pa=;;XdH=~I;OVp+yy`^HvUwGF8ap;Y?Q!> zSPAWMGSkD`_;$KFms~^UWN_S;b^Q|R9i^7Hw8B)syJSqAQCkVL2lqiP72~6J3Ns0; z0NmL8V-ngz@(0aZbZ(}fma*>2kM8WJ$qTnz!nLQ;%=EW(vIZz^RwmP z)V_l37g|!{@nM~-Na4>Q=(3f(R?v7mq>bB+gl~+afCXXd!%Nb8Ui`7XlSly?7sc0_ z38b_WEFBga>~Vx@qz1Ac@!(d(a5a!Ki@mrrYP~ANDS~SB20I!XaqUl(iIo^; zDvh(TM~J&4g_}MXyTJ?987gW{Tt7A==cf({)Z|Tav}Ok2s+fzlKG!!iKGLP7o)8=Iak_b4-JX9OD9m}-vnK2&40S3%|0Ba}S0+bq!Eib+X{7%W#wLt6p1Hu*X??(jr(=Tl;rB`Cwj0D#=YyQ?aOA2Hww@2x zb-IZM_2clN4Nm+nTQR*Iz^BLP+WqOeYq_4h?7Aqb*T8l?V&Bc>2IX!uReu)lvm^1S zOOcS@Qsz#c^RlZQL%=b?fCq2k{^&~rv!K8y|EA&Uq&gfLB#Ld9@qzAqay@5TveU)V zV>g>rTps9U73l-#69;!lp_AopX@jnjW9(HbPXZYAlHZQ5ViXxPCLavWy&-_efj8Rk zRZ@5XNK)hJEChI7)A7CzxXVN3`R6w`MS5Q}ID(>PLMF=35kJvCd^72mW|u8nNmr0D zyuDwDz+;054L|CS4ZwL{>X_QuKC6C(=c8kQYlCmim(nh^{jNd(sMo{}# z!kSD^zp;37=Eo;CR1X?l7jrB`?nUvHM>P!S=-S$5><4x5W9j=Ke2v?Unf;+om8+An zQ9D<>-m)3hN-kV^_){$*FzG`JNSDy$MDZ#*LnEMGJvfhacbW(@mHhF&Lj{pR`Vn|SZ75@Fvpo;(R#S`do{oq0=6hEiQs5r^IZ=|=Z)u? zeJ!3OX|F=0C9USfz8|-R&$LP~{-;F`%$a+pU}(4^xAT2jX`D$gvn89RGiOdR&j++t zYu=$TBZ=7qKjmUvk$k$tuN}WymGsLnla}{sda>Co7w@nDOPY&D-pZ}}*geZCvm(Qg zp4VX!`alH^#dBeRyeLbjnP}!kqkb0uefZAA@+kh~*ZjOXDrfFR!4lWTlM0BByBBLd zyUIi7f3PjQ^)b)-BZL`xsjKO8mDpk@uG3$j^sjTR`gFKAEXT++-MuOirA?ew`xXn1 z)b!Q7RS-HVz>~cpI`RRl*;w}0V~zT#7PBQ?mqf87mo*btPtDWG;6&8bVjgL4j(COm5gvVCWColZ&6W0Ev2zoe;N(<134q6c6t-JSU#r__ zFlZu-71j(1Njm?`o#tcZHg|w^$unAv{QbKA+csW8XxdOh%eOjM+26Ah#8^{ISToa< zJggsssrkG@7lfyJ|H(|Wv4U1>0U@E_@($&9)Uau%;q7*7Y))=|=DC$tjir(!&@{pl zM}lIz_tdIl2@!K~jfQV2L*KA2tX10Tj;#k7YY--czWs;y?A!cU4Ee}Nos|0$@E>)_ z4+Yobz#--9#MW=z&n`ED`3Ug1RiM=~7Qzhck`TMV_a$jwPTX@TN9`lt{7eIR z@K-5;0i6r_>dKzH^=JHAcU{EJrk-i z*OkZp+qO9NiJ8Vs(0@vkzBZ!ud^=3A!LK>&p_aw>HG!`K`4T4qvRB$UK%g4{4rHC9 zq7sxbqz+47N2k!pMyLS(@q7QczDyPxns_s?Gi|0-33K01nj%T6HA@_k3x-p|UCGEk z|N8P;1>)U1O6IH0JO)Ms$f9v%2PJ*{VpXeTdH zGa#jejP}R3e*XIguWM$;@Z86r8#?g7pR@A^1?Ji{RTisKBZUM|B@i9evHt+b3rkNx zQQWEhCEA9NJhxlDBM2YcXdTF`Udq*K>gtXCVC3Wg05M z->l(Zw9O+!e1i@2t28P!OP&e&4-;fub%i?CiD7sNkm(Y`pw!5!m|d8;SrbIuEcPt^ zmDl$D1B5&eahXQgNwT4LzMZRWmNgJ20G3s^bejWbpoFan6Nxu=31ksNqo`ORD6lZ( zui*7T1N3a(hAA0gqqc@Ks$~5%gzGS@i0dB<+_rXFUnhn$Gds$2(nr5ju_=Aky=Nv4 zgzxeFxF}joa~4!dPOgBD>QyqO|Kajys1p%OHpo>dHQI_6OD$9-S)}p zDl3h66nRfv+ZI9NU8f#dvQm)0srA|K_w) zWV2ZPt6cBCNu|lC5dGx#K~{W}wTg?@3l z?R;!z&Xj0hYI>#QRQ0CcW|4T2YS8GyAAV@ZeBQOfUV0#~*=38yoM}+`P{{l; z)P^sgm&?CFM1}0JTG^i;HrL-?=_;3Lp!t@hvK9G%{ShrsN5Q9+f8sjBcY)Ws?>|o$ zz~b%YOtb{R?Odd(QZ5_T^Q1$9*)&c#hO1<7r zuzA3$Y&@XHFdAOyspmf0oF_N4JJ|p+UsWDd0g_L;J=qc~M@HSW9($M&$7h+m-lFkk zwvT$)kKB5_-HQBSR>$Yq3{q0X%-P@MWC$;bNao+UzfSd_nkJ0{M(*i{~ zQ7oX)VY7wM+?fWud?Me}Wf=jHgdc=LjYL2^g(F=g$+|1Q$Tnv$18OCqiudX9T2HSRnaYp@km^w^2`vW zuH?~>O>3ks*G*A7Y4Ba9{>n9V(!A|rc#PHKG)~h>Za@T{j(aSXC#AZUfwX8SZ(J7g zS)5cA6PlW(Q)^U(C;iF#V${-yCd5A0AZTa&w}~tF=RJEzc2jnPnJcsFWMx z0}w40_;c812!?ZXjkU6@VSd8F@7B5TGSdk72?9?DhRo%kmn3{wR%^0R;Gjc<9#|5b zC(wZDdJ9)S#dgC!K`f4#bVZ`OdZqqOeARK>UM=<)OPu5!5WB^M^PHjGFa)f(Te0GE z3QHz`+SKk+(YujY0tpSq2sq}MN>;jWlLwx~-h#@7GjUd+6`5;_gRPx|*UIwTE{KY!(t3&ZG0$b?*nHLet%F0$G)QudtswkN_6^FL02wG&*!E6wDr z?oB2V1g++>0*H^7Z`q{mOO!pf@RuX5VBYg_=byCw&iqoPyq!QVW-pf%bdq{^M)bSt zl44y-9k)AP0|&;U%acDDu8Ivcb*BRdRrC;PX=mZ-la6ng(K*@aRb6W(`>;gAmd>u4 zH*MKy1Sw}Bte)p&EJc;E(^hzD?{8T0szFrQUJy5|hf6Rdh zvqs8BPX9xCDAkbq+pQMiGGrhA7Gm`w;UWCl#0CMscO!`Fx~na3j^FTG_2_yNfbf~w z4z~1DLgjJ?CM^~&s~>36&UoEE;I5(ew5$U8T~Qi#Be6yX`(-Z!?xRc|)vvGkJz^<4hj@_s3_CkT3I| zS__FZ#sErjmmhI=66AbjuK4Iz<=6LSWxCW%3xsU`_*c1_NB1aGo-`YRsP#hY(xyK z)(nwJ#}gJ~yN-W2V?F5u!6v)Q9rDXmP5ghA-+5G{BI65v2o~!6y@>{|$OR{=i3om% zdINI<$?9KuZ(}U}RcM<^FhONF@{z5agApopn!YO=kiZ)5n7&0P-?}n>lwK7H%YeLB zR?v%u%tf%Z`@z!y=1>U-0~d==*ausuoCk@~^f---FL`sk-k>nV@Hv^mu>X!PfJU{z zM^=yz*8cRxo7-qtJ1ELGjP{H&Ai9gN7Pq#LPXV?2`-Kdd!G8V}H(JhkS;aM0Cp*#s6)v6_Y%oD4b9BC3ZqG;@3OQtiVj+W2v6X$1!fCr-5% zS53JgV|S=&XOzwzdEDfFwes?%$4$oS0g~6QkihVyY|`x^E>d2?nFPG*MIdqef^k0# ztf1WQ=e&~psLug)4x6(HGjjEJ2P9_;)dH_$U9^TVKtC`#<@IX<>s>DsGSl_5ELo7) z_4mQJ-z_!Cr80p$?8C0)=-A02piEPTI9ZMhQ}l>3JwgqgyJsrjgGvF>aPO{KOwlpY z3^E~~kzl6%HO|wB>*?JRm0B*CwFn;>5s%QAZ94amGaHpyD!BnDZKBB;;pOl(BAT)v zaY71J^rI(4c@;{o@|ly=>j9zBxbG$%91Jld91Mx|xdg)Fmg^5he18?vqQq>O(*+e7 zcOPVE@mRyJMxBlpg^s+^CXRLoq5>}lw8Kl64-!+4)&6T5j4-`?J!zZ+%=Sib459e3 zzKA@0OmV{J%?Jd~yd3v(P<-|&lpAM~foMd6G2WfPY3NxE*D*-H+6`yEo5T9KdqL~g z206g88YkM_)is~OB;N@K$ozZ7-S}qHg`YiD`y5Wq&?M_^w(<=YGhu|^gIc}lZQAN_ z^*XP4)1zFR#`wFgTsyzd++o-f!?s3g5ufz3qY5mcHD2voqCf7Z!1pG~3xB6Q_dX~3 zeGqdnu7@tU&KLE@?EP>RpY-(bXGN<=czD1#V9FnY+~Zs=+XpE`xOX7hY`UPH1{sw0 zF*-}!4ZmCw1?ScneAvM2E5(9vKwmbg&t^kh`j8F2@I7C|j+^HBBLuXYIl!WY!$Kmm zY9b2xWbC;){3RZJ@<$)NS6Wifxpcv2ozq7fFnqY4=Erjr)2J5M)&pH}zO@YT{Wf4Q zy*KH_jdwcvaC%DTaBm`IwAr;f=*D%_oA67!?`tqbSht{!mU#B$XAeBNg=C z^tm3sU7C~Gwy7Xa%I3?DuP!H{O~AbP?-V}TSUS+pEw>m}%RMf+I!v6#>E1sCcj9>yF*Q2R;_k%I|Z$5N2n#TU>2FQ3E@^k3TJY z5zfY&o-7Y7cO>!?UHH7YT4nca4T}xj_2#AKgSO3G(bgU0+-qXTxZzL4N-uJ7X)^57(cxD5ySLZ%pZAz;!jY6>?LV9$LthdAA@P4q;RwCYlIcRHH{A31xLM z;Jc=Hm)abl6d9bUn{BQtOY4DGN@(7{?c9$xUR{1aB;<1)$2ym5_9xD^R@?DgXNqOoorir{xRVq(R)Z#Hjn9{FAFD+# zVZPl)C%q(iR%Njovv|q&iXS|AG}K^!D_eC-?CnZx&di1nxqeIOc-Xhz&Gp;_jMnQ& zv0E?K1wvpy3q1KPD)9h^O&3wmhX6xOYHo-1+;eY5ly|n2EcHFsUe_klB1055S86ia zu5q*;f+E=}^4B`CsQq&JSbr`eSQFVk@c|`ThZkhiG0})(o0TE+W%VssTa2y6vwzqV z?p%BO`dx&_6Aj?9ibnsQnxbL?gWszt*sqaYQz~Z290{H!Bg+uai8jO4%;wzz*v;nz zCX0SElh+_*rsdj5M17ZH)qon=Ic1oU%ixP_({4 zDG0MlF;@xxv+1d+-U!%E48{~m+0CrWy$^3FB4lDyh3g4xkF@E@0$cU#L|U$I)tg_a zIHr+CJ#8|0F4}l5>14COTiMu;KiuL~$flWM%|jNGseAgAr=qewO#beBmHjJ86c$Cg zp(9w4OTvANIm7#CbNTIknO2tD#HtORL0yp^lAlyOrk@*u#!((N`YByjLz2SFR!7F#koRPb4^5IBGIV6MXR*28Drw#lOTR6zAwXka9tXnKMcD|q zCdrzu{2IU|y_M?ukvv44juh{s7_kpqCX(=EkdGIv_QU13aVGjJy)mmGJ>=|V{&|;= z7#NA4_ogaGA+{e_R(9uBfBwdE`S3>C{rWSsXHhs)OA z=!62^nUCpw+JrcwiF=52>NU}ENzO$ZcXA<(w;kt0%^89_jWt(aAuG-m;3@U-Fb~(t z8H@@lOe^i18(;P|-EB{uSe`n&Ea?N^9q=nl40>&1w@MDbUa%Vw8qAfbcNNS|aGJjA z%dYf}U2tyMOlKcJ!pf}2iI#&A!nor21zN0E=54GQm+?BV>d->(c5Y!OmD5W+Q?M8N zEC#lH6iO4Y{S(L9)60*mnyXpzV#{LdmDTl6KOKX3v9sgVW_he^AzIlBjq43dw`ZsS zq`lxKa^$wUZyIFS(1+;t(AWBsq5MKa_~`yv_;T*>fU54+`sowQMT^+u|AvFJolkkl z!m|K0u$BRrb|IU+?}BP*m93%W+$+&sa7?p!Q8P{|3N84aU@O$2hmr_)!5aN(qjyp$x}9ggbHHt4h+;9BiOZA`~Wii~syv3jGXy%e>8 zykx@C#mHgXP4$wg>hEVxG_xkXc7UqeboW#1&04d$?D%+b3jMg==}ca3kCl_MiZWJk zq1M-IP2iB2Y6<)%;qGDMohYuj516n<>Rv-z=Jpqn2cLt+HV%fYs46IJ)*|A23|-FZ zI=WQJ?>PP+KW0#k{=qs?I85!0kr$+76pX_%cq3mCx7AvzBJO0!GZ+LG4mu*ON+50B z?K+>p{B~%~it?y~6gd4vI||(yhmKlPsLcdsf&^VF6X3z$V;Pg3!f^!AL7W(zYtQyd z+@3Jndt*-fXHMar0hC93d-F`OZ5-cXN52>nDn<0l)Mx&+^+Ay0V5(S=+(Vqh#O9Qu zXG8mnQgzCpY=HSj4wU#95>n;L1aS`USLUupv2?}CW6c>Bcv6`E z18)Vt1o8KDHY& zS7D70<`?n^@73h!p33*)F)k$X;r!Rs0t+aH$08Qhi!I3%_;gdD?$yNE17yi|-abs=G-8@~1H~MOtQ%+|8Cra)R zv8>2W4yA)h17n*HXSUG{92lb`S20kd$VHNaG4n7EI7_?tC<*E>Lp^?hClAMqq6n<< z{YncCeSU3IUyoH8bkrL2Z9ye%+P^QJ$h?DUQczJ;Ia?m5Z!OjQtmLH7NBeq>WGYSw z%1^c>AoC@|C!eg1umQ>EHoLmUb5g#T4qzhUDI#7V;CR{E!E!lra;U*YjlP`r3 zJZ>loSr7W9;`m@rSl5cOL2>_X9*=k^@-dTHacnhh_JC@#e(@g1;gv}cl1HZqqbkS}`McbEik(ZJqL1KA|W zr>I8vhcgudTC2gKA*qkxgUHaXE~>>8D+g-kUkgXVU-*oW-Tlb_Lv*;6g{V@Xrpt&} ze_|ywq_pj*&oBZxk}u_KSf2OE)oE&LMqsdYjqe>?!=gn$W)ALOBn$$1qHmxNLS^MG z>dMKnk%hsEKeKHww4fr3w#I(A(v6!GtUM+B?KGm_SU8;aYGY)K)D&~7@91zxy{JmWNRF8Y&@tKAZ^ zG4o0m{c=}K-HPT|&Wg&IQgODx3f-QmU|?Sd>ra7Qui?oNl1;xgtVX7yd}4~J;|7=qAh~6F~ZB2a}`Z89GBV%X6BjI=j#F=m2FFDsnTDmXas*R6yuT;d@ zPNYHElQOxVV3X-hhKeppE*YE2L>f^NTqBOj+r@i_L#8j5@gwYc;_FwU50svh>j$2B z(Yj8q^-+4du)3_lt#lH|iUEw*+y{+ccx>QP9XE}(CjFJ+s^b!Vh$rFyXhAU9>U6Wh zu4~Oyi9 z)ZOYRX*Lp<@%~jUG4j8Ve=Ip++qHzn=XvNIIlfr^G-O<%`%uB}eHlDnPOr<2@fn?s z0wu^^KTS7Ye<<8f+}lX2D?tf5LTszUbnenD_*_CEdmUZrzBAQO#|pYY3agvr=s*u* z_cj|1o_Sqb0R(Y%{wjEU)v5GVUC}+=oY|ILo#5NVMP6d0ynM&$mdsAG&gS(R2d5ic zTWQMD*%sgiDrl>HGPNfxbCb14<$({OO0)auGUgkf#)wGI!RtpCVi;gERSBEt20>CD zuQ?yrQyFc^0Y_V^fjj^RRO>{kwSyzBL@qP{;D_lxk&Z^B+GOZ<^5&-XT|GA9b6b5x z!(C6Hgl4DG{`NUEu)6w``>myiV~mGjl&H%HV`^w$huUSOER!I*C4Ra*;N2;$z+AaN z=i07=t7>yqHjT6JmRtLz{Qrq4U=a5Io+!}$--!aRVuz~wbNexrEeSXCEFkp#psV6L z90K-jjSLK={FZOieLJA|t>4MywILlsB7w{Bo}8%->Q>V!Plfwp;6Tk*s*o0`*1(hx z*4N4?c2!(o9Z~c4aQw?ONj^<2o6L(DaO-vL=B|-$HTA2AHXW>xc!@@d3r)qO4r>v- zW5`S2+q@D^Z3(q)9Xv(R;BK3i4+VO`>qtYBC|az4e6iIoA;_yTWhU@{d73xi2uD!_fJ|K{JFqU$4iMd&ZP-HRK_$E zd$Z?Es90xfAfCf!o;)1!ZrN&z9!c6ZYp%RU_FuTa;j=je)GO2ZK|O*s&|PQ>wf+E(6}mVVuH1Q zFGrChxyhOZ!ngx83lV54=p)bEOz0BMaVOL|blfg#1=t>lO&`wOpJ787WXOv5+s6o& z_cK%>1sGhw1n_VgJ`jVmRdeQa#o^;jAA^VB?m;oI8rFz*)o$meRexvT@#?i;RRMk4d zX0c2`mK1jX`e^qupe7PaBz4iXZSRID|Pf8iYQEV;qr>Te& z5oxBH^SB}`5G?kfLIgn#oKZv*h$a68#x=um5g_4lT1xGj$g1E1gs>t1MwwBY`*f;E z49H7${yX%}L3WCZQ?Wp8G-vWR6lXjO=_%XOy=`5E4+TH78xGtn$!E~NEfdg2R}Ky> zX;3x(4=5=NU?*ck*e#0;pEjtk@43_@;7f}X#5VXsN0MW|? zg5FK^Y$7y8{=PPn=iN;0?73ct#o*6D(4b@W2 z&|YCoX_Wib^eN6Fk^*yZjMu-uH9(qkAmPrkChW|}nz!gTsl|*Yql*;IIrCC00<{gR z_0vqI{{?2ByM1!IlGkw7EyU0E(E|HNX!3qzZR2=ZOzOXF;b2}{1oyX+L>a9ARcoEW z{{P!X!*x4YfjO3^*5BRjiJ0PuS2vEPU1OmUA%B#e<=d6`_q73PLCQ_Ng^b1cpaSz9 zpqpLmdhol494zwRh2lgR*ap7{^)gj0m#LSlT5;h2X#@&BMv`qHY9LW8bn>WJ0Byo@ zYHMmH-stZ(wp+HppjuSa<}GIzs9F9ziggGJ^aGf#Z9DyXP5&Csv`Y|)!KHPbtcm3h zynVr61xF{BblzKJzV|<(1vD~p2KN_NBZTV>q|i(&SAR69Rv+0j>$z@d0RrQLKm>qU zo+`Lk8csDKs=pO&Ebg5B4TRClwf-s@8(Rt<+@-Efp5pyO)T05L#?a;BYHdtKrFN;m zyBWzl+`=1^jHK1H&83411~`-Dn5TiL9kVe1kV0xfOo19I#_}Wb{+|W55dkYvT<&ldg#KfhSKn-UJi2y;eF5zJ>0W%L_lCMIQAsgc z2>Hc?PYzni`O0=Qi&f zN4bCw(p2&)mV@5@D&Y=1_C&s&Kb_m$%!p}eT>8t1obRk1`Y$h&#Js%XKaxN#mu1vs zv`;+UoG6?S(~mkq%~rW9ffljvKc7!7$D18dl{okHZ<;#h40K**g6Y)L)t?yd1%FaI z2&iAXu$(<-=84_r>Cp;uovmR!pNV#CV#LzsL8Tuov&| zcJq0}wVRvjb+0QHVm96*PzadNM>bp-c1zTnFZz74##D~{!(G1O=EPOy!SMxey4HqC zMgY{oxK6w0;EQEKEp$@*klDCA5WJ3S>vSnWKySs1@?8ND>7Z^Fl(4fqTM>`i`Ln;t z;14ifZwnW|_>|C9EpqkF7PDe`cU_5c0+LFFk@g!K-ghE{?P~B0A^&zh>n|eqEkD!~ zl0=B~?FZ5IK4_CY#*^CMDlO(4wflTp7kOV!y(BW#X^D0FaZc`5AjiC+37I!02syKCawXD>kEe@48%%gGtVGt6Y&3p@_Qe@zZR`L z#vXIYKJ(H?lPNU~nTGrNX7UlDW7&J*-cH4~cl^GwJ4-#fuT>Cm52~GKnr4Rt*4e){ zv6NL={pntmJdFc?!hyZH%+)qTTL<10_ZYkXEEcpDQ_*zVejBv;WsmzRkD~RYDZarU z88#_yC%;sjtpS4Qb1oiIlvWr&l`)Jz-M?l_q)?P;%Zb#|j#{<<5obe?FPs%6zkdGz zimDGK{*S2oG_{_elB$eR&se=xv@{tzFL5a-0eQ^6zw+if%Rhqc_dkK^qd)%v)ls_s z0M!pV=_h@tmUev%j?>t*GoNO1ncC_OtoPP=H%%lf5-mVGlUk=qK0KUe^gsmqex6Xw z)aoD@<~P9Vu{|CQ^qXABjbb#g#!xE&1+d(^>Eq|_FH6y{hc0dcN}NQbyuZ+HJ(0Ki zPvk#BlNYmI$^{c~{%@dd;q80(Kr6$}?6DvX%W&?bX!CpWBE^p?woTCh&$&QcTf4q z|3%NJQoPTBHg7Y*isU>eh1DZC^SnN)o&E?xP1~`*JDF6&J>R#?l}1v|_x>eLp@yX# z*YnZ+)OBpBDm~DgLLiYwsnA;O=}vHZ@&lbaun5;oT*)JE(=CCvwlK~Z&V?|QhiX8+q~T=7qdZ?kc7?Y4jziv7rj zQPY#fHO}t>p>*}lyN?`w@~M*{O`=y(LNrKT@%Gj(K*i3<=~=A%nL?J< zw)~)Fz*3tTr@O4p!8QeNDw{q2@c!}ab*KCcZBGQd5hHv@%5V>&XPB&H?|uWSI(0x% z1?TnD*?43DXZGmhRwc&A2ULB|Umvb?09a@qkBYu~5|q}Fkw52W-EZgb-T6=Hb#1g4 z1Ulr`iT^tp;PZYn6tywNlcMWVUK<6*l10}B>@Z0eUVWmXFQm#TWP}~ z5O4h8nKcd5$Y z_P&fxLJ;7pwfU|N759JO3UmZPpQntc@b8C5z)*Oaeuljk*u|BAq(HRrL%;lqoa?bp zMbk7B9xF|@%h=i3x&&O;ZqVpyK4~x!BhmJ+WtdKeta0+@@h#g#oTdlkYTyWgfXbV< z8o*4XH~mOXuH=H+<-piNYNFO|iIPr|x_f6!_-ma1nsj)^gE>~EE(^1x73E7cWJ~%c z{wVfz!x3a1(jCr~5_h9F;VALB_(=gC1{^o1JHdsmjJQ~6V5$F1nKb4G?H1OG#>7e` z&f7i-3nt-2$6HVuYdCK;NWj4#Om71%^F_e23_GOA?#Qhz{Rl?NAI{BwijyWz<&AN< zgK*p}Kq_u}YcRR13GY6dZ(H+G=ZLlhQra!%i1;(?a~{_gU0%8AjBtwcXNyJ2OC60E zg5K22(8)}|6zH6D4@E{|qlFRl8{8EXodH~**IN|(7$UcjxdFBDU2PdY34qUVy320L zW-qqQiHDs;L*T@90+ zM&0pbSK}^oVaGae>f2ewsVtVz*wWV9I`tKkmlFx77~YQp5H&;wz|aOE?VPyR+z?iQ zJwZC+Ly4UdcNs0802wEBg4d&mCzLE$*KWHb?2V^M`?TAd%M%N9!a}dE1l`U}{I)tx zs`YHMu>k5&u9CVu9eeO}f%NX3godoECqEOucU@o2R?DgU?5$RLKsD=lkRgi^y>Ho1 z>Cz6Pkt_5)Fmf*R%e}}bbAn#a-iayuZ6=-*%(_-n;OUYXo_zXHI*I1ZkrwXbm0a4S zcWlFnt8ypK7|EL3WhEhscj30Zs(+5^)f@u_O17K%-3fL^c>Y~yVcZT@Zt(j zq;577@A%U53mHe}E);?!2t@>jgp90hw;s@q?_==sgBYawOOf+7dNg?dQO4YHB`#E% zUuL2=HJ&Z!f0;<&UqrueFg#PB|g3q#j44RV$ zrNps|3GV!MO8*}gJ@zVOtn z0tfRd*u$G0sqKu5`Q>or^{q@67T8YB&$z4i(s%E|gRI_9HXWO(at7CGhv&Zaiy;vZ zx4$iN1np2xfV&xrpNk6*>~^-uKfI)Ilz(qBo?j~)vov;Z+^?;Kn);gD{j|8bd$PpW z@ahzl@pX0FTjjXya1PdxJ`S+y|1Ni-hIN`U#G5EO!M;l5`w6QSyk8+O<-vq(pyqOG zt(i~zgQGf34o`5(sAL{kzM!0`d6dY|Y@oYpf{awLl)VrPNlCboqwk)2yi}GiG=(g# z5UCDar#D*?W2}zJZbmep(#FX_iPc4t7uRsGIq*ALdVh`;KR2bA{L_dv}2Ioh^B~W^^`eMrhOSEx!-HtGSH5s^m z#HZY!8ppX|tPK!~N*Us6OGd9~fDa_`p=ISD%cZ#sY6CJS@I31a)cI|b`tI*^?~id! zMO4SDSemZhy0Nx)OSpw6uf|l1;6se{>g?f@Zh!?`Cj-Awi7PEd-E~=; z;yeNxgOI6TbPi3@$;W1lQrhCTkB4L?h%+WC4N_{OuJZ>R&z{(BI zHSBFVXMHgajt>j$tZ3vMu z*n{hLO>6f3lOMelzpP3Bc)UGh_NpQaq`!M;q*E6x?oCF`%;` z90X*abw5qxn20w00`2+Q%VEXEW{P1X3MaGL6L7(*%a6U2utpw>&4TD~%4N8Wp4LM0)ITLHFXZ&M_w@+D;f;1lnHBcWEva zA6dDdeA&oEAKJ-rp2ok?7Vtvdl<(9JP7e89uats8*rm0JP|%#48p%2UY!D-Vz>3XN zD3nl8Qh$`(H)eF74=iW(p*TNc9y`<~l$B(`FJv$reO$O9ek5qk)(%;1vTxgBWPm*nm2!@Au3^*c=j0fwfFWXBkT{$uJS`TF-1nR)o`+VKhoAyfAocIj<(3{N`E!Yix59r zr^kKgCly|?N-3*^EpgktQCw4&{9C5fcV#6_agIEN^)$?Y7Q7sRDgeP9z9|cvqu@BP zmDK1E%yFpua)5>@G58ur20D6^e#_ULmG)D~YP+fhBfkAaA(}1R)5ubfaN@}oj|IrP zs_`!-{h=}Zc&yKd9Af}^yRu*SZ;ObN2!}v7cXjBkgWVVrq;UI&etoz!m;CfjLM)=K z;X#YqyEON%5k@Cm>}C9Pjcr#Kf$cf?s?s_iTE^NqbWa9-)vgrZtZec3IjCnhN$PG&sW0Umq{TK`JCrm z8I|w&HM%a})32cJHz#DX%GG+fBE^Ohnv9CUt40&Yy27^Qbc}iW8a|2zA2Ys3hCD?K z%BQ<#ZDO76?WOiwNjKA({-C+72V-0Y(@cwdj=>LEt6TAmj zF)tGSb-A}e13#=?+i&>0NV}CK9_`jUCq30~ntHRj;n^7Mi^rTuXc`ZK+myhuKMizH zOO(3mt6R!UL7-V#05CsR-C}0e=7;6E2&N~IlLNSvBn=fj?Txry zH521K&NT~CU^9kkEE0j$N`@vo-V`NuJn+7IBn5VKp=NSNu9Z@2(=Mp%7C~lnPw?$t;Por`DI~_oA_@t^VC(?Vm9*NQWv@Y4Fq?!927!Gm)sTim6qH zFvUQLWg_37XO6nsm1LBc6r5#j@ta0YwOWqGE$%idz9qc z8|#J+imVTLYaw&``$C3TzyD=n>&(^ZXP3gk+FgK~%Mxd6blg-{3-QXqr4rTN(9->5 zT&P=x)ik`U4^(i0sA}gGUZ-}kgp|F#3u&Ns4SXFJw{E+fdM2Sg*i{W26F2`i5+)ai zxrAgDq3Rvkbh$UL>RYO2g>kM{AYB&W^GuTYwo;s%$(TlcN{LM|&%c%_&>21e zo(r7zfX0@}rkcUSSIg@m-rhq%zA$=%{cMdRhMH!qS7>*nYOVc@ddJW-8p?e7qOQf4 zlov`F(AyYAAY-Vy`?{K15iQx%u(Y${=rbPsAt6flN>eUf%?C3x z7L{66#Nu z;LWI$mm&|*?V#S0G$nuUnYKQ;IgE>A7vHIwKD~$GA?P_@-Y5%%GBO0uZ z_?9~JY9>s;Ic-*pv&zbmPs0*$ZCxndhJP+pl#QQel$x>W5P>6$!p~!t(td)Fj%Z#K z0qy_hQq;6{Uh(VuPsN|{Ixg@?2T=$$A4)gCCZ$9|q@JIsJyv&6gg})%fc@@|4O?fE zC2hVrL=u!*e#z73I9eM~ZSGuKhaWJ0k4llE(jdYSu3K+cbTB243c>s;YW|M3B!Xrt zu?UL2`DHN5Pdd#mBnkLRxU+Q>5ydb6U z`6fbboeGK@Sr_WFrvWX$9#ju2wQFTM1+#P%(ABx$JY5R4n4EA2@9W-;%52E7UVfw)Kl7D{Zad!F4(j3kf5+Gj$Z-nDx@)2gv&&eO*EOACB^< ze?kXqK$>J^tTBO4lxs~H4h!7r&D8)huyr`pZv|N&;-Sv^BhLu1brn> zx0^6M``9q?QJk%0GH~+@u*HnXoI_uHMj!&eNX*^wjuF0~d4n`>*shFddi(M5k)}zn zIcqQOLLD&~zhTBNH?X7URy;lROH$AHI||C%13P@O@^|N_*Bbf>|2o1K8f)l4_9L!Z z!IY8ElOFYluouq1qUgn&O%bxO#A+K28+x@92}%oO%#=qKABYnf8YCNJ;FnjQ45W+h z_$P>EbR2O+xF!7B;A*%y$0P_7dY|ef^zD%9sat3bCOm&!G6fRfUmYkuq19osOC803;XbtpH#GtZA z{?%|_&hD&p4x3aVAaCF%0VTHPm4gE~7%$EZ*nYsmB9Ij8Po=Q7+`##nBJ)$*b0<|M zMW#`W!q5H$&qs<*JlQ9JyfXv(7uTncg7wGF1@b(D8%r|ExugQo=`-e+v~AiAmy#AD zEAv}td(3iq7)JILa^+aoN6cO*nf&Zjdk)mANh}Yb5cqXqZ}MzD(%kwQ|;G zWx=4t7t7^8=_kAYBwnOk&9Z;MUdW&YDMMWLwPbB# zO!&5Vw?w#9SATuxi#ERp1q;&qz*x4C?c*BTGe@o^p(opS2X2Y}Q|06hdY#Hvyn`yE zVGaf!iK)&X21AQbbVSH5+j_!0pu0$zI}J5_x9?~RL}VTtT#jarh0{EEAt(%E$X|;s z6LVY7GU`U9KJmOC_(C?-sK=@RbCo6se0e_6Rgz9Fw5^Q>lCmZ{&XpP7fzF-k!dRdW zr+{9~_<@ZjXL=eESrma?o~E|@k|x`is?0_X`@-||MPMZ*KquFvZiH*3Cbyv@|LMlN zOwh1EV^wp1u>FfZ_-f~RkAI7T6V=JB1}_cx;`+%W`TW8_xuj<*|C8q(W|41qp#>_n zm}Z7Z$u8Vs0#dDsC!B{8iHXCKpbG{mCz%qsdh|RDY_*QWso}!aTNwBL>J}DGgqlX0 zrG2qPq=jUeu5;G0Z0|RF^0sjQS!owk!i^vvng*U5In&+|b*ZZIuN_9#2uQN&sx1$4 zcA}r|pk1pfg~W2=5(F;}M2^nR-Y*i@e?5?0hj?rHL{C3Gpd6Sq9T+N$YQA|hje!y+ zpcmGQFC{aN)vvo378N=N|4cX5I_BG0|rYmGb0ywwccfdtkPmI!PPMT&XLp)@;~0mC&o@7>pSBDdA8Y@f*V) zc)}cI_fZ8{&?~`sozfaorl$tfsvsTTjA=1vjHl)FCYU-NIdhX0ko9n zoEAC-GAfOvN7KVp`3GM*7+HviY0(Vg< zV|@)i8^gQCSF&{5R_jH_R-ynHI()Gx`)fu-F10NzG0l~3mIxi_L8 zMJQc=HO@>&|I-Em83{dvbRkbr$PrAf)@YJbVq4rd5k$BmsLxrx0b#kD_H;l_0S^<- z2@HjAa9G}mJcx%kq0ipsRW@g^I-PM#b7x*WT>dL}8lrkMFIuZ*tGV)*mCo7KpZ5ty zLpplWjw_lD01nMVQDU&a)k1CRpqvP^x0+QbA9*-po2W_MpQJrAs6ThkWo=a}I6=v< z=eiYNn}}Pi zVZ8tC8ys$C2=gA+y-I)LfzD?;p7zBB+KcYHx@Lwx_5mxy;pxw(o-X;L#87P>PI4Z< zZbHn#-$u?F@dC{?H)H4rBZjn@Lv7j0FC*9cYs!xn=_s5n7a^*GTeeq>M6ojtTDyCf z71zN^DN=~gp_SDQ2FRSfwa6;Me-;*8~7qud&_OtQD=-u zEmJ0Z*GYoENX&*T+knd;c>0;ueQ9^$3Rd74H*6WD&o_jU)%ZBHO}U|^`hNC{duv&H zansCiBc|EH%z}j?@qHyBbuEE@%47hub>B(ZhX_npi(A63+88*D*XGw6n);(VFQXVQ z{vRaQI#ux2{ZorXfEV9nX2UebY{7M~PDaYBD_4prKlWd!@9wW8r!Ut>_#BbAj7Y{j zzwop#z1svYg3J``K?_9a0=l_t8PK_Lzw|P{U13}GRH~^j^Z9K!J(_(0CYS=Bu8oyo zMQ==cx_A<83SW(0aqM5-AD$j_ymnQir-N z+e~HNw?634^Pt%#DW~(+apq)$2q`{~)vARdw;HJJqlGfNA}c9JlhaXt(lb z+#8oIll$1BnI31$r3_0lL-jdXHE@QCcP`>~ou2pcb;+B7{+CT*!DH7KYy~aVs`?+> z1I^AY%nb$vn=f`jWc{grMH_98it9Dp`4L~EB1!K?PDWx3gcS*@{luzgy1r|fJ0Hn2 zYa<;^JeX8;^6qJ=w3#pW#oD4XtfWaMP_Qj-dNA418iR8=;r5LtFI=v_N$+vZ|B~G7 zZqr$SuPSjiL9!5i4*kJJkRWrMn$VYYYe`1k7a=bNAj)EZu)n=i71Y0u862Cq1NwlvsZ5*?JBX-H(r4ZtI0Op z(Pf{{;RvVpl{z5$vB2Q0@090Nh*HggQ5)yy^y1nEJ08|}`0ers`S;HSKCSN|+_3L7 zSO91@pX29`-mOvDcUh#{4p7t{o*AzeXd88e<9t7_T!2G)$o84_e)+tU@o_p|<8z^f zW$~{WePuOpc7>MGNpUaQEgso{l7O0q;GVs6m0_IhjO^+Dl z1_~3hpzxrPEjVBbdhU|Q90*PfKOefK=7%~3%>mT6BHk=ZvFl4 z0boF{{*|Vp&ih%Gr9-`|gtSnDm6qr}PMi-|sp-{vaLPVewsN~=;dwITt1D@Eh5Q^Q zEltjWPZyEIhORdF`CY6`N=H{yV0Ylov}_#yx^q967Eont^dxT~^Dfmp^I^sJQg21g z<<;#HR$SnrPjEYHb707Sn)v?SPFFz~g#F|F7S`!M zlP`^3KUae0DuCpd!dm!#CsM9To1dS*2f+Sz_9CK)kJPlytt$Z-nr?Yj-t#v~ z6bpVARGCb*dZk-*Z|B4AJ3_$$U5B-JV!AiQ686{qsU4wIx#SUn_&9GXd`R`g10$TI zv>(F~S?%@bETaU~!@RSr>2TDqi8V|^@}i^M3>YZc3~uxscD?-n40Mq&UxvzD#~~PZ zEJ{iVCmuq)@lC-|Jvp0m>&^y^TCDv|=tYE|SwTA!-xBq+(gl+NTNNN)YQ-r2*vZu- zqf*!i6^4V`2!Jj^l9XG3Fu_p%u=c%$WQcSAbgXGU({?)(@!{FCr#o`b>LH0*%=r?@ zQn)hoODPJ|#w!B$U5(opgyHFD?s6f8Xd#h|pS~|UN`K3R1p7xqTDw<9u7`UhiTV%)<;Ff#e9KW}k+Pc8Y5 z(Yf_ZW#X^IHp-4%-1fi@qssjDz+avGG1a}3-9OnGMH7hpVcI{}FEAwMwyxG+)xVS` z=Q_s58~v3G`{Sy;ZHwiXo{b3~2d8sphp~VCLDu!GhrIVi^OqXN0)PVDnmUQp85@3a z1y~h{T=~F}Jo2As2Q@*jPse>z_?a$nP*H38T_1yVALmcM`m^&e&#nt&9amnBCei;A z<`0j1UL%Bu#S{}(z~I5tY5tZ$F7dahpsQ;M3>c=<(}$W_uIrKKpmafgj}s65`dYvv zJ+GW*!oPZ^v>p#HEVYz%;yL5T$Jm(83eqf3vXL`59hElGJvKN}p!(O4aIqt5Q<6PW z)|(}7>?(p)OWRF7(Kf1srLFj3o!xLV9>ypmvacq9x-VE9iF;q+(f<+2wLL~$t)*pQ zk2RaB4MFbEi3b+ewm)%X#-bhv0m_Swp{>Yv=tJ^TQ3>3)x5|G6L~%`^U0z5$x~bEo z=gF*Squg%C!8ks53bv@%WWz%&DM|AEM^iBNjjd=fy3fv)ExEnXmG8RX|1zOHV=>)s z3-(do=l`<(AHp?tn&-j&GeS^X^6S4w+~+MP@~=mboI9eS!TsYE_Sp$>-~MfrjG&-@ zhAgrMAS1*0r@bRUNs{xgk==8zXKs8@QK5a+{ns;EGk;Sh`uJ8h822!O+q<>tR*~J0fGU@gEa=Pi) zwAuOaMv$wO^DZqvtQsIYx49M>qCNeq(7#lDjw|ePj&NfmB8{!5x}OOmDX-Nk9j?d6 zU5?o8&M3l{*6to|VbPUK{iCb3gx}hhH58N`UhUDI%v5c;{IDu2#!8YgH=q8c_WLrB zgdlfZs|6!j>;AXBVC-F|)`xq188yaf=y%?K+*J#3rkY7t6BM_qH!Z}3>Ug>JH*%lz zcow7l$L4Fv=kvNjcA;N-Hd$|HbPG%erB7O*MYSTrCn9>wN}`Y~C^0|($6)rc&6c6~ zjH9h&5&#fa@vpqY9|PFga9RurN~SmA=DEk=Hu*<=_Z0sv%>QSXYGOQcxM%Iq^7U}W z?SY1t$`|>ke?5?cuBn{L(H#iJ-saw)*1FESQ14Zptdlr-Ua>A<6*b0-b!Nu(GH`&88WeCA`)ciWXydLQ? zx%J)?Zg(}k3VT3#S5n*7sdU(M7;xm-m~bml8hc6im3i)0T)J%nb(&hNEoiH;uQ(w#smkhSyeNVn*^SEA>%oYlde011YO=z~UfHI&Y>ysaqf_v8 zA$={I>RPTkYkeYMe~{{OB-f>qLRoEp#$GT({%@t9d!wPLCe8=YUr>GGAx&kY#kr{n zYIUFGxMV)4&1gxU?W#c1>RCn4S7J)7<<8Fhzu0@L;5e2g3Q%M**kYDt$zo<6Teg@b zi`lZ6nMW2gGmgMwW@ct)W@hG{_k8X9c7HZvV61P z(H!hSHi9d>O4(^yPuIwUvX4plm9^2xz@s@>VqjG}=dH(%+W>g-hpfz^1*+_&o|{c1 z8ESj@&3J6yCpPF2p!`dJ_-Zd!Bi8llf2&u_?>sxVYz!_O1F z(3gtuyNm1YMsO(mISY=m6OjF#o99(|1lN0SF&DDOl(>JPC5F53#`c3i>X9I;8XS&^ z59dbY=h1QcE(dRde^yqt?VBM;T%AY5hW%9bJ(XaN_|_>u9q(g?McrGoG@?ra)osq@ zn<=78k0;KHom?*gAf?33wvGwlXPW+cK+P6B2*8>++^q(+n%J=`XMtsLu*v@U>cIl} zxtZ+#vJoRbQL}p?uSl}Y*S~RvaNw5_>p~c`(_&rtDJSS-hN#f#bQI?f^7`j# zj&Juv9lk>^+wj)LzAA+LXXXoGxvC`e zNM-dARy+$AzeZfg$2bA+tlXxp@^0eNdKa*(;9DMNKBZ)7wo235E zkR7dE(795K-y*d*e!5+PC74ril$%5DSv>WpYVhnzO(ZsnTFWd8#qZ>lrxjzN`iq8_9xyg2`v?Azb)K~a%)>ir%Q>JC9Qt;Vy zDogVC2Ed_SJq6re{d)8mxEiMM9@-VT_<`kenQDn@Yp6qYNX+=@nD!W$YpU? zL2=vvc73sb`JC}lHQ6yz3gflcAA?WjYG2QTCcVK~C$4Sh6qunSY5IY%I`&jG98L9E zzPLg!ZX{4u`)no8*=d+i5~=wj{IRbb_u#Cf<95ImBR*w$s7g-38JtO}Y1EK)|Rgw$-XEwum=x*`l-~edMYDh=qjtAYV`s`yPPuMa-V-R@)VlL zt?RaSAcR9HvNAeE+4Aj;9=nAImMDet6;LJ7v%0_ z;XPdqKIuydX3wwa1`F&{T)xCtdCicY*-1E`tG}GFZATG;q*q7`5GkY?)ds~vNk#?c z>-fyd@l7!lM-%DcY`JaE2)_zJMbrS2VBXkx5nM*p!djTtnQ!ZBmD(Lb3DEnRutVn+ z??gtVNHNsBKcDh>y=b4oEaGwH^Mr=>JJRz`lkc$*l63Lc*XhZDO}=Y}_K^~7)iaqI6;=ltKi zO1P;zNlJPOnV zQs9h`=wF`G`3bvhVDAMtx}?<2v5CVMSZT23ATwmEQr{qxcZp>)hN$gqBmp ze4)nfO{#Kth6j5-6Ny^quD1D_26e*F+DZkEW%JJH4p7_5VvAhZ|8Q*k?Dr|}P9~j?Bv~^O zhE$-F&YxVL5mb58#2P2SJ5TStGat{F$3*XqaDGobqM~=ZCZpb*PaW_OL5LG~a)%w1 z_-}$Hx$~#BS&>yBd1)UJE83@_EqRsSd&)7pIu(7ENOH0NQKKxJ&BT`n5!{_)$P&t^nqILVWRua^beCn8^ zF!zMj@Th$}|7+t@bDr8B8ex~q6IX7G=s`Yr3YQZCL~D)|LjCalorTg4N=9f;crRtR z&ZjzhuS#-l3WYa%LySXnQG)r*0cp`9EKFU*QesemR)HW(3{)0dJt(3F7&*HGm~!mC z%hNWG+*{g1Il~X9-_W`B7fa{^b?2AJe$d?9weTbvYr4x(Z7pq$hs-4mHTX8-z8w!t zH(b@-sMOJX#B3oSygLq%&ky(VES%`v43O|M>s?D#vrE9jnBUxkIHA0t0_ zA$3rZNWbO2wdD?EOTX&frwf&XcXnYKHHi|VR-{ISC!KOhBiTKvedA-UyL*i#(oVk8Q(RLQAD{^UD2$5vF?L2=zn2AX}F%AQ)a(ReSt-V>g*$?@E}iVK}5Ft z;JLTR2eOFgIOU_AO(k!1U_8i6ms#zfvOes0cEz++bNMd1&`)V8Whma3>$c|lvwl_U z!}l4StJ{fmnT5f4?c?>nPSPr#vV=$pmVI{kefEMsSO8u0<>zCng%_gE9-#kgif$Fb zzj(PbUh`KM8W)HoRQla$tdCa-_aDO>4fz1(Sdo8s ze0_&d{`yG5N+3T+6Jo+ItDoPPR% zc1lHlJD}fX%K~e{Mdz7wzR1fOqyKLMl#BuZ=r^yWwars^}NHRHMh(XnZ75P zv6@r!7J1}oB{gH_u$2C?OnRd!(Af@ygi_F3{%FRmlnpDD31!hJ04yCc&1OFyU|m;! z+%tAJktGu4bpcO#Y$n9HIWimG6}=KR>_2_CHqT?O5ITkz;ViFl$xC*lJ#x|UreoqF zQH;OU8r;8Cc=p909IW<&ibGkwXpgI?_=}XAq^Fn79IWeGnHxfEt-M6Y)%v+?-yY6y zzrfLf#pKG7scG)&KtxNvZ-;($CZx!hfBA`LG0uHdCU71MS&=nqkrYU0-;IjYe8pt*eWT>3@Lt-X z>s>Ge5xk@wk1xvGgFm`wJlMmESvcNPv{dx1zqiD!!)ls#7n+|gj6v`A9;*pw zKHsjXgX$K!sVr0@$503J=1{B}JQa?hg)HFv^R!{1`O~46&V8ON3C+hD^NR1ShFII0 z`SOd+z1$7;v%mksq{9vjkl!Rj^I3gSSV|1Fcjvi77mRr#EXWph4@+E4X2NCr3EAqW zdsiY(K{UqOxEAf4aGfl(fvp;x4hPb`@~+W6BtF5vs4+Ke^f3K!a5tvgMNm_-K!ZH# zSGjeC@Zt6oAp<{2k7>VH7>%4AmrY7^MyRv8W1^xzD?HIs4P6n(+qq(tb<)?v=Go58ZKvY%Hoi6Ya~=ajnL8Mqr0yLoMmZcbh1^7GA}Q!7X= z9lEWbrE5-Z(-k{)ywaXIvmk(uQf9vs1YOVKf*tBgZRLagTU6OGDiYK~GCIy1)I0d0 zvQa{rirI1HJoKT6lM*Fj(wP4O2@ujYe#rZ#%)R#miSGwI;j#au>HCacO zuxP+LaGLl1V4y~Etx912PxQ8zu@5h-E+`1uvsmeKVfFb(y&E(~xk0q4D?ram5QENg zz2wO8ov$q~usyT%b1LVmfm;k3m6p#P-Z+U2#qq|Qkz8Xd`{}o?d}%NW=*~f5ZL-=i zPhT)0Xc+G5wTZBg){YU$^+2(oO9XvyGBP_`7DD+OVxj~uupnHQOu1tDXCZ6xQ9rHI zgJf#$vXMNR+@uA;i`NwwS6bb0M+VSgIa48o`vA3otIO#0F?({qWvq8MZodQuY`M$1 z@J>j=m(20-2Y1*rwOI(n3aL3zT6^2`0DEcWhJ1u}_VGmNgHP^E%ye=)SmB*B#fOZs=Xt#W~)* zap0=ctoqRe;!u zU#5T$D8eGnnR&UuTC;e8FZWYttP;B({o#5@Okck;?^PQb9e)IE{hk2=#90vXz0{?+ zqVCqWzybeY2YfN=QE0kcTT)xl)rW=Iyw9gU;b(e?^dsMV`jT~kcAi-YDY>hfrU_#) z%&o!d^)O?L9Jn%YR55Pg{3FUjF+bacNdt%ciHtz!+_*gKoV9gjQ>bt{|~CQ0xt^IH;Z(dE__6m?w`H?2YqH235%ti zR8<^^?w<5(IhSh_m_+yHD7hx@;sa5w+DTSmnZtjQ(2dGrZY26vQI(B-`OKS-U^P;)YgC?3|6t5?u3wFDYVz_ z_9u_D8{@R4HFxfmgXdRFaLun!eIDvFxa0co>2m|dVaBt({pK4-%H|{d3Y^~VS^5`P zx0jneGV15ASuBQiV5*-Mf4DWN`zCJ~TRdEdPh=$z8N`H>Fu*WRF?7GP$OE!a78?$G z;x%C{l~V8Q)Njx5ZX^?5BMW&baLoR47@JOEM>6hn^64c|-;KqEXLTy^6B;Mn)QEQ? zf7MASBhAaEIrFU(Xgd~p7B(R9`Xk7&%J5*y0)_w+oJ>rAO}M(~?#!4!)&>sy}w2+dYn6ZGX#CMp{xCp;W(<@%bA z%O&Lb++~4(*Ei@5XYY4u!}u7FUnZ=!eac4&_O0g)n|nr|v}HxL=9|JIL`*WSa~@5Z zr`GRHu^IB64!B<}=4nb`ZubTQh~39y$Sc8FLfq?EKyWWx)TqrrgE7i~@Lj~3Yex_a zs5Kr$$TdITeqz4Ve}bJ!{6@G$yy5@w%tf@~O?C}eLCF+?k6FfPfTV}@ewPkzNX_o% z`o3XB;Ryb4D5s1##cVDx4&OI($cRJr<-R)c`Vij%lXv_eFi-qTxfhf=?so^o9L>g0 z8pwzbTPm58w5|CL;(TpOn)_5HJgxLAGC@!uPavmmTB>tO2Kn13rgy24Py5mXk{K~K zmzuckzcCT6c6gEH=W$K`G9M;LGh|7ms=w)CO=L>6C>;XGH|yd;(s1RR7{0`9UD&XH zVbS>XF)KyTj57tGecwkP@w&c9nHMs52QWz)`8adTXqF z8xYdj%6z#yhp110>~cl3hlnV~s+dM>(tvC@57taKpo+0E*yoW>Ruq?h>IkZ= zsaaF+#qs?xq;F0JsNyF2!p{UHl$%-awEXx@AJG2wsU=#2CEv*1%V=s{PRl(D$^P3I z)>cHC;D@z^?0$Hi5(f|!P{RM=tYNNz^Ltpg7}|>cQMyz&-@6!Xz`PQ{w5x0PUAVR&7VK< zxuD~sFopO?sW@sRBppj;NW%F+l<%&Oxm$k_^nU5gFZ2TAF{bEE=k$t&CL!nx z&_5OKOb2K-hIUmP*VVc3<_ab&;tBwL-IpJxq~=ElRMee)c{wU+cWfLcQ?-Qw zvW@|LcVr&=W}=?2GW_FgU5MOI;A^Xiz}E)n@4sT6I*=Vco)~s?R~}h0J!P3nsM|#O z4u0=L*dJ~)K#jc-y)?%SyY;dq-vwdA2=8-z+L zh`kpPHvtT^DK+9%@_HvAknQnyLd#Sq9D!e@4MH7tY`ow~Z!Jqz)dT98pfEm}IBk3% z*wTC7BE7_ZT zi{wYoW#_!?GGw#}s}{-faU70~+_>E42EM>v`GpDcmWu!Gf=gEBx@XSkJsD8v@g7f$ zidlB(H537mFXXVOvt&w?fazpTuIjqKZ+niLZNrzbZCKWZRUG7P+k6C;^y|Zg6AO(f5iq2#r zgoxgy_D%73Z6_ICIX_S?kq+ZJ*_xjuUcG5`#2wjwM|3}mvETGM&Wh~T+hsKmZ|T8L zW}?_8CSp;1`EJu6c@w(L&c{lwmDlN49F}EqveW(<<_giXHV`5VcYUha{(UVBzEi$P zyn#axA9u=nZRk$1scDQZ8WCIlvl@fDF8b}#Jv*sfb)Cr?!~^kD#3)IUaHw)g#%V$z zAN$&+mn|<B@@UBSh(VZ6X{*f^zLi262zK6?ZfIRV@EydLyi7IMS5FMZS5z_3eGhuxRO+OwX#b!2434EaK0 zyAoafj7fXp-t8ZH%AJDM$&7m2yn1!F3Y5T~oH=-CFSqi7WfOT4nuH*}&S7ujmi+2ex5OlqK;NGnu$c~Q=~ z9gR-%93A+|C2*pdsJTh}{pz&;S9MclZBh@IDDRN#2MosKHjgmhSfWuI)21|!6keMG zSGI%foU{P`#Rrh@&31IOWHueO`4a|mqTE<(-1xl!(51@#yc$Xd9wA%-6J71H7BrIp zTmQuU9k2g=MxE-yKvw)Vn<4!iuQNT)OiFCNuk5 z?wV7cd}1-KXK3;y{W@bge0{qlIopw3pmI6OeDeTCSgHi#pTm-9S(NE?eQ9c*w(lsH zQGcQ*@u&|7g`4cR{YKtkO<+;y2tgz+IbV}-=v+8@ICQI)|J;fYVs6NItzrQ>dw2Ts zfb+QY^40M~!sFI#MKhtJox<_rkTxE}WdTI_n{Dd8_G>Zdt9Y?GJR)M3L8Wf_(<6ox zD+}wW)ACI5A~qnbSXGlWBSkZp03G{{Lo}_%h&stKaN=+$wInlwDSJ9~$m!(5!>-eh z4A9y>=2$Cx9?9G^|MBQG+hX_o(4}I3MA>oOtaDWhe%fm*K;}26lf1(=KlxOxB{!uc zS=^#P@{qaJ%5q9~>+BcFef#xr>Ic4mc$qNAL;qz^b4%et*!Gfj?ILgk2-?@Zt^3e$ z);h2G4gPmaB40%g$p0&k_n+VA*1P7SxW?6odoip$f7#u(C!GnKcng0?& zfkqZh(NRlhup4!mC|T2r)5v(_cgr7?JWAm62i$S0<(U5xUuD?hqvQKoPt10hV>xBI z;G~T){}FYHv|0P6I(sk8$?&^EziUDF59!+p&cF(pF%SA5Rx5diWBw>%$JViprb0d* zN>W|9b~6rI_m1z0@x-~6zeSwEcOA&~#dF;wQo~3dR__~wtLdM+uZ zQ5g1@Sc{tThFDBbd_yhHfOsQmtwE)D#%58>X~aT9eoV}WY~WqjT!CgnVs&)y4V{h! z&~Jq4Pc^wu?QfB?9gU=Vl#T^s$l#NQb0W&Q)$%1}=?!bC*e~$?K4?|Z#xPs_k`m&A9|50bVX zm2r>tTzC6m@HJM0%Yx#|Nm>^Nv__Z&F8i|>&&b2i-d2r)n-Ih*k7vCej`HWM=RX|| z{I2}=c4TZUI97K`SaEVGRFsU148TAu>6$i>vHN12-p`n&}W#K{u! zMs4xVu47u(YUgU%fIe}h+Km?)uHCF{6SE}|Z?tW{gZE&ni1?B)kmQwcmhXu_S-b?} zs7>y!aNS0a;W*na-CxA$Y99zI;*w z=;%8Ycd24@eRS|kQ0mKFn~Z#&aF#7yyfP>o2l&7@-E%ClkqR{=n)D}z!mSqsu6#Q3 zo^#2hCFq|*fojcWRW$FDjb ze}*HsE@(8qPfku;{4Q5I#|zcD%_|Glru*YraFLOb$OIgc;7W0ToZ@##IlA>B*Vqfj zbo;I~6c^R*zPl%N!|1uypvrVJ(e$eILDQ`LIE-hV+oM*YY2H5$A+j-UpW2|osz1MP zH*2_N-yvb{Phw5gJ~8_G+NPHat3Cw7aZ=npZTT8biSH9L2jVv@1N`ZCEm`u};zOyt z@h8iTla+=t2BT?Gmpj8U^&|hO-Fzh_xOPKnU}MM9c!n}h6Czu7EJRs z>MNi+ShrX?84w!pkt-QiqL=(FzjKrHwcXqUUAj6n03r~9k@NfF3(z&8tC1o{&BG`lpK zDWvq1;jFtNZJx&XQiJ^m`=aFr|I|jbs0{{Ags6lZ&`oa^LkM3; zSG6^3qKCTtUgsA`QVaiisvDgJO2uk?*L5DRFOta|C_i7GA8}fhs!bHJ4v=FDBgRU< z!m5z+26kFWIyvafeopCGH75^IOKnsrv~{$2(!UEqqFZx-eOU_Oi#3oXZE8!b2iON#;j{5L!d=X6OM*8}VJp6Mzn;#7L);wR7a!@v zBm9qd!N_ts)x=g3#KVjo1>!hYKIFPxUE({CZ*QO!eZ9^h!nJyZa8biy8iheFtFy7@ zfpl$8T2}`2C|_eUZ5P(lEjs-4sY0$;)~Qu|Po4v2m3G|DQFj@;s55<4Z2Ha>eNb(9 zpj??yNgC!ZR=>4q?(=mz-TH8_eshGjjy&&Z7Kv~2uRFZsUJTJ{b^)ib#FkFDAZjcZ zGYkHBWI)O;%l~OfOQd-u{bvmA>IKsi|CwXX|Cd+RE#)8Owf`I?$&L6e9twH`JWR4!g+U7!CbX*4=R6-Z@GavZu7$j zlWnoVi+isevb#4TNV4nYI&PwM-Z}Bl+Vi3`ZS5?xAP$vDUOv_6W4VYd}}UMr3g^M^VbXCLLn zM$h+hqXuhLOnY<&QM)nnrL_hgj#Sk@r^{P>g;NbE(QBXEHqi1)B`|3iJpOn!BDSbC zZl_nf*xB*bNL^^z`|s%j)qxddcV%*Jt)UmVm}!-EC3!lHHe;B2f4T>CZKgNf(Llx$ zn9~;Ze>iQq0V$J6kZClk!&V(Qi%Uh)=TQd}o;)DMsxO1$*cQ!m!g*h=|0p@He$#3M zYt){Qr!-9uTOuIB4|;#Yd5T-pzfh_TBK@Z_@~Ek`hSBS`f3N_g>jOVZ!=$;h^#1C} z3Z&MO>eh(Bt1f!FZ1|~iR_le3tzh|RM>5)oSXck7n z%EkOQY>Gq@7dD^(NWZ3a1|n?j?nZ+^qw8HTJ0thLcVEtFWU0XmZJ7#vhHYQd(p*c> zqLW$Z$-r;=eJ~01e4PafakxKzdG(&-Cd9)s*H7?QM6Ja#r8hLbf`Z(oca|?9a3w8a zrWBTd(HpwdkKwDrqmYbedSR+L?6z?-yPbw4dUK(iwo;(Oz2#f$7C^Ci%6-E2LAdM- z>`Lo4EQ6~P{v(Pz?C&;hfh0t!_OQ*COQ3Z#GJJgfCK@#Ty(|}fC&RZ@8YtEQTjiax z*F_rI-Gb%-B-BDN>5~2)Cq|I@ZCQSqSh2k=ZK)8bXo-S^&d%By2hLuGC#pX@SR-VZ ziE+(jr^4xg&*5IPBcjFrbvm*MsLN)uf<6hqwo%q9lk-$Fx|59JY&Bf1L8MYcPOH0a z22_pxw&VUpsTW7NU)iE(q4f3`Rzo@@zx5wJ>x-Mj*Z2)7iq5b*BcR)kG(9m)H18wp$X@uEKC=36h|@P!Zzj_ zXh64o%8ZX`XexYR7SV)!ulr1DDbWy9LLE{0Zs*J2l}@ETESh(g+MpabwD>(*?2A@y zvaL8?Xb&yC>r*_o*Zmz}QUgv1`MwEJ9o}lit_@A+>#aA)(*D@FxJbb?*ikRv_&B;c zaJl@!=;%tGvQ0-XFh3KlYK~jpZ$IQ98B8Sch8GFPVB<{_HbLp?nuu#*@_caR-GDii z5}zk27sDLF2E&Z|3&!G;iS;6i)H&n1_(Bxp-H#m`juU=Kml7n?PgIdOwZCTD+OLWg z%eF>1PxQB%$XN(h`v%87&ilR0f7N}-TYAP8MmrWi?r^K_s6eaV_gxjGMUIoCvFfjk zcB=302Ox&=Bi*~`!`?5SWP3QZLzWS%4jjK~Kc0vO<< zGar$+fo&ePP@oh8c5woXiNxEp=BDalA>gWEqPrs*-HwUY@hwX*|I3t%1G*)43!21> z7Pi|R^=i>lk7tNilF+xpFInpT7prmaL`Iv5JeaI6zF}KPlcPkZ=RL4Bzc`#UH%SMU z(Z&OaTY{MvHB=hVw%_%F+*))99xo)#B2#gn_n8(F*OnW-#7NvbJBZ-K2HEr9VvA4D z%@0fo80KJGk zTlhV=_Z#E!UNo`IPhAsg7pF-(A|)=g2k#%6Z*il3Tny()gd?y^M7IJdEQ{;E{lmZA z_ePp@9WW-)5kx{^%DKfdNgUW&x_19oMnDfJy%x}oM#glNpfg(a<~xdjnAN)@e=yb3 zNCzcyNV-)~n%7mA3*_ts@hk6Yk$BJaIfDxk4%M;d8EtUQ7KZ%&2mBf2uH?GlV$@DnrLOVa%g@>kp{XKu|A7)@)MK#^ zhnB45IO;hgI?*5fcuPzb3AJ3F2*#>)nJ#C4ROk`lS`+)_-QH~i#MtfYajPpx3M zv>NLCV554uAkox9hv%q+h0ht>1>gnCMiwSEQde`4+a2Be_6oZX@u1=ADh8rhMY`!= zcr;zZBu|122d=NB=PC@>bDOsOg22k%<%#VrcXkuhd+;ewt>r_a*Bf~n$p+U@=fAo! zY8|i!(ULW=oHw-aceH?aK$JkaW#>BEJ-nF%l^ktcdsoM-mnq8t30BgvHsaPO1WUJ` zraLk&GgL1%t#!@@S{c2WRKz#x{kg`)PAA1_Kf>tUc|WSMDl=Ojpnf26ihq|Hl-UOH0pDS>wUjLj z^iGNJZCa7=^^BzM*`4Ct@?zcLx;Nj`gB06TjMaZ9WNw+^#AjBx+f&|ReM;aJsh_Tu z*k(?Z#iDfg7t-}a|95`zxE|0}mAs63T<~UcgB{hno}b=B;GJt}g#&n+xoR>1Na4x+ zE?YnSTTGX?9?jS_j~*T`vd=t$x#9$w|7| z@McR$@}4y?Wd_Yg%C7&67rN>F*98S+hLnLrX)Z~8o#54u2TVp{3HpcD3q)u{Z!MV)hnv)2&(qaE zU;z}k-$-U&i=?{Q!EVfAkD5$7li?WirjxHLytHYs$p!7=Bw@#Amt-q`JpA&mmi_MN zK_x1uX;>nkDDh0l;~=S@4(Ns{BV#&6yBLcs4jU3|WxEx(&6Ic-GlVT}H{u06 z@9h?8l=#mHS8GDr8jgq_QhxBpN=%KH+p-hz;n<2aV&rPNdUxh4ZMbnehn}nj5+Bxc zHw%?_$8I#s7(&3`xaMpK%g~lq8IA(ir18$x?Db$B+daH~ z22?Cg9xHYBttsFD3)z8aet4FQM1MA$$Y{KRnMi$_tfp!-+&Q3rm7X$Ro;U4O>psL( ze|WX4Yth2s`}!$EZEC>IasA_bdsVeJJ&^zMM#N^CRSMxv6L5NOyb`i>M04G zBUTRYu}^{y`sZkDX0BQCC`dw$=G3YDjF|?h%arwUOakw%l_Qq%r_! z4b;hcnJA~#4;mQaFB4W%!yp6^q2F0rQGNdD8N8KJQTu70CH`XA%L%L+n#^meDg&>( zL@KqX)CMs_w|HRs+c#AQqM++kF9u`$GyDrvEJ@`oV!2BeX64MC6X{G*XyU_ zyP;uoJ45KPYk?;N_@{JCj@et$dJxe$+j#5lM5zueEu^2N%jW`I1=Xx?JKaUyT{#XX z9m_9zHH)6!2_Rc`nUC`IgjR=X4yp)5mpatv^Hq_UzqUU+@c~0|zd1<>O1z*_pt`2L zA$k~RZP4M9P-3YcaUXg*eG6m`UP%j%ShI@ynLy-mw!Af8G)%R(S)JYezOc+<#p5qya~yp!A3{Z=QP7{ zl8+KeNfx^;*6Pr_gz%;(a`Ro6EZyTMNn-Rtw}g;dh)Y!DvB_JL)&5_e;mpM`YfN0n zZ^dAJl60g(DY2N!F|1)Z%S7+2VS~ecCeI&6dL=jcXhPyrP4&zcyI%B@=ZI?(*`S}) z?f5qLD?I=Py32^rd}OUxgerN)Ex{u>_mq_Ge zs5!mDTYCs@OZ!(Y*w9?gnK*Md(rJ$y*es3~dYyi;Lm+N{Me1ZH(*eN}x19lWNGqP; zR&_x^Du9P+7_K?{#( zcWT=DFz?|zL~@UNs%H*JE_h_Ch{R}xrlybVshH5;#a&P3bYU1*2irSrOGP(Mz7QfD zst}$jn%T17*3{$oZLby8;dNoX$SQbX=IBGC}u*z4%jb%B*iTxX7?RHHs74l!o zkB+b(xeiX~7433Vjq+Gk=Wc#*u(==3KhejrEPyC1NiBU|~+rUSptOWT{yxa7Rl z1*Zg;ez)30q|{`nv**Q6T5CP$q$rhXBlnp}H3gMvv`jh=IqPtxZt)KCl+(WsSKhgp5(D`k zQ?DD*7I~LDJPM9~EXBnBfH`x1W21ud^=O@_7l9zImLwg9lF=3-BnE+$>pFFGhUG} z^^wihsKyf$(GwBIi=$Hy>o!#@iytQ{?PxNEc#1mb%%*&2`@w*spRS`&=&!&>%2i$#!@Es4N7u;#SsRv#}5X=C9w{K>ORtwN*vr<8wec5#(+E~@1nZYBaZjE zYD5RhY;DNt*KGtwOfHD(HP3wtwZk7-=-$e>uozqPg*A9NS{%sx>D7m8YrBO4o@wNZ z)yT1+m&wf*JO?3K=Ul$xw1PCSWm0UxV zy&g`d+md#b&JTHuwe;r2gA%XmZzqD_v>(tg01tk>mLwuH98g(@V=i-(ORM?mVCShl ztTz~TB5u5%^s@IVm~^{iYDm(-V~Kia+1gw8d9n{q1IyI5`&l_2KLupM+l$nEpu2+l z5-HQ_nmm5`F)}nXy$_$w$eF&Bf5rR36PALq^j(_}k=ppqd>}G@llMBwipS7$*KcX8 zt&WU{GY||qHm~p}++a?FN*#L|tx`BN?6QS&uBY-lp*8Kfwi*j(sAHV8Bc%q|lZXNs z%#p1sCw{)0d*TaZ4?0~eYmM~Pep`>!_9i+&63QuXf)|pce#;<@z}m+#m+t%E(x|he z6mlrzq0FrBYFT=WMMucPvvYnw1BF{-L)qjWCt;s{C{iVj}%OWeh9NoRTwCPZp zfcO1qjZld~BNMr%sX#4sFpN=nEuvEaAm29234jOl?0RoCZ&$$WKF7LHSc*Y?z-QF~n< z#qEt6cRV|p%Z)=^o|~r;mCSI4}P>eyVuO++#!ZNw{1Vv}5y_XQT8*17#h9GXEvMa;#db zi5>AZda8u;@UlC3t7Gn63fHuQ^bg9X31CQ5)hcy7k7DSM1ngPQ6y$^V@3G&;lhx(= zK)<1x!n$!2Vy?w}uvH*$_OScdAv!hnODh2CF2jG*NB`k@XKI|r#{_JIRK&(mxC zpf)34S6(&g(oK6Ia=_Fy>OVu%QR3aWzhTu%dgCcS?dI*{a6cN6Ey7<=%c|@D1=N!2 zV`GsZOmvd+MDN()%sII5S|Dpx0_zt<}7-cjGW+#dJN3@6W3oA$2y`DU!IPnBrKKg6@iuY+X|V~_+>{RMvXd@1n$ zC-q%=VasK-(DX(sxmK2WWC zSmHm|5WTls(TYABdEhYfOP;3IlGo&s-K`9AFjDRBD8@NNkN=a<-X7ON{Q~A+poP3ZvFOzV^#m9}6t|dumg4YVX+j}xRgejbdAvH2 z@1GjUTY#?({#UX+HATFl^?x@EDA&0gSw%lnW`qVHY6-Is8G=*woSaVcu~`^@1l+!k zcvM4YDE#Wr^JDlxqf$0+hKMkjnWyqwmhe~iFbezfRq%~Y62+(U)<5_9up6CeR=8*5 zqEv{IJgl@Nrb6s3xLD8dSC#Pm?@n6ZeF@cDx_bq!)XDvxUVzfS(siW4Q3w`{P%71tMzoLZy@9r1Pk6fcBHAr&iTktami4jIq`9&N~5RF4-KsJ7hQ4f>=^Dz{y|7ZUTQhE%0BpXPf+He%7wirM1=!ifyx71 z*sh^`@t9&faX9bJw+f+TV3560z7wEfnDD3nq}=`om!#!Fn6-h!Qe%Qspe9(RLbqL! ze3r7jT$dMZrF0{`0Hdrg8;gwy|HsO|mh#p~M$&2MjYw?Xu~s;&ojVQuck4z}g>8B6 z_=bs=KWwn$yS>sMM-i=``dCE)&_AMXSrv9hMjSP!1WOVQ4Ufvg{oSsLQD=YZOiTvJ zSwIVN`Tr~T7mz|?wrg{y@UhVNgl`2<<;9(NZgx#L9?26vUHf1wB&vAQn1`}ngGlqI zO?SU87KGq;xyboqYf%|?y|4Ir8V5YVxG^)S;7uG(pj@u|^BwGy^WUKUj5PIQzp{T-nDvwV%FTs-i;XRul_M39 zull8%+Ls66kJ+}oqGA*K!zT@`xR~D=0tH5%+)ybgSS&{@$q9p$J;gJm1Y2!tqJ@|#Qt4i)8-pxVvpgNqWq{I(GChn%^nTayXWqun{22b1b%;-9A$cHXsj!lJ=R!! zR0VU2alG^=YzMM?ux?tmMAM24ZT%F0`fVW_Y;p>6sIdZfZ~*iL0Rbgj@v#Qt2c|S2 zPA}j6zz)0vlhmVW3>=EKs0bskyg3sWCS2(~*wD|y!&vq@^!ku*aNB9MYejYYz7jqD zd^Myft5CfIPqmrf+nX^n4nzVJT9i=lj)uFfsJl>1U^hv11Er|fdu(c~w0^n(Gl9ps zRR$~rQE=_dk3H<^CK+Rd>63KvvFhqF&u{%b9yM}GMmy_jIGdqDSwgW|*Bp0>BpDv5 zZ_#?_WF`!y4Yds3n-iZ1R=rI8V?))5RKWnpJA=M3Ye z$_q-8jk6hw+M}iZhQEi+*xhX}9V6X+vT5sGBCj54S9d3>Ylpi`>MXWcmCS7tGj3I)%L9QtX_?yAIFtK zp2V3vP1U8&Zr?{n&4WV6;XyZHtJlkx`l(tXc1xUr`9SnO$qvBXbQRKno8(Djch)GM zULCLS95nn4$j}feYXF=TZ3jGeGbKf+Km8y389B{gH1L>N{p#|;)d5L_( zdp>wV62twqI~{kTzmoQBJdZ8zv40wsQAl(gzGr0uqGXY9p(mXVt1~iN#MJtT=F3sx zU{q9?kl&nh24QkI+aqRk)?_K2n3Z@LB8vI3RfmRAlzh3jjiwGnp`Qs#-&F~x+XOKM zqz!^vdEW{%|EL<7FtyqjqBWh_9QsA?4F`AbY0cxaLVK(J-7gCdReq(5x(MMsdD1Hn zfdu{3BcPof_L2=%8x~jIvkT2TROMH-7Wp~p(;njzhWZkbkF>37ZE0Bic3ajXK z&SGBZ>GXasf3NSer^@TaT5}dA?Dm3?;E?7V!u4{xw-- z=7|eOQ~JcU`XrbLeKk=QG|B#2>#ezv>RiF5YttF*T>eG7M01|(bF}eo!tuo%DC8|` znc&#`n3-ZkE7(YDy7Rsc%WIMQGVP8)m50_@vpmA_P=DO##CM{EnhpwZ<>R~#qp2b~ z__KbOZq4_E*S)1{FYxsGfe({I=Tg$<3r{6+j#j1;J#}IGdD{6$Phd+-%Kn>UjxC+osE=P z3WJsN5k+hJ>cHdvqBTyRbCr%;s`ms1(=Y2)|kDHe%lMl*#Z9qo_RP zB8X!3$t)u)gf|{0**u(aQOncp)mVauvIfkk4rSVBwjVGUg{WM>7YTc1HvzR5e!h2> zK8!HglCPUUJAKr?H&mTh#EKaA)eI#ADp7zfDb6Z(#EV{X+F4j12oDdn&zY9>uajAe z=4$r3i~bYSPQyL@;N!l|QV$c~yJXH*Jc5R{H|@{4UnVHn?} z`C^r@?(Vm{fUpzs2M0)Y6~3XVV;%0uRG83P?xe@c4ykoZ|8qS5;Zu+(jrY{4N#Mb> znKnE=ZsuAdP$xrWcco~AJ-e7eH;1LW- z`+1o{Tiv#bxJImo@6VYANA)z2mr%la^%%}f@`sXmQ=kxaB;6|So_IC`k1eR z>;(^jmY%sP=}yrMTb<5#ltz^Q?0!FbM#}e3orge~2w=|>o*%zTZW*?MPxhspEG<11 ze!h}Up0wWdKK@!J-55S=Nu&|gzZqqPyB3hKXh@iMl`(Auy(hR9li8BR9K$KhGomj z8wONrpfJJ`91d+yB_qAA$fXgPTjm)`hT>hVVx2u5#kzpDX2KZwkkDK(N_p|0X)MQihc@p0n?O*Ob`zA(IDqH) zW@@Xc_=hZu5BW5MMLCf+CaXr7oIG5RzV!9^n1oVosj6;zqqWTaoZF9&ORVWgqxbGK zvPn8;m$+CKYDJludgrfte)tlNXdJ7s@2s%jT!6ygTd%Dqz`MgFKv^4l5RN_81}5>Z z7Owz#Xni+(H>;q~OSsE>BsqZ$M-7}A_!X4jb};Xx?qM(;t#}2LJgqXaOWyomiZ{1B6}E=e;@#420qhbyh)9g%(9T zW2bR}QB|1>98qxW8_`;=L35)bcIo*B(`IXMi@W;tFj00IJbl$4O&4t3uwCDF|0@HM z3}kWB*X%g>!*m6rkoyFQ)BnzJiOW>I7GL7fjNoN4G@SmQEe>&}`MH9#gCgoSb8 zoKivn=HJFi9(Qv@&_z#P2!IYrlPE@B_%e~ zBH*Gc{Zoda-2QcaE%v6$lJKP8UZPifm4cM6ClzO0`NkiBFlj%A+ zvVW~fOvOM_3@YfF9wqsR!IZEI7LL@9{5elVpfkr1yZGL1FV)J!6*Iv9$ZKu93j6s$ z99dJ*Yq!gs&wosYK{!cDBW-?+v)SFfHe;1vgqHR1;6$qkHdZGvhP+%OBDY&oX(1$y z*_FajcqOY-eke`Ms{PgT<6E8zvsTlGiP3>OEL~U4~{~(&@MuQ=?app zfV6|#>-h&$9>Sj+cXZj&{2u_DjW}!uj8l-fIH3iR5aoNa=Y5(GKu^Pmsu)Gr7ohx_ zxfm%Q`V$=x^|-^^b8do}A6v1NSRj!|JS}+YyNg0(xh`MbC0oN62g&&iu*{T;zBDJe}@a_}EI^yS1IRml!N zK15DrrIQjXAAm&DymD`xruDN9!Ok3_a|Z)3EHR?yUBO@Al(&wnBklZw^}>xYKB2s- zEoXDOeqY@PZXND21+$sTV}8BU(tb^nF zDP6dh3u$P_4$?y(y^S5{_f>dcyUTXY@yL=Yciz8Ya@vYF@d{$ZvbBFBxEy>MI3cT{ z2*3C_2@zVTYj)4~=E1dtxGj{2<7}w$cAd@b_FV44<2iu+`R7bo{R7ra-E%8GHXzU+ zh6E0pGGG&!CHyq7rXQ-Ue&kQ$f5O>^Iu^hs6PhR!^!-g>z$@c7{q@QpnGYkrEM%oF zgoj6chWj3DOhGZVDuKo>Z1MC+Acm>z&!`!40T7<>OXa^m)LO4^yh)p=oioTeHByw3 zD@Jdd`mJU@b21bY_1M#EUB4JJCZEn7LJ3`E#FOB%^;OBfw+cpayL2fFT;{G+$SWM! z5En37-+8M2*kL&4Mjit!Ei2#E+=5lQ$;vE{m^r=omkoVXXugO<@E2iBCQRi6- zy{;LD-P+IZISx?W6H=h1A#f*=W{=5n@r19}H|9cV zNi`~Dk*qvZ=
W2hL&B1;Q#r{{UGgp5?9Qq+QJJ+U*pA8n>-YdQ6t~I z_((%-y@D*BS~fnvdn_*a=?7Fd*frylqjA=D;LNO4IZq2I{+ze^(-_WLmSA>-%Q>?SgVG#*?g$sHjy(&5mV3YR{r72i*R7)q3Jo}grZpS8ANbu{kNVI>B4K(0DGnlt zL(aNF=;Jb@m?uz>0_E}0TGN;!#0k_kPKn32eoSG_`9aWgitr0&bE8{H{OZ;1`=shh z@dhIMTvA&IYa&R08leHQbclnkBFQ}AdUkj1S|x$q{B4fIi)~P4qT9wItt}h+$^Ne6 zu{ir=YEi)qomK0-DNT>zY)C1i7LupSt?UVs6YFDs5(&jx&2M%-AlRn!#&|*7R_G;o zo4pA(LPp41Ot?$w>}KN>-3>{2PPsS5uwAL!(;~E>_UL#l*xZwIJR);#EJApW`8r-s zwb@x)4u8yKr_O5|K31)kC#k1=KDkSis>9Sk1?F+ObZ))HUtDg=d2kHX3rw@-`5SLd zFY6s(l0(gEC4BnisOOuyyYNoyR5QNQ*@Kef`rb(b%yv@6yQ0t1D#`DMvzH>!8-+eR zO&b=x2d9&`^$(HMx)QJ%+|jC&i3CQEVK-fL;=4F?GI>?ahpn{idy#cGQ#e3tzq8uU zbcXly!1CQqvh0`i$Q-(e=^WbVh^b2U3|BAidIwVpgha!gm@=;x;}7^gp#~`du;{h4 zoA(MpjxvaGt9mZ{6b}v}p+?G|?Rhi10)28!H}>F$+{6<^R?c$Zx9Q+XTt+=s{I_r+ zbiBb7pMg;^n@je%VYE1_-^eg7OOBu5WS$403Z=Qst%ccB?>lnEV3{|GvWxaR!1b>m z0`j@_IPcLa(y|f2lY#2;*w_>_#S|35ffMm_PSa()O$-(zH;5Fh=<(PnQuOu?t3`=V zr+zZ@uL{BsU(<};I^r1S(@kyzqg(Zn_)0%I*b^j<Or9`K|4_vci1v!8 zZcVk%T-&e=;mVp&t%MBLIJ= zAH2#=!3cHkn9YV{8h?*r4+6DZyNh9=cF&Jpc2jNq<&WKh*N=z%AIMyYKni}r7685B zse%lv#YPm|9D@2D#xcqvwWF2nhMWoG$9V8)YR-o`OC&}f(cp+Xa;2W~fr{m*Z#T)C zi}>pnQ*~x#I_)91!NA1lv~@kvmaQxc_nhBl)%SkwiSfwm55^Z#=3?r7_UGx4%>7I0dfDA3*O6piK2r;fTa5X*0 z+ChpIU+lgjM#g6kvNKW+0qv_P?*{fEKn>*KXH{`>EkF9HX@Me3E zUycG#ll-FhE}%$S^_^cC4xnF0qC$7QS=JhPphZ2fMR%!y9p6?VPv)7RA(HI3qxK2* z^P7&>YPqvCDd#>B{RpP)J6~eb($un4qa7fvD0cr0)%g4;776hOCU?Zc>l)2;+&y=O z36JHQ39#sii^>ntTfo}cb;@%w>LX~H4=Lt7EpAVhekvo$^^Up(*mD0gKwbRnVD7>a zkoL%Vu{-+EQ&nZUH#JkHA907kRrgOR>m22SntKATG`HA)iZf`?(9%wA|1{^~uS)B? zMR}7f_NN)ya62D6nI=QMz;@Na1&fz=U#1O+v9B2n7S|X|4>|u)`Sap#Rq02!*_Wi7 zfC(F^XgJOW*1n!GCuR6NxrYDMiTl8Fp$dEEzbgH&B>yjzVJt$88iYN=w~0;Wbu1}zd+WC5bFDb;5L**E zkuFxND}BBw-RPup#&_~p=MSaNRpgJ|kZv;F2(BUuFD`gFwNll>T6WNm|8|-n(4_fn zQbr;AXe}~+5rx2OaaO2>KEYC4`&wxcRm|KP|JWP+YDwun6AOH`w^WDSH`VWQW94!F zQ4db%@??T0lqcU=oV64b2`X;Q0ZyAOZsV3t6l>-vRKm~F>oR}3NH2h#9`>M;3Wp|1 z8}!_yG%h;Bq49&Fc=YKeFUj(!str{f_p2F31^=xhw_a zojGysau-{gF7)$dZ*gnPFXmVzylkc5W5t^7EnLRJLZ|A)gGb?}2_I&*RxVT}S^e$V zNe2ujhGGN_*f=I6~X`SH(we{WmOeEYd1h-_z(>fF=er`WOHQV8)n;rpWE z38(JdMj4cF)f-WKC&`<_8AiM+uQA5mHdoP+ylX_Q!OZO~vttRdx!tV(hEZ_i5LYr9!uZ%ktx;ilHbI-=)J4CCwvukEOFW9{RwT ztR_=iO6RA3Jzg#vw2NjsF0)Y zO40*;c6}uOhl?j?yZQP3p&hyg<4)}y$CE6j)7u4pQ<{QUME{N~!CC#6A@3tDA45IL zk}fVApirvy8ZwSp56-hw@N4LGYKQ?vB>X3p>u49hAzYd-8Or>g5?!dSDqz zCPPX#8ASm5glh(=6Ctz3kwgobR62By>)~$7)g1TS!fCppWRl|%njHCZjLexsfkG6w zHuX`oIFJnGG8~%Pl#5xE91h03+H4k)ll0lH)Qu3cjh!DG3kd> z=gM{J5-vKlqM6C5W(IIWCe@bpc!mx8b_r?N*l!|7LZ(n$1+SDyv8e)jgTSvWErx z`9#A7uiKwJT6QWMYx;ybawKg;*Fk|!(8~XA#RuaCwK@AfrzNHogZZxYz7I6IbA*KQ z&6d-f%n1{%bgsZ@1pQLv0+G?q$H@S`6p%fpRGMJ*k;$#d#C9bS?VY4R_F}}EzTk9= zElD4(Z`bV@<*dNvM(myXkEvX&@)(i01fl{+R26efPCsCx+b7G--#@c32+@2zF_1^I#&XW0Zb82m4l? z$KV|fnjKAhI7<5Zj|*P1EAmr^twb@UiSF4YgLXKM`arjzIsvXkt{7BvqsG9JdSwx> z(1)V-E50lP6#g6Y-oPQr|k@Js->at!>SX0y2BpbmH|t zr*$CX2p|j16zg;neVft3wN%66H0?c?XgP=lB9m`^Kd2>=F%jB1ya0)PV{;=f_yR^Q z=V4b{+)ysQQ(l0^5I)U~68b>$CoL1(+Jt!QmgM)_QnVK@BmFOL>27}8OuWD`t40H_ zryc)G&z&Nnk@R#w28}ZMv$M1JZ+rl+PC_m(JKG#alFJ((^h}HpN+Td ze}+Qrb5M~IR8*r)iDoM!yPu$=spJVcWlvBUgKqV6Tx^#8G=d1Q2 z8+B2>aw~uo9*yjM>2*2tphNFTDVpF9Hy3p4)fKWAToeBoP)hvarTqWL;Oak``XAyo z)J3z=rqmouoa2a@L79Srk!=f1-gdvieUTiVqKMlUcO8ziJv7U5}Eovl<*N)5(gSfj{_U6?3X86l@G$#~*G4R~>MD5SGGS-qoM$;jA zzg*{wc%gMMtu#7}%Ao&zt;Gifu{X1&IN)W_Q29(?2dH7;+6R+$H^Itgp=~|e&hRcD zNq#Ie#Qmz%JO>@gC43eXELf30os3(~DZSbyd*eScGnv4qL>&5WEP&R^mX>wdW18kM z`MAycga!9d{+z(q4$W-ooD#{eO5_8x10p|v2&{VMK46pidO^F{`v)!)SDH*oD4qUp zvSMDrR59&bBJq8M(_8Mqe%F*42htmUh_HEkdhIUnv%yVU7Oqn0Cu%QPNt$tl@SLlKCA3M!tk~)Ike;8rU=Untx3fInK`GyFAjBLfxJ%C0PD?#5P~~)us4E9#QOpo%};5 z4Ja0;a3G5@f3ZjEaq*j%iGFrV&t4#pVW`u$ubj7Iv<|Af7gh$5z8Ydf2DW^S;A$&< zKLuTC;FEf=e*d|^&D&+RK-e;yJ0Zs2Cm3Ht>a!>IYYJAq-qH#ALSK+Om+YW%-vU0N z?~$r}nG3<{0Uo8vn&!&wB~H!fU@$qy?@qQ;gSkf%K}(8xSNfqTtQ+UYuW-tZC@FQ7 zDyOkzGg(gtp0I8`WAe@SJn%8uA1$H$BLH=4VBBnKMP899N9+w$bE3$x@%V{WxS5scw8M-_Vd7D@CWu6Ff0UV4 z|CVQcEUT|%Lu^XqF0mM_e3b900=Zp>;nKEqS99N+U$`urJmjDUWRuT-$Qf~zNXs> zUIu?UZ1weg#}xe(WWzf7L->eMxGMvN3dg~XPd@|vd5d^AeUC0^$$|M$=JtOsd{@EYPYqBk70Aoz>D4;8lGmzm&0%uHii)66pLkO6l}5 zU4@IBD>dRE>C@xud6(%%Lcy$7Q(!WAJo2L~eRAE1jrg*B=gE94}gvRA~@!8;2 z1ka3-Mo^(m^mpsq0}r4|Da?U;i+w=ic*>dU)tvA38g>5|BrS)|cbxMz-6{CzGT~ZY zjZ)yt{QSDo+gt7g`>dz^sp#Rnb#oxTD%j|Vc}}{-dH>t&BWNCX2z&)1aX$LdJ}N?r zy^B|>P)Qloz@k^v?zl-}zT4Hm6zV8mtAnFp*tg`?N&ao1Tzjx=_M@{LSFTb@$RWA@ z*l4}~Rv|iev2s0g*Y=mPb-j2P^dIlU;3ND~2U>s()JMQ~$V?h0PfY=78N3o#-XzLg z6fL%Rsv7;(>-K?z0VEuFtY+>8EG_zL*=u6#SCWq|i_7xa>D0X?<0X5-2(N>2Lga?{t7l--0J ztvHy{zPKjp&%Ky zRsheZchA=oQrqqN_f^GM)Z7g+-Tn?&3#X+`Am|P`b2xALxXWmMQ=JjRA=|7sU_#Q14C8nhwIQF{Q$3>qTDQDoU(D%J>WzYGvlP zUjk-FJ+BB2h6OUW1ZH?DZ^bB{)HzOm^i=4s~>-+h&uXuJirzCP?WB@roT$g2gfDXY`OCYH>F7kGW70g|MAcj%!xHI5eC&H`1EEwOB?pC`o%PdEiHVD| zn(thKn{pwBz7(=z22rf20!3)tz-h46OlTzQeAWkyN?5VodQcR zgQsnIly9Oq)vzyCnu_iJX&_InCuZuMR&>^>mooPo*xEeV9cJD3%w5%Z|}Y`z{Hl4k&&ln{pXX~YhQ|}S;D>(qsIO$(;ItQiH8{=Bi07}mmoqe zF0VCvcA6kQj8Gv_Er)BSCTRQ_%+yvkbLs?rCvdZz6ff+J%BIHP1&b!{U&qA%l*{>V z+v)%6o4W5mh9*P*Q39nACaZaGn#He&#Cx!-OH)t7n`tS>W2Bo4GfbbXtfo_+HL!b> zNBfHk+^aBRs3VKyua$&2AapfH7EpM8T=7MnhE|j>1?qxs{hF<=q6HHpQw%ra?A@Bn z%+Fanaal8aRWYJIt(0AvB1p>E0bngq_Z3RN3u16bKAmH=V1z;ozlj8R2-xt3{>o>< zH=GVCM1p>o+K+J~+I3pFTgAsoUG0n}aM>AvqMJyAsL8(H*5wQEG_>{;3e+sk9Q~ak zh%HY(mWR+Ety~jR49X*P^}`^dhJ`h)+Rg%bd2VBj>gGr&cfi}-Wp#l`ORJC14w|-)BzqA& zCS8nmE4;4tmdsczfO$QHkFz9myZx$l=A7Ob5De*gd0G)aA>Nz&*a!F-i^tbF)xYa> zJ?nRj$=W?&x6I4UsfH3u*W(k|L?e2J1#Mp3&Bg4?MblZA`_fJT(EASGTbSJ=V{a#F zA2POw8>EhfCs=!*Evq%pGi&bx|KX@c|Neb_5;i0m1OBv>>7}=~&7ofQw>u;q8>As3 zoG<}p9@(3_Lr5e}PcUs)T~sSRrr2921RZ9>E<#8@hn46Oock0aTWZjTHkl3DykJGi zJb7-~E`%I}{Gb&qI=d7c*p}SKd$~o=?KLdyz3%`99c^&Um7?P`hO`o%9d8M_XrLvpT~Ne~jueSM5jTCeLiQ4_+{oGlP0@}T zT~pmS-Gtxioo=hPT_5YO{BkB`L-z`iZH7~=3v#3}j2p=rPe|&3Py1%*$^oN$FE>>PHlIUia`PRSZx0im6)Y#r>TXZBlJ#eAz%^iKO zWWz`Yzl~ohHPOMsAbLgI|FEkcqAM9ROyVYPBKPVaR=-wNoulJE+6x+o-x^9uL?kw^ z6pxPj%YJD6H-l?AfDR$c0{`2XI`~j0m7sGRrsVfrKSqj`L_b3j31^;?YDX|1XZT~qmYR8dE!*fXV{%N_Z6wf5ZL3MGVRl9 zm!Vl^u74Ij!;JScS)RSqSe;R-sP>Z?j&=W$Z`*gXVlCR5qx*u96ly0wfzkj0ekSns zXH5cbAN~wy@mJ>ynVOMv%So-Kv(D6Z_rjrW32U3pyd68BD}Jb6J4L#R#Y#{FZF2rX z@<9Zi5_O!Z$_G08 zVC6l2+Led|dFuP&ofOV86Yltcz8eGC&MW_R!4DWsSw$@=%Y6-*r2c5YFxteThX)VG zMb>WRDg48g^W6cb<&iAy(<9Rk64AIDc*BtkX@k)!lnjCNmz|{;Ga?L^t~-}G6#Y=4 z{Vm(+Z^)=!PLUdu^p|I+S>xXP47)wpI9YmF*`GJUsn&>a5W;oGC|D z=@+})2ky-+E&yw7#@bCuoyt#^OiQDh@olKQmVejk^J0O7! zpE3TT6eB#&k*VFo^|*)@d=rSvmUb)6%&j6WZ{YCB)6d9+#`6-Avi|R==?s86NCus(Ui=t%|sL+y5MRw^z%y zH8pP@oBSFNHl0%=-heTwM&#eYqT_qW#T_?nk*PRe8FD_C+~sVfob=kBFZ43hV7F$A zjE1wC4;Moqgc4@?(EZL<@hl`$*^lTZ=Mgr7XF#=1lFfckGlL!R_d+M{`WhGqpNumz zQHN#EXHiT?)Q3(Y6m|D;xiWXtAsW}tg$vZ^6JUg|_cOF_Sh;atThB$C)V}i$jkSgU zOG-54X)Hof1SoH>{Y0X);?6>6H%Pfqi5xXfGMG!t05!v3!|itBomP84u^ni^mOE0A z;|PAHK$US$WW1!GUC@jG<>iRU3CABNAxk(|u$_eWMACt+gQLf0|#%g#GK zq7adn#ZmT)_OEi?Ms_wL4*xY@DD$z*>H#I9D5;^55tHe{vHWqLv?PTIdYnGSyB#VL zFrVJlIn)wC+fWLG~^fVO^pxk9TX(p4Oi+(N2ZAdCo&|T7Vpab@ATTlWf^QiJ`ij2zsd?WA`s`= zS9rlk`Q0o;EHj*DH-WLgtomO{Gj4zX_AfP%-#zl*4*dbLyf_|x42%q})RyL9-+jUY z^t@Eh7q+zFBM8|DnS7t5Z*Fd8)~Ue>0Qf1fQzSmp%$BHc z&ZsKFegdE4Ztw15M@g}a;{EOMqjz90@V2=HOqKst3EulVd^%Rka zNdmc$L{LR`Nib*P2UC>8GmY~_POo1Lo3$x3nEX*^Dd%*^j~bQCit?qW8@9S>7}d^I ziF$r@ZKp)n{s|vLhLdKL70LMW%;{WzrO=aw#I}A@5lj`u9e-qd-x-)9)e}z7b@+a5 zF;rrT5lHTh4Hyd8RoDOxIYJa-*#vZ4&|H;mGW&T|Ogw=ek{RXsx1g`9^ z-TUGy}hG@%=8dNWIXECdOg7_2}~EJ0Ll~Hbi42V?MgSzgXq{DMa#QI6Ty3#D&Pyp#-f*H=XSABRd%aPeSC8+ga z*WpyutJy*IP_`# zEMWx8-C71v954vMt#U^@OF?5?z)PgEe{|Nu7mtA%Qeu_c{uzE6h0*cPe0OSMmwEmul9%^}{w?li`V;E=GMDSe;s z9p_(%roy~fR7CtAf{cPL1ubHi-ESX%#idU`#h@v=LNchm<1a^c*u9KY5Mg`s0Xx%$ zvl#(z^;)+3AkW_dRDLwR^q?6SAH1CN-C58%^b%YTAY2#F`x7$v7~B><#t$CdeOfr1 zqEj-q7U5dC z8lm5%&>J6gVcq#AIqm5nN2b3_^L%(%nyk~Gk6clAct00bUB`9joGAwMvm6>YiqU-5 zpqW<1nDM<{kp1-3_t?jjq_jnEAov{u$5luz0Uu@PT_ZZE8t+V3>QR4an#soHx>SO% z+;!Vy0xP!ba6Wtcb7QB;^*-u%N!8-X3Je&wBl+6OlcMQgl|x7YSo4#+3DGN@z&A@L z#%k_jWP3RxLy}7VU1e8J`3-_$2NnDM&`5Aci1?A9GimA5#kztr+O!!R1-`q$$IQvx z_gzu3*PKdgU_=K6-4FI@-mWK_8JR2t(Iat!iOSAIsz^J=g2qGUhX)(^oBQ@fn!ZS$NL{-YFVvq$4>;V*IXg`_VSoOo zv@o+DvxWF6Xusbd^#)MLn@pJ~aoR#An3pi))hcL7$B!ns?HD;OIzH>&dJ#dX+Vf>e z*|D1T@0_i80%3gB_CFnacZeQ!2mJ0169PY9EcJN7F(p$dA0>79+aIrJp6MMXD<}$D z^ZazGMbGh`lG)o6Hivcjz3-UDW#@jNot@V0h)PYMTKXJwjSF%2*eKvan+$M*U|wVv zYgm_y1_SR34CoE)OquuP4h%IQ9Elm3OGwiS*^ffcTMt+xQK1#ryPMbZIj8bfzSqhY z2E3jSb0Ht|tdsl7dIu07_vT{AsX2gx|i#0294uiYyN__cx3>DnK^+R0&j zc#2jsWrbY&FtZIZmBio;*L*P8AWiWrA)!v@6Z_15SFg?O!4CQW6~7izz3AJ_G+zNP ziFd6v=IrhowAyCrF0Qqr*)62-JDZ7lMvKe6`j1eiywsKNkiCw4#M*_L4p_Bfp$d={ z)90&Rb43J&9*5u-!@XN4AGDc7SnBQ35850f{8d3XnM*{n3}4tO={OOpoF`;ze!u+> zCR=KK(wa!`H{liO`5`H3={yeBn{1%A>2DwYSp;At(2Y|*m9{cnS&o$9kBneDmWa4j zm-l&uKF&;srWSkkzMH1)ZeNcm%L!exu2k-Ew0gz$Nt*fY^?Ymo?-5E>BUOBeDy^7$nC<|u(R?R zctSxrjThw%U?Qv^t!oQR~lTxcwSmQkj6i z;|$6;u=E~{t7AzU3$HZTER0F^$vcKV>3k9yxZ3Mss%)#7ZqPLF`m)yU{nmPh9;F`P zaGL5l_;JjrcVqdMgkqG+=8OcOl`QREj-EM95#5ADe)f3o%OQBV8J@Qs{~iS!0C~P4 zz<;=vNzmmH=oG0CQPbd#k=?oPSrX#-X-;gMsQysNu!lTr>$dMXkW?%T{^<8iJr+%C zL&4<7k)cwpFEVM=(YD&rf&*-nI<2I_0*8;!;1d8BeRm>umlPAL%og*Sjl3`N%#agw z=$!~T74A3v^6TLZ8PPAqfFM;37a&Tl=kWvKrKlNI4j@O-9te%vwnyTPi^f;%3_q}T z3Remp_@2nkN_uC~fzxdCCf`FSb81~_w{9{H>Ez z%OHE@^ApQ$j0ayv(@7`Gg6Qp)3skhA^$14PkpE>ii|EalP=A+kRfMi$!EDN#+^vn= zEx^Nu8u`om+Png+6L?^@E}AQJi73HwNbVaj3AxyT@G}=O6a>8gqzZON6}Z9N$5gfe z<3#)0!yEeYM^t@8!$x`JC>$oVm?~0`eXMN1M~^8Py%&8HNf-@|#gq&98Mr2-RYg=Tuqbv$9H|N5W_3b)WUH-?oqH@D!%$mov;mgM5HKd z3ZMAi-DWtCTbagL1OSPI+K8-yR^sN&z~1NU1l$)W{dNLzccN44COf|;edEZn58N1T z^nV3Y2t^9ONS#`#ppPI9S2>Fc2W${eJ3qq*_a#;Y^(?XvnY$hvT;X)(?=T#&StKf z5{hUh?>d^yH%fPgis!BD81f;RxWUxZKhz)eFXk<^Qk?%+cV8J6)fc^sf^><}EsZn? z3@{)aDlOgJC_SVy4r!1O>5`U)L1Jd;hWU@8-uw05=eb|*`FPIkeb%16 z&fY8DckLB_8(stH#c&xN(g>#t{X~VWJE6q(;~BmyY+Q5vLHjwhTbcn6quR4N&d#cY z=#fu$f>mhdVd|O^;9pkbR(mmX>)ow^PkRT?WGNuJN*WsXW43VvNN2yqbcb=&MIz+PDD_)!>@hQNElpc1`mf7)Ga9K6R_wttz6xp_>5#F@J&d$ytC6z6XXi204`ttegnLWC%NIyeQ zxE)u?aE%S8G`V=*nXz&@<~OQq+aduzZF@g3M@c=S0}~x~%4$*MMyesPF=IGDXN)5%zic-g z*LlB@y_EZ=8Z$?7@WuntZI{%J>(5}b?Vu}bssc2nd1r%;5u$3`n=Y;F;8%nBnI4;>U1Am{X_%*;*G({o4^>XAasgT2-Fcs1G3<<191 zFdaUv801<~Q(b`H?}@CMd%g_%^!2bZt>>D#^skVVYSm)Q3*neio9$Ki3VYi{!o|RP+*DJJ_$ShRfb;!%iRQxdNafaZ37I5&!w9DsM8R3?3Y^cV|CWQId}8xc;n+pEcH(+ zqyk<9%Z>4UhxL$~@*fF9yQD0TZh<1xBSFJKq(tMw&=a13i1qtQQja-WX|rJ5yO-@`P@9k> z4cMM6ZI~X35-wd&C%tcw>51bl#rH-86B!2Ic=S*$`|EdxsN^S_k)e+7C0zZs?wS+U zkh!ulCqa)N&k+a7qk<+a!cG)Gzmq)Mk*r@6x4v$LS0m+^#g1h_@_i%Yv6#TcaMj@1 z=0LQwH*IenAWN$_s2k6;y~jD^QHF*!zbo(*N6XP;qbSHk;aHefly!~CZ4-Yp_NM2E zv;F;NHrGYVR!@UTCSfny9g_e^M3kY{L7%A3*I?t^BQoSS;nglWs9KktEc_lZi}LBnQy6kn7lf${;JkFSkp1cTYB6<;m0YvwV3q*>?c_7KC@QM zVZ0D7qpv2BSo;khJvcC$I#F$PCgHpV;J~q8`^gC8Yxz#lZOcsw@HJ2{iX85rme(cA z8pyYCNj}|Q1~`waetvmC4VA0@Iae2N`#WI^!_u!SLEF(6ba{x#*R~li)R%jV0WlT_ z6S48++N)o5RqlS}kQi@&R4{4W+l;W%l3@|c9u3U|9-vJToQtEoqYhf$ZuU1Isjl0K z_b8#5J#IdZ_%78*UA6>D<6z2O$@;D;g!G~rQ8Sj7zFf1@e3Iy3jc#GM$?tTwxz2SS z8E-fq8Ah$gzpwS_^R7ap9_O8%)WeG?e2$-ksS$|A8!BV9PiuRMlp;_Z7Ztd|X2L!E zAhvWL14|yMvip@AiaWJVa21tchpX~0i?Sn=u0WOS&$ZervZZ^A#`TT#kqxzds-%`&6zL)wasARA}IqVm?=lqj0qOs*iF2;$ZJLM zBj`3+QJxEpT06OI70~Wm{HbC38l|)i+@WQ-AbR zFK2~*h&lexQ+A>@{)n^t1$)cJz-L_@Olqs8U$-Epf*~o3nK!NX3xhjF!{iwaOT5>& z;rx;`Cp?G>wH!DO`QcCfW3)(Jcn2+!$16 zGTpIe4f#%2N$3`MY699XC@KVC;GkRbe1Tu|w6{Eh7|&cQL^Wgf8&4Z*=}oAWvho6^ zI}(TRe(0^mH$F!9aYQQ3&TS4#vzu{IoziAAgBH_{S9{yh7_Y-7j@U?IBPylH=jp&GP7!0~)}_KSo#OT=KT?Y`d9 z1R%5{yfBo+1F;C?;>6K!DLm5oetrnuJ&j3*K#0M;eM3X}+gFSa={%RCRUXia$E{t) z+OBbY=5HKX!I?JGk$8>1NoXn^_85!ris%V21Yq z8hGxTJORIq)Tb@CfjTR~tk(cnB_mIcOf}z15mI%$tRu-yR7_PyuIF7!T7My$l)tMg zStf!eYZ90ieXyads>+IegUEU1)R|lA>_$jQNhy~*W@_~&DdWeF>{h2{FGaYoak~j^ zORAw%-_%>gUVkV@^tBzwIVt#q+B6lJ{I8imO4s1qVWRVM#56?zR;WG|taSM;PF+fQ zSeO0~>h}z%I_>q?3*Aw`kjwP1pXV*b!rWQ;Y))7c9Is=kRmH#mZt#k}M;w`YDux8C z#5O~d(0vdp?Logb^$QeZH3+97rfogK78`}O9V6dLd-?>$LH1gNSmPfMuUYnE? zNYekr+%Lz1ET3X1XQf~MyQA;mz=`%OnWu(gyuxl1pl8zyC_?-Vo@b*_AjEQo26eb- zyhOALUMFsNOxL*8C>Gp!+%#IDLB4=Aah1r53r&OE?-5+Ha{%7DEW~aU6<`X!9=Bpu zO>@%7Go5|QMyICfmJl`*^H&gMW)eoKnDLzJ``v4=CC|)gcBU)Xq}n@HmD(#I5Q>*2 z+AEgolpv z+HD?6CnhAKU@|DiLqbB!f{*v#Ksy@>a23vo>Q?U2eTymza|$YAmG<%ThRda4drs?H z*w1)itsk~I1LO6{@Ux^pqHd9!u`_Oc9%MB7rKqS0#`p(JVfkOclrR6N&^Z67&?>Lm zw|{<>CVt9nb`JG?*b~Lh!Ld*M+ni7yzr%zAF44RQS92H+)WgMP!Gl@b0LDRK_bcGtR8FybL&SL~^_Z#8%MRUc@a}=<|G^;3xjOj8{ zE-CwfWkWRw83yJrR^T-WywY}nz0VSZfrihQz_T7u+7^(;S}Tts-|C&=KnI+%3t&HO z#C33Q>+%Ck;EAx7F7!TD@VjHc>cK!mlk*1s%_6kDCn?BqMP)LKOvh;+;;MuTq9yacK2g;aO8HS8svfC&9o86dGU87ypoL&|Ef;lNM~hmp-?| zU^Mvbw59Lkp)2$)kEc>j+)#GwAnY3XWv{?xlZX`7!TDS9j||;V-f-BEnETw;62knm z!#86i5vvC;;EfTNa|)>w2w|60h(bj#0vgQecz%RX2=q4-V-w*WM2Yc%`GGqHqTSm>31E@XC4qb=w5ESoEpO}h2$>(DVXswQMF)W7fjjg z_LyVH@6{S@(1(|^z*8262#{>{3cc&_qvk?M9>ZAZF(5`g!+ zaY&W)1;QnD39VORvm#fCX)kWLq?Wb0z_#B3BVvK~l{21Dw_F#iQlU+?r^S z8aZo?KjzuN-{0N;5a&tX+TF#gYU?SH3XxUIL&+sd!w_9_P=p0KKg=(ES1@sqMt$xW z(T}mtcUV*aI!Nq!*!@wCXy*&TG>zi5UWWbWo78D)?}Dk$EiRL)tdEw%Yva*<_(%)D zz;yoTN^E*Vp)_aTj_rYFJ2~a&k8!~asklJim2$y!t;L-;zrTN#S6H4Y<&c)+QvEuw z>80WrLk={DlcvxK)IoYZTCV(-7fLq(7eP|wroeDXlHBn8$o^RaX*W+>>VGLhOp^8# zr|r$SN~}}_g?!FjYlKpX1M@E}cA_T0BhW%oX1)iPOmp>vgtQCTQb`Dvd4SXB7 z?zFqV#HE~bdW{@d^Y&)4?oMn`Z$niLoJk2ZmI;q}f3Ks3+;n}2%61%?)GoN5qs1=e z#LhGg@gcokCU6iR;K~$%qLC$%SMY#9Uq2VnwVH=Y(U87~hwWTUZX;q?6hPo7@WI;{ zn{n;?jt`VM@>4;4a&uU?+Vjopu*d)5qa9Qmyw(9v)fS4R=R8=n)t~3daPz>^8S#%8 zb9FVF{6xY~SKC1AJ6;3gPN4G0g^lXBNLmsO*w;G--L2=}=>j;Z*bkDn8fuAf57Qg8 z3~f%Z@{8L&k6OqY(9IYRR*kUTyNTZNe}IrH;V`Xc&`i>5`p(*}Ljd<{r5-yH>JKg1 zQeX3wG;DoGdPnH$ZJmlfU*_AAcgl#_O!yFWV#3^iP)o)A8z*)(3^lC5?OR#X70Q-}AiY zpQ9DqB-zflNVvTTpI+)6?WEvmFll8R5>yaCouz99y1a$JsyJbJDpAw|5~)>7>vsYu zhVnLsm&;zWsg?_dE0TqMmnfPje_C$#MKp`zjAvqMgP3>KFM#`y^W`Ta?CyS&d;PkP z2}Z{`d*1lF;kFxodagr-#!_{VCy%aK4^?mIHRF7JP)|m}ozb;i!)wDw+#RL#LchoD zLl2!{>=^WYSE11UW65h@g1!eS)s2B7v1OTZ2Tg$J?bAn=&a zik#+R4dWT3d%Rj>dp<&Doq-%^<{2$5qW5@@H$Lim`+t6@0hx{eL3=2#^)Y`(!rLOTyn_&q~}A#n7IlkqX`ue0pFKCjx4U`%inoY z(yb@tflcoPmrbd5Jlm@>j~;UEAqc6+H{7@V5eOIbzY_f14w|+3z!M*Q?6gRhVCt^e z(eMmXdqlCXXL++(_3SLb&AyV9B!AUJ;gkR@k>+JBX}cYIs%#vmHg)c7Pu#hxYhpFC zdpCCb)Z0bUgBoG<`Z0Ky`wCYjq_zw+{{~l1*u`=!7OWyLkVv@hiF`nGRYh2pNswoh z6yT~&O(x)?5uH0f$z$xT0&<`YA`7}QlRzimj89JPpK?y`yVH-ZY&ccLo+Uj&Z(vTG z@6udzZxBh2t{B6KyeLY^~D;8?Rk$-L2q4bRZ{^#_m~?2qg(s_;y%ykUh;Te`I-| z4Sed=(r2mH7)ToN%(rUM7H%3!LxkKjz*St1X9KE3h3XBVYEpX7hquF8q?Q@sAWOP) zKYi2JDwa*P{bmCZmZ{pBAw0AWchukO&S~)kt)WVo0E5-06eBmvY|T%SfyunZTA5;f zaficpUteT(xXZHEc=p=fiyTh_Qvv4VBU&Inm(($){8BOTo(o5&xmR8|3mz zQ3=Nkzw=Hh5oMiFl5Cnz-jDlbTYE4JNxdw!r-Y^wujrA@Yv;jI>dJ&$qtV>-0sR?1 z*bl2+2F>8t41V+{4&sJu*5CR$VWf>)hidQB5*2fRe<;a>@T}0mO9(l#tVHjs)<9_H ziUa|jhVBl4^BrQCZ%B-qN3V7#M14;lKL`1s4S=_YhNhD?#;|%1z?W2YU>U|{I+A0cN5ii{!o^TaR&SBfvlx< z50@RSLBWxe&1ye4m2`cb00Yz4KwaTys9MlQL5rH0i{B&S_pQcJ8@%a=Ge(r{)hk&b z>O671BcTwU)^HzlC1sdb#&L?l(=oNV0zSLJqE?4jBMSiy#TKkDo3g&hFOZER_Wz6C z4MHzCH@c-tKstiq3(XPoER0d)+?ZxJEr82_Tox9Vhoq!co{n^>QQ_54Z*n?q`4_jg z49n%}rO$D66P9(?xx-vat;58e;4MfD=i9}_Jz=3U`tgYgC6uR3GK-{5aC37HI;n=u z(KhaQUi*4hUj8ZI^uL?!`L)L1zU|**K4uKu3C@JOrJ2AaSF+MdhFeL+mW@1C z!lce-4|!Ym=u?ipEE&QmtlyY9+VS7|haOKaav1h{@5=BoGv{#~iwS+;40D=+SUTRe zAHTD+g#IO)gjgmxB6J_Z)M$UCZn6`A{bkS_v{mz>{lfmUj=Q)P5gs|G@uW__11HSNV#}~+PqA_6NHCo&C$B0%n+x__OflQ~f4xZ+2|}a0 z-FSF?Se-{57Po52u|h$D%9)GadzxQLm1ju!dxDu+-v5dYnZKWrXHNl!jgYw%IHjah zoG(5wn&z8K{!_dxG#l@Hf!@g6#70-gQjgxk+8|+{ zn-BsahlmLY$U7$P&N;b8t~K7@pMN=Xlb$?xiz0d0j`9_WU^c&KVr??nd0NY5`lKkQ zH?NaEM`Xr$X0%;V+H8mZF$FRORe*oFAUXP*u%AD1${8ZMKQhI|Es)W4)`xNF=#PoTuE?i&V}gX%;D@z8^5c<1oCZi@(SJ!iA^$#}K2IUY{~bOThM@g-r2GPj z{J$e@9cCnu0sOVILY_Bj*{J$RVscK8!g6PS z3So+V8JVnnA}KmJGFC_o=0qW~Uw>H*+{9BGk!^$$I|H!dG(Lr98>|^?&XZU4dZJw3 zxHr0Fiw~x-=VRh$ajx0Ea30vQk_eC2(4a9gx*A)^`TN({eV&|-h*K68^diG*D-NlM z2n=!k-xcwiGdZkNhH=ACbph2E~HBV9U;8RTXJ2}t2U_x#<8DF3cPfNHK zZu*j3{#X}g$#i0|ZWBCmFtHzi+*(ff)SItPp2VMg@ss@&KLIP~NE(^99&4}v)-=)qG z)N;4U2w@&CC@WwR#RsEURR0%ptzRPwBxu>1GS5w<6Ep(eP`~0-Rl*_}-`P-|qi@vE zc=0n1uIxa)(domi&h~$+54rM8hz6_L>OZqF4o`sj zDqOr1JO7esZjTPWzxmq!-;@7N*>{niDUgP5O$F6D_IC}8Ru&RtN{P!Dn--3&ERXM< zbZ=P|^zJCwLFEiM6FH}69jlQ)znaaqD~26SB+8uqG@S+>#Yv3WK#Oq#8fl7I(V6lMaR& zEZe?J!3S4d@&%KqE!OK7JQ5L8!VG|S+qQBL|3QH-!KDn9lJeY$Cn3{F zg(E&XD&ycwN=E$vRuLWT#P*1;^R;c(P58GxnXBUqYPWbY#5KMCEzFZhPSsOum0xLP zWoJ}8^^S4-$sg!aSytb^**1wV*aUy6n#-j47Vht+_#S~7UZp^kM6?Ie&j~h1{Ve!q zwCEefYl0Jq$DF~|-uBUhof(Tzfa|y7YIq{1yQScr^b5lCwIexi(mOZx%A}Y;@V~!9 zlx)F|wEwcX>{)^&}Ji~&4?ZR!Q&jUZhFRH?5G>CYL z`nN}83&)1P8P1}p)Cm+Lin*c?E~1i0jN4Tc5wfv!Pon4K1)w$;ii;)EjP;ihS~>8fdoObdFTm&#HO~d$>wETChDf|*-K(Chm>UW2XAkl zCED~2t;(+nYkNL5n&#PX=W~5Ctv?q=*_J3{Ma3s_5d zHe&l~9a-jYi&DoA+d=4lZL1@azOf&E=4P{9m^$u>ZF}v4MfPUP)&Fc%B<>bZt&bprcHyzC9%71 zHcaI7^6r!^zb4T3uCiESuNfR&xFYhufLtRfLr=^bM3@!nzS~|O#szcC5=QxGtORRs z*kQq=pwo&~Oj@%k9PCw`vj4V)zO9mS*67n!@vE9g*t@aU=GK-N$PjY>LYEM*smMQNc6JaEo}X=r*SUF3#raATMLdt!aH20!YG$uh z!f%4}=5ea6PN8Gu>va{ zX&?}9KSbQMd>1|_FagP`d)b>@Y1AWFviHfB^JTD|YQw%tk|EEzTdJ3JYLg<9=1oB| zJ@u1rXOJc6Nr>H%7Khh`HO{9AK|*yQcG>uu=WKaL`G?^##FP9VTKzD!+GpCPd*E_! zrch~x$Nu6|)q}$oshS$2DPMxI$?=zCcAC_D#eow%88il)`@a)^C*}09>q6B8*FZj} zWoF3Jf_*G7`0>%0Fj0HAk zbr}j;&MOM=A{3E9R)y&8b-I$%NjTR~^plrQN8rvQyW1o9M2ZEo!_zC1J`a31L7JeM z9;lIkKul86LH*@_QER@ce7kI-hANu6iir@q zm>+*+p%p2iZ~kLxLkd6)5@Hg-I9*zqZ_f^S+n_CHFqq-Kg!DEx0mCSmupg>wRIXst zw~L^xXKkTO)wwyFTgnDJOV7}&nX;W)T#Aq2`f6kI$XM7u*&6p5=HLqax~eQ)9(&_p zm}@-e3Ez1J50`Ck>@OsMZnE3*h%~hfHjAO5lV+@RFF$^&35v_@Ma2e5Vaz7P!KsmR zVkFmLP=2dF|FIdDMw()c1p4r>D!b+3WfMBm(wURp?GD4v?er*52uWOgc0aATqHrt; zfI*9{|FF+buRIkFM12Gypnnl3o+GGa;Vi}$L|BqC2vuGg5uEu;5w@SwICOfwyvv%v zT2w1`V*QBvRs8`&M20D=2Gh5o;+KrVh)fh!!&WfWl4SF4MzyNCL5M9hd1#}GZ#l-c zGl9sie(oa4gDp5Ym26NY^Vdri-;`ef8lD!aOAhvo!*crMV`|hGqMjF|=DpJP+P^%! zx~<;}#+Xhntgt{5THjN(?(FT9He8Pm&3I+?wPIPv6#m;Ls1@GpEhWXRF9I)wzHBmih&cd zOTNUL*YVe6q1^g?(`dZAy=$coN?Wz&EsCTl zU$chC(&ilx*J**Ei!(8SZp{veQsbl4w10y+K~&xMMoMN?_T!3vrj36$Um){8?7M%j zn$KFJCLM*B4C7`Y*}J;*I)^X*(NN_zjcnr8%Z|n*p@#N9PZb{~Iv-S>)P+!qJt8!| zB8F7gYxqLN_}sgr;uGWMa_m~CJjf=qQuaMcI;}veOIES20{iRjLj@#=Hhl=>0YIg3 zR0)zEP*WdwZiCc~FojC>P zNIYJ%ggO(~e7o56WF|sqYaUoAAlsdvUtz!YOVMRhIcx@yBgN-GFf?VxI>+ad##+*# zQiuKvDdAkmq~dPD#lmP(E_8c_Ye60qp3=JE3R=J5ZL7Xt6YIQ#_XZD?$R;yO4M(0D zhCU}9Yi_J5LR76#x+Yl!F1kraY}x;Ij%jz;Q_f=_j}fJJ8aQEdo@7?evse8~ZiFX& zDeyH=LX;(RTh==^65``VS7H?(a9!|9fF>z~dE%Vid}`=-{I}E)(PNmd$Z0T%+S-9m zqU6W{y=uNkd{-1E#PjaIhv-`a*ljSdWT=VgU@Ys0ABwGc=wMEc_+u8h&%0ANiJJVv z)YGBMqtGB?By%e`9b{Hn#@KLnZzbo{=*|*uE0)%Dd}eZd=@M|CsiogAdk_CCj`u)TfB?bZ5(N5U(d_bvB_i; z|A&p}_wFBTyzX=-d=T5$^AOL>9<1}jwwAx&B?tPjrkt&0WL~LZTy&*oe!tz=^{Mwb z`?QzKo7Id@HXNAM?uHe{Yu*`0)30%oIl<5@P+^1mFjZAl))wMm=B^%9A!&yiV!`m@ z13T4@pQ=S`vxRg@#(lhl)xU}Qmto%Z(VxfMm_F8t-TTu;E!8EIgkLBKqg1M(} zZ=epxPk3CH^!}TLf6cD;E6}>ugE}_HqZ1#(%C`MW*AFglm1fK}bIq)k&{S=P%h1y1{L(&kl~DYL&G5Qyl%{=KvT;9@O8F0o6tg(9VxgFr>&9xl(t;WpwbI-RlX%k!U z7lbi{xJ0Yst!G~T`)RrMN#>5-l#Wa0pfZMR4|Y$w-fjY?#-`zBk} zYSde=7&khP2iMykmXa%>LHIA0&^~~^Nz{M;(!kPPENQNT{_N2JOanK2+k00Xz*U$+ z@6P$7y7WcklUg{aolioWzio=*dmI%wujLcj&UKYFKEBVy-k*m$4t>4tEW~h8%YWtc zOAIlRU^@&f2ct48)y7@UTf+$-Uk}ld{iDrz*u*A)ZURLht6K^Ig31#;i1d%iISzz^ z8Gx}S7CX?f?2$?jg;t`@RCET8?QVZ$K5O1!m{^ZuJKrw;`;{m$($rAhN0!e*`D0#8uEEAJ#`K~_#zV-wK!azqXsGFeP(Yp1MO4ni3?&o1GRBHsv zqQDc95XHpLmuw?*i1FC7`{k5x>%an-pl|Rt&)1p1c3-6 zK&&=$hKQ$xDmvt;>Vqfql(6ABGeh-nL1oD{X-^;piC7#@W_`}9x(TRy7O{jYDBJ%6 z;3piqg2g)4vvT5-nm&{e!zOB>X%lGu>=|Zo35b&Vev@&dL*17UPf+;nz@M=%~HpCxTY_s+yOCHI^-53`qY>MyWhoXym8FslN^PUiRA2r9%g`QO098I5?@@Emilil5E7pMEo&1f{;MumVO?p|E-B9H_-+xgtp|Cyyww3g zw2mNUTHZu3mAeqzG*qbo_Ehj|u+yiylec5RZz&`}ac0`<*ca*zlK8+(-=ohomY7yc zX-l>t%!fs>m%BUi>h%*66-^~~b-rq*LgqERBg-^r#-0;-WM<2 z2`WT=%7OmN*B;6K+V$$Lc<)tim%4;+&=8YEG3yv{JHys~jppd9Kvpp%h$jMxMZ9R# z+GP;I?j=g&f;~%jF^3Ej8lL}s;eu`Wv>UUl?{60SwrL#zJr;9Xwsb^S(L&_A)3^FhLd>L6#mw|*? zy>Tnpkd>#>3`Yomn4~Mh527M=={hpv>RcbIsTj#2a|kcYc{QV zFUa9n!6f4S!&#JCP&3_2U7*I2?Do^3vnH8g&7aZUEVKT*soBp*KR0Q$ByxOyW<1{{ z?jOR6CtgfPD`N*O%cfeQ01ymYpZ)`LbyJ0)gqE8oB|@Gs>UitC3&W4#1Zd^DlouXdf8%%zDp zGEldnt65wo{O-QN?ZnA1;p7+<#MO_vnW~be^@fSZl{5wk^PC#gV%xj>6J!`z#_NF| zqm|>*o1jz5-$J`7Zm|y);p01O$}VyL^tm6?HwA&LD2X<=udOmmBJgZP|ys9i{Q_Sb|3D9l|37vB}@qIWZp(86X>PESjKBs@MhD@~7 zGSq5{u_vWgp2tXlCgO=on3<5UdV^;DGG{wcEyEbYR)`tsr5oukd;m;FmciyeYWc8~7tH#D$4Tiy@N9kPGAT-CDa{Mgf35naSsGETw zSca5(O}y!+=X_7yb-)E=v3< zbbCCMO!|Wi(M$wjtesr5a4>aRicD%>$t}GCh5j;+uViO;C|Yg}v@{6Bc&GD1N6y)_ zVHuNI`7(20-`uQ-1_lcDFs!<{7$_dw$Ih61DG6b@((W%h{DKpVS;d|lKH2R4PG`H77f5i z=eD=}J`9h3%cB{|5HnaHlV>E(WLbH8vIE~-y5ZR%#wEbdIw0cixOVb=o0DGvRXdoF zf9al`nkRth+|qnGFX4YSB8N+B0Ls4U;N)>>5XIhgQ(AC6$85Adu6?Wj-KgnL$tw#* zg;V8&$;vNoMze1OPu!xtesvSZ#+n*->Wb8e02bHw`hwcGO&+k{QT+#@rA(lVyZ1A6 z-)#!=gg`;Tu-dMUd%(U?|Dat#`9#P z@AF_>TY!&YdHqgW#R|PAC{zhxKR`% zC7mIg`!%fad{QxlWC4l}GcS)^Tew`*Q-YG!SmpM~z3t8Y=-z9O6azraZF6$j);CI0 z7WMa7WP0TBtCbA!tC6_VF$3|U#Jx8N0aNhJ9@yJ(*jHU{5r6{E|+D{c>7 zx_9iIZ1cMn0Rn<%f;6i{vcBc80?;xO1(4F24LQJig9t?Q+2$`;cvq1Y(|qeS{X9k< zIn*dra(I6oHn<+%^>M?xe?tw3&V@MM&LOP%P!j+yrQms*B{dc6wxLLiF`d%GfHiV% zKC>-Z=bcq9X#=BA@llQJ&nAf0ETCm8-na2v`v<@$&Q~r!OD;`0U7rU=8!Jgxr420d zP1VTt`h|`J?Ka@ZqeA#CEgz8sYKxrXt>RA1LTaBT@kTJ-LNc$&mX#aVM-JZCC%Yl9 zUz-!Lspw+bI^4bo9VB6L_(YDuVCN{M&nvBpnmfB`(_6=Z^poTb7p4s&b1AE)pof1D zRE}o(NO+0^nOJDxXmk{t3DC*13l|cuDFvyi-wF>hKs$!4>}v)KW)@~s=`9ZIzdnHA zNFnMW8ZKyKt906fqcL6sJrNH5EOC9hn;A6k^O?~edUqaPfH&@UUH?|^JM~zNtJAGJ zh*zq=1%w2868f(Zz)xD80IwXIv< zz6xi;B^+{3miSXlcLlVa=>B$>)jYzO~t)!Vc(V~UF_j`}4f2HBe~0)88Um{r?PhHi}?Jc~HZDvN+$2S#sKTSd6QP1WTqqp`^lM6yiH zLJr89uNu$kKmq_nyDF#cPe5m~8t%Li@;J1gXVJ;O374v5Ey#!=X$|g@6dh~U_@Lw< zndn^21+QyRQAl%enXKYqrIXVlWTXB{DIWfDc{vIPzX*nDD^YCjK*RP*26V5emB7~E z-M@Z*^7=i5|GU%XVljXM1AW6iC!5NI=7dnrs_H@^B3g+mXtgR}XWf!%Dc>r#$;z@& zP)e?hFtPB^Dvg#~GXt`^>}+TkEj-h=SPCH-55$j7%ee@*(*<8$1cocSL{2v!Cuy=E zU2>a|)*$4gz44sNoMMDCB4xVW=D6Ag2zL!3p3#5-S?Qw&#Fi>_Cm*oQR5gP{VRgE@ z_WgY?Q}qOeH>ZE=vM?EV8bPsVxXl}toxhYMbEi4vOXqcu10fR-+)NcGmEXJ2iijCi zf#A?hnmSYusxezWO=VsA7nhxZ+}t`DsZi+PNfvNN@P7*jy&KCL>d%1uVy0O8p@k4= z-?F?WCp&0dJG!i_%2zLmOc{b2cXEMK z?`@~OJ^?{G$L#!xpCclb#`gnI$Y@8ff`j)g;20TD$n0C zi~%@&0>r2Ae|9|ne-i=up9%5* zR_o(jL8JSKpd!@J0BRe;$V6Hq9S+z!POf?L3Tir5sc<~dk`!J%M1&U; zz2?->FT&W!La$$9*$n_~f)9Bu8nC}jfXvVq3`dwIWaKiWs;joYs}%sV3PNqv_?Kru zPX7q(DY7HXl(ZspA1hcWnKn!v|FgDHg!UhtU-#1^dyY-b72U#FoJ-a#=Sz5s9$;31 z5i&#Hi;a}7%J+AZQD201b31sB_YU&jFoJyOKQjXP-O$9JOErezh18|@5` zn+h0|pJnI*+ljUYBdBjLIK^?9%KPXM9w-RJ6$_c4{O7D@Nws3q*mU`~M)&Q^ZO)l~ zdTMfnkpR@~dYv(L!1p5jPg9gYoB@xfY=1pDZL7YUs29ty!h3*W}dNv5VA)BWrxKfL|{N-P%y(qIxGXhV+VA`|#y zbP(r4`uHJe55u%ZCP0Ha_xpBQ)lQa|w-KZ{8JT+hix&vLN`EiWcYBV1XP?qO^yG)s zHfka)B7jcGPtBX(wQim6OXPOb?Rh`>&+9yx%@+5)Ej@+;M*Rd>pKK~_B&so zqr27=J-`r%;CtadjJSF^M*mHwfFK0z)T?u26ysgUcFwhZenZhXICuKz>H9f0-DGnT zu3!6qz(q@mb2^Iv8vuGJ)maUL`zg?lZJZa3?}eWVobdb2+%EB558PF}`8;JR9vmF; zrhR%(Jm427Lc*O?JoK6>M@Ky&((>k?(#uzHBZ+Z|mTR8z*MgXGHqyAXa{qdXUowR}J-Z))IGWMFqF6IX5LRbePR&D4 zPi~Xuz|EVIpJKevY9*0oaPm8*h^z=xQl-xgJ z-GoWeX^eSWgb~qJXP{~S)@2g#Mq}RG&fejXtrV|?4l6Q$OR6pa9Q#1ygUhYy$9yxQ z^$nePjllrxie#>k_~Rt*xT|C5^x(P`J>>vs(+Iy%AN>Q!;Y*;cW4dh1aPZD<*)^Gl zW6inNWb3Ux{LXvxI%S~*R8B;3(~)Zoh$m8*p0;K^t@xDD3}iImjT%p!MFMWfuQn&B z2z$wto=>nA@oW(=1=;g&d;v3hl6>&cRw|V(y`LU}=73cr!8ctY7>ZBwaV^K;w!b4l zwh^E;j}%7fg+}e;RTL0URlN z#XK2+VX(7x>7%F31x0tSFmGfaKEJ{v8b2kquI@ng6r#6)2>=mqz-~Bk zyp&OC+vQ*3fP?J+<)LSbf#L4<7~6uIAVV}MVhlyV%^RrYqU zg%hA#PE~|MxOE+s#0VHf<$eIAv1jk3$GoDO>x#Fc_pj5HimePrVhf}N*ET4SIGj|`mz2F_|*`g6R^znPNzb1Tu#39T>~soW(X<> zcrxI7yXtOAa%T4krKCTrieHX?OXqxPGXbZ)jb>NU5$Du2119FmNOGrJ&ZsXh{y)TL>wX4$X*{r zAZVib#ulRl)eY$&^Y+oBY%CsKo{qY4Rn_bV-Pvzh>vg<~)c~@h0Z-|<#;xOmR7EfU z3q(JVT5{wE?y(=_B>w9c=9Oy8yFKux1w27(0yvbe2B!jT_qxg+dhy31-jC3(-W!jK zZ(gTvZAL2xPYyI)jq7a_a6gD=1!q09pB_o)JFwJ|bpf;88uQ$zNU?%duGnrKgj|pTpKhr@-P(d6|hNW+t1bbV-X`7 zUAH$GU#}vLDh*p8n<`;2*C?T95Get*L{umX>{%WU(xdQFl!(u!WBD@tVkQw&NvN#GZJ8O5mzmPo)J z_yuE1JTwhB4R?_tkWU5K85+_tc=6SD#nZ-SCA@mmSNzhN>{jv^TJEYjiC7g9NLM7t z{L1qPN8#P}3FjPu0(d{Wz^`;}aHpgtH_)MfLNKS<3u1lr{6C?Bn0EDsb4;89G1}yr zXb>GEV360STS6HxCn{W^HZmHKoj(VQA*8BK&+{Bra-CHvn!34cVE3b^O}?Rx;SufN zwlW5c2`TLp1g#gbp`(a27pV;S6h}rf@e?%VD++pd!FxxZ?L4ZEAKu6RzNEZu{Yn3q z-vTf)Rj1M$+zcYlgi*gJRF$3^{)S( zytF#HKRf{R01#(^t_ytMVpog01HgdMKAr_?Bvz;dNSI*8dUx}?RhDH3I`Zin}03?`{}c^>Vbw_)Vz&a(Hr(A z3A`{NCQ%`-nqZq*rU^AEh+0s7H|QtujhzZ?!Xh=;PE`~eZ2)YWe+UQCD(mXrygykj z05vWE8&_w2?fBWP_PIR|`~5RSZGDjtokl4r9E*5S;(n3M?>tVUHHTZ%R+KN^E)BGS zV1vCSOpHwbBSwvo#w$D~)`0SUrD|6EeiWdz*5!e&B87KwAgI7`O2)lXm$3Mid(Is! zVD-l<`Y~P~;G%b$w{@Fg2sZ@;!zt$~64)nWH9B&b>*`N|qK3#I{E*R7wG2 zp!IjQvzyuV4j3vLBDJ=&X)4%>T+EJw7L`H{*jsm`eGj%GfVNIYVdF} z2<+Ls2-KX`P%CHjr!^z~rHK9vsffW~2@+ z>k>c>0&W6Y7FK#~8~0V1-xP{}Q*VIu&;o;6T1;^*FjJ&$(KP0j%X69Je=2`Iqy^jw z-#{ulJ%G-$f*JY%pckSx|0{bSsL__;*YIoaTco5DfXflSCE#IH<==?P+z27a%g*3g zJW2Pc(;0JYbhtGG>^)&PoRqti_RS9D-*9EY@Mq>76$T)8WB9jnc7AgA#{u5j68QC;{oFkN&l3khmlasJGzIkTr!yPDf5~Xx#(IXgfWjJN3VH ze^i4QDGlK#%WJg%s~jvb82KhxRBSX~X601YNV}x&Qf^7W&12oQUF3+4d|(iCR*lum zZ|PLeD^=BtQ;SwkuHoP7&Af@2njw8%&jW17r)C&XKMzws@BRkC0W>A8KoU${+ii=VX z;nYm{CZUMu1Z3B#AXMoJkH~8 zILXsX9zaXBd?{2{%~P%OLCsthjtAcL9U#{TB#~`0fR~{d!HY=lwFxAuWHHZh3!rbm zuz>sI8Jdho5h?Bffa~oXxp;CxdAl>fwsvw-0emorM)x+~J@~aUf}{t>fS;T1qkrIK>W=qhSSe_-UI)Vk68?L(r=Z}tt6rjm#<_`=$?d?!Zn?SvDbDE8`38^z! zKJ|3igG*fL{`uoMfPV9RKYb9h;=$4!lY&43(I&$ta*+RFgd5lRTGml#rv{sKBI0c( zjSeTLjP1;(YB!t7yi{6i8jH#XETKRZ?RMAtdOV(;f`;LmvH5WopJUq$Gk{AZidJ z++%=?ej0hXP?R^){7^^a{ZI->X}d{=W)r|;1+u~Jjfb28c!@6sG_5;)=leszC-8I6 zjTDhOfFzR^#BM@dhU%RM`h4Cci-4LU-4j#bsS8_&@@QXwBV#xC)89zV*8WWL+n-|3 zsC>^67Zt>*DRb24*m(FzHQa*@2Yok9DcEGV+Ji2hQ*#ajF3C2ZUVor{LtN2D@w97D z?Ui^v*O7`YT}V|`YVP%Evqk4&*pNxB5wri{v2bDy**7=WryF~Lg)Q2v4}HjAlXvAs ztN+%Wnfe-dBV{FmW2Z_-+LO$iy>M-LX1j#aGXE5JB6>BH<57B)- zGI`#-2gwvh1v18~F;f8{W_=%Fs-%vec(&Cm`$2|l5P!l&(60@pq?^v=&ZGOZhL_>w%HVCzfA)Hisk_l{e2Tf*vf z{u)0eg*N11C?-g5GwMAI`Kv$IRhEqHM6whMXY zdTZ(Sjc{LTP(Yu*bR;itptL>6fmY&3@iSxfm|`-AcM)oaBJDBPOxVKISycqYinmD_ z?`7cU-;A})Gy_YWI_m!8`hp`SgVaeKv2J`0*@7uw*;C@1G+n=Hyk%b3ty{Tvab`ED zKSb7+QF>GjQ(7tCbWF4Aw!0*HqD+h*SY)Trs*A1Gm%Ddhw_S26R9*%V%|FYD@YAWv zgNNmQ48K7xk^8$qA zNR!z3r|&C`pTfVHiSdH%^#Ef1&N0?=|6o#XgNFj`MZ0HoPbNKBNvoRre;SC zY`Gp*NqZVgM#@ths*)7xOSU)TrT@n!BBh6~1DPDXikw1K90>;^4&_pTyorDK93oZ~ zkNW6P-|6>=QSLwn2NQ8V{C2KKmb-jI*UwGv(DV=bjb)Fi){Z4g zT3IR5Wb{4tw~lQl~5j`2I|iq>bRPrAI(Tz3(~`>JfSDIECCoaIV}^kfP#8TBQ8 zpYfl)<@!1gTNp{Ee;+Is_B^F)%4&}=Z({UPgfTrmL(~|@@UW$y8oaB+_?GAO+t~^m zT=SW`w6^E>kW@QEPYJ^=y@xNJNU7X#shN2&?BAPW=oh=7r?5_dk!i#hDRM`7JjpBJ z3vFH{v5H?_n2LXN{dBASrPrH*9X+2GnBdb%35(p^Vc1*!vasnNSu0fR zjlwURWrt5Gb*pbm@xXMrWPJLwF9Fl$&lQV-~DE;oH;@rhC4N`>=!{&)VgfM z5-+yUJD0+t@Re0-`vqw$(Hi$2|2IPxLL-b*RAsQk@W2j>x>I;>KM}xGOG$~VBMi4+ z$Fr-_J-W7741I^qv9|nt_{771jAm+3YYPugW@aJq^FedS#$#!!qyU^B>oT*Ydpxss zx*4OS=R7fBZ;R*fOReVJ=zdz!z>oRKPPQP{Udzo%D%P~zEX8LhzMB%Io=`GRPD#%N z`SsQh%tX@iKGv><`BzR%g<*&zyNmCLQDOupD|dv)lamEEd?)0T@PdBd9$8z?%V7?R z78Wi@vv#@iVT*y|e>ZbnU6ak>zg1#>Jeh*%iN#tljeG|cM{R3Z-M zM~#^;q_Qt?Uv(ebH0r8w8 zOu8QrYTbLGj;{Aov5Bray`}AaMPsYaWYudS7e4Q>_F505iRC$i(epO3Y?hM&jwe?I&;Tq-fK35l0|pEdHl=H!Fm7Z2AXEq=%qU9r*9YAYj}`r z;YlP>=9ljw;rSr-78d-Ms^5sX&3POsM15PYk14Gx76*^DZq~%wjgonRLX+f8bMyT^ z=b~V7>Asu=8%ul3&myh7Mr}(T?>7>>83szIhWGyJ< zZw37XfqK;2d>5VOTs9F~yxxwsuR=lNAS1LQ()P{#IV_=Ln9 zaz^sh!!TQlQe6F4_UOcO(y8bnuVfGbNzaJ+RM;Yok>OXs=lbT0qYr}w$#~AEaq4o- zDg!bajnQiu>D`M4IC;M9`fV=bdg3{0G*30>l_{6}J00VS6mQNpdDZWh5^}AV_MbuM zIpkB+u|ttO@@BrbR)HARYEO8SGQhX~At~Tvzdlmf!%5hzXNFn-C*1($J1bU9a=n+L zquP8ECpzyW!wV1Mh)?*LIlw|26C0U*>Rt2%PtnshHJTRlVbzHnWc;$tTi9WO?zK+Y z6kh*l%#cc!6FYIK&`!_h;!G%RGkb%`7V|(}Y6VFsV9;#|15Rw%$oo@#OyV01F#giF zcA=rQrgtf)F*aMe7)M9yk$Vu_IPe$eNQ}g$LVo!DOFWdevY1M$ZyBHSIy=l#I%@Vg zPHMLWw&xs<-llwOd2bNn_qu%A^dd72wI=&JP157v%-#0$4^79mbS$}U-&T9r3Pp7e z-q~+cnM?P#RxWTeKFKZ`HKt848LBm#~ zNoT9@6D};QrPPdRK?%)?{`d0EmXWebfsg?tz>0 zPZh59l5cH8pJbHR5Jff4QSI%wCTBYno11v6cp;}v8Mi-3F>AluF;|jIGmVXJTo7g z3I1STUvBGd7S9=9{cNNUnMW0Z zuRk!oL7wvZ)Ajg?uy;vwm^p#R52QQP2g`0ZmPYckaVZC6B!32cRU4FDACpZ)&Q!f{ z%&kJ-p^+$kOFd@}<0d2WOd-AKqlZ0ab6#>$8Aaf|a^xRA?c*j@qHt`XZ50uR8DG@0 znmQydBI8m3rSy0-76?tGsMsK*t00kaYdOjq_7O#lW|66=6GnI%T95(2iyjQEenZxr zWW#7sJX{;~W%1R3a=Y`jc%bC!;|u&Gc^wBip29>1hmQQ8@_#2UiDtv5g0x8IgL64QfVH0f&p8WXDDp5gx?|`uH;o%8K_ok1VUaV3m1M7{nPUk z?*@h6Nt%Earua?HjFQ)+us!TDK_uD0kHg?mDr$?GM2P}tFe9!Cd36l{qPchto4a0x zMxR!a0uzLPKu4_)SBKp`t=K=o*3tNkZZ6UPj4&oqOH(AFauU5MQRd6PTu%eQ^;b?5 z1t<2-ra2Fh>3GY*f&SbVf-xvb)irg=DE!u{?)#`eoe7unMwdEPpQt}g@V{IdCg3hF z_({b;c5^=a`C=(j<>FxF(IKqi^+*gp2>|V9SA_3>g# zh5q>b0I3atAh^Ss9)zy;wWK=Xk--#J*1}y)3-en1$Gu-zXnhVCK1&@owEXe-@jx;E z>2c`RD;;~Tcy|Y9dUqSs=0m@&LHuM8b{%F3f9v+qdEH08;npF8x`BVjQUNNW&(q=9 zm)}x|ge%4aniDy_--YfftPQ!b%z&(TFtyap`&-t&u=Zt~*PjtzSRmfA(chGHjQ|$0 zty+^#;k*++$RhSTY-&}ADH&-iR3S_D+aOkOOT8ZL-nU`W8R7CU;-nN{pY+6FVg=;G<$McwThNQkCVz&U4$LVuz zhDOp~y9m@{$J6sJhWR_I1YgIhC~0q!QOwTRDSR;?*kT2U;T0EbNG|c)ky2iw`R^Z5 z-?@#?mWmQ~grxG*xZbodSo~t9+H# z&#Sqh4z?%l8n*lYWcwIGHN^nw{q`(NYa_V)Q=evYbW%*j(1)SH&(uORt68v>xiy&B z+?ZL}RHofxaQoSn@e-qMqg&Zg57AcDP;1E4 z_~!8sb&jxwYpj%M)Rvgj^%z6o@wyO3|!2GYO4vso&_{54w4@If;??^pq_byjht*=f z2^fb@Z$@%E3}sLaxv1kUwwle$r0aTwNvd!}I%7MyfwlD2uo-nA!UW+ZgFd*?K0V(r%pAfW*}-SpWc>e^_cA$ zTh~yPGhXD;qFgxumyFAr|_#8LNmg zm5+?v4te!v>6vOXQL$ZJwXA;F6FmO+MtiM}S17A|muELI<(yVG@ z{CT_b4hVfuX7RkT4}r>dyu`r@juziAgf`@;m` z{aM+>+)B8HFyvv^f(ssrpQ?ZnpKaBJiX;o&$m`Cs7jPWx!FodL7lD75mP;onN#d0= z+9nY86z{{H;pdJu1j^qD$sRPd(KL@8fw~Y`DtOs{MexVmd0v`p4AH>N(r_Bqp#yCI=UF zmGkKpJI9wvY&BB0qx11zmb4P|)d)Uw&h++B>=MQER;xpyf#G0zGBco16cfA%7tz9U z+ZGyh`%KCz>15?RuO0}|(6ce-IT+)33Ao&SWp#veAX*S$tJ zwu2A|pg_{Hs_9vqB-Nm;x3WqCAyRIzr0sZX-%rUNAd5ydR(Cx9ufGsGdmq<1F)q*< zJ?GKA5!-!*VS4k|F>Uq53`@2xN1lARrP`9`mb@pt!rv$9e-{7oRbWQ2+#myQDi@TjM0o8mE9Jk$E zH?f0c_;PdJcQz&H(n>_Iq*G+ zYU+V5GLy@94^}n_D9=8hFF>8=xV)t#zO?r~YM~*Nv-f0rUu*8>6U4DmzZ4Nd#k|A( zxr#0?Fsd)7Mm|7U4!Qm#q{tzH^LkU@**#lZsl*lG@^2)9XbqrP5e2f=;{5MNwD|EM?Puf) zt}+|)Fc&SAurp*zYN{l3a+pm{N+2M)wtoA!=f0nReW#igjs~Li=z;fJPBdqMtGK99 z)db9=jmhBC%g5*K`$1n85;$$Iu@fvk-CyXMP%OD25!IeEXdtU7aQVt!(RtmAD5-^N zNt;QiFnt0z+>DWAB>_x7oIi5`*hT6gI6!~o;_wSk4~3^qYUemd%oBTOa-zYSGUOTE z7PX-ysy`wP@*;k1y)jqdy_{6UF~)#qoM=$m|p01J9?#xE}a`mLAl# zIq19(TkQF$N@swb;0#QedflS7(DyR~jChG?If)^yf}rRQ66X~Px0)|S6e&S_7@~PESVFHxcK#eVL zUU&NZm-VOgees?j4qw?bq$vePmXq%saLs4EX?IpU_f#Q4{ua&EuTgTXG|rVTiJ8Mo z`hIXywrO=Zm1H&LMCx{wj+!5@<)%wIA8Z|;q)xHih-h|6RX^XwmJmQDtUnGYm+3Fh z`}J)tIj8GK>vmOtVL8-}EsKZ#;)W|~prB#{^%`{cD7#5%v9$*{F;7S9*eJ5MMt_-# z^vaNu#8_%L5}hEab0uoR01np$KPAJt5s9O*MOR1;=cmh$&kuep2*pGF_4n6o?R~|% zm{b&Zb!|4TtZX^Ip#T$6gYhol1dbQygqw(~H_UUMy2!^yVTsv3-fK$#W;rG;es?kC zJ9V0!lGC&Ii8|l>vIiI?m*%pX>qu6ov!Hy(1@OmWuKAf6?JwEun(Buuv8Xl6$1nacCAGP&AsIw{0T#lzEpNTF+?748bkzQI|;)V9nDBgw{ zmGgvnIk}pZ8$C5yM>^X^&M=P<%5?-FGX%@~6x5nKYR;JWvMo77fe=N=&8+?r;GD{V z_Phm|q^ZK@S~k@KSxl_v-?J~@+`K`@%yE|l<>~ftkoIxv{b^SxA&;HKO3SVq$Am;VP3yM z{*TV2JH0Z6KLU^6WGd0vn$*hBJ>z#fibBm!vCma7abiHCH{h3H8m8W?dOd>MUj>B( zk`vrG;6{ou5C$EmFvcDWeUx9#+mZ4(_?$9`@ZZ}JGkn64h3cp8Xnf=ENB$AqT@|$4 zN(N!I{vAwMq1GdzAs*!J9BlW4jEfJxng-fTGIcRTT1i3BN~|y?>OyR-B4?z zr|A}vT;ZypgEAAjG%>83w=!%z(W<_)4RSz1FuSpf;2X_3f009^HKPbv9ic+8^pgkmTEd7h-Z z!gk$gdfO2h73QY0? z2e&70j+*&0CTA@NZ z%PZm7ngP2E0z}1-N@`XSvCU(a)0*!{B+)x5^GzV2^)+o75PCN5DQt-AdQI)|8v=Y*yAj-Zxrq z2_vZ6H-wvTR9)#EP&$*5$sf)hW>d%L3QDC_bOrUZAN!kCd*K-J10=YK+b_9$AmKB+ zXa%W+ooH!J#E2D#G?u&>h>7#pKUuV5KmGI*7H4YheF39}FG6$fxyh2Lgg{_??naif zF!fQ1u5O@nIR8kIdD78&5E42YAXbBO_Ok?uR#+}`M}f^t z@1xpPXdX{t^O?$9BOYP=fPKYI#yQTU%rk60VXA|keR9TPUA9iAYBnTAZoM8&qx%h7!L5r`%&vXnG?QoixwU^hogQHXK_ zxqS3Mj~Y2vM=lT~^Od4^yF1TvfSCHtY_R}Yz(hGZF(vizzp~&l|61@PYy3`&>)Cwq zvrHY2f`N-I zw=2KzHZ1w}_&@fJ_W1lbz+l+`RXR#{{9M)qL|?W2l~`bWb>%1;BP9;?qoxQ4-rVwi zkaR7a;3UbS(yskEx!FRDwL}yo-sX{+cY==QEigdR*>+FMpn`?rJRSa4jcc?{tF{?Is=@o1ug6gU}3*Kc!&x_q))qNF<;otCk$M4_sDtgW$MW)qEfxb z=YN2_DWHYqR1o_PPVF1HV}aTYVbI_&X`uxRlbAZ^f%rq|YhlgeLw3WWL49(an9yeFl zRHPQ#v|#M9J3q?XlwL`=R^34~(wd~|57Px0cqjg2@4(X+PMhz@nYtPhg#aAon3 zjET`&S~agnpgymVRTSa;Ew_qv`R!RVfeLa4w~Yw1HJ;DbXNTB33bH;xgHe+rAQ_Q$ z8Q@#DABONv7hZ<;DC7|qMH9}KeHR7k_y^-Qv!d>iMz7Uj6aWwUtD8{>~L& zm7TSWgfGv01P5&)z+snirGcLVl4O&?VC-a>>MsLK2TTQ__{(Fbd62qwU z>pXB5H!z(F5(XIY!`~wbN|#mpI3jZ24@f@dp$Tvor4wbotcD<#d7kS|>&=l(E>CnD zG2G#Q2X%zj`K4LT)|Qe0;rGbf>AX7C`%LE(rBz6jqpqgF;;#1|g9l+1O+44UYI|;a zcd2TBi9l38m#X7n+Q4LtJ=p`F0^UIFvQApY2P0Bu_JWwNP-xjraD%sf+=Qm zx)=?Xvtt#d$6)RD13+!@QJ4L=S|ujJL0)W3bkw)4*z1UVwD)#iT-wwsYRuwDe7Mz} ze!Nqkbo`~7oSn5kFyX;UaBLtn^>(k#)(wl-DaMD*^idQT|d{bNx({6l%Dh0U^F0y$ZCngnGBA zbDQ4#KY)_cPxxm%1g9@=v0;EDM@V1k2- z3)3h1_92Jb)39QBSwCPh@@+{VHtN zFPV!TxRyEiWe=H@0d4@~gW>k?SVS6(iiLRkY0$k>lb=t@{_MYHDEq-L;aENsSQMM9 zY)xmSyoVYgsQ(fG#-dHBRUXZoec zB?N&vi^cVd`}clsNxE#vBo`@w{5C+sQAhT3TQ?b??o3{-9!OypLZNbUI=@V7mChD* z+*jOT%vAbfwWDuCiS8{f&h&$xI)n6)SQD=0zM?>g4d?1U()!XDm#&KL>1Iz=I93|k zDnb}fLHh2ce7kEB9?A~@{33~b#<6B^QEwm>kJQ;LHyf|N%B_K3OL$8QK~I{G!$<~A zIz3U2n`>MDSuw!3*s7Fs{lR9y(z8HYYOD%m8~kRv^m!n57lymE;MEG3zrPmTPzEWC z1U#N-IRh3Ue8-G=jE-0*taN1pfE`Yy|4BXj_2}CC^$* z?!r6$AsUTQC~&1ESU|WwlK&>d$mECK!n&J~#KnqK+AS8HL?!}jLwUfvBj}u-f4iB8 z4aN2b`*)>Ce@UCwN+r6}Nld8~>1+-|xCv}SSz^2{R5H+qo12bwnma>PX*i{X3Z8!$ z!rCXLKV=%5@`60a=h~d){J>3MQvKMDkyxT_b|Z5nZGL!b{;cZJT;>RKd@qohXOE|T zy$WNS&BP;$CT}Hs>%%gPhsziYa60+~x8Hx~Pyy7-s)uZ7xtH%ELk9)8yoWTWTj^>< z+P=6bKQX-51<_Faj*iZgH`q|jSf^=EH~WBd4_OswoZXVDLCw_-u3$&jZpt%%(#+ z>|9WT%slZ%@z3}@s+ucuFl6B4G6o@iL~f64QxBUB2F02<*o91+sAj;Bg8W^qDem5( zb2RThhqELLP*}d`0jp6O+KHs%$g@CQ1P_ZQ{abti{>LEEc?vo3{Isf~5=pSZ4Hh_~ z#M~s|1cM}b4SHXj!LOw^L7Kvk+ zrV==xtq}Au54RID&nvZE*g*N*AY6?d>#=&Vul49>;UM;{O^evkQpR zzvC@38rpm4GhI^|SJ|~b#8@s}IQz}GvDrco&afHsiQa;bSPvkB?$My90xZafnQMrenUddZB*W!n7w_-X zjN2%SstMrYdI$n0j)v8?*{iHe4AQ?K0DRJs_F4s1|C=z_P+doSryZ+L5tKQwV+`oY z&XI9P*)IRh0$B@b!Al)9-LV?{JKwP?7(P!-eV`#oYP#;IrOciRlrs-{*hoR-z*WTY)0YRbVKkohNHGK(Xf z*ivq87u#0rCf*%ySpR5YQiY6y95-$iHC$)rDQX8N3FF8&prG=LhuMM>-w+@mTLxMv zs?w3P(t#=iCDEJ>h4#XehN!b`-k*1N{9J)q4eu)a7 zvckjpc7iEW@k71yM`Z#2#`DfM;*>XTDH)DAXwLco(JB!Y+k4Zi7knBi>oWms<>s$< zdeM%n$+js7*)wQJgQjsK`<%hsEOaT;1nNVv-WbyjnfNQ_yqazEOW)!oyt`qRjVaBc z>yUUm;xgRLOtU`bgv$R#^Dh%ZeIfqF38wc~&`tG1D3V9RTIu*7u^q2TOvH17NF0gG5eEBYA1YUx_e@_TRl{(#DiR^Fl zrk%A&3H<;)E}e6OzX8;mk6n+Q@fU@)9eaY`?C?4xJ=6g9xEMX?=$Z^pYus*!N$vZP zMLx1tP>}*6%x*qWRU}e4ZX_DnBEByi}n(s z>a5NswUW~2w6p@eo)vUMGP?)u77LTL%;P83SEgsK){AXU+d*PAocd*>{LB$65=w?r zE_4!anNK<;UC_;gnSh#&qp>v$v4L$5ir%x77 zejb!?+;9(A%vf_vkN!3*W&fh}Rla(nMTvhElH!$~k5e*I)T)EloFnxL_c^;z%ch9! zHGzU-lCN~{!wB$`Ho%O*Xm@|@^z381kmeV^;A>xw3wb3TfzP)3C*GV*r27S@nrM6O zqtB$|fx+I?jK$bxJgGT(V#uF%dY=f>+YPEeJ?~L<>jO3^N!qjpdFJco zdDE?w&vyhAr>&NBu3541Q!~uc&p8PFC~~{^xn>wM_IW#6*o2keh^4JK3Ne_A4JNl|%u=bAT|-4=Tl=%jbhao~b1p7;M& zwKxaO#JeJO-4_!5e0^a+A^Nl@k>G`LT|JqzNmBT(i-6_@$pGz{89#7+E9&Fp_KzSh zznB&ELW_vPD%Om|!F4i$^QMpXhWh^W+VMiCn7io8pUtks_hS*3)&WuT%*S`F2cI-( zhbl&xfw}N@7{6)uNp!!?ygYNI?Yloi1J~5=O?nkic)7oEMER+)Eh;Xq3rz<61eo4N z>^i9Ig665U+}!w9vBY|fHAS=yk$X;w+8<*prUYLEtkX=7$QuihA*~Ri26#!b?3H$e zpX}~kYT@1R>OuJtGcG;HNzpp4xYJ4lR1$Ay2eUDMI(O$vpXr~(a427+>>vQGdxJHK z5Bdp?Pl8Vdib}wE7Pd8m1}gl8Mn-J|TO|ex>dpjNKvc{+^W}p@qY#HC13ZuDV14fj z3QEdrPoQ66I}T9y%b_*b3Gc9S?({Du+Rl?Q>GL>x1cG_}W6xYpX6OCyG8W%@E>T=B zdLCm0b5#I2SA}B)mBv{j*ulmo6(YZC1CBWG`73}EmNDY|+^5~WChab{|C5J9hX;4U zRt&N?HebC11(65A9-W9sskZTk0&{UUETHO@{s;($^8fqVDibc}x1B@VHWj_yLi|ON z>AcAr&Q(w?Nky{geHH~;8I<$eVKVzZNJqB;svD_KyHG5+8Rxp35VJn>LN(o-${&DB zfjR^&Y%Ov{`!ppq+}XT6u-50WF{TGV!cu1ThgKH7lvk}xi zeog#>b3^8VtrvU`uyRcccots^#P?>s`{Hz5h4pJaeA;S10pqDS|JcQz3Q8Wv;-Tlm z;V&t#k`fc>E#*BkOT8E9{r&F&^dg@gL}!$ zzLaMPi{7(ZU{Y;iXntUl&eJY~gWK5(Q(egG!>#F8<#;-d_F`BvNsTITH5{{ z#mViSm*h_>1V>us$pSWqxlKsuJ?=48H6Ks31l#DtGyGdNm1R=SlQ-vV1$KLupr?h= z4uLXjl^r^tPP)^|$?Z~7dz(%*XuA?XgiM0o5)=4n)p1i;STDyTu7OF)UyB0W`ws!o zRNIqji;!)wo&X7S(4|V37oV1q8NHo|c{tkth#yU+23G7|q2ki%vHErsp7?`t13|O=a zAzu1(Zbfsj902AdEB}I)0`iEyo1{w%U0YZ0S;Az+y&J`(*Fn&DoX-{u$-QF;W~kg^ zg&mQU2a{Cj#!jxCwQd^$Kx)Mnu;eN(5IZ@&K0J-xf!hMBVXP~wnp5GtWcxpTK%qf1 zZkRhoLP6k>#eir+&|&HR)3y5Besjh4hq?*9nBT3HEcc&=bJA2~8#Yqg{Qd}<6VgHa zm6hye{{-uXfKkb)IkLQ8E3XZTE9;$QI1_nhpt%1)Zlat?` z&*MFQRfIxItCkJ^0orgbL~_F$e$!L8;)c&+A6H~X7_#O%$It<2OLD5U)6+FAvzGCJ zU1fgk{ep8UA2#oOquIuMlQ{hQOLsGv1xE**8+D5|wpy>qMZB;0JrQH?Xet@`uLCv9 z!ODlBAtlk+zGkfX=+brdfet%!lbU4S#=eSk8%|ehFA>u>~z@M>-O(Kp0s0bce;?zbzrL_Ne><<}8b`46@CS>z?o)zTnqd(}9t4&Qx$=s%L9PsS zEZX2D=Qo~4WXXKLeW7nd)IS?HR-%1jciH&`I-1z?C!eN>XhFC7R+CFv6TkW`Hw*71 z>2O8v#qL4H*?B`zHEehZ?Gh-{Vk_MN!fb+@S8wO62zst0R_rC8D!msi_8en! zuH5OO=)d@^w6j!8_n=~-IsEH1<@sgHQ{Oi^ufFg%hRyE*g`^^6Tfv>ZHyD-g3tz?89(-PS9(C#r~S2jlRb$N zL|cS6CXao>3xs!lKQ74D@Gyl|)|@{3LSJTR;+Ic0UitayDHU0^Cw|7Hh>A?ZIiLol z1Fmd4fp-t^zp)^wZ>yo8GmRt^SUj&BxG=FEBw;a9gEkCpZgdtexx>@@VL~0O`&6zh zU=vhbf)Sd|qML8P@sb_|1vTtZ>ytILJI{#{2sSpc4IzIy8K1X4Z0r4C-9Eb=Mrpp#n6x|Ohjp{s)!WP@Y#ct?QC22 z*&fUl@wL7$Q-u{3Ri2YKe?Yl!p2I|#9Hg4w*xkv3_-UNJsV$<-2Y<#4(RAQ;JAgR z)KusS1>=j6Cp?R^E-v@Ke#}Hy-a8amgqc=geS!C2V|{_v2f~yqB#|0xq7KY7;&|_z zzRx7OVMrTDK;QzUv-Q;67&!b~Jl^I;^{xRaO(lM4CLYbny(ZS7&UE3&j?)yprArH zg`{|vRbB>{#s-*Q^Q`(4_3=gJxmfvx;^M_cy{am;hZGKOII~TXAg-+mBM^S(iv^O0 z=%Bbpm;<4V^p@kYT;e9FWyJDc+H?{^e#E!VX}zTI4!zI&;0DlkTA*?pYFOX(I4(zT-tyTA+ir*YtHzY4Cp@N#4(2wW1GgD<(E?Sd>uH+#8! zW$z?xg;)<9N^s?F=TMXe4Gn7Z@?RLG82EkUNYEp{3^T)~yFdRTI$*bw}&Ag8Ts*`Pz-d64uo4q2PW#%kRxh@A0)w(K;gKNEr#!!ZPsRhO$R z_9i7O%WKePiR}DltwD4or)d~Tbum)!N6=OdWSbFPfpKB@Kiz~N=}ShXwv?8Bk|d&^P;Jr_PITH zDS5OVJ}C9xI4|e-rc#YX$|vX5(B1^i)^tK_Ni((E#`mP-8%(ZmOXnuaA%Aw$HELXM zs4*N1D@jCB_*16XnGCJ%nyvn71%t^=DuG+<&njC$@U!WVquBtGJuQ8?&*`)04eYF- z(B_hDH8#kntZla&vxkMYPpeeWj7bG;y2Z0=A)*{g_x}EQ;pb(+M~dkUE7k#}UNIhf z$1M#&D>`uhDTDB!Pl8>LVM^S^g*gc;i5?h2H`OWM_UxWHS>f2O`tWs6-4;iq)g`GgH>)h6^^bu7;T;0P76RE2o5cc18MEPbWO8O z(+{;cvt03KnUyTeb^6JeczTQbb}ClbK(|N*%8CCW1vXlsi(a9a&#`%`@^guSGR(Wi zBRiwH*#oE7(w_{vooQ|D!Rv_R!d_Yrdcbl)cV$Rc%YqF?o+9UJzjeFV@%GOlBx-^r zl{xr5$fQwEy}_JNuCR(MFEY{2pbYxEsQ$9HLC2U=rHilbq7}z%>iSBDrXBWSVfDuQ zBSo+g>;E<-Ocqmp&9OyHfHd({=_w4%H)u7zr4|>DOhsZn?mHaLvx_oCP2HU&&To3# zb5_<=1n6U<@bA7iB4)LV>kbv~VS;MLELJN-guN1DjVTbRRwk|+s|c{xz=%s8po96C z0|dXvS61-XhmDvII(e-_Hd2)WY-j*BZfTH$LVt6*hzZ$fH?{kz8yYOl79zp@&l4lw zUh^yK2wHlPLj{!)c>{m>3=NNnpEGH((0vprQVdL9G{y05)oa;GOFw=XmO4;T0~Rh& zCNo-e)Ly-BV6DY?o}?q~PW#Vh1pPMwh_~#JZMK0eBgp7ZXd}y<6t_nt43^i6hT9^E z54&=C?+FM(%)B2oYW0Xxz;i+o85EC<R8Zi=Dec4CtHvDgL|hF+0zG#+)i(r5R|}*LwIrdKV~0Y$vqOIYt#P1y0R%Qt6LQ1 zQqRkGcS2SQM$o-jtEK(QAMN$6d~9G- zuf3mXV$O4`M)01ZBvkj5GY$9&^xb)>gI2DEGB299{_4EvqteYBq)Fp6C0%bH9%mvq zHffnFzn{2j9BEr>^&P3zbCcqNNsmLGF0Ia^hu~};9&b+MuhBIMPCa=$VMp#^12qE* zXdunqoN0l&F8GtR76FkD_)+7?h&ufqFX??(|5lQUiX2UVq)@xOE?Z!Auk1&cNt^$6 zPk<8iL7cVjt$uNF@lfBJPtt70p;$pOyx)`AGL>wYkj}Ywm)L(A&U114?_p~GLLwB1 zRX^QoUQQ2&>?X5;&YI?7{8F;wi)#AQPks#7Fez_yIn(wvUxz}%$a6F3%Ga3`chy8Z zE<09+z3=W_if5q9OS@ISoE&-w*8$T{8sHX>2*Ivv7?eo=Q-m%8vRgPbTYgT(h175T zIlV3oX=Qrj#vEE!0Vorl$+hM`APdeX{J>lkoYfVd+wANaC%0$-1c0Tc1MSY!+OU*S z5dqUe2v})*4D6&! zClXT8iEV0V#_1vFhj!R^W3H!;#S>iaS>AalvsWiO51a~!fC70h91N6<`1VrkC}<1U z=*jmo_SPO^B28If)r4_s1)K`qIM8dlOhv)J$G-@TwmNw}ki+|R9^ZcCn!D;R2tO_W z2#fxE!Rjo|xqk>eoz@Yn$Uctt)HgS7%t3HR_H>q##$5XU`+5U7K++qCB$yO)@(kDu z)@G3dD>=x>itHL>##fwcYoda$g;%&e3jzG2PbW48MlG1-StCm-dQMLF!6g1QO zbn4@}vdSl6!5u^YqC{Ge2uwd{DSn^t{rb4F2EC5x-%-aM!-m<4)q(oODx9+CY@&~*+Pxmd_Z!a?3we@Ih)b~;o=ZNelCHqq2_ZFoRvP=dtq~fFQ`Jmo@?O5W|YPeHRrun^A zGUlz+aD7ad%Ouk$?aHZ~VUwj}n}@PP!A?R*eaRSE(1|fZTikNsdP1hp#Iu z$_`H1j*g{Yv$AElg_AVwPRca+b7_Y+P!Ncgo7bMeJMTp`xNe1r_Qo`m9F6OgPWcn?Hp#o|DF5zr{r}`)WFxL4@OqlL-C#E!P5r6o>QlUC^^|cn z9K#myod^z^YflhQnpPaba_Sp+hb=y;gqhtNW<~IcaSPs3o|?+_n4&PX!CKWNW)E_J zBgLtI-A2Hjdq46XT<8xC9ahrQb#U@KbaL`sM-jKt5I~`Z^Bj?Pp8v*w`Fc5Hh=_1qYIhC?8lbO}{Zg4nQMt1On9kH*+dTZ-K zv!^Uv1@Q@aLukeddI3a)F&{6gd*Pg|yNJL1iu(sHOFkav$oKdY#K188^ZI`?qvKeI zkxzEp1`cbT_7vSp;w$vfrVyAMNgt-kPE5DN3=3uIHdL#gDvT(_fD>?sgKMv`vat=G zJj?v~jiJ5zA`B*osUz0%@vJx4xPJ5X6_x8{!`_LokKW|t6jUM0kW=?nr<$nGi7Y`Z zOj0rnJH~wrF%wHe zpNy1O9BUq~JHBNQWURjDL$Xqq=4s5JXLnjki@}0Zki#l;sl!@as->VZi zx}pc?hBqt>zJ?6mFsh*iKePR&{$?1V$0fsy2VdK{)3O>-EUZLmhLX;GzS{lZsQ zaGoOiUKtjDSxw2t?-b2@NlNNz30bNuy?K8h^oHYYmVX#nvt4Q9)xnrJuuK-vhHv!U zaHWEuyg!;sD(sT+oJ=!e5#73I*(PM!S~u)B%f)jl$%vl=j~oVnGfh46D9l~M93?$+-OQnZOtUb&<~FLmcRlXl9Wxqbx;OS9o}h-QN0nnQfXLSOEvM6{-$=pb%6+{(5l7r`{BhcXpR#<-BOa#J(TkKks}#F; zyGn}9o)_6`rVdJXn}z8pHQCiHCX;9Qi~+g-JrPSI(P? zNrb6e@(6@}2nli$ebJ_{sNTBZEzK;TUmq#l*cf27s?U(X-W+L`De~{wZE0{`I*2T> zN$X%=$@HZ=ZH8?;v+ZJ6FRRc+4v(yODF$b(ymVm-(>**?lE7N_N%z|i^CB0Vv}2hu zG5H-`l#jNja((ag!nN@7Q*xe2hRo0Vs|>^}BkSij3R`po*Sa~-{a{=!&R|3H^Ot3m z_0G@44SVRB8ZvZMw}vaq>xs)>Uy9X>c>3xJs}?l~8Wq=bZVAPpUPrv}6MF)IpW0JO zWV%LsN#v-;|DzYxfvZ6s(c?2?@oUBEuu;0}>p_93s!c*Tq>CHtaBWpBrGK|f{`q-b zb*+wYrAd;dn_0ngk9z-5LiDu+GVZI-68p1R4)DQZONRWcw-Q!{>J?_ms}6*<_r^;UK1X99o^T=4(;sKC<8tblT~ zRL)Y@z9|}_A)CjFRNa(43)8~0!Sq{4W+yW8rhg+F`n%d!(*zE;ut%0dD|BN>j`T zMV}zNbCHLoe?mO3O|RZOSlVxyi;oMgTRku;WBqaIAFvE_7!!3=hD|Y9$o7fI$!m!h zuuC9J0*&)?gYo`;(lym4eGLpOoA8W=iNoaNT*14M#Re8muzkAgTtLqH+Y>24IBID( z+UNyuBq753p95g2dtk%ki?NBKSNVk$$X0tTOwcq?BD|fOXs7`vRKbnbCl}^%YgvWwR|P298$64S ziIIEhVtAN~x#8j206hcy(*X;v<(pr09;;@jDuku3QBtjs=+>Xz65gva6LTS>k@>E! z9Fd$VMKZ=DEu-|xHS3PjV3eM`YW&8Zu!nMC5(Uk2Wqh{|E&~<>cyOjk#BzBOh=Q4i zU-7%d$AxD${pNbbN(6)85R8#$;4JCqT2q!$vzKVR*%dS=FyhmkyFr z_fN9y`BHnrjEt|;i+|jtOUC|4N=Mu~sMcDw#muX3P=gU3TD#*Z^~bxtK&41HuRCwc z|CK)x7UZfo;c~1K<*y3j!xM^1&;z+x1=Zlcs)R?UpGPS#3MfXI%PLk+v90_`_S}G5 zhL}1h9+}Lim401V+qZqwp=v*bb!$2NEpzi#+k78fhT|%Rjym;p+RGOcp=vd|qK03Hn#_fNhbd~N}flMfQUD>DylmH%hZ+OEqD0b2e zT|29(xLYXFkn`fIc;B|WoBjNELxlzllbp;*2%!oxt%Q8IIQ{8cu|MD6>|1zce(|(! z==Yb!g)Yn^g0JkoI$y3Hnbl{#S?n;xwCryZWDRjOZogk(hUKx<1Zm7NUL$jY*=Tis zAi&r4?tbNgZrRy>T~Y$3A2Ba?61w%b#moH3s+#VFPYxxSlB94~pb9wd_AgJgbdZ64* z_jiBz6{D6U6;$Nv+1QeDtXS3ajaIQ%^Abk#^uF}=aZ`$DrEjs+zFhn~X5d1GLYS46 zdl=)^>{zF)x!qtX^vxz|&il7v*(!4h@vooYj4C#MM|#3mNa zxE?Oe3BFVWlMOo-+B~URj8f;THxQCqEGVc5YB_#kN@M^#Ie!-x`0Z7}>55NR*)OoZ`G~y z!y`ui1Yg1W9Eq5M-@7}DzHS}g7}mPr_I|J)E$^a5V%D{>uMnR(-5N5;S(j3s;<4~G z{7ZP_!l6uqMT4VIU4-vSpL_CB?KFEY29@yd(4phXn!@S2_)oz+%b}xQ&9C(I3z&Xh zDT}9pCCYYt!qp|F<*BC%De?{c+l_H;Tur9M4@ScA%CFvIcI%ksj}&3Ab1MtNES`9L zb@O6v{%s1$rAIu>uCJ$AZl(F=5vixuuz&k^*RZ)YIG!;KiD&(luBx39QpcWl4&M`Y z7-RBqIef1q(ta3Pu6FL{(ZGaODoyLI);^=C&#ubGwv106!|Q7 zg(fUvO|n0TS#|9^?V;E~?-12Pg8jB2`x@<;IH%;pDf`&Jgajm>Q@ux918u`uw;f~D zVc}jJMld@-bV+qBp+cSCIlM3#shY@a+0qe<|ir|!kMi^#4h53~nrt2ii+NEvVSasv>>rn`5zz7L9%nN7>fL8kx0<~)yXDJ*;$TSk3=10WJ zsa%ssxAR_~uGX``^BcpqXExwmPp6e3-)l;A0Co?8g4lSK$$sg*fOtZ7?Fj-g|I6P= z`RWzEGj_hz=U1owH@lhy97CH^VZ~9up7M9%N;Pa|VNLpSvT%)^aPs$fzCsFnzJZs? zu#lJogwSRIOD=lN$o>7r5z@zB=3xu^U~7qhrD2mt7#p!h!-(zHbU#drpEg&MJ{pP- zAHyd-f4-{j`@ucJLmu9>hPgJ1K!~HGP$LjFf5%S6VB^g@>PJvEJ^Ld*q?z6QvXGAc z^;GrWwr^n+QWdfZB;O2;f2TKl2hx=B7#Pn#=c( zB30#cx%Ox2`ZwL+EG>UH6y2L|V9RkOWC0Up%`!jdVW}2}vW)b#lwg-{6*9tw;r<6W zmV0*sF}mY~`X&eui+dSau+#_F$v;iR9(SQP@L#!$IrI)CoQY4o`~?wOeQ?S4S!(36 zYyIV?)XXC%Wz^(WvafHge@_;fMmM8{RMv!jI&+Oj-m{r{X2CszytI0IGP*&0mI(AfOGthItbVvaC!S zdscy%Jh#R@p)w#$He62sS_;CCq<`i7_H-bU}&nYsl zat8I6H$hFuHU8}B2{YVN>F=|i9!m+HCWYo9X6m}$tM_F+)IX}{gjE1RnHE>%vfZXo z7rSbq*c1<&{?0(E3i@q8Qy?zIXKS_S~4bm?wX*}V4!^-9s z>j$c?uVqRgh7Pe#Lnv|Ca5Cw< zZ|VA8|6mzR(I(B(vl~#)C)OG(@vIfq75ab{fuSyPuH&fj#bMKL5DD$H09G%>%Knak890x&O0>LTuEg7%3&E9 z0Wd*Fi{E?V)pmz?XzC9L*SQCsUGz+lN_zM_C>TztZDGu6`!sbKThvBBWc?5od341X z*Ss;mXAl-t(J23{tLYU5UjzNrBC_~@d=$#!?yvy4fR>ql2Gf{?^B_kYVmKtkK9ex{ z>@pt|1!|*U(Mf2v#!yPRPwWJzUQxo_tINyR$xpuvtlMw~REQILGA?=0jGGXu*jpvu zY9-&+6;tFB=0zV>)0-dWz!@#3n#B@WT=xz}Xaz!poTgox7dtH;I-L)&rdS|b!`O_0aN8vVC^7pB%6h5adrD0VjLE6VHY7>J12GSJpb-(rsIbjwIQxHaaz?Nisg)}Pu| zfPUczqs8ac)IRi)7zZn#f+R5xR@-I$(cSl(ekrfJ>K`dv_v0!(3l#rKxn|S44sc{W zssBTuKBSPu14at^w_M)JMzE|{)94`Hl_H-H+509^A&Fz}Pa2Dy+tZ~ugU02U;y{0^ z8|=?P^t^m>`95^6)&;;o?9yB$r%D1;>qA46jGK;w-}xHqpQzAXD2h#4+1qWNEoi8(nSSua^9jCX5}OR}SF9)L7m?=lOx5t+uo$bMF{p(yKjM7tn}adnevJrypn0Jyzg;U7 z(-Mf$(W`wpy7intfON3HoSMKT`E!>{nk2Y@^aNp>S`PYALbxUM6ctZn#;rzT zCYm;ptG24@0q~o0>^CLgn|Jmv;-r;ElHY%@$zCTgw#n7_ zu}ZYhb^!3;*sQIU%HtHMbw;8SXRQE_YN$3XNy7qLTC-aa`|;eXkB7&F0^S`>GaM1F z#KhO;EZ^HD<9uqxWoz7>4bXR1JD@Ac1ybkxcg?$%HnBo|S;zvU=Hi0C03WmCpR*Yo zYuNm(Ft#^NiXa{HSG6tv?u?7KbV3iR(kYf7oaq&2JPW1sx<|rYBG#L!G@tzHJZb^E zF_NnoD0Oj5`ZAxKat)Z1;)Nj}jmO&%2tBK^yo|QF8`_fFFG8Uo5Nqg4Wo5-;2RUt8 zPtPq8?Y$x7hP>~^X^%^Sx3xi^xgCz*TESFs$b*8HkgiuJ7t8`Izt`_?zny~sqnYzG&^GIo|R_@AETzj#g-xf7#n#ji6Gtme?qY-JA4!EH_^|L|7e`|eZnd*+IglJ#U zhTT+ZM>s+2rm->OAj;qM%V#wGjr~3V!?m~m6yb-uSKNv*64@n^$Z(XrIy*eoaLOPr zLeW375q7ui0$@j0CyA)(GQwfRJPAbC%OrO019oWan6I16tq>b_YouD?T6}^@cKV>} z{9QKEx`X{?*9RofVm3%hxP{W@fi;KHyVf-|OO;h!BxfA9U4qi}@UWG=|446&`w7n4 zKV{vANJqkqf(aUhswzCT$5l^|-94I`wl#Lck-cvz?>WHmdpVg|4}q-N1z1ET<)vX% ze4&X+9T&f^iK!3-zfU+gi1EOWW6!wm;o$~)dg*05@5t7#=*1-GtlZ3{T@x0&C!TvK z)==O-QK2vPNP8()`GPnmd$8}$e7G$OTOXQtt=3~Bq?&}y(6->$*?BzlBa7&_H;lCJ z$dqlL)_xz6$rdDsC_J}YZM5uCJ19OknB3!D;3%01A`AzO5i8{(?8mh4C(dg+>Lv{; z-$x|*!oM)*z9N-5vPvL5GI0wi;IF%H^7i8=o|7olijpQUYCzNjeRD7&NEhlY0$Ta$ zlyC0R`XI;vtgWo~RsLq=NMJS`n0%TIvU;EhSR3WVjV`_ z3=a<3B8}Ekj*=8k+rS+`)}`>{-D`omT*;xHiWQYJZ-)J5&xUJA@uXOk8SIX3=V>Qe zKDQiMdeG7NqwXaau+75S{>2zWlkuaT9HLQNWY#IMDee^;%Y8(-b#QW#)KyF`$~px6 zxR~&qC`fuJYnj=`18D*xr3MI|qIo*(DY~yX#`0V|U9~~B14c)0-6COYo#<5MaqyV1 zvo3(%-~*H}d~kh$yW&um#j!c~`lPr$dgkDr3eRHJCBc!{pFZ4+y&(G$c}7SQ$(w=$ zNiKkrb>2s?%-sY`Yhn_n^m?0g2yz^sqUHC|W7Hq-3J5 z-7KXH)O8#F=gW%QfpMWGiEwqPnrW@(!&H`Y2fn?3B+#~#g+`N^{TWV`_7Z#U= zR@jzTR&r6ksEF`N9;7lEtlgUCR2A{+G z55XZe%!R?}t4gU})FoNdi}`uib*zcq9w_~>j5lE}kJOTl4ZU6g2>jAJ=?8N_1mLRu zk+DVnQb`zXKIH|hq{Pj!G)!T&J_xV!u52LKA`8;G$)Yk8USG>?SYk)q_4twV$|gOS z8GQcVFAOyjtJbOzo67Tt6C;Y(={&`Olk#<5xc51ppBx^UF>04vyY}wDf2UXX(e{%1 zSGsTcbaJG!jP)WU8Nj^aXGXf(VeAkUo-^TpTI+VoH!^Eo z$IF>jP8a)|a&-Xa5Sr)M?{U#M+XD3`7JFTvpY*2ItnjrL3LqKFA=n^)=uo5Tq1_W= zaP}@jY^q#X8Va$x==YKmT%6D2Ya}|k)S$n!JMlx#JG1ankr+GO$_ug!`tTaNuNDFv zQpz{4t7*n}vsa?HMW3+&Ogr%okJ;%Rc&KQ*yZNG?w6tvRM(|%Wy6yaSJy}vQuQThP zdylypTCFUW0S!m_98!}e8yK*~2U6x8Ig_?JPt}Z-em&^!88ZJ~G)$D*<3ecFs-u?a zbFaTFYX%zNMsL?}RK6X_?STYRe3F>p>aVMS@vONB_FcMRwUpxF_=7PBB{J|3uoLi{ za6rgx#;W5DHL{;RjRe$Ft54O5+kOx?aJ;T&Z}0dc?Dr#5s=BTHJt<^>1>*7uQGc{m zTynI?evOB;6M-<9MA}X<(q0VKCO9HlDo4F+EIKwK{kdW;$P(%##uUDc(hgNcJ_M2N zt=51}%!i!qE<2EeKo|6yR@E8TkN0OI3%aobLxtSF2(YUGB$BszHh>;Vf2{a6)8Z*g z%`Z)mR}fe+Y88y)hMCO3bbx3D4CR6<`|akjI4a!;ZZJIm+e>N(=ANhH6{f8-zhL9- z@WOIFscu8y2EQg60k^FON<)A&B@>|(2nV<#0&MDF!FxtIU~W#H>=Ah2HwJrMLLQKk ziL%i1b%+INtWpDh70g(4((0S`ezUUeeL@y}W3gdDCRNPAq#(%aeI8rAEtRzAdKBSp z#Lco7?Zlq1$BAfB$M~IoY2=dcsJubSKAy5Fk@ej(=bH jZ}8R^yv4 diff --git a/img/github-social-preview.svg b/img/github-social-preview.svg deleted file mode 100644 index 4b7a75760e..0000000000 --- a/img/github-social-preview.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - FastAPI - - Full Stack - Template - diff --git a/img/login.png b/img/login.png deleted file mode 100644 index 66e3a7202ff04def7dba8c9486f612c9603aa185..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36530 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 Date: Mon, 23 Sep 2024 02:26:40 +0530 Subject: [PATCH 02/25] added menu routes --- ...73e_add_column_last_name_to_user_model.py} | 62 ++-- backend/app/api/main.py | 3 +- backend/app/api/routes/menu.py | 210 ++++++++++++++ backend/app/api/routes/venues.py | 273 +++++++++--------- backend/app/crud.py | 164 +++++------ backend/app/models/menu.py | 21 +- backend/app/models/menu_category.py | 33 ++- backend/app/models/menu_item.py | 8 +- backend/app/models/venue.py | 24 +- backend/app/models_/menu.py | 86 ------ backend/app/models_/venue.py | 228 --------------- backend/app/schema/__init__.py | 0 backend/app/schema/menu.py | 51 ++++ backend/app/schema/venue.py | 39 +++ 14 files changed, 613 insertions(+), 589 deletions(-) rename backend/app/alembic/versions/{ca5d4c361c2e_initial_migration.py => 36749727673e_add_column_last_name_to_user_model.py} (96%) create mode 100644 backend/app/api/routes/menu.py delete mode 100644 backend/app/models_/menu.py delete mode 100644 backend/app/models_/venue.py create mode 100644 backend/app/schema/__init__.py create mode 100644 backend/app/schema/menu.py create mode 100644 backend/app/schema/venue.py diff --git a/backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py b/backend/app/alembic/versions/36749727673e_add_column_last_name_to_user_model.py similarity index 96% rename from backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py rename to backend/app/alembic/versions/36749727673e_add_column_last_name_to_user_model.py index a936f4e3a5..b35c9ce2e3 100644 --- a/backend/app/alembic/versions/ca5d4c361c2e_initial_migration.py +++ b/backend/app/alembic/versions/36749727673e_add_column_last_name_to_user_model.py @@ -1,8 +1,8 @@ -"""initial migration +"""Add column last_name to user model -Revision ID: ca5d4c361c2e +Revision ID: 36749727673e Revises: -Create Date: 2024-09-21 06:23:39.920369 +Create Date: 2024-09-22 11:44:15.716637 """ from alembic import op @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. -revision = 'ca5d4c361c2e' +revision = '36749727673e' down_revision = None branch_labels = None depends_on = None @@ -136,16 +136,16 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_group_id'), 'group', ['id'], unique=False) - op.create_table('nightclubmenu', + op.create_table('nightclub_menu', sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_nightclubmenu_id'), 'nightclubmenu', ['id'], unique=False) + op.create_index(op.f('ix_nightclub_menu_id'), 'nightclub_menu', ['id'], unique=False) op.create_table('nightclubuserbusinesslink', sa.Column('nightclub_id', sa.Integer(), nullable=False), sa.Column('user_business_id', sa.Integer(), nullable=False), @@ -227,16 +227,16 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_qsr_id'), 'qsr', ['id'], unique=False) - op.create_table('restaurantmenu', + op.create_table('restaurant_menu', sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), sa.Column('restaurant_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_restaurantmenu_id'), 'restaurantmenu', ['id'], unique=False) + op.create_index(op.f('ix_restaurant_menu_id'), 'restaurant_menu', ['id'], unique=False) op.create_table('restaurantuserbusinesslink', sa.Column('restaurant_id', sa.Integer(), nullable=False), sa.Column('user_business_id', sa.Integer(), nullable=False), @@ -305,6 +305,16 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_nightclub_order_id'), 'nightclub_order', ['id'], unique=False) + op.create_table('qsr_menu', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('qsr_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_qsr_menu_id'), 'qsr_menu', ['id'], unique=False) op.create_table('qsr_order', sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('pickup_location_id', sa.Integer(), nullable=True), @@ -325,16 +335,6 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_qsr_order_id'), 'qsr_order', ['id'], unique=False) - op.create_table('qsrmenu', - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('qsr_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_qsrmenu_id'), 'qsrmenu', ['id'], unique=False) op.create_table('qsruserbusinesslink', sa.Column('qsr_id', sa.Integer(), nullable=False), sa.Column('user_business_id', sa.Integer(), nullable=False), @@ -395,14 +395,14 @@ def upgrade(): ) op.create_index(op.f('ix_groupwallettopup_id'), 'groupwallettopup', ['id'], unique=False) op.create_table('menu_category', - sa.Column('id', sa.Integer(), nullable=False), sa.Column('qsr_menu_id', sa.Integer(), nullable=True), sa.Column('restaurant_menu_id', sa.Integer(), nullable=True), sa.Column('nightclub_menu_id', sa.Integer(), nullable=True), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.ForeignKeyConstraint(['nightclub_menu_id'], ['nightclubmenu.id'], ), - sa.ForeignKeyConstraint(['qsr_menu_id'], ['qsrmenu.id'], ), - sa.ForeignKeyConstraint(['restaurant_menu_id'], ['restaurantmenu.id'], ), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['nightclub_menu_id'], ['nightclub_menu.id'], ), + sa.ForeignKeyConstraint(['qsr_menu_id'], ['qsr_menu.id'], ), + sa.ForeignKeyConstraint(['restaurant_menu_id'], ['restaurant_menu.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_menu_category_id'), 'menu_category', ['id'], unique=False) @@ -421,7 +421,6 @@ def upgrade(): ) op.create_index(op.f('ix_payment_event_id'), 'payment_event', ['id'], unique=False) op.create_table('menu_item', - sa.Column('id', sa.Integer(), nullable=False), sa.Column('category_id', sa.Integer(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('price', sa.Float(), nullable=False), @@ -431,6 +430,7 @@ def upgrade(): sa.Column('ingredients', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('abv', sa.Float(), nullable=True), sa.Column('ibu', sa.Integer(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), sa.PrimaryKeyConstraint('id') ) @@ -470,10 +470,10 @@ def downgrade(): op.drop_index(op.f('ix_restaurant_order_id'), table_name='restaurant_order') op.drop_table('restaurant_order') op.drop_table('qsruserbusinesslink') - op.drop_index(op.f('ix_qsrmenu_id'), table_name='qsrmenu') - op.drop_table('qsrmenu') op.drop_index(op.f('ix_qsr_order_id'), table_name='qsr_order') op.drop_table('qsr_order') + op.drop_index(op.f('ix_qsr_menu_id'), table_name='qsr_menu') + op.drop_table('qsr_menu') op.drop_index(op.f('ix_nightclub_order_id'), table_name='nightclub_order') op.drop_table('nightclub_order') op.drop_table('groupmembers') @@ -482,8 +482,8 @@ def downgrade(): op.drop_table('event_booking') op.drop_table('clubvisit') op.drop_table('restaurantuserbusinesslink') - op.drop_index(op.f('ix_restaurantmenu_id'), table_name='restaurantmenu') - op.drop_table('restaurantmenu') + op.drop_index(op.f('ix_restaurant_menu_id'), table_name='restaurant_menu') + op.drop_table('restaurant_menu') op.drop_index(op.f('ix_qsr_id'), table_name='qsr') op.drop_table('qsr') op.drop_index(op.f('ix_pickup_location_id'), table_name='pickup_location') @@ -495,8 +495,8 @@ def downgrade(): op.drop_index(op.f('ix_payment_source_nightclub_id'), table_name='payment_source_nightclub') op.drop_table('payment_source_nightclub') op.drop_table('nightclubuserbusinesslink') - op.drop_index(op.f('ix_nightclubmenu_id'), table_name='nightclubmenu') - op.drop_table('nightclubmenu') + op.drop_index(op.f('ix_nightclub_menu_id'), table_name='nightclub_menu') + op.drop_table('nightclub_menu') op.drop_index(op.f('ix_group_id'), table_name='group') op.drop_table('group') op.drop_table('foodcourtuserbusinesslink') diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e11d6fcba5..ab1354f889 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,7 +1,8 @@ from fastapi import APIRouter from app.api.routes import venues +from app.api.routes import menu api_router = APIRouter() api_router.include_router(venues.router, prefix="/venues", tags=["venues"]) - +api_router.include_router(menu.router, prefix="/menu", tags=["menu"]) diff --git a/backend/app/api/routes/menu.py b/backend/app/api/routes/menu.py new file mode 100644 index 0000000000..75fc2f0051 --- /dev/null +++ b/backend/app/api/routes/menu.py @@ -0,0 +1,210 @@ +from app.schema.menu import MenuCategoryRead, MenuItemRead, NightclubMenuCreate, NightclubMenuRead, MenuCategoryCreate, MenuItemCreate, QSRMenuCreate, QSRMenuRead, RestaurantMenuCreate, RestaurantMenuRead +from app.models.menu import NightclubMenu, QSRMenu, RestaurantMenu +from app.models.menu_category import MenuCategory +from app.models.menu_item import MenuItem +from sqlmodel import select +from fastapi import APIRouter +from typing import List +from app.api.deps import SessionDep +from app.crud import ( + get_record_by_id, + create_record, + update_record, + patch_record, + delete_record +) + +router = APIRouter() + +# Get all menus for a nightclub +@router.get("/nightclubs/{nightclub_id}/menus/", response_model=List[NightclubMenuRead]) +def read_nightclub_menus(nightclub_id: int, session: SessionDep): + """ + Retrieve all menus for a specific nightclub. + """ + menus = session.exec(select(NightclubMenu).where(NightclubMenu.nightclub_id == nightclub_id)).all() + return menus + +# Get a specific menu +@router.get("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) +def read_nightclub_menu(menu_id: int, session: SessionDep): + """ + Retrieve a specific menu by ID for a nightclub. + """ + return get_record_by_id(session, NightclubMenu, menu_id) + +# Create a new menu +@router.post("/nightclubs/menus/", response_model=NightclubMenuRead) +def create_nightclub_menu( menu: NightclubMenuCreate, session: SessionDep): + """ + Create a new menu for a nightclub. + """ + return create_record(session, NightclubMenu, menu) + +# Update a menu +@router.put("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) +def update_nightclub_menu(menu_id: int, updated_menu: NightclubMenuCreate, session: SessionDep): + """ + Update an existing menu for a nightclub. + """ + return update_record(session, NightclubMenu, menu_id, updated_menu) + +# PATCH a menu for partial updates +@router.patch("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) +def patch_nightclub_menu(menu_id: int, updated_menu: NightclubMenuCreate, session: SessionDep): + """ + Partially update an existing menu for a venue (Nightclub, Restaurant, QSR). + """ + return patch_record(session, NightclubMenu, menu_id, updated_menu) + +# Delete a menu +@router.delete("/nightclubs/menus/{menu_id}", response_model=None) +def delete_nightclub_menu(menu_id: int, session: SessionDep): + """ + Delete a menu by ID for a nightclub. + """ + return delete_record(session, NightclubMenu, menu_id) + +# Get all menus for a qsr +@router.get("/qsrs/{qsr_id}/menus/", response_model=List[QSRMenuRead]) +def read_qsr_menus(qsr_id: int, session: SessionDep): + """ + Retrieve all menus for a specific qsr. + """ + menus = session.exec(select(QSRMenu).where(QSRMenu.qsr_id == qsr_id)).all() + return menus + +# Get a specific menu +@router.get("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) +def read_qsr_menu(menu_id: int, session: SessionDep): + """ + Retrieve a specific menu by ID for a qsr. + """ + return get_record_by_id(session, QSRMenu, menu_id) + +# Create a new menu +@router.post("/qsrs/menus/", response_model=QSRMenuRead) +def create_qsr_menu( menu: QSRMenuCreate, session: SessionDep): + """ + Create a new menu for a qsr. + """ + return create_record(session, QSRMenu, menu) + +# Update a menu +@router.put("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) +def update_qsr_menu(menu_id: int, updated_menu: QSRMenuCreate, session: SessionDep): + """ + Update an existing menu for a qsr. + """ + return update_record(session, QSRMenu, menu_id, updated_menu) + +# PATCH a menu for partial updates +@router.patch("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) +def patch_qsr_menu(menu_id: int, updated_menu: QSRMenuCreate, session: SessionDep): + """ + Partially update an existing menu for a venue (QSR, Restaurant, QSR). + """ + return patch_record(session, QSRMenu, menu_id, updated_menu) + +# Delete a menu +@router.delete("/qsrs/menus/{menu_id}", response_model=None) +def delete_qsr_menu(menu_id: int, session: SessionDep): + """ + Delete a menu by ID for a qsr. + """ + return delete_record(session, QSRMenu, menu_id) + +# Get all menus for a restaurant +@router.get("/restaurants/{restaurant_id}/menus/", response_model=List[RestaurantMenuRead]) +def read_restaurant_menus(restaurant_id: int, session: SessionDep): + """ + Retrieve all menus for a specific restaurant. + """ + menus = session.exec(select(RestaurantMenu).where(RestaurantMenu.restaurant_id == restaurant_id)).all() + return menus + +# Get a specific menu +@router.get("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) +def read_restaurant_menu(menu_id: int, session: SessionDep): + """ + Retrieve a specific menu by ID for a restaurant. + """ + return get_record_by_id(session, RestaurantMenu, menu_id) + +# Create a new menu +@router.post("/restaurants/menus/", response_model=RestaurantMenuRead) +def create_restaurant_menu( menu: RestaurantMenuCreate, session: SessionDep): + """ + Create a new menu for a restaurant. + """ + return create_record(session, RestaurantMenu, menu) + +# Update a menu +@router.put("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) +def update_restaurant_menu(menu_id: int, updated_menu: RestaurantMenuCreate, session: SessionDep): + """ + Update an existing menu for a restaurant. + """ + return update_record(session, RestaurantMenu, menu_id, updated_menu) + +# PATCH a menu for partial updates +@router.patch("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) +def patch_restaurant_menu(menu_id: int, updated_menu: RestaurantMenuCreate, session: SessionDep): + """ + Partially update an existing menu for a venue (Restaurant, Restaurant, QSR). + """ + return patch_record(session, RestaurantMenu, menu_id, updated_menu) + +# Delete a menu +@router.delete("/restaurants/menus/{menu_id}", response_model=None) +def delete_restaurant_menu(menu_id: int, session: SessionDep): + """ + Delete a menu by ID for a restaurant. + """ + return delete_record(session, RestaurantMenu, menu_id) + +# CRUD operations for Menu Categories + +# Create a new category +@router.post("/nightclubs/menus/categories/", response_model=MenuCategoryRead) +def create_menu_category(category: MenuCategoryCreate, session: SessionDep): + return create_record(session, MenuCategory, category) + +# Update a category +@router.put("/nightclubs/menus/categories/{category_id}", response_model=MenuCategoryRead) +def update_menu_category(category_id: int, updated_category: MenuCategoryCreate, session: SessionDep): + """ + Update an existing category for a specific menu. + """ + return update_record(session, MenuCategory, category_id, updated_category) + +# Delete a category +@router.delete("/nightclubs/menus/categories/{category_id}", response_model=None) +def delete_menu_category(category_id: int, session: SessionDep): + """ + Delete a category by ID from a specific menu. + """ + return delete_record(session, MenuCategory, category_id) + +# CRUD operations for Menu Items + +# Create a new item +@router.post("/nightclubs/menus/categories/items/", response_model=MenuItemRead) +def create_menu_item(item: MenuItemCreate, session: SessionDep): + return create_record(session, MenuItem, item) + +# Update an item +@router.put("/nightclubs/menus/categories/items/{item_id}", response_model=MenuItemRead) +def update_menu_item(item_id: int, updated_item: MenuItemCreate, session: SessionDep): + """ + Update an existing item under a specific category of a menu. + """ + return update_record(session, MenuItem, item_id, updated_item) + +# Delete an item +@router.delete("/nightclubs/menus/categories/items/{item_id}", response_model=None) +def delete_menu_item(item_id: int, session: SessionDep): + """ + Delete an item by ID from a specific category of a menu. + """ + return delete_record(session, MenuItem, item_id) \ No newline at end of file diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py index 3c29ac4988..ab5ef266db 100644 --- a/backend/app/api/routes/venues.py +++ b/backend/app/api/routes/venues.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, HTTPException, Depends, Query -from sqlmodel import Session, select +from app.schema.venue import FoodcourtCreate, FoodcourtRead, NightclubCreate, NightclubRead, QSRCreate, QSRRead, RestaurantCreate, RestaurantRead +from fastapi import APIRouter, HTTPException, Query from typing import List from app.models.venue import Nightclub, Restaurant, QSR, Foodcourt @@ -16,7 +16,7 @@ # CRUD operations for Nightclubs -@router.get("/nightclubs/", response_model=List[Nightclub]) +@router.get("/nightclubs/", response_model=List[NightclubRead]) def read_nightclubs( session: SessionDep, skip: int = Query(0, alias="page", ge=0), @@ -27,19 +27,19 @@ def read_nightclubs( - **skip**: The page number (starting from 0) - **limit**: The number of items per page """ - return get_all_records(session, Nightclub, skip=skip, limit=limit) + nightclubs = get_all_records(session, Nightclub, skip=skip, limit=limit) + return nightclubs - -@router.get("/nightclubs/{venue_id}", response_model=Nightclub) +@router.get("/nightclubs/{venue_id}", response_model=NightclubRead) def read_nightclub(venue_id: int, session: SessionDep ): nightclub = get_record_by_id(session, Nightclub, venue_id) if not nightclub: raise HTTPException(status_code=404, detail="Nightclub not found") return nightclub -@router.post("/nightclubs/", response_model=Nightclub) +@router.post("/nightclubs/", response_model=NightclubRead) def create_nightclub( - nightclub: Nightclub, + nightclub: NightclubCreate, session: SessionDep ): return create_record(session, Nightclub, nightclub) @@ -47,10 +47,9 @@ def create_nightclub( @router.put("/nightclubs/{venue_id}", response_model=Nightclub) def update_nightclub( venue_id: int, - updated_nightclub: Nightclub, + updated_nightclub: NightclubCreate, session: SessionDep - -): + ): return update_record(session, Nightclub, venue_id, updated_nightclub) @router.delete("/nightclubs/{venue_id}", response_model=None) @@ -61,134 +60,134 @@ def delete_nightclub( ): return delete_record(session, Nightclub, venue_id) -# # CRUD operations for Restaurants - -# @router.get("/restaurants/", response_model=List[Restaurant]) -# def read_restaurants(session: SessionDep ): -# return get_all_venues(session, Restaurant) +@router.get("/restaurants/", response_model=List[RestaurantRead]) +def read_restaurants( + session: SessionDep, + skip: int = Query(0, alias="page", ge=0), + limit: int = Query(10, le=100) +): + """ + Retrieve a paginated list of restaurants. + - **skip**: The page number (starting from 0) + - **limit**: The number of items per page + """ + restaurants = get_all_records(session, Restaurant, skip=skip, limit=limit) + return restaurants + +@router.get("/restaurants/{venue_id}", response_model=RestaurantRead) +def read_restaurant(venue_id: int, session: SessionDep ): + restaurant = get_record_by_id(session, Restaurant, venue_id) + if not restaurant: + raise HTTPException(status_code=404, detail="restaurant not found") + return restaurant + +@router.post("/restaurants/", response_model=RestaurantRead) +def create_restaurant( + restaurant: RestaurantCreate, + session: SessionDep +): + return create_record(session, Restaurant, restaurant) -# @router.get("/restaurants/{venue_id}", response_model=Restaurant) -# def read_restaurant(venue_id: int, session: SessionDep ): -# restaurant = get_venue_by_id(session, Restaurant, venue_id) -# if not restaurant: -# raise HTTPException(status_code=404, detail="Restaurant not found") -# return restaurant +@router.put("/restaurants/{venue_id}", response_model=Restaurant) +def update_restaurant( + venue_id: int, + updated_restaurant: RestaurantCreate, + session: SessionDep + ): + return update_record(session, Restaurant, venue_id, updated_restaurant) -# @router.post("/restaurants/", response_model=Restaurant) -# def create_restaurant( -# restaurant: Restaurant, -# session: SessionDep - -# ): -# return create_venue(session, restaurant) - -# @router.put("/restaurants/{venue_id}", response_model=Restaurant) -# def update_restaurant( -# venue_id: int, -# updated_restaurant: Restaurant, -# session: SessionDep - -# ): -# existing_restaurant = get_venue_by_id(session, Restaurant, venue_id) -# if not existing_restaurant: -# raise HTTPException(status_code=404, detail="Restaurant not found") -# return update_venue(session, venue_id, updated_restaurant) - -# @router.delete("/restaurants/{venue_id}", response_model=Restaurant) -# def delete_restaurant( -# venue_id: int, -# session: SessionDep - -# ): -# existing_restaurant = get_venue_by_id(session, Restaurant, venue_id) -# if not existing_restaurant: -# raise HTTPException(status_code=404, detail="Restaurant not found") -# return delete_venue(session, Restaurant, venue_id) - -# # CRUD operations for QSRs - -# @router.get("/qsrs/", response_model=List[QSR]) -# def read_qsrs(session: SessionDep ): -# return get_all_venues(session, QSR) - -# @router.get("/qsrs/{venue_id}", response_model=QSR) -# def read_qsr(venue_id: int, session: SessionDep ): -# qsr = get_venue_by_id(session, QSR, venue_id) -# if not qsr: -# raise HTTPException(status_code=404, detail="QSR not found") -# return qsr - -# @router.post("/qsrs/", response_model=QSR) -# def create_qsr( -# qsr: QSR, -# session: SessionDep - -# ): -# return create_venue(session, qsr) - -# @router.put("/qsrs/{venue_id}", response_model=QSR) -# def update_qsr( -# venue_id: int, -# updated_qsr: QSR, -# session: SessionDep - -# ): -# existing_qsr = get_venue_by_id(session, QSR, venue_id) -# if not existing_qsr: -# raise HTTPException(status_code=404, detail="QSR not found") -# return update_venue(session, venue_id, updated_qsr) - -# @router.delete("/qsrs/{venue_id}", response_model=QSR) -# def delete_qsr( -# venue_id: int, -# session: SessionDep - -# ): -# existing_qsr = get_venue_by_id(session, QSR, venue_id) -# if not existing_qsr: -# raise HTTPException(status_code=404, detail="QSR not found") -# return delete_venue(session, QSR, venue_id) - -# # CRUD operations for FoodCourts - -# @router.get("/foodcourts/", response_model=List[Foodcourt]) -# def read_foodcourts(session: SessionDep ): -# return get_all_venues(session, Foodcourt) - -# @router.get("/foodcourts/{venue_id}", response_model=Foodcourt) -# def read_foodcourt(venue_id: int, session: SessionDep ): -# foodcourt = get_venue_by_id(session, Foodcourt, venue_id) -# if not foodcourt: -# raise HTTPException(status_code=404, detail="Foodcourt not found") -# return foodcourt - -# @router.post("/foodcourts/", response_model=Foodcourt) -# def create_foodcourt( -# foodcourt: Foodcourt, -# session: SessionDep +@router.delete("/restaurants/{venue_id}", response_model=None) +def delete_restaurant( + venue_id: int, + session: SessionDep -# ): -# return create_venue(session, foodcourt) - -# @router.put("/foodcourts/{venue_id}", response_model=Foodcourt) -# def update_foodcourt( -# venue_id: int, -# updated_foodcourt: Foodcourt, -# session: SessionDep +): + return delete_record(session, Restaurant, venue_id) + +@router.get("/qsrs/", response_model=List[QSRRead]) +def read_qsrs( + session: SessionDep, + skip: int = Query(0, alias="page", ge=0), + limit: int = Query(10, le=100) +): + """ + Retrieve a paginated list of qsrs. + - **skip**: The page number (starting from 0) + - **limit**: The number of items per page + """ + qsrs = get_all_records(session, QSR, skip=skip, limit=limit) + return qsrs + +@router.get("/qsrs/{venue_id}", response_model=QSRRead) +def read_qsr(venue_id: int, session: SessionDep ): + qsr = get_record_by_id(session, QSR, venue_id) + if not qsr: + raise HTTPException(status_code=404, detail="QSR not found") + return qsr + +@router.post("/qsrs/", response_model=QSRRead) +def create_qsr( + qsr: QSRCreate, + session: SessionDep +): + return create_record(session, QSR, qsr) + +@router.put("/qsrs/{venue_id}", response_model=QSR) +def update_qsr( + venue_id: int, + updated_qsr: QSRCreate, + session: SessionDep + ): + return update_record(session, QSR, venue_id, updated_qsr) + +@router.delete("/qsrs/{venue_id}", response_model=None) +def delete_qsr( + venue_id: int, + session: SessionDep -# ): -# existing_foodcourt = get_venue_by_id(session, Foodcourt, venue_id) -# if not existing_foodcourt: -# raise HTTPException(status_code=404, detail="Foodcourt not found") -# return update_venue(session, venue_id, updated_foodcourt) - -# @router.delete("/foodcourts/{venue_id}", response_model=Foodcourt) -# def delete_foodcourt( -# venue_id: int, -# session: SessionDep +): + return delete_record(session, QSR, venue_id) + +@router.get("/foodcourts/", response_model=List[FoodcourtRead]) +def read_foodcourts( + session: SessionDep, + skip: int = Query(0, alias="page", ge=0), + limit: int = Query(10, le=100) +): + """ + Retrieve a paginated list of foodcourts. + - **skip**: The page number (starting from 0) + - **limit**: The number of items per page + """ + foodcourts = get_all_records(session, Foodcourt, skip=skip, limit=limit) + return foodcourts + +@router.get("/foodcourts/{venue_id}", response_model=FoodcourtRead) +def read_foodcourt(venue_id: int, session: SessionDep ): + foodcourt = get_record_by_id(session, Foodcourt, venue_id) + if not foodcourt: + raise HTTPException(status_code=404, detail="foodcourt not found") + return foodcourt + +@router.post("/foodcourts/", response_model=FoodcourtRead) +def create_foodcourt( + foodcourt: FoodcourtCreate, + session: SessionDep +): + return create_record(session, Foodcourt, foodcourt) + +@router.put("/foodcourts/{venue_id}", response_model=Foodcourt) +def update_foodcourt( + venue_id: int, + updated_foodcourt: FoodcourtCreate, + session: SessionDep + ): + return update_record(session, Foodcourt, venue_id, updated_foodcourt) + +@router.delete("/foodcourts/{venue_id}", response_model=None) +def delete_foodcourt( + venue_id: int, + session: SessionDep -# ): -# existing_foodcourt = get_venue_by_id(session, Foodcourt, venue_id) -# if not existing_foodcourt: -# raise HTTPException(status_code=404, detail="Foodcourt not found") -# return delete_venue(session, Foodcourt, venue_id) \ No newline at end of file +): + return delete_record(session, Foodcourt, venue_id) \ No newline at end of file diff --git a/backend/app/crud.py b/backend/app/crud.py index cb3a37b2e5..6ba4dfbeff 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,17 +1,6 @@ -from http.client import HTTPException -import uuid -from typing import Any - -from app.models.venue import QSR, Foodcourt, Nightclub, Restaurant -# from app.models.user import UserBusiness, UserPublic - -from sqlmodel import Session, select - -from app.core.security import get_password_hash, verify_password - - -from typing import Type, List -from sqlmodel import select, Session, SQLModel +from fastapi import HTTPException +from sqlmodel import SQLModel, Session, select +from typing import List, Type # Generic CRUD function to get all records with pagination def get_all_records( @@ -24,9 +13,12 @@ def get_all_records( - **skip**: Number of records to skip - **limit**: Number of records to return """ - statement = select(model).offset(skip).limit(limit) - result = session.exec(statement) - return result.all() + try: + statement = select(model).offset(skip).limit(limit) + result = session.exec(statement) + return result.all() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} records: {str(e)}") # Function to get a single record by ID def get_record_by_id( @@ -38,11 +30,14 @@ def get_record_by_id( - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - **record_id**: ID of the record to retrieve """ - statement = select(model).where(model.id == record_id) - result = session.exec(statement).first() - if not result: - raise ValueError(f"{model.__name__} with ID {record_id} not found.") - return result + try: + statement = select(model).where(model.id == record_id) + result = session.exec(statement).first() + if not result: + raise HTTPException(status_code=404, detail=f"{model.__name__} with ID {record_id} not found.") + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} record: {str(e)}") # Function to create a new record def create_record( @@ -54,11 +49,15 @@ def create_record( - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - **obj_in**: Data to create the new record """ - obj = model(**obj_in.dict()) - session.add(obj) - session.commit() - session.refresh(obj) - return obj + try: + obj = model(**obj_in.dict()) + session.add(obj) + session.commit() + session.refresh(obj) + return obj + except Exception as e: + session.rollback() + raise HTTPException(status_code=500, detail=f"Error creating {model.__name__}: {str(e)}") # Function to update an existing record def update_record( @@ -71,17 +70,54 @@ def update_record( - **record_id**: ID of the record to update - **obj_in**: Data to update the record """ - obj = get_record_by_id(session, model, record_id) - if not obj: - raise HTTPException(status_code=404, detail="Record not found") - obj_data = obj_in.dict(exclude_unset=True) - for field, value in obj_data.items(): - setattr(obj, field, value) - session.add(obj) - session.commit() - session.refresh(obj) - return obj - + try: + obj = get_record_by_id(session, model, record_id) + obj_data = obj_in.dict(exclude_unset=True) + for field, value in obj_data.items(): + setattr(obj, field, value) + session.add(obj) + session.commit() + session.refresh(obj) + return obj + except HTTPException as e: + raise e + except Exception as e: + session.rollback() + raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") + +def patch_record( + session: Session, model: Type[SQLModel], record_id: int, obj_in: SQLModel +) -> SQLModel: + """ + Partially update an existing record. + - **session**: Database session + - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR) + - **record_id**: ID of the record to update + - **obj_in**: Partial data to update the record + """ + try: + # Get the existing record from the database + obj = get_record_by_id(session, model, record_id) + + # Convert the incoming data, excluding any unset values + obj_data = obj_in.dict(exclude_unset=True) + + # Update the fields on the object + for field, value in obj_data.items(): + setattr(obj, field, value) + + # Commit the changes + session.add(obj) + session.commit() + session.refresh(obj) + + return obj + except HTTPException as e: + raise e + except Exception as e: + session.rollback() + raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") + # Function to delete a record def delete_record( session: Session, model: Type[SQLModel], record_id: int @@ -92,46 +128,12 @@ def delete_record( - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - **record_id**: ID of the record to delete """ - obj = get_record_by_id(session, model, record_id) - session.delete(obj) - session.commit() - -# Example functions specific to Nightclub, Restaurant, QSR, and Foodcourt - -def get_all_nightclubs( - session: Session, skip: int = 0, limit: int = 10 -) -> List[SQLModel]: - return get_all_records(session, Nightclub, skip, limit) - -def get_all_restaurants( - session: Session, skip: int = 0, limit: int = 10 -) -> List[SQLModel]: - return get_all_records(session, Restaurant, skip, limit) - -def get_all_qsrs( - session: Session, skip: int = 0, limit: int = 10 -) -> List[SQLModel]: - return get_all_records(session, QSR, skip, limit) - -def get_all_foodcourts( - session: Session, skip: int = 0, limit: int = 10 -) -> List[SQLModel]: - return get_all_records(session, Foodcourt, skip, limit) - - - -# def authenticate(*, session: Session, email: str, password: str) -> User | None: -# db_user = get_user_by_email(session=session, email=email) -# if not db_user: -# return None -# if not verify_password(password, db_user.hashed_password): -# return None -# return db_user - - -# def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: -# db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) -# session.add(db_item) -# session.commit() -# session.refresh(db_item) -# return db_item + try: + obj = get_record_by_id(session, model, record_id) + session.delete(obj) + session.commit() + except HTTPException as e: + raise e + except Exception as e: + session.rollback() + raise HTTPException(status_code=500, detail=f"Error deleting {model.__name__} with ID {record_id}: {str(e)}") \ No newline at end of file diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py index e43685f895..1034635800 100644 --- a/backend/app/models/menu.py +++ b/backend/app/models/menu.py @@ -6,27 +6,36 @@ class MenuBase(SQLModel): description: Optional[str] = Field(default=None) menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") -class QSRMenu(MenuBase, table=True): - id: Optional[int] = Field(default=None, primary_key=True, index=True) +class QSRMenuBase(MenuBase): qsr_id: int = Field(foreign_key="qsr.id", nullable=False) +class QSRMenu(QSRMenuBase, table=True): + __tablename__= "qsr_menu" + + id: Optional[int] = Field(default=None, primary_key=True, index=True) + # Relationships qsr: "QSR" = Relationship(back_populates="menu") categories: List["MenuCategory"] = Relationship(back_populates="qsr_menu") +class RestaurantMenuBase(MenuBase): + restaurant_id: int = Field(foreign_key="restaurant.id", nullable=False) -class RestaurantMenu(MenuBase, table=True): +class RestaurantMenu(RestaurantMenuBase, table=True): + __tablename__= "restaurant_menu" id: Optional[int] = Field(default=None, primary_key=True, index=True) - restaurant_id: int = Field(foreign_key="restaurant.id", nullable=False) # Relationships restaurant: "Restaurant" = Relationship(back_populates="menu") categories: List["MenuCategory"] = Relationship(back_populates="restaurant_menu") +class NightclubMenuBase(MenuBase): + nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + +class NightclubMenu(NightclubMenuBase, table=True): + __tablename__= "nightclub_menu" -class NightclubMenu(MenuBase, table=True): id: Optional[int] = Field(default=None, primary_key=True, index=True) - nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) # Relationships nightclub: "Nightclub" = Relationship(back_populates="menu") diff --git a/backend/app/models/menu_category.py b/backend/app/models/menu_category.py index 072e9dd00e..b29d83d080 100644 --- a/backend/app/models/menu_category.py +++ b/backend/app/models/menu_category.py @@ -1,18 +1,35 @@ from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List +from pydantic import root_validator, ValidationError +class MenuCategoryBase(SQLModel): + qsr_menu_id: Optional[int] = Field(default=None, foreign_key="qsr_menu.id") + restaurant_menu_id: Optional[int] = Field(default=None, foreign_key="restaurant_menu.id") + nightclub_menu_id: Optional[int] = Field(default=None, foreign_key="nightclub_menu.id") + name: str = Field(nullable=False) + + @root_validator(pre=True) + def check_only_one_menu_id(cls, values): + # Convert to a regular dictionary + values_dict = dict(values) + print("values ", values_dict) -class MenuCategory(SQLModel, table=True): + # Use .get() to safely access values + qsr_menu_id = values_dict.get('qsr_menu_id') + restaurant_menu_id = values_dict.get('restaurant_menu_id') + nightclub_menu_id = values_dict.get('nightclub_menu_id') + + # Count how many of these fields are set (not None) + menu_ids = [qsr_menu_id, restaurant_menu_id, nightclub_menu_id] + if sum(id is not None for id in menu_ids) != 1: + raise ValueError("You must set exactly one of qsr_menu_id, restaurant_menu_id, or nightclub_menu_id.") + + return values + +class MenuCategory(MenuCategoryBase, table=True): __tablename__ = "menu_category" id: Optional[int] = Field(default=None, primary_key=True, index=True) - # Foreign keys for different menus - qsr_menu_id: Optional[int] = Field(default=None, foreign_key="qsrmenu.id") - restaurant_menu_id: Optional[int] = Field(default=None, foreign_key="restaurantmenu.id") - nightclub_menu_id: Optional[int] = Field(default=None, foreign_key="nightclubmenu.id") - - name: str = Field(nullable=False) - # Relationships menu_items: List["MenuItem"] = Relationship(back_populates="category") diff --git a/backend/app/models/menu_item.py b/backend/app/models/menu_item.py index 302d07ce30..ebac08835a 100644 --- a/backend/app/models/menu_item.py +++ b/backend/app/models/menu_item.py @@ -1,9 +1,7 @@ from sqlmodel import SQLModel, Field, Relationship from typing import Optional -class MenuItem(SQLModel, table=True): - __tablename__="menu_item" - id: Optional[int] = Field(default=None, primary_key=True, index=True) +class MenuItemBase(SQLModel): category_id: int = Field(foreign_key="menu_category.id", nullable=False) name: str = Field(nullable=False) price: float = Field(nullable=False) @@ -14,5 +12,9 @@ class MenuItem(SQLModel, table=True): abv: Optional[float] = Field(default=None) ibu: Optional[int] = Field(default=None) +class MenuItem(MenuItemBase, table=True): + __tablename__="menu_item" + id: Optional[int] = Field(default=None, primary_key=True, index=True) + # Relationships category: Optional["MenuCategory"] = Relationship(back_populates="menu_items") \ No newline at end of file diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index 9690cab74b..a478123e36 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -1,10 +1,6 @@ from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List, TYPE_CHECKING -# if TYPE_CHECKING: -# from .user import UserBusiness -print("Venue models imported") - class VenueBase(SQLModel): name: str = Field(nullable=False) address: Optional[str] = Field(default=None) @@ -27,7 +23,10 @@ class NightclubUserBusinessLink(SQLModel, table=True): nightclub_id: int = Field(foreign_key="nightclub.id", primary_key=True) user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) -class Nightclub(VenueBase, table=True): +class NightclubBase(VenueBase): + pass + +class Nightclub(NightclubBase, table=True): __tablename__ = "nightclub" id: Optional[int] = Field(default=None, primary_key=True, index=True) @@ -47,7 +46,10 @@ class RestaurantUserBusinessLink(SQLModel, table=True): restaurant_id: int = Field(foreign_key="restaurant.id", primary_key=True) user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) -class Restaurant(VenueBase, table=True): +class RestaurantBase(VenueBase): + pass + +class Restaurant(RestaurantBase, table=True): __tablename__ = "restaurant" id: Optional[int] = Field(default=None, primary_key=True, index=True) @@ -63,7 +65,10 @@ class QSRUserBusinessLink(SQLModel, table=True): qsr_id: int = Field(foreign_key="qsr.id", primary_key=True) user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) -class QSR(VenueBase, table=True): +class QSRBase(VenueBase): + pass + +class QSR(QSRBase, table=True): __tablename__ = "qsr" id: Optional[int] = Field(default=None, primary_key=True, index=True) @@ -81,7 +86,10 @@ class FoodcourtUserBusinessLink(SQLModel, table=True): foodcourt_id: int = Field(foreign_key="foodcourt.id", primary_key=True) user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) -class Foodcourt(VenueBase, table=True): +class FoodcourtBase(VenueBase): + pass + +class Foodcourt(FoodcourtBase, table=True): __tablename__ = "foodcourt" id: Optional[int] = Field(default=None, primary_key=True, index=True) diff --git a/backend/app/models_/menu.py b/backend/app/models_/menu.py deleted file mode 100644 index 6844ab62c8..0000000000 --- a/backend/app/models_/menu.py +++ /dev/null @@ -1,86 +0,0 @@ -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List - -from app.models.menu_item import MenuItem -from app.models.venue import QSR, Nightclub, Restaurant - - -# Base class for Menu -class MenuBase(SQLModel): - name: str = Field(nullable=True) - description: Optional[str] = Field(default=None) - -# Schema for reading a Menu -class MenuRead(SQLModel): - id: int - name: str - description: Optional[str] - -# Schema for creating a Menu (excluding the ID which is auto-generated) -class MenuCreate(SQLModel): - name: str - description: Optional[str] - menu_type: Optional[str] - - -# QSR Menu model -class QSRMenu(MenuBase, table=True): - __tablename__ = "qsr_menu" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - qsr_id: int = Field(foreign_key="qsr.id", nullable=False) - - # Relationships - qsr: "QSR" = Relationship(back_populates="menu") - categories: List["menu_category"] = Relationship(back_populates="qsr_menu") - - -# Schema for reading a QSR Menu -class QSRMenuRead(MenuRead): - qsr_id: int - - -# Schema for creating a QSR Menu -class QSRMenuCreate(MenuCreate): - qsr_id: int - - -# Restaurant Menu model -class RestaurantMenu(MenuBase, table=True): - __tablename__ = "restaurant_menu" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - restaurant_id: int = Field(foreign_key="restaurant.id", nullable=False) - - # Relationships - restaurant: "Restaurant" = Relationship(back_populates="menu") - categories: List["menu_category"] = Relationship(back_populates="restaurant_menu") - - -# Schema for reading a Restaurant Menu -class RestaurantMenuRead(MenuRead): - restaurant_id: int - - -# Schema for creating a Restaurant Menu -class RestaurantMenuCreate(MenuCreate): - restaurant_id: int - - -# Nightclub Menu model -class NightclubMenu(MenuBase, table=True): - __tablename__ = "nightclub_menu" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) - - # Relationships - nightclub: "Nightclub" = Relationship(back_populates="menu") - categories: List["menu_category"] = Relationship(back_populates="nightclub_menu") - - -# Schema for reading a Nightclub Menu -class NightclubMenuRead(MenuRead): - nightclub_id: int - - -# Schema for creating a Nightclub Menu -class NightclubMenuCreate(MenuCreate): - nightclub_id: int \ No newline at end of file diff --git a/backend/app/models_/venue.py b/backend/app/models_/venue.py deleted file mode 100644 index 5c90e879ac..0000000000 --- a/backend/app/models_/venue.py +++ /dev/null @@ -1,228 +0,0 @@ -from sqlmodel import Field, SQLModel, Relationship -from typing import List, Optional - - -# Base model shared by all venues -class VenueBase(SQLModel): - name: str = Field(nullable=False) - address: Optional[str] = Field(default=None) - latitude: Optional[float] = Field(default=None) - longitude: Optional[float] = Field(default=None) - capacity: Optional[int] = Field(default=None) - description: Optional[str] = Field(default=None) - google_rating: Optional[float] = Field(default=None) - instagram_handle: Optional[str] = Field(default=None) - instagram_token: Optional[str] = Field(default=None) - google_map_link: Optional[str] = Field(default=None) - mobile_number: Optional[str] = Field(default=None) - email: Optional[str] = Field(default=None) - opening_time: Optional[str] = Field(default=None) - closing_time: Optional[str] = Field(default=None) - avg_expense_for_two: Optional[float] = Field(default=None) - qr_url: Optional[str] = Field(default=None) - - -# --------- Nightclub Models --------- -class Nightclub(VenueBase, table=True): - __tablename__ = "nightclub" - - id: Optional[int] = Field(default=None, primary_key=True, index=True) - - # Relationships - events: List["Event"] = Relationship(back_populates="nightclub") - club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") - menu: List["NightclubMenu"] = Relationship(back_populates="nightclub") - orders: List["NightclubOrder"] = Relationship(back_populates="nightclub") - pickup_locations: List["PickupLocation"] = Relationship(back_populates="nightclub") - group: List["Group"] = Relationship(back_populates="nightclubs") - - -# --------- Restaurant Models --------- -class Restaurant(VenueBase, table=True): - __tablename__ = "restaurant" - - id: Optional[int] = Field(default=None, primary_key=True, index=True) - foodcourt_id: Optional[int] = Field(default=None, foreign_key="foodcourt.id") - - # Relationships - foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") - menu: List["RestaurantMenu"] = Relationship(back_populates="restaurant") - orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") - - -# --------- QSR Models --------- -class QSR(VenueBase, table=True): - __tablename__ = "qsr" - - id: Optional[int] = Field(default=None, primary_key=True, index=True) - foodcourt_id: Optional[int] = Field(default=None, foreign_key="foodcourt.id") - - # Relationships - foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") - menu: List["QSRMenu"] = Relationship(back_populates="qsr") - orders: List["QSROrder"] = Relationship(back_populates="qsr") - - -# --------- Foodcourt Models --------- -class Foodcourt(VenueBase, table=True): - __tablename__ = "foodcourt" - - id: Optional[int] = Field(default=None, primary_key=True, index=True) - - # Relationships - qsrs: List["QSR"] = Relationship(back_populates="foodcourt") - -# --------- PickupLocation Models --------- -class PickupLocation(SQLModel, table=True): - __tablename__ = "pickup_location" - - id: Optional[int] = Field(default=None, primary_key=True, index=True) - venue_id: int = Field(foreign_key="venue.id", nullable=False) - name: str = Field(nullable=False) - description: Optional[str] = Field(default=None) - - # Relationships - orders: List["NightclubOrder"] = Relationship(back_populates="pickup_location") - - # Optionally, if you have a specific type of venue for PickupLocation - nightclub: Optional["Nightclub"] = Relationship(back_populates="pickup_locations") - -# ------------------ SCHEMAS ------------------ - -# --------- Nightclub Schemas --------- -class NightclubCreate(VenueBase): - pass - - -class NightclubRead(VenueBase): - id: int - - -class NightclubUpdate(VenueBase): - name: Optional[str] = None - address: Optional[str] = None - latitude: Optional[float] = None - longitude: Optional[float] = None - capacity: Optional[int] = None - google_rating: Optional[float] = None - instagram_handle: Optional[str] = None - google_map_link: Optional[str] = None - mobile_number: Optional[str] = None - email: Optional[str] = None - opening_time: Optional[str] = None - closing_time: Optional[str] = None - avg_expense_for_two: Optional[float] = None - qr_url: Optional[str] = None - -class NightclubDelete(SQLModel): - id: int - -# --------- Restaurant Schemas --------- -class RestaurantCreate(VenueBase): - pass - - -class RestaurantRead(VenueBase): - id: int - - -class RestaurantUpdate(VenueBase): - name: Optional[str] = None - address: Optional[str] = None - latitude: Optional[float] = None - longitude: Optional[float] = None - capacity: Optional[int] = None - google_rating: Optional[float] = None - instagram_handle: Optional[str] = None - google_map_link: Optional[str] = None - mobile_number: Optional[str] = None - email: Optional[str] = None - opening_time: Optional[str] = None - closing_time: Optional[str] = None - avg_expense_for_two: Optional[float] = None - qr_url: Optional[str] = None - -class RestaurantDelete(SQLModel): - id: int - - -# --------- QSR Schemas --------- -class QSRCreate(VenueBase): - pass - - -class QSRRead(VenueBase): - id: int - - -class QSRUpdate(VenueBase): - name: Optional[str] = None - address: Optional[str] = None - latitude: Optional[float] = None - longitude: Optional[float] = None - capacity: Optional[int] = None - google_rating: Optional[float] = None - instagram_handle: Optional[str] = None - google_map_link: Optional[str] = None - mobile_number: Optional[str] = None - email: Optional[str] = None - opening_time: Optional[str] = None - closing_time: Optional[str] = None - avg_expense_for_two: Optional[float] = None - qr_url: Optional[str] = None - -class QSRDelete(SQLModel): - id: int - -# --------- Foodcourt Schemas --------- -class FoodCourtCreate(VenueBase): - pass - - -class FoodCourtRead(VenueBase): - id: int - - -class FoodCourtUpdate(VenueBase): - name: Optional[str] = None - address: Optional[str] = None - latitude: Optional[float] = None - longitude: Optional[float] = None - capacity: Optional[int] = None - google_rating: Optional[float] = None - instagram_handle: Optional[str] = None - google_map_link: Optional[str] = None - mobile_number: Optional[str] = None - email: Optional[str] = None - opening_time: Optional[str] = None - closing_time: Optional[str] = None - avg_expense_for_two: Optional[float] = None - qr_url: Optional[str] = None - - -class FoodCourtDelete(SQLModel): - id: int - - -# --------- PickupLocation Schemas --------- -class PickupLocationCreate(SQLModel): - venue_id: int - name: str - description: Optional[str] = None - - -class PickupLocationRead(SQLModel): - id: int - venue_id: int - name: str - description: Optional[str] = None - - -class PickupLocationUpdate(SQLModel): - venue_id: Optional[int] = None - name: Optional[str] = None - description: Optional[str] = None - - -class PickupLocationDelete(SQLModel): - id: int \ No newline at end of file diff --git a/backend/app/schema/__init__.py b/backend/app/schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/schema/menu.py b/backend/app/schema/menu.py new file mode 100644 index 0000000000..1c9de2e3cf --- /dev/null +++ b/backend/app/schema/menu.py @@ -0,0 +1,51 @@ +from typing import List, Optional +from app.models.menu import NightclubMenuBase, QSRMenuBase, RestaurantMenuBase +from app.models.menu_item import MenuItemBase +from app.models.menu_category import MenuCategoryBase + +class QSRMenuRead(QSRMenuBase): + id: Optional[int] + class Config: + from_attributes = True + +class QSRMenuCreate(QSRMenuBase): + class Config: + from_attributes = True + +class RestaurantMenuRead(RestaurantMenuBase): + id: Optional[int] + class Config: + from_attributes = True + +class RestaurantMenuCreate(RestaurantMenuBase): + class Config: + from_attributes = True + +class MenuItemRead(MenuItemBase): + id: Optional[int] + class Config: + from_attributes = True + +class MenuCategoryRead(MenuCategoryBase): + id: Optional[int] + menu_items: List[MenuItemRead] = [] + class Config: + from_attributes = True + +class NightclubMenuRead(NightclubMenuBase): + id: Optional[int] + categories: List[MenuCategoryRead] = [] + class Config: + from_attributes = True + +class MenuItemCreate(MenuItemBase): + class Config: + from_attributes = True + +class MenuCategoryCreate(MenuCategoryBase): + class Config: + from_attributes = True + +class NightclubMenuCreate(NightclubMenuBase): + class Config: + from_attributes = True diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py new file mode 100644 index 0000000000..f413a8bc04 --- /dev/null +++ b/backend/app/schema/venue.py @@ -0,0 +1,39 @@ +from typing import Optional +from app.models.venue import FoodcourtBase, NightclubBase, QSRBase, RestaurantBase + +class RestaurantRead(RestaurantBase): + id: Optional[int] + class Config: + from_attributes = True + +class RestaurantCreate(RestaurantBase): + class Config: + from_attributes = True + +class NightclubRead(NightclubBase): + id: Optional[int] + class Config: + from_attributes = True + +class NightclubCreate(NightclubBase): + class Config: + from_attributes = True + +class QSRRead(QSRBase): + id: Optional[int] + class Config: + from_attributes = True + +class QSRCreate(QSRBase): + class Config: + from_attributes = True + + +class FoodcourtRead(FoodcourtBase): + id: Optional[int] + class Config: + from_attributes = True + +class FoodcourtCreate(FoodcourtBase): + class Config: + from_attributes = True \ No newline at end of file From 29baef2ac213372a60d7352617643ec30708f7ee Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Sun, 29 Sep 2024 12:58:40 +0530 Subject: [PATCH 03/25] Removed frontend, added auth --- .env | 8 +- .github/workflows/generate-client.yml | 49 - .github/workflows/playwright.yml | 67 - .vscode/launch.json | 9 +- README.md | 4 - backend/.gitignore | 3 +- ...l.py => 1100c57e615e_initial_migration.py} | 189 +- .../50ffb0a7eef5_initial_migration.py | 35 + .../app/alembic/versions/a392ecf44925_c.py | 31 + backend/app/api/deps.py | 151 +- backend/app/api/main.py | 6 +- backend/app/api/routes/login.py | 252 +- backend/app/api/routes/menu.py | 49 +- backend/app/api/routes/users.py | 36 +- backend/app/api/routes/venues.py | 67 +- backend/app/backend_pre_start.py | 1 - backend/app/core/config.py | 12 +- backend/app/core/security.py | 70 +- backend/app/crud.py | 9 +- backend/app/models/auth.py | 64 + backend/app/models/club_visit.py | 9 +- backend/app/models/event.py | 5 +- backend/app/models/event_booking.py | 7 +- backend/app/models/event_offering.py | 7 +- backend/app/models/group.py | 19 +- backend/app/models/group_wallet.py | 5 +- backend/app/models/group_wallet_topup.py | 5 +- backend/app/models/menu.py | 13 +- backend/app/models/menu_category.py | 13 +- backend/app/models/menu_item.py | 5 +- backend/app/models/order.py | 25 +- backend/app/models/order_item.py | 11 +- backend/app/models/payment.py | 15 +- backend/app/models/pickup_location.py | 5 +- backend/app/models/user.py | 34 +- backend/app/models/venue.py | 27 +- backend/app/schema/auth.py | 0 backend/app/schema/menu.py | 11 +- backend/app/schema/venue.py | 7 +- backend/poetry.lock | 612 +- backend/pyproject.toml | 10 +- copier.yml | 1 - deployment.md | 4 - development.md | 6 - docker-compose.override.yml | 8 - docker-compose.yml | 40 +- frontend/.dockerignore | 2 - frontend/.env | 1 - frontend/.gitignore | 29 - frontend/.nvmrc | 1 - frontend/Dockerfile | 23 - frontend/README.md | 160 - frontend/biome.json | 36 - frontend/index.html | 14 - frontend/modify-openapi-operationids.js | 36 - frontend/nginx-backend-not-found.conf | 9 - frontend/nginx.conf | 11 - frontend/package-lock.json | 6478 ----------------- frontend/package.json | 44 - frontend/playwright.config.ts | 92 - .../public/assets/images/fastapi-logo.svg | 51 - frontend/public/assets/images/favicon.png | Bin 5043 -> 0 bytes frontend/src/client/core/ApiError.ts | 25 - frontend/src/client/core/ApiRequestOptions.ts | 20 - frontend/src/client/core/ApiResult.ts | 7 - frontend/src/client/core/CancelablePromise.ts | 126 - frontend/src/client/core/OpenAPI.ts | 57 - frontend/src/client/core/request.ts | 376 - frontend/src/client/core/types.ts | 14 - frontend/src/client/index.ts | 8 - frontend/src/client/models.ts | 99 - frontend/src/client/schemas.ts | 444 -- frontend/src/client/services.ts | 517 -- frontend/src/components/Admin/AddUser.tsx | 182 - frontend/src/components/Admin/EditUser.tsx | 180 - .../src/components/Common/ActionsMenu.tsx | 75 - .../src/components/Common/DeleteAlert.tsx | 113 - frontend/src/components/Common/Navbar.tsx | 39 - frontend/src/components/Common/NotFound.tsx | 41 - frontend/src/components/Common/Sidebar.tsx | 116 - .../src/components/Common/SidebarItems.tsx | 56 - frontend/src/components/Common/UserMenu.tsx | 59 - frontend/src/components/Items/AddItem.tsx | 114 - frontend/src/components/Items/EditItem.tsx | 124 - .../components/UserSettings/Appearance.tsx | 38 - .../UserSettings/ChangePassword.tsx | 122 - .../components/UserSettings/DeleteAccount.tsx | 35 - .../UserSettings/DeleteConfirmation.tsx | 96 - .../UserSettings/UserInformation.tsx | 157 - frontend/src/hooks/useAuth.ts | 101 - frontend/src/hooks/useCustomToast.ts | 23 - frontend/src/main.tsx | 33 - frontend/src/routeTree.gen.ts | 129 - frontend/src/routes/__root.tsx | 34 - frontend/src/routes/_layout.tsx | 35 - frontend/src/routes/_layout/admin.tsx | 170 - frontend/src/routes/_layout/index.tsx | 25 - frontend/src/routes/_layout/items.tsx | 145 - frontend/src/routes/_layout/settings.tsx | 58 - frontend/src/routes/login.tsx | 144 - frontend/src/routes/recover-password.tsx | 104 - frontend/src/routes/reset-password.tsx | 122 - frontend/src/routes/signup.tsx | 164 - frontend/src/theme.tsx | 61 - frontend/src/utils.ts | 53 - frontend/src/vite-env.d.ts | 1 - frontend/tests/auth.setup.ts | 13 - frontend/tests/config.ts | 21 - frontend/tests/login.spec.ts | 117 - frontend/tests/reset-password.spec.ts | 121 - frontend/tests/sign-up.spec.ts | 169 - frontend/tests/user-settings.spec.ts | 288 - frontend/tests/utils/mailcatcher.ts | 59 - frontend/tests/utils/random.ts | 13 - frontend/tests/utils/user.ts | 38 - frontend/tsconfig.json | 25 - frontend/tsconfig.node.json | 10 - frontend/vite.config.ts | 8 - scripts/build-push.sh | 10 - scripts/build.sh | 10 - scripts/generate-client.sh | 8 - 121 files changed, 1321 insertions(+), 13234 deletions(-) delete mode 100644 .github/workflows/playwright.yml rename backend/app/alembic/versions/{36749727673e_add_column_last_name_to_user_model.py => 1100c57e615e_initial_migration.py} (81%) create mode 100644 backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py create mode 100644 backend/app/alembic/versions/a392ecf44925_c.py create mode 100644 backend/app/models/auth.py create mode 100644 backend/app/schema/auth.py delete mode 100644 frontend/.dockerignore delete mode 100644 frontend/.env delete mode 100644 frontend/.gitignore delete mode 100644 frontend/.nvmrc delete mode 100644 frontend/Dockerfile delete mode 100644 frontend/README.md delete mode 100644 frontend/biome.json delete mode 100644 frontend/index.html delete mode 100644 frontend/modify-openapi-operationids.js delete mode 100644 frontend/nginx-backend-not-found.conf delete mode 100644 frontend/nginx.conf delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/playwright.config.ts delete mode 100644 frontend/public/assets/images/fastapi-logo.svg delete mode 100644 frontend/public/assets/images/favicon.png delete mode 100644 frontend/src/client/core/ApiError.ts delete mode 100644 frontend/src/client/core/ApiRequestOptions.ts delete mode 100644 frontend/src/client/core/ApiResult.ts delete mode 100644 frontend/src/client/core/CancelablePromise.ts delete mode 100644 frontend/src/client/core/OpenAPI.ts delete mode 100644 frontend/src/client/core/request.ts delete mode 100644 frontend/src/client/core/types.ts delete mode 100644 frontend/src/client/index.ts delete mode 100644 frontend/src/client/models.ts delete mode 100644 frontend/src/client/schemas.ts delete mode 100644 frontend/src/client/services.ts delete mode 100644 frontend/src/components/Admin/AddUser.tsx delete mode 100644 frontend/src/components/Admin/EditUser.tsx delete mode 100644 frontend/src/components/Common/ActionsMenu.tsx delete mode 100644 frontend/src/components/Common/DeleteAlert.tsx delete mode 100644 frontend/src/components/Common/Navbar.tsx delete mode 100644 frontend/src/components/Common/NotFound.tsx delete mode 100644 frontend/src/components/Common/Sidebar.tsx delete mode 100644 frontend/src/components/Common/SidebarItems.tsx delete mode 100644 frontend/src/components/Common/UserMenu.tsx delete mode 100644 frontend/src/components/Items/AddItem.tsx delete mode 100644 frontend/src/components/Items/EditItem.tsx delete mode 100644 frontend/src/components/UserSettings/Appearance.tsx delete mode 100644 frontend/src/components/UserSettings/ChangePassword.tsx delete mode 100644 frontend/src/components/UserSettings/DeleteAccount.tsx delete mode 100644 frontend/src/components/UserSettings/DeleteConfirmation.tsx delete mode 100644 frontend/src/components/UserSettings/UserInformation.tsx delete mode 100644 frontend/src/hooks/useAuth.ts delete mode 100644 frontend/src/hooks/useCustomToast.ts delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/routeTree.gen.ts delete mode 100644 frontend/src/routes/__root.tsx delete mode 100644 frontend/src/routes/_layout.tsx delete mode 100644 frontend/src/routes/_layout/admin.tsx delete mode 100644 frontend/src/routes/_layout/index.tsx delete mode 100644 frontend/src/routes/_layout/items.tsx delete mode 100644 frontend/src/routes/_layout/settings.tsx delete mode 100644 frontend/src/routes/login.tsx delete mode 100644 frontend/src/routes/recover-password.tsx delete mode 100644 frontend/src/routes/reset-password.tsx delete mode 100644 frontend/src/routes/signup.tsx delete mode 100644 frontend/src/theme.tsx delete mode 100644 frontend/src/utils.ts delete mode 100644 frontend/src/vite-env.d.ts delete mode 100644 frontend/tests/auth.setup.ts delete mode 100644 frontend/tests/config.ts delete mode 100644 frontend/tests/login.spec.ts delete mode 100644 frontend/tests/reset-password.spec.ts delete mode 100644 frontend/tests/sign-up.spec.ts delete mode 100644 frontend/tests/user-settings.spec.ts delete mode 100644 frontend/tests/utils/mailcatcher.ts delete mode 100644 frontend/tests/utils/random.ts delete mode 100644 frontend/tests/utils/user.ts delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/tsconfig.node.json delete mode 100644 frontend/vite.config.ts delete mode 100644 scripts/build-push.sh delete mode 100644 scripts/build.sh delete mode 100644 scripts/generate-client.sh diff --git a/.env b/.env index eb3cb9c08d..5110b5b506 100644 --- a/.env +++ b/.env @@ -14,6 +14,9 @@ SECRET_KEY=PDma3zqc5pq9LZVfVj7qL5bB6mC4OCIN0sKdjSiKOlI FIRST_SUPERUSER=arpit.singh@example.com FIRST_SUPERUSER_PASSWORD=SOciaSOcia +# Otpless +CLIENT_ID = "CLIENT_ID" +CLIENT_SECRET = "CLIENT_SECRET" # Emails SMTP_HOST=arpit.singh@example.com SMTP_USER=arpit.singh@example.com @@ -28,10 +31,9 @@ POSTGRES_SERVER=aws-0-ap-south-1.pooler.supabase.com POSTGRES_PORT=6543 POSTGRES_USER=postgres.uiwsgdtnmovxahfgxfkj POSTGRES_PASSWORD=Aa1sociaaicos -POSTGRES_DB=postgres +POSTGRES_DB=sociadb SENTRY_DSN= # Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend +DOCKER_IMAGE_BACKEND=backend \ No newline at end of file diff --git a/.github/workflows/generate-client.yml b/.github/workflows/generate-client.yml index a81f78cb51..e69de29bb2 100644 --- a/.github/workflows/generate-client.yml +++ b/.github/workflows/generate-client.yml @@ -1,49 +0,0 @@ -name: Generate Client - -on: - pull_request: - types: - - opened - - synchronize - -jobs: - generate-client: - permissions: - contents: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - token: ${{ secrets.FULL_STACK_FASTAPI_TEMPLATE_REPO_TOKEN }} - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - name: Install dependencies - run: npm ci - working-directory: frontend - - run: pip install ./backend - - run: bash scripts/generate-client.sh - - name: Commit changes - run: | - git config --local user.email "github-actions@github.com" - git config --local user.name "github-actions" - git add frontend/src/client - git diff --staged --quiet || git commit -m "✨ Autogenerate frontend client" - git push - - # https://github.com/marketplace/actions/alls-green#why - generate-client-alls-green: # This job does nothing and is only used for the branch protection - if: always() - needs: - - generate-client - runs-on: ubuntu-latest - steps: - - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} - \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index fc208993f6..0000000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Playwright Tests - -on: - push: - branches: - - master - pull_request: - types: - - opened - - synchronize - workflow_dispatch: - inputs: - debug_enabled: - description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' - required: false - default: 'false' - -jobs: - - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - name: Setup tmate session - uses: mxschmitt/action-tmate@v3 - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} - with: - limit-access-to-actor: true - - name: Install dependencies - run: npm ci - working-directory: frontend - - name: Install Playwright Browsers - run: npx playwright install --with-deps - working-directory: frontend - - run: docker compose build - - run: docker compose down -v --remove-orphans - - run: docker compose up -d - - name: Run Playwright tests - run: npx playwright test - working-directory: frontend - - run: docker compose down -v --remove-orphans - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 30 - include-hidden-files: true - - # https://github.com/marketplace/actions/alls-green#why - e2e-alls-green: # This job does nothing and is only used for the branch protection - if: always() - needs: - - test - runs-on: ubuntu-latest - steps: - - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 24eae850d0..c1888bf5cd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,13 +16,6 @@ "cwd": "${workspaceFolder}/backend", "jinja": true, "envFile": "${workspaceFolder}/.env", - }, - { - "type": "chrome", - "request": "launch", - "name": "Debug Frontend: Launch Chrome against http://localhost:5173", - "url": "http://localhost:5173", - "webRoot": "${workspaceFolder}/frontend" - }, + } ] } diff --git a/README.md b/README.md index afe124f3fb..80188b0e26 100644 --- a/README.md +++ b/README.md @@ -216,10 +216,6 @@ The input variables, with their default values (some auto generated) are: Backend docs: [backend/README.md](./backend/README.md). -## Frontend Development - -Frontend docs: [frontend/README.md](./frontend/README.md). - ## Deployment Deployment docs: [deployment.md](./deployment.md). diff --git a/backend/.gitignore b/backend/.gitignore index 9340d4daf9..101092a3ec 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -5,5 +5,4 @@ app.egg-info .coverage htmlcov .cache -.venv -poetry.lock +.venv \ No newline at end of file diff --git a/backend/app/alembic/versions/36749727673e_add_column_last_name_to_user_model.py b/backend/app/alembic/versions/1100c57e615e_initial_migration.py similarity index 81% rename from backend/app/alembic/versions/36749727673e_add_column_last_name_to_user_model.py rename to backend/app/alembic/versions/1100c57e615e_initial_migration.py index b35c9ce2e3..b24dab34fb 100644 --- a/backend/app/alembic/versions/36749727673e_add_column_last_name_to_user_model.py +++ b/backend/app/alembic/versions/1100c57e615e_initial_migration.py @@ -1,8 +1,8 @@ -"""Add column last_name to user model +"""Initial migration -Revision ID: 36749727673e +Revision ID: 1100c57e615e Revises: -Create Date: 2024-09-22 11:44:15.716637 +Create Date: 2024-09-28 12:41:42.435197 """ from alembic import op @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. -revision = '36749727673e' +revision = '1100c57e615e' down_revision = None branch_labels = None depends_on = None @@ -36,7 +36,7 @@ def upgrade(): sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('avg_expense_for_two', sa.Float(), nullable=True), sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_foodcourt_id'), 'foodcourt', ['id'], unique=False) @@ -57,7 +57,7 @@ def upgrade(): sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('avg_expense_for_two', sa.Float(), nullable=True), sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_nightclub_id'), 'nightclub', ['id'], unique=False) @@ -78,17 +78,17 @@ def upgrade(): sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('avg_expense_for_two', sa.Float(), nullable=True), sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_restaurant_id'), 'restaurant', ['id'], unique=False) op.create_table('user_business', sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), - sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('is_superuser', sa.Boolean(), nullable=False), sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.Column('registration_date', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id') ) @@ -96,7 +96,12 @@ def upgrade(): op.create_index(op.f('ix_user_business_id'), 'user_business', ['id'], unique=False) op.create_index(op.f('ix_user_business_phone_number'), 'user_business', ['phone_number'], unique=True) op.create_table('user_public', - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), sa.Column('date_of_birth', sa.DateTime(), nullable=True), sa.Column('gender', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('registration_date', sa.DateTime(), nullable=False), @@ -104,10 +109,12 @@ def upgrade(): sa.Column('preferences', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.PrimaryKeyConstraint('id') ) + op.create_index(op.f('ix_user_public_email'), 'user_public', ['email'], unique=True) op.create_index(op.f('ix_user_public_id'), 'user_public', ['id'], unique=False) + op.create_index(op.f('ix_user_public_phone_number'), 'user_public', ['phone_number'], unique=True) op.create_table('event', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=False), sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('start_time', sa.DateTime(), nullable=False), sa.Column('end_time', sa.DateTime(), nullable=False), @@ -119,17 +126,17 @@ def upgrade(): ) op.create_index(op.f('ix_event_id'), 'event', ['id'], unique=False) op.create_table('foodcourtuserbusinesslink', - sa.Column('foodcourt_id', sa.Integer(), nullable=False), - sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.Column('foodcourt_id', sa.Uuid(), nullable=False), + sa.Column('user_business_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), sa.PrimaryKeyConstraint('foodcourt_id', 'user_business_id') ) op.create_table('group', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('nightclub_id', sa.Integer(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('admin_user_id', sa.Integer(), nullable=False), + sa.Column('admin_user_id', sa.Uuid(), nullable=False), sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.ForeignKeyConstraint(['admin_user_id'], ['user_public.id'], ), sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), @@ -140,27 +147,27 @@ def upgrade(): sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('nightclub_id', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_nightclub_menu_id'), 'nightclub_menu', ['id'], unique=False) op.create_table('nightclubuserbusinesslink', - sa.Column('nightclub_id', sa.Integer(), nullable=False), - sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=False), + sa.Column('user_business_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), sa.PrimaryKeyConstraint('nightclub_id', 'user_business_id') ) op.create_table('payment_source_nightclub', - sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('gateway_transaction_id', sa.Uuid(), nullable=True), sa.Column('payment_time', sa.DateTime(), nullable=False), sa.Column('amount', sa.Float(), nullable=False), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.Column('retry_count', sa.Integer(), nullable=False), sa.Column('last_attempt_time', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), @@ -168,13 +175,13 @@ def upgrade(): ) op.create_index(op.f('ix_payment_source_nightclub_id'), 'payment_source_nightclub', ['id'], unique=False) op.create_table('payment_source_qsr', - sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('gateway_transaction_id', sa.Uuid(), nullable=True), sa.Column('payment_time', sa.DateTime(), nullable=False), sa.Column('amount', sa.Float(), nullable=False), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.Column('retry_count', sa.Integer(), nullable=False), sa.Column('last_attempt_time', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), @@ -182,13 +189,13 @@ def upgrade(): ) op.create_index(op.f('ix_payment_source_qsr_id'), 'payment_source_qsr', ['id'], unique=False) op.create_table('payment_source_restaurant', - sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('gateway_transaction_id', sa.Uuid(), nullable=True), sa.Column('payment_time', sa.DateTime(), nullable=False), sa.Column('amount', sa.Float(), nullable=False), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.Column('retry_count', sa.Integer(), nullable=False), sa.Column('last_attempt_time', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), @@ -196,8 +203,8 @@ def upgrade(): ) op.create_index(op.f('ix_payment_source_restaurant_id'), 'payment_source_restaurant', ['id'], unique=False) op.create_table('pickup_location', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), @@ -221,8 +228,8 @@ def upgrade(): sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('avg_expense_for_two', sa.Float(), nullable=True), sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('foodcourt_id', sa.Integer(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('foodcourt_id', sa.Uuid(), nullable=True), sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), sa.PrimaryKeyConstraint('id') ) @@ -231,24 +238,24 @@ def upgrade(): sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('restaurant_id', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('restaurant_id', sa.Uuid(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_restaurant_menu_id'), 'restaurant_menu', ['id'], unique=False) op.create_table('restaurantuserbusinesslink', - sa.Column('restaurant_id', sa.Integer(), nullable=False), - sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.Column('restaurant_id', sa.Uuid(), nullable=False), + sa.Column('user_business_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), sa.PrimaryKeyConstraint('restaurant_id', 'user_business_id') ) op.create_table('clubvisit', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('group_id', sa.Integer(), nullable=True), - sa.Column('nightclub_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('group_id', sa.Uuid(), nullable=True), + sa.Column('nightclub_id', sa.Uuid(), nullable=False), sa.Column('entry_time', sa.DateTime(), nullable=False), sa.Column('exit_time', sa.DateTime(), nullable=True), sa.Column('cover_charge', sa.Float(), nullable=True), @@ -259,9 +266,9 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_table('event_booking', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('event_id', sa.Uuid(), nullable=False), sa.Column('booking_time', sa.DateTime(), nullable=False), sa.Column('total_amount', sa.Float(), nullable=False), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), @@ -270,8 +277,8 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_table('group_wallet', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('group_id', sa.Uuid(), nullable=False), sa.Column('balance', sa.Float(), nullable=False), sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), sa.PrimaryKeyConstraint('id'), @@ -279,14 +286,14 @@ def upgrade(): ) op.create_index(op.f('ix_group_wallet_id'), 'group_wallet', ['id'], unique=False) op.create_table('groupmembers', - sa.Column('group_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), sa.PrimaryKeyConstraint('group_id', 'user_id') ) op.create_table('nightclub_order', - sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('order_time', sa.DateTime(), nullable=False), sa.Column('total_amount', sa.Float(), nullable=False), @@ -294,10 +301,10 @@ def upgrade(): sa.Column('cover_charge_used', sa.Float(), nullable=True), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('venue_id', sa.Integer(), nullable=False), - sa.Column('payment_id', sa.Integer(), nullable=False), - sa.Column('pickup_location_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=True), + sa.Column('payment_id', sa.Uuid(), nullable=True), + sa.Column('pickup_location_id', sa.Uuid(), nullable=True), sa.ForeignKeyConstraint(['payment_id'], ['payment_source_nightclub.id'], ), sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), @@ -309,15 +316,15 @@ def upgrade(): sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('qsr_id', sa.Integer(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('qsr_id', sa.Uuid(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_qsr_menu_id'), 'qsr_menu', ['id'], unique=False) op.create_table('qsr_order', - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('pickup_location_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('pickup_location_id', sa.Uuid(), nullable=True), sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('order_time', sa.DateTime(), nullable=False), sa.Column('total_amount', sa.Float(), nullable=False), @@ -325,9 +332,9 @@ def upgrade(): sa.Column('cover_charge_used', sa.Float(), nullable=True), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('venue_id', sa.Integer(), nullable=False), - sa.Column('payment_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=False), + sa.Column('payment_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['payment_id'], ['payment_source_qsr.id'], ), sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), @@ -336,15 +343,15 @@ def upgrade(): ) op.create_index(op.f('ix_qsr_order_id'), 'qsr_order', ['id'], unique=False) op.create_table('qsruserbusinesslink', - sa.Column('qsr_id', sa.Integer(), nullable=False), - sa.Column('user_business_id', sa.Integer(), nullable=False), + sa.Column('qsr_id', sa.Uuid(), nullable=False), + sa.Column('user_business_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), sa.PrimaryKeyConstraint('qsr_id', 'user_business_id') ) op.create_table('restaurant_order', - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('pickup_location_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('pickup_location_id', sa.Uuid(), nullable=True), sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('order_time', sa.DateTime(), nullable=False), sa.Column('total_amount', sa.Float(), nullable=False), @@ -352,9 +359,9 @@ def upgrade(): sa.Column('cover_charge_used', sa.Float(), nullable=True), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('venue_id', sa.Integer(), nullable=False), - sa.Column('payment_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=True), + sa.Column('payment_id', sa.Uuid(), nullable=True), sa.ForeignKeyConstraint(['payment_id'], ['payment_source_restaurant.id'], ), sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), @@ -363,9 +370,9 @@ def upgrade(): ) op.create_index(op.f('ix_restaurant_order_id'), 'restaurant_order', ['id'], unique=False) op.create_table('event_offering', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('event_id', sa.Integer(), nullable=False), - sa.Column('event_booking_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('event_id', sa.Uuid(), nullable=False), + sa.Column('event_booking_id', sa.Uuid(), nullable=False), sa.Column('offering_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('price', sa.Float(), nullable=False), @@ -379,15 +386,15 @@ def upgrade(): ) op.create_index(op.f('ix_event_offering_id'), 'event_offering', ['id'], unique=False) op.create_table('group_nightclub_order_link', - sa.Column('group_id', sa.Integer(), nullable=False), - sa.Column('nightclub_order_id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Uuid(), nullable=False), + sa.Column('nightclub_order_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), sa.PrimaryKeyConstraint('group_id', 'nightclub_order_id') ) op.create_table('groupwallettopup', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('group_wallet_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('group_wallet_id', sa.Uuid(), nullable=False), sa.Column('amount', sa.Float(), nullable=False), sa.Column('topup_time', sa.DateTime(), nullable=False), sa.ForeignKeyConstraint(['group_wallet_id'], ['group_wallet.id'], ), @@ -395,11 +402,11 @@ def upgrade(): ) op.create_index(op.f('ix_groupwallettopup_id'), 'groupwallettopup', ['id'], unique=False) op.create_table('menu_category', - sa.Column('qsr_menu_id', sa.Integer(), nullable=True), - sa.Column('restaurant_menu_id', sa.Integer(), nullable=True), - sa.Column('nightclub_menu_id', sa.Integer(), nullable=True), + sa.Column('qsr_menu_id', sa.Uuid(), nullable=True), + sa.Column('restaurant_menu_id', sa.Uuid(), nullable=True), + sa.Column('nightclub_menu_id', sa.Uuid(), nullable=True), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['nightclub_menu_id'], ['nightclub_menu.id'], ), sa.ForeignKeyConstraint(['qsr_menu_id'], ['qsr_menu.id'], ), sa.ForeignKeyConstraint(['restaurant_menu_id'], ['restaurant_menu.id'], ), @@ -407,21 +414,21 @@ def upgrade(): ) op.create_index(op.f('ix_menu_category_id'), 'menu_category', ['id'], unique=False) op.create_table('payment_event', - sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('gateway_transaction_id', sa.Integer(), nullable=True), + sa.Column('gateway_transaction_id', sa.Uuid(), nullable=True), sa.Column('payment_time', sa.DateTime(), nullable=False), sa.Column('amount', sa.Float(), nullable=False), sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('event_booking_id', sa.Integer(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('event_booking_id', sa.Uuid(), nullable=True), sa.ForeignKeyConstraint(['event_booking_id'], ['event_booking.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_payment_event_id'), 'payment_event', ['id'], unique=False) op.create_table('menu_item', - sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('price', sa.Float(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), @@ -430,17 +437,17 @@ def upgrade(): sa.Column('ingredients', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('abv', sa.Float(), nullable=True), sa.Column('ibu', sa.Integer(), nullable=True), - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_menu_item_id'), 'menu_item', ['id'], unique=False) op.create_table('orderitem', - sa.Column('order_item_id', sa.Integer(), nullable=False), - sa.Column('nightclub_order_id', sa.Integer(), nullable=True), - sa.Column('restaurant_order_id', sa.Integer(), nullable=True), - sa.Column('qsr_order_id', sa.Integer(), nullable=True), - sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('order_item_id', sa.Uuid(), nullable=False), + sa.Column('nightclub_order_id', sa.Uuid(), nullable=True), + sa.Column('restaurant_order_id', sa.Uuid(), nullable=True), + sa.Column('qsr_order_id', sa.Uuid(), nullable=True), + sa.Column('item_id', sa.Uuid(), nullable=False), sa.Column('quantity', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['item_id'], ['menu_item.id'], ), sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), @@ -502,7 +509,9 @@ def downgrade(): op.drop_table('foodcourtuserbusinesslink') op.drop_index(op.f('ix_event_id'), table_name='event') op.drop_table('event') + op.drop_index(op.f('ix_user_public_phone_number'), table_name='user_public') op.drop_index(op.f('ix_user_public_id'), table_name='user_public') + op.drop_index(op.f('ix_user_public_email'), table_name='user_public') op.drop_table('user_public') op.drop_index(op.f('ix_user_business_phone_number'), table_name='user_business') op.drop_index(op.f('ix_user_business_id'), table_name='user_business') diff --git a/backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py b/backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py new file mode 100644 index 0000000000..e5d215b7bb --- /dev/null +++ b/backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py @@ -0,0 +1,35 @@ +"""Initial migration + +Revision ID: 50ffb0a7eef5 +Revises: 1100c57e615e +Create Date: 2024-09-28 13:06:36.733410 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '50ffb0a7eef5' +down_revision = '1100c57e615e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('orderitem', sa.Column('id', sa.Uuid(), nullable=False)) + op.drop_index('ix_orderitem_order_item_id', table_name='orderitem') + op.create_index(op.f('ix_orderitem_id'), 'orderitem', ['id'], unique=False) + op.drop_column('orderitem', 'order_item_id') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('orderitem', sa.Column('order_item_id', sa.UUID(), autoincrement=False, nullable=False)) + op.drop_index(op.f('ix_orderitem_id'), table_name='orderitem') + op.create_index('ix_orderitem_order_item_id', 'orderitem', ['order_item_id'], unique=False) + op.drop_column('orderitem', 'id') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/a392ecf44925_c.py b/backend/app/alembic/versions/a392ecf44925_c.py new file mode 100644 index 0000000000..e38427df1f --- /dev/null +++ b/backend/app/alembic/versions/a392ecf44925_c.py @@ -0,0 +1,31 @@ +"""c + +Revision ID: a392ecf44925 +Revises: 50ffb0a7eef5 +Create Date: 2024-09-28 18:47:47.766797 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'a392ecf44925' +down_revision = '50ffb0a7eef5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_business', sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('user_public', sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_public', 'refresh_token') + op.drop_column('user_business', 'refresh_token') + # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 55b219e5dd..fbffad4295 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,22 +1,15 @@ -from collections.abc import Generator -from typing import Annotated - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from jwt.exceptions import InvalidTokenError -from pydantic import ValidationError -from sqlmodel import Session - -from app.core import security +from app.models.auth import TokenBlacklist +from fastapi import Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer +from app.models.user import UserPublic, UserBusiness +from app.core.security import get_jwt_payload +from typing import Annotated, Generator, Optional, Union from app.core.config import settings from app.core.db import engine -# from app.models import User - -reusable_oauth2 = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/login/access-token" -) +# OAuth2PasswordBearer to extract the token from the request header +bearer_scheme = HTTPBearer() def get_db() -> Generator[Session, None, None]: with Session(engine) as session: @@ -24,34 +17,110 @@ def get_db() -> Generator[Session, None, None]: SessionDep = Annotated[Session, Depends(get_db)] -TokenDep = Annotated[str, Depends(reusable_oauth2)] - - -# def get_current_user(session: SessionDep, token: TokenDep) -> User: -# try: -# payload = jwt.decode( -# token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] -# ) -# token_data = TokenPayload(**payload) -# except (InvalidTokenError, ValidationError): -# raise HTTPException( -# status_code=status.HTTP_403_FORBIDDEN, -# detail="Could not validate credentials", -# ) -# user = session.get(User, token_data.sub) + +# # Check if the token is blacklisted (optional) +# def is_token_blacklisted(session: Session, user_id: str, provided_token: str) -> bool: +# """ +# Checks if the provided token is blacklisted by comparing it with the one stored in the user record. + +# Args: +# session (Session): SQLAlchemy session to query the database. +# user_id (str): The ID of the user. +# provided_token (str): The provided refresh token to check. + +# Returns: +# bool: True if the token is blacklisted (invalid), False otherwise. +# """ +# # Fetch the user from the database using the user_id +# user = session.query(UserPublic).filter(UserPublic.id == user_id).first() + # if not user: # raise HTTPException(status_code=404, detail="User not found") -# if not user.is_active: -# raise HTTPException(status_code=400, detail="Inactive user") -# return user +# # Check if the provided token matches the stored refresh token +# if user.refresh_token != provided_token: +# # The token does not match, consider it invalid or blacklisted +# return True + +# # If the token matches, it's not blacklisted +# return False + +# Dependency to get the current user +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + session: SessionDep +) -> Union[UserPublic, UserBusiness]: + try: + # Verify and decode the token (ensure this is as fast as possible) + token_data = get_jwt_payload(credentials.credentials) + user_id = token_data.sub + + # Query both user types in a single call, using a union if possible + user = ( + session.query(UserPublic) + .filter(UserPublic.id == user_id) + .first() + ) or ( + session.query(UserBusiness) + .filter(UserBusiness.id == user_id) + .first() + ) + + # If no user is found or the user is inactive, raise an error + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user", + ) + + return user -# CurrentUser = Annotated[User, Depends(get_current_user)] + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + +# Dependency to get the business user +async def get_business_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + session: Session = Depends(SessionDep) +) -> UserBusiness: + current_user = await get_current_user(credentials.credentials, session) + if not isinstance(current_user, UserBusiness): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a business user", + ) + return current_user +# Dependency to get the superuser +async def get_super_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + session: Session = Depends(SessionDep) +) -> UserPublic: + current_user = await get_current_user(credentials, session) + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a superuser", + ) + return current_user -# def get_current_active_superuser(current_user: CurrentUser) -> User: -# if not current_user.is_superuser: -# raise HTTPException( -# status_code=403, detail="The user doesn't have enough privileges" -# ) -# return current_user +# Dependency to get any public user +async def get_public_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + session: Session = Depends(SessionDep) +) -> UserPublic: + current_user = await get_current_user(credentials.credentials, session) + if not isinstance(current_user, UserPublic): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a public user", + ) + return current_user \ No newline at end of file diff --git a/backend/app/api/main.py b/backend/app/api/main.py index ab1354f889..9cf1e07260 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,8 +1,10 @@ from fastapi import APIRouter -from app.api.routes import venues -from app.api.routes import menu +from app.api.routes import venues, menu, users, login + api_router = APIRouter() api_router.include_router(venues.router, prefix="/venues", tags=["venues"]) api_router.include_router(menu.router, prefix="/menu", tags=["menu"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(login.router, prefix="/login", tags=["login"]) diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 1b352fd6e7..5fc9ae0fc7 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -1,124 +1,128 @@ -# from datetime import timedelta -# from typing import Annotated, Any - -# from fastapi import APIRouter, Depends, HTTPException -# from fastapi.responses import HTMLResponse -# from fastapi.security import OAuth2PasswordRequestForm - -# from app import crud -# from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser -# from app.core import security -# from app.core.config import settings -# from app.core.security import get_password_hash -# from app.models import Message, NewPassword, Token, UserPublic -# from app.utils import ( -# generate_password_reset_token, -# generate_reset_password_email, -# send_email, -# verify_password_reset_token, -# ) - -# router = APIRouter() - - -# @router.post("/login/access-token") -# def login_access_token( -# session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -# ) -> Token: -# """ -# OAuth2 compatible token login, get an access token for future requests -# """ -# user = crud.authenticate( -# session=session, email=form_data.username, password=form_data.password -# ) -# if not user: -# raise HTTPException(status_code=400, detail="Incorrect email or password") -# elif not user.is_active: -# raise HTTPException(status_code=400, detail="Inactive user") -# access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) -# return Token( -# access_token=security.create_access_token( -# user.id, expires_delta=access_token_expires -# ) -# ) - - -# @router.post("/login/test-token", response_model=UserPublic) -# def test_token(current_user: CurrentUser) -> Any: -# """ -# Test access token -# """ -# return current_user - - -# @router.post("/password-recovery/{email}") -# def recover_password(email: str, session: SessionDep) -> Message: -# """ -# Password Recovery -# """ -# user = crud.get_user_by_email(session=session, email=email) - -# if not user: -# raise HTTPException( -# status_code=404, -# detail="The user with this email does not exist in the system.", -# ) -# password_reset_token = generate_password_reset_token(email=email) -# email_data = generate_reset_password_email( -# email_to=user.email, email=email, token=password_reset_token -# ) -# send_email( -# email_to=user.email, -# subject=email_data.subject, -# html_content=email_data.html_content, -# ) -# return Message(message="Password recovery email sent") - - -# @router.post("/reset-password/") -# def reset_password(session: SessionDep, body: NewPassword) -> Message: -# """ -# Reset password -# """ -# email = verify_password_reset_token(token=body.token) -# if not email: -# raise HTTPException(status_code=400, detail="Invalid token") -# user = crud.get_user_by_email(session=session, email=email) -# if not user: -# raise HTTPException( -# status_code=404, -# detail="The user with this email does not exist in the system.", -# ) -# elif not user.is_active: -# raise HTTPException(status_code=400, detail="Inactive user") -# hashed_password = get_password_hash(password=body.new_password) -# user.hashed_password = hashed_password -# session.add(user) -# session.commit() -# return Message(message="Password updated successfully") - - -# @router.post( -# "/password-recovery-html-content/{email}", -# dependencies=[Depends(get_current_active_superuser)], -# response_class=HTMLResponse, -# ) -# def recover_password_html_content(email: str, session: SessionDep) -> Any: -# """ -# HTML Content for Password Recovery -# """ -# user = crud.get_user_by_email(session=session, email=email) - -# if not user: -# raise HTTPException( -# status_code=404, -# detail="The user with this username does not exist in the system.", -# ) -# password_reset_token = generate_password_reset_token(email=email) -# email_data = generate_reset_password_email( -# email_to=user.email, email=email, token=password_reset_token -# ) - -# return HTMLResponse( -# content=email_data.html_content, headers={"subject:": email_data.subject} -# ) +from typing import Annotated, Union +from app.models.auth import RefreshTokenPayload, UserAuthResponse +from fastapi import APIRouter, Depends, FastAPI, HTTPException +import OTPLessAuthSDK +from app.models.user import UserBusiness, UserPublic # Import your UserPublic model +from app.api.deps import SessionDep, get_current_user +from datetime import datetime, timedelta, timezone +from app.core.security import create_access_token, create_refresh_token, get_jwt_payload # Adjust the import based on your structure +from app.core.config import settings +from app.models.auth import OtplessToken +from fastapi.datastructures import QueryParams + +router = APIRouter() + + +@router.post("/verify_token", response_model=UserAuthResponse) +async def verify_token(request: OtplessToken, session: SessionDep): + try: + # Verify the token using OTPLess SDK + # Uncomment and implement token verification with OTPLess + # user_details = OTPLessAuthSDK.UserDetail.verify_token( + # request.otpless_token, + # settings.CLIENT_ID, + # settings.CLIENT_SECRET + # ) + + # Simulated user details for demonstration + phone_number = "8130181469" # Replace this with the actual phone number from the SDK response + + # Check for the user by phone number + user = session.query(UserPublic).filter(UserPublic.phone_number == phone_number).first() + + if not user: + # Create a new user if not found + user = UserPublic( + phone_number=phone_number, + is_active=True, + registration_date=datetime.now(timezone.utc), + ) + session.add(user) + session.commit() + session.refresh(user) + + # Create tokens + access_token = create_access_token(subject=str(user.id), expires_delta=timedelta(minutes=30)) + refresh_token = create_refresh_token(subject=str(user.id)) + + # Store the new refresh token in the user's record + user.refresh_token = refresh_token.token + session.add(user) # Add the updated user to the session + session.commit() # Commit the changes to the database + + return UserAuthResponse( + access_token=access_token, + refresh_token=refresh_token, + issued_at=datetime.now(timezone.utc) + ) + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/refresh_token", response_model=UserAuthResponse) +async def refresh_token(request: RefreshTokenPayload, session: SessionDep): + try: + # Decode and validate the refresh token payload + payload = get_jwt_payload(request.refresh_token) + + # Extract the user ID (sub) from the payload + user_id = payload.sub + + # Fetch the user from the database using the user ID + user = ( + session.query(UserPublic) + .filter(UserPublic.id == user_id) + .first() + ) or ( + session.query(UserBusiness) + .filter(UserBusiness.id == user_id) + .first() + ) + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Verify if the refresh token in the database matches the provided token + if user.refresh_token != request.refresh_token: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + # Check if the token has expired + current_time = datetime.now(timezone.utc) + if payload.exp and datetime.fromtimestamp(payload.exp, timezone.utc) < current_time: + raise HTTPException(status_code=401, detail="Refresh token expired") + + # If valid, generate new tokens + new_access_token = create_access_token(subject=user_id, expires_delta=timedelta(minutes=30)) + new_refresh_token = create_refresh_token(subject=user_id) + + # Update the user's refresh token in the database + user.refresh_token = new_refresh_token.token + session.add(user) + session.commit() + + # Return the new tokens and issue time + return UserAuthResponse( + access_token=new_access_token, + refresh_token=new_refresh_token, + issued_at=current_time + ) + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/logout") +async def logout( + session: SessionDep, + current_user: Annotated[Union[UserPublic, UserBusiness], Depends(get_current_user)]): + try: + # Invalidate the refresh token by setting it to None or an empty string + current_user.refresh_token = None + session.add(current_user) + session.commit() + + return {"message": "Logout successful, refresh token invalidated"} + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + \ No newline at end of file diff --git a/backend/app/api/routes/menu.py b/backend/app/api/routes/menu.py index 75fc2f0051..3be858f6c3 100644 --- a/backend/app/api/routes/menu.py +++ b/backend/app/api/routes/menu.py @@ -1,3 +1,4 @@ +import uuid from app.schema.menu import MenuCategoryRead, MenuItemRead, NightclubMenuCreate, NightclubMenuRead, MenuCategoryCreate, MenuItemCreate, QSRMenuCreate, QSRMenuRead, RestaurantMenuCreate, RestaurantMenuRead from app.models.menu import NightclubMenu, QSRMenu, RestaurantMenu from app.models.menu_category import MenuCategory @@ -18,7 +19,7 @@ # Get all menus for a nightclub @router.get("/nightclubs/{nightclub_id}/menus/", response_model=List[NightclubMenuRead]) -def read_nightclub_menus(nightclub_id: int, session: SessionDep): +async def read_nightclub_menus(nightclub_id: uuid.UUID, session: SessionDep): """ Retrieve all menus for a specific nightclub. """ @@ -27,7 +28,7 @@ def read_nightclub_menus(nightclub_id: int, session: SessionDep): # Get a specific menu @router.get("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) -def read_nightclub_menu(menu_id: int, session: SessionDep): +async def read_nightclub_menu(menu_id: uuid.UUID, session: SessionDep): """ Retrieve a specific menu by ID for a nightclub. """ @@ -35,7 +36,7 @@ def read_nightclub_menu(menu_id: int, session: SessionDep): # Create a new menu @router.post("/nightclubs/menus/", response_model=NightclubMenuRead) -def create_nightclub_menu( menu: NightclubMenuCreate, session: SessionDep): +async def create_nightclub_menu( menu: NightclubMenuCreate, session: SessionDep): """ Create a new menu for a nightclub. """ @@ -43,7 +44,7 @@ def create_nightclub_menu( menu: NightclubMenuCreate, session: SessionDep): # Update a menu @router.put("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) -def update_nightclub_menu(menu_id: int, updated_menu: NightclubMenuCreate, session: SessionDep): +async def update_nightclub_menu(menu_id: uuid.UUID, updated_menu: NightclubMenuCreate, session: SessionDep): """ Update an existing menu for a nightclub. """ @@ -51,7 +52,7 @@ def update_nightclub_menu(menu_id: int, updated_menu: NightclubMenuCreate, sessi # PATCH a menu for partial updates @router.patch("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) -def patch_nightclub_menu(menu_id: int, updated_menu: NightclubMenuCreate, session: SessionDep): +async def patch_nightclub_menu(menu_id: uuid.UUID, updated_menu: NightclubMenuCreate, session: SessionDep): """ Partially update an existing menu for a venue (Nightclub, Restaurant, QSR). """ @@ -59,7 +60,7 @@ def patch_nightclub_menu(menu_id: int, updated_menu: NightclubMenuCreate, sessio # Delete a menu @router.delete("/nightclubs/menus/{menu_id}", response_model=None) -def delete_nightclub_menu(menu_id: int, session: SessionDep): +async def delete_nightclub_menu(menu_id: uuid.UUID, session: SessionDep): """ Delete a menu by ID for a nightclub. """ @@ -67,7 +68,7 @@ def delete_nightclub_menu(menu_id: int, session: SessionDep): # Get all menus for a qsr @router.get("/qsrs/{qsr_id}/menus/", response_model=List[QSRMenuRead]) -def read_qsr_menus(qsr_id: int, session: SessionDep): +async def read_qsr_menus(qsr_id: uuid.UUID, session: SessionDep): """ Retrieve all menus for a specific qsr. """ @@ -76,7 +77,7 @@ def read_qsr_menus(qsr_id: int, session: SessionDep): # Get a specific menu @router.get("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) -def read_qsr_menu(menu_id: int, session: SessionDep): +async def read_qsr_menu(menu_id: uuid.UUID, session: SessionDep): """ Retrieve a specific menu by ID for a qsr. """ @@ -84,7 +85,7 @@ def read_qsr_menu(menu_id: int, session: SessionDep): # Create a new menu @router.post("/qsrs/menus/", response_model=QSRMenuRead) -def create_qsr_menu( menu: QSRMenuCreate, session: SessionDep): +async def create_qsr_menu( menu: QSRMenuCreate, session: SessionDep): """ Create a new menu for a qsr. """ @@ -92,7 +93,7 @@ def create_qsr_menu( menu: QSRMenuCreate, session: SessionDep): # Update a menu @router.put("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) -def update_qsr_menu(menu_id: int, updated_menu: QSRMenuCreate, session: SessionDep): +async def update_qsr_menu(menu_id: uuid.UUID, updated_menu: QSRMenuCreate, session: SessionDep): """ Update an existing menu for a qsr. """ @@ -100,7 +101,7 @@ def update_qsr_menu(menu_id: int, updated_menu: QSRMenuCreate, session: SessionD # PATCH a menu for partial updates @router.patch("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) -def patch_qsr_menu(menu_id: int, updated_menu: QSRMenuCreate, session: SessionDep): +async def patch_qsr_menu(menu_id: uuid.UUID, updated_menu: QSRMenuCreate, session: SessionDep): """ Partially update an existing menu for a venue (QSR, Restaurant, QSR). """ @@ -108,7 +109,7 @@ def patch_qsr_menu(menu_id: int, updated_menu: QSRMenuCreate, session: SessionDe # Delete a menu @router.delete("/qsrs/menus/{menu_id}", response_model=None) -def delete_qsr_menu(menu_id: int, session: SessionDep): +async def delete_qsr_menu(menu_id: uuid.UUID, session: SessionDep): """ Delete a menu by ID for a qsr. """ @@ -116,7 +117,7 @@ def delete_qsr_menu(menu_id: int, session: SessionDep): # Get all menus for a restaurant @router.get("/restaurants/{restaurant_id}/menus/", response_model=List[RestaurantMenuRead]) -def read_restaurant_menus(restaurant_id: int, session: SessionDep): +async def read_restaurant_menus(restaurant_id: uuid.UUID, session: SessionDep): """ Retrieve all menus for a specific restaurant. """ @@ -125,7 +126,7 @@ def read_restaurant_menus(restaurant_id: int, session: SessionDep): # Get a specific menu @router.get("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) -def read_restaurant_menu(menu_id: int, session: SessionDep): +async def read_restaurant_menu(menu_id: uuid.UUID, session: SessionDep): """ Retrieve a specific menu by ID for a restaurant. """ @@ -133,7 +134,7 @@ def read_restaurant_menu(menu_id: int, session: SessionDep): # Create a new menu @router.post("/restaurants/menus/", response_model=RestaurantMenuRead) -def create_restaurant_menu( menu: RestaurantMenuCreate, session: SessionDep): +async def create_restaurant_menu( menu: RestaurantMenuCreate, session: SessionDep): """ Create a new menu for a restaurant. """ @@ -141,7 +142,7 @@ def create_restaurant_menu( menu: RestaurantMenuCreate, session: SessionDep): # Update a menu @router.put("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) -def update_restaurant_menu(menu_id: int, updated_menu: RestaurantMenuCreate, session: SessionDep): +async def update_restaurant_menu(menu_id: uuid.UUID, updated_menu: RestaurantMenuCreate, session: SessionDep): """ Update an existing menu for a restaurant. """ @@ -149,7 +150,7 @@ def update_restaurant_menu(menu_id: int, updated_menu: RestaurantMenuCreate, ses # PATCH a menu for partial updates @router.patch("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) -def patch_restaurant_menu(menu_id: int, updated_menu: RestaurantMenuCreate, session: SessionDep): +async def patch_restaurant_menu(menu_id: uuid.UUID, updated_menu: RestaurantMenuCreate, session: SessionDep): """ Partially update an existing menu for a venue (Restaurant, Restaurant, QSR). """ @@ -157,7 +158,7 @@ def patch_restaurant_menu(menu_id: int, updated_menu: RestaurantMenuCreate, sess # Delete a menu @router.delete("/restaurants/menus/{menu_id}", response_model=None) -def delete_restaurant_menu(menu_id: int, session: SessionDep): +async def delete_restaurant_menu(menu_id: uuid.UUID, session: SessionDep): """ Delete a menu by ID for a restaurant. """ @@ -167,12 +168,12 @@ def delete_restaurant_menu(menu_id: int, session: SessionDep): # Create a new category @router.post("/nightclubs/menus/categories/", response_model=MenuCategoryRead) -def create_menu_category(category: MenuCategoryCreate, session: SessionDep): +async def create_menu_category(category: MenuCategoryCreate, session: SessionDep): return create_record(session, MenuCategory, category) # Update a category @router.put("/nightclubs/menus/categories/{category_id}", response_model=MenuCategoryRead) -def update_menu_category(category_id: int, updated_category: MenuCategoryCreate, session: SessionDep): +async def update_menu_category(category_id: uuid.UUID, updated_category: MenuCategoryCreate, session: SessionDep): """ Update an existing category for a specific menu. """ @@ -180,7 +181,7 @@ def update_menu_category(category_id: int, updated_category: MenuCategoryCreate, # Delete a category @router.delete("/nightclubs/menus/categories/{category_id}", response_model=None) -def delete_menu_category(category_id: int, session: SessionDep): +async def delete_menu_category(category_id: uuid.UUID, session: SessionDep): """ Delete a category by ID from a specific menu. """ @@ -190,12 +191,12 @@ def delete_menu_category(category_id: int, session: SessionDep): # Create a new item @router.post("/nightclubs/menus/categories/items/", response_model=MenuItemRead) -def create_menu_item(item: MenuItemCreate, session: SessionDep): +async def create_menu_item(item: MenuItemCreate, session: SessionDep): return create_record(session, MenuItem, item) # Update an item @router.put("/nightclubs/menus/categories/items/{item_id}", response_model=MenuItemRead) -def update_menu_item(item_id: int, updated_item: MenuItemCreate, session: SessionDep): +async def update_menu_item(item_id: uuid.UUID, updated_item: MenuItemCreate, session: SessionDep): """ Update an existing item under a specific category of a menu. """ @@ -203,7 +204,7 @@ def update_menu_item(item_id: int, updated_item: MenuItemCreate, session: Sessio # Delete an item @router.delete("/nightclubs/menus/categories/items/{item_id}", response_model=None) -def delete_menu_item(item_id: int, session: SessionDep): +async def delete_menu_item(item_id: uuid.UUID, session: SessionDep): """ Delete an item by ID from a specific category of a menu. """ diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index dbd3d12b3f..a8118434f9 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,14 +1,14 @@ from typing import List +import uuid from fastapi import APIRouter, Query, HTTPException, Depends -from sqlmodel import Session from app.api.deps import SessionDep from app.models import UserBusiness, UserPublic -from app.crud import get_all_records, get_record_by_id, create_record, update_record, delete_record +from app.crud import get_all_records, get_record_by_id, create_record, update_record, delete_record, patch_record router = APIRouter() @router.get("/user-businesses/", response_model=List[UserBusiness]) -def read_user_businesses( +async def read_user_businesses( session: SessionDep, skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100) @@ -21,8 +21,8 @@ def read_user_businesses( return get_all_records(session, UserBusiness, skip=skip, limit=limit) @router.get("/user-businesses/{user_business_id}", response_model=UserBusiness) -def read_user_business( - user_business_id: int, +async def read_user_business( + user_business_id: uuid.UUID, session: SessionDep ): """ @@ -32,7 +32,7 @@ def read_user_business( return get_record_by_id(session, UserBusiness, user_business_id) @router.post("/user-businesses/", response_model=UserBusiness) -def create_user_business( +async def create_user_business( user_business: UserBusiness, session: SessionDep ): @@ -43,8 +43,8 @@ def create_user_business( return create_record(session, UserBusiness, user_business) @router.put("/user-businesses/{user_business_id}", response_model=UserBusiness) -def update_user_business( - user_business_id: int, +async def update_user_business( + user_business_id: uuid.UUID, user_business: UserBusiness, session: SessionDep ): @@ -56,8 +56,8 @@ def update_user_business( return update_record(session, UserBusiness, user_business_id, user_business) @router.delete("/user-businesses/{user_business_id}", response_model=UserBusiness) -def delete_user_business( - user_business_id: int, +async def delete_user_business( + user_business_id: uuid.UUID, session: SessionDep ): """ @@ -68,7 +68,7 @@ def delete_user_business( return {"message": f"UserBusiness with ID {user_business_id} has been deleted."} @router.get("/user-publics/", response_model=List[UserPublic]) -def read_user_publics( +async def read_user_publics( session: SessionDep, skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100) @@ -81,8 +81,8 @@ def read_user_publics( return get_all_records(session, UserPublic, skip=skip, limit=limit) @router.get("/user-publics/{user_public_id}", response_model=UserPublic) -def read_user_public( - user_public_id: int, +async def read_user_public( + user_public_id: uuid.UUID, session: SessionDep ): """ @@ -92,7 +92,7 @@ def read_user_public( return get_record_by_id(session, UserPublic, user_public_id) @router.post("/user-publics/", response_model=UserPublic) -def create_user_public( +async def create_user_public( user_public: UserPublic, session: SessionDep ): @@ -103,8 +103,8 @@ def create_user_public( return create_record(session, UserPublic, user_public) @router.put("/user-publics/{user_public_id}", response_model=UserPublic) -def update_user_public( - user_public_id: int, +async def update_user_public( + user_public_id: uuid.UUID, user_public: UserPublic, session: SessionDep ): @@ -116,8 +116,8 @@ def update_user_public( return update_record(session, UserPublic, user_public_id, user_public) @router.delete("/user-publics/{user_public_id}", response_model=UserPublic) -def delete_user_public( - user_public_id: int, +async def delete_user_public( + user_public_id: uuid.UUID, session: SessionDep ): """ diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py index ab5ef266db..1d4cc10d5a 100644 --- a/backend/app/api/routes/venues.py +++ b/backend/app/api/routes/venues.py @@ -1,9 +1,11 @@ +import uuid from app.schema.venue import FoodcourtCreate, FoodcourtRead, NightclubCreate, NightclubRead, QSRCreate, QSRRead, RestaurantCreate, RestaurantRead -from fastapi import APIRouter, HTTPException, Query -from typing import List +from app.models.user import UserBusiness, UserPublic +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Union from app.models.venue import Nightclub, Restaurant, QSR, Foodcourt -from app.api.deps import SessionDep +from app.api.deps import SessionDep, get_current_user from app.crud import ( get_all_records, get_record_by_id, @@ -17,10 +19,11 @@ # CRUD operations for Nightclubs @router.get("/nightclubs/", response_model=List[NightclubRead]) -def read_nightclubs( +async def read_nightclubs( session: SessionDep, skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100) + limit: int = Query(10, le=100), + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) ): """ Retrieve a paginated list of nightclubs. @@ -31,37 +34,37 @@ def read_nightclubs( return nightclubs @router.get("/nightclubs/{venue_id}", response_model=NightclubRead) -def read_nightclub(venue_id: int, session: SessionDep ): +async def read_nightclub(venue_id: uuid.UUID, session: SessionDep ): nightclub = get_record_by_id(session, Nightclub, venue_id) if not nightclub: raise HTTPException(status_code=404, detail="Nightclub not found") return nightclub @router.post("/nightclubs/", response_model=NightclubRead) -def create_nightclub( +async def create_nightclub( nightclub: NightclubCreate, session: SessionDep ): return create_record(session, Nightclub, nightclub) @router.put("/nightclubs/{venue_id}", response_model=Nightclub) -def update_nightclub( - venue_id: int, +async def update_nightclub( + venue_id: uuid.UUID, updated_nightclub: NightclubCreate, session: SessionDep ): return update_record(session, Nightclub, venue_id, updated_nightclub) @router.delete("/nightclubs/{venue_id}", response_model=None) -def delete_nightclub( - venue_id: int, +async def delete_nightclub( + venue_id: uuid.UUID, session: SessionDep ): return delete_record(session, Nightclub, venue_id) @router.get("/restaurants/", response_model=List[RestaurantRead]) -def read_restaurants( +async def read_restaurants( session: SessionDep, skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100) @@ -75,37 +78,37 @@ def read_restaurants( return restaurants @router.get("/restaurants/{venue_id}", response_model=RestaurantRead) -def read_restaurant(venue_id: int, session: SessionDep ): +async def read_restaurant(venue_id: uuid.UUID, session: SessionDep ): restaurant = get_record_by_id(session, Restaurant, venue_id) if not restaurant: raise HTTPException(status_code=404, detail="restaurant not found") return restaurant @router.post("/restaurants/", response_model=RestaurantRead) -def create_restaurant( +async def create_restaurant( restaurant: RestaurantCreate, session: SessionDep ): return create_record(session, Restaurant, restaurant) @router.put("/restaurants/{venue_id}", response_model=Restaurant) -def update_restaurant( - venue_id: int, +async def update_restaurant( + venue_id: uuid.UUID, updated_restaurant: RestaurantCreate, session: SessionDep ): return update_record(session, Restaurant, venue_id, updated_restaurant) @router.delete("/restaurants/{venue_id}", response_model=None) -def delete_restaurant( - venue_id: int, +async def delete_restaurant( + venue_id: uuid.UUID, session: SessionDep ): return delete_record(session, Restaurant, venue_id) @router.get("/qsrs/", response_model=List[QSRRead]) -def read_qsrs( +async def read_qsrs( session: SessionDep, skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100) @@ -119,37 +122,37 @@ def read_qsrs( return qsrs @router.get("/qsrs/{venue_id}", response_model=QSRRead) -def read_qsr(venue_id: int, session: SessionDep ): +async def read_qsr(venue_id: uuid.UUID, session: SessionDep ): qsr = get_record_by_id(session, QSR, venue_id) if not qsr: raise HTTPException(status_code=404, detail="QSR not found") return qsr @router.post("/qsrs/", response_model=QSRRead) -def create_qsr( +async def create_qsr( qsr: QSRCreate, session: SessionDep ): return create_record(session, QSR, qsr) @router.put("/qsrs/{venue_id}", response_model=QSR) -def update_qsr( - venue_id: int, +async def update_qsr( + venue_id: uuid.UUID, updated_qsr: QSRCreate, session: SessionDep ): return update_record(session, QSR, venue_id, updated_qsr) @router.delete("/qsrs/{venue_id}", response_model=None) -def delete_qsr( - venue_id: int, +async def delete_qsr( + venue_id: uuid.UUID, session: SessionDep ): return delete_record(session, QSR, venue_id) @router.get("/foodcourts/", response_model=List[FoodcourtRead]) -def read_foodcourts( +async def read_foodcourts( session: SessionDep, skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100) @@ -163,30 +166,30 @@ def read_foodcourts( return foodcourts @router.get("/foodcourts/{venue_id}", response_model=FoodcourtRead) -def read_foodcourt(venue_id: int, session: SessionDep ): +async def read_foodcourt(venue_id: uuid.UUID, session: SessionDep ): foodcourt = get_record_by_id(session, Foodcourt, venue_id) if not foodcourt: raise HTTPException(status_code=404, detail="foodcourt not found") return foodcourt @router.post("/foodcourts/", response_model=FoodcourtRead) -def create_foodcourt( +async def create_foodcourt( foodcourt: FoodcourtCreate, session: SessionDep ): return create_record(session, Foodcourt, foodcourt) @router.put("/foodcourts/{venue_id}", response_model=Foodcourt) -def update_foodcourt( - venue_id: int, +async def update_foodcourt( + venue_id: uuid.UUID, updated_foodcourt: FoodcourtCreate, session: SessionDep ): return update_record(session, Foodcourt, venue_id, updated_foodcourt) @router.delete("/foodcourts/{venue_id}", response_model=None) -def delete_foodcourt( - venue_id: int, +async def delete_foodcourt( + venue_id: uuid.UUID, session: SessionDep ): diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py index c2f8e29ae1..9b9a3c3b81 100644 --- a/backend/app/backend_pre_start.py +++ b/backend/app/backend_pre_start.py @@ -28,7 +28,6 @@ def init(db_engine: Engine) -> None: logger.error(e) raise e - def main() -> None: logger.info("Initializing service") init(engine) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2fa45de608..4afe4e6d36 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,16 +1,13 @@ import secrets import warnings -from typing import Annotated, Any, Literal -import os +from typing import Annotated, Any, ClassVar, Literal from pydantic import ( AnyUrl, BeforeValidator, HttpUrl, - PostgresDsn, computed_field, model_validator, ) -from pydantic_core import MultiHostUrl from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import Self @@ -30,10 +27,15 @@ class Settings(BaseSettings): API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 DOMAIN: str = "localhost" ENVIRONMENT: Literal["local", "staging", "production"] = "local" + # Add CLIENT_ID and CLIENT_SECRET + CLIENT_ID: str + CLIENT_SECRET: str + REFRESH_TOKEN_EXPIRE_DAYS: ClassVar[int] = 365 # Use ClassVar if it's a constant + ALGORITHM: str = "HS256" @computed_field # type: ignore[prop-decorator] @property def server_host(self) -> str: diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 7aff7cfb32..8a08e1882b 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,27 +1,69 @@ from datetime import datetime, timedelta, timezone -from typing import Any - +from fastapi import HTTPException, status +from jwt.exceptions import InvalidTokenError # Import the correct exception +from app.core.config import settings +from app.models.auth import AccessToken, RefreshToken, TokenModel import jwt -from passlib.context import CryptContext +import uuid # For generating unique token ID -from app.core.config import settings +ALGORITHM = "HS256" -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +def get_jwt_payload(token: str) -> TokenModel: + try: + # Decode the token using the secret key and algorithm + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + # Create and return a TokenModel instance with the decoded payload + return TokenModel(sub=payload.get("sub"), exp=payload.get("exp")) -ALGORITHM = "HS256" + except jwt.ExpiredSignatureError: + # Handle expired token + raise HTTPException(status_code=401, detail="Token has expired") + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired" + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Token validation failed: {str(e)}" + ) +def create_access_token(subject: str, expires_delta: timedelta | None = None) -> AccessToken: + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) -def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: - expire = datetime.now(timezone.utc) + expires_delta - to_encode = {"exp": expire, "sub": str(subject)} + jti = str(uuid.uuid4()) + to_encode = {"exp": expire, "sub": str(subject), "jti": jti} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt + # Create an instance of AccessToken with the encoded JWT and expiration time + access_token = AccessToken( + token=encoded_jwt, + expires_at=expire, + token_type="Bearer" + ) + + return access_token -def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) +def create_refresh_token(subject: str) -> RefreshToken: + expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + jti = str(uuid.uuid4()) + to_encode = {"sub": str(subject), "exp": expire, "jti": jti} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + # Create an instance of RefreshToken with the encoded JWT and expiration time + refresh_token = RefreshToken( + token=encoded_jwt, + expires_at=expire + ) -def get_password_hash(password: str) -> str: - return pwd_context.hash(password) + return refresh_token \ No newline at end of file diff --git a/backend/app/crud.py b/backend/app/crud.py index 6ba4dfbeff..8870556a44 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,3 +1,4 @@ +import uuid from fastapi import HTTPException from sqlmodel import SQLModel, Session, select from typing import List, Type @@ -22,7 +23,7 @@ def get_all_records( # Function to get a single record by ID def get_record_by_id( - session: Session, model: Type[SQLModel], record_id: int + session: Session, model: Type[SQLModel], record_id: uuid.UUID ) -> SQLModel: """ Retrieve a single record by ID. @@ -61,7 +62,7 @@ def create_record( # Function to update an existing record def update_record( - session: Session, model: Type[SQLModel], record_id: int, obj_in: SQLModel + session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel ) -> SQLModel: """ Update an existing record. @@ -86,7 +87,7 @@ def update_record( raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") def patch_record( - session: Session, model: Type[SQLModel], record_id: int, obj_in: SQLModel + session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel ) -> SQLModel: """ Partially update an existing record. @@ -120,7 +121,7 @@ def patch_record( # Function to delete a record def delete_record( - session: Session, model: Type[SQLModel], record_id: int + session: Session, model: Type[SQLModel], record_id: uuid.UUID ) -> None: """ Delete a record by ID. diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000000..e86339e5a7 --- /dev/null +++ b/backend/app/models/auth.py @@ -0,0 +1,64 @@ +from sqlmodel import SQLModel, Field +from datetime import datetime, timezone +import uuid +from typing import Optional +from pydantic import EmailStr + +class TokenBlacklist(SQLModel, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + jti: str = Field(index=True, unique=True) # Store JWT ID (jti) + user_id: uuid.UUID = Field(nullable=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False) + +class OtplessPhoneAuthDetails(SQLModel): + mode: str + phone_number: str + country_code: str + auth_state: str + +class OtplessEmailAuthDetails(SQLModel): + email: EmailStr + mode: str + auth_state: str + +class AuthenticationDetails(SQLModel): + phone: OtplessPhoneAuthDetails + email: OtplessEmailAuthDetails + +class OtplessVerifyTokenResponse(SQLModel): + name: str + email: EmailStr + first_name: str + last_name: str + family_name: str + phone_number: str + national_phone_number: str + country_code: str + email_verified: bool + auth_time: str + authentication_details: AuthenticationDetails + +class OtplessToken(SQLModel): + otpless_token: str + +class TokenModel(SQLModel): + sub: str + exp: Optional[int] = None + +class RefreshTokenPayload(SQLModel): + refresh_token: str + +class AccessToken(SQLModel): + token: str + expires_at: datetime + token_type: str = "Bearer" + +class RefreshToken(SQLModel): + token: str + expires_at: datetime + +class UserAuthResponse(SQLModel): + access_token: AccessToken + refresh_token: RefreshToken + issued_at: datetime + \ No newline at end of file diff --git a/backend/app/models/club_visit.py b/backend/app/models/club_visit.py index 4ba5df98d5..4b8bd4949d 100644 --- a/backend/app/models/club_visit.py +++ b/backend/app/models/club_visit.py @@ -1,12 +1,13 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional from datetime import datetime class ClubVisit(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="user_public.id", nullable=False) - group_id: Optional[int] = Field(foreign_key="group.id", nullable=True) - nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: Optional[uuid.UUID] = Field(foreign_key="user_public.id", nullable=False) + group_id: Optional[uuid.UUID] = Field(foreign_key="group.id", nullable=True) + nightclub_id: Optional[uuid.UUID] = Field(foreign_key="nightclub.id", nullable=False) entry_time: datetime = Field(nullable=False) exit_time: Optional[datetime] = Field(nullable=True) cover_charge: Optional[float] = Field(nullable=True) diff --git a/backend/app/models/event.py b/backend/app/models/event.py index eb7af2e2c5..1a2923061e 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -1,10 +1,11 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List from datetime import datetime class Event(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True, index=True) - nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) title: str = Field(nullable=False) start_time: datetime = Field(nullable=False) end_time: datetime = Field(nullable=False) diff --git a/backend/app/models/event_booking.py b/backend/app/models/event_booking.py index 2b596fbe46..e28e1eb381 100644 --- a/backend/app/models/event_booking.py +++ b/backend/app/models/event_booking.py @@ -1,3 +1,4 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List from datetime import datetime @@ -5,9 +6,9 @@ class EventBooking(SQLModel, table=True): __tablename__ = "event_booking" - id: Optional[int] = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="user_public.id", nullable=False) - event_id: int = Field(foreign_key="event.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: Optional[uuid.UUID] = Field(foreign_key="user_public.id", nullable=False) + event_id: Optional[uuid.UUID] = Field(foreign_key="event.id", nullable=False) booking_time: datetime = Field(nullable=False) total_amount: float = Field(nullable=False) status: str = Field(nullable=False) diff --git a/backend/app/models/event_offering.py b/backend/app/models/event_offering.py index 251737db37..27db18d4be 100644 --- a/backend/app/models/event_offering.py +++ b/backend/app/models/event_offering.py @@ -1,3 +1,4 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional from typing import List @@ -5,9 +6,9 @@ # Stag, couple etc class EventOffering(SQLModel, table=True): __tablename__ = "event_offering" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - event_id: int = Field(foreign_key="event.id", nullable=False) - event_booking_id: int = Field(foreign_key="event_booking.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + event_id: uuid.UUID = Field(foreign_key="event.id", nullable=False) + event_booking_id: uuid.UUID = Field(foreign_key="event_booking.id", nullable=False) offering_type: str = Field(nullable=False) description: str = Field(nullable=False) price: float = Field(nullable=False) diff --git a/backend/app/models/group.py b/backend/app/models/group.py index 0f66b3c45b..3f90a1886b 100644 --- a/backend/app/models/group.py +++ b/backend/app/models/group.py @@ -1,24 +1,25 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List -from datetime import datetime +from datetime import datetime, timezone class GroupMembers(SQLModel, table=True): - group_id: int = Field(foreign_key="group.id", primary_key=True) - user_id: int = Field(foreign_key="user_public.id", primary_key=True) + group_id: uuid.UUID = Field(foreign_key="group.id", primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user_public.id", primary_key=True) class GroupNightclubOrderLink(SQLModel, table=True): __tablename__ = "group_nightclub_order_link" - group_id: Optional[int] = Field(default=None, foreign_key="group.id", primary_key=True) - nightclub_order_id: Optional[int] = Field(default=None, foreign_key="nightclub_order.id", primary_key=True) + group_id: uuid.UUID = Field(foreign_key="group.id", primary_key=True) + nightclub_order_id: uuid.UUID = Field( foreign_key="nightclub_order.id", primary_key=True) class Group(SQLModel, table=True): __tablename__ = "group" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - nightclub_id: Optional[int] = Field(default=None, foreign_key="nightclub.id") - created_at: datetime = Field(default=datetime.utcnow) - admin_user_id: int = Field(foreign_key="user_public.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + nightclub_id: Optional[uuid.UUID] = Field(foreign_key="nightclub.id") + created_at: datetime = Field(default=datetime.now(timezone.utc)) + admin_user_id: uuid.UUID = Field(foreign_key="user_public.id", nullable=False) table_number: Optional[str] = Field(default=None) # Relationships diff --git a/backend/app/models/group_wallet.py b/backend/app/models/group_wallet.py index 10d74c0c95..007418e3ba 100644 --- a/backend/app/models/group_wallet.py +++ b/backend/app/models/group_wallet.py @@ -1,11 +1,12 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List # (TODO) Added another model : Group wallet transactions class GroupWallet(SQLModel, table=True): __tablename__ = "group_wallet" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - group_id: int = Field(foreign_key="group.id", nullable=False, unique=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + group_id: uuid.UUID = Field(foreign_key="group.id", nullable=False, unique=True) balance: float = Field(default=0.0, nullable=False) # Relationships diff --git a/backend/app/models/group_wallet_topup.py b/backend/app/models/group_wallet_topup.py index f609c3374d..61f10c80d8 100644 --- a/backend/app/models/group_wallet_topup.py +++ b/backend/app/models/group_wallet_topup.py @@ -1,11 +1,12 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional from datetime import datetime class GroupWalletTopup(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True, index=True) - group_wallet_id: int = Field(foreign_key="group_wallet.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + group_wallet_id: uuid.UUID = Field(foreign_key="group_wallet.id", nullable=False) amount: float = Field(nullable=False) topup_time: datetime = Field(nullable=False) diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py index 1034635800..0aa46454c3 100644 --- a/backend/app/models/menu.py +++ b/backend/app/models/menu.py @@ -1,3 +1,4 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List @@ -7,35 +8,35 @@ class MenuBase(SQLModel): menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") class QSRMenuBase(MenuBase): - qsr_id: int = Field(foreign_key="qsr.id", nullable=False) + qsr_id: uuid.UUID = Field(foreign_key="qsr.id", nullable=False) class QSRMenu(QSRMenuBase, table=True): __tablename__= "qsr_menu" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships qsr: "QSR" = Relationship(back_populates="menu") categories: List["MenuCategory"] = Relationship(back_populates="qsr_menu") class RestaurantMenuBase(MenuBase): - restaurant_id: int = Field(foreign_key="restaurant.id", nullable=False) + restaurant_id: uuid.UUID = Field(foreign_key="restaurant.id", nullable=False) class RestaurantMenu(RestaurantMenuBase, table=True): __tablename__= "restaurant_menu" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships restaurant: "Restaurant" = Relationship(back_populates="menu") categories: List["MenuCategory"] = Relationship(back_populates="restaurant_menu") class NightclubMenuBase(MenuBase): - nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) class NightclubMenu(NightclubMenuBase, table=True): __tablename__= "nightclub_menu" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships nightclub: "Nightclub" = Relationship(back_populates="menu") diff --git a/backend/app/models/menu_category.py b/backend/app/models/menu_category.py index b29d83d080..e6df4dbe0f 100644 --- a/backend/app/models/menu_category.py +++ b/backend/app/models/menu_category.py @@ -1,14 +1,15 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List -from pydantic import root_validator, ValidationError +from pydantic import model_validator class MenuCategoryBase(SQLModel): - qsr_menu_id: Optional[int] = Field(default=None, foreign_key="qsr_menu.id") - restaurant_menu_id: Optional[int] = Field(default=None, foreign_key="restaurant_menu.id") - nightclub_menu_id: Optional[int] = Field(default=None, foreign_key="nightclub_menu.id") + qsr_menu_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr_menu.id") + restaurant_menu_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant_menu.id") + nightclub_menu_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub_menu.id") name: str = Field(nullable=False) - @root_validator(pre=True) + @model_validator(mode="before") def check_only_one_menu_id(cls, values): # Convert to a regular dictionary values_dict = dict(values) @@ -28,7 +29,7 @@ def check_only_one_menu_id(cls, values): class MenuCategory(MenuCategoryBase, table=True): __tablename__ = "menu_category" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships menu_items: List["MenuItem"] = Relationship(back_populates="category") diff --git a/backend/app/models/menu_item.py b/backend/app/models/menu_item.py index ebac08835a..063b2be15a 100644 --- a/backend/app/models/menu_item.py +++ b/backend/app/models/menu_item.py @@ -1,8 +1,9 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional class MenuItemBase(SQLModel): - category_id: int = Field(foreign_key="menu_category.id", nullable=False) + category_id: uuid.UUID = Field(foreign_key="menu_category.id", nullable=False) name: str = Field(nullable=False) price: float = Field(nullable=False) description: Optional[str] = Field(default=None) @@ -14,7 +15,7 @@ class MenuItemBase(SQLModel): class MenuItem(MenuItemBase, table=True): __tablename__="menu_item" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships category: Optional["MenuCategory"] = Relationship(back_populates="menu_items") \ No newline at end of file diff --git a/backend/app/models/order.py b/backend/app/models/order.py index 146dc0e7ae..571a2e4908 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -1,3 +1,4 @@ +import uuid from app.models.group import GroupNightclubOrderLink from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List @@ -5,8 +6,8 @@ class OrderBase(SQLModel): - user_id: Optional[int] = Field(default=None, foreign_key="user_public.id") - pickup_location_id: Optional[int] = Field(default=None, foreign_key="pickup_location.id") + user_id: uuid.UUID = Field(foreign_key="user_public.id") + pickup_location_id: Optional[uuid.UUID] = Field(default=None, foreign_key="pickup_location.id") note: Optional[str] = Field(nullable=True) order_time: datetime = Field(nullable=False) total_amount: float = Field(nullable=False) @@ -18,10 +19,10 @@ class OrderBase(SQLModel): class NightclubOrder(OrderBase, table=True): __tablename__ = "nightclub_order" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - venue_id: int = Field(default=None, foreign_key="nightclub.id") - payment_id: int = Field(default=None, foreign_key="payment_source_nightclub.id") - pickup_location_id: int = Field(default=None, foreign_key="pickup_location.id") + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + venue_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub.id") + payment_id: Optional[uuid.UUID] = Field(default=None, foreign_key="payment_source_nightclub.id") + pickup_location_id: Optional[uuid.UUID] = Field(default=None, foreign_key="pickup_location.id") # Relationships user: Optional["UserPublic"] = Relationship(back_populates="nightclub_orders") nightclub: Optional["Nightclub"] = Relationship(back_populates="orders") @@ -33,9 +34,9 @@ class NightclubOrder(OrderBase, table=True): class RestaurantOrder(OrderBase, table=True): __tablename__ = "restaurant_order" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - venue_id: int = Field(default=None, foreign_key="restaurant.id") - payment_id: int = Field(default=None, foreign_key="payment_source_restaurant.id") + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + venue_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant.id") + payment_id: Optional[uuid.UUID] = Field(default=None, foreign_key="payment_source_restaurant.id") # Relationships user: Optional["UserPublic"] = Relationship(back_populates="restaurant_orders") @@ -46,9 +47,9 @@ class RestaurantOrder(OrderBase, table=True): class QSROrder(OrderBase, table=True): __tablename__ = "qsr_order" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - venue_id: int = Field(default=None, foreign_key="qsr.id") - payment_id: int = Field(default=None, foreign_key="payment_source_qsr.id") + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + venue_id: uuid.UUID = Field(default=None, foreign_key="qsr.id") + payment_id: uuid.UUID = Field(default=None, foreign_key="payment_source_qsr.id") # Relationships user: Optional["UserPublic"] = Relationship(back_populates="qsr_orders") diff --git a/backend/app/models/order_item.py b/backend/app/models/order_item.py index 9fa027b0e3..0e0447ab26 100644 --- a/backend/app/models/order_item.py +++ b/backend/app/models/order_item.py @@ -1,13 +1,14 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional class OrderItem(SQLModel, table=True): - order_item_id: Optional[int] = Field(default=None, primary_key=True, index=True) - nightclub_order_id: Optional[int] = Field(default=None, foreign_key="nightclub_order.id") - restaurant_order_id: Optional[int] = Field(default=None, foreign_key="restaurant_order.id") - qsr_order_id: Optional[int] = Field(default=None, foreign_key="qsr_order.id") - item_id: int = Field(foreign_key="menu_item.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + nightclub_order_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub_order.id") + restaurant_order_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant_order.id") + qsr_order_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr_order.id") + item_id: uuid.UUID = Field(foreign_key="menu_item.id", nullable=False) quantity: int = Field(nullable=False) # Relationships diff --git a/backend/app/models/payment.py b/backend/app/models/payment.py index ef6db00299..d47db3c729 100644 --- a/backend/app/models/payment.py +++ b/backend/app/models/payment.py @@ -1,11 +1,12 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional from datetime import datetime class PaymentBase(SQLModel): - user_id: int = Field(foreign_key="user_public.id", nullable=False) + user_id: uuid.UUID = Field(foreign_key="user_public.id", nullable=False) source_type: str = Field(nullable=False) # Changed to str - gateway_transaction_id: Optional[int] = Field(default=None) + gateway_transaction_id: Optional[uuid.UUID] = Field(default=None) payment_time: datetime = Field(nullable=False) amount: float = Field(nullable=False) status: str = Field(nullable=False) # e.g., Paid, Pending, Failed @@ -13,7 +14,7 @@ class PaymentBase(SQLModel): class PaymentOrderNightclub(PaymentBase, table=True): __tablename__ = "payment_source_nightclub" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) retry_count: int = Field(default=0) last_attempt_time: Optional[datetime] = Field(default=None) order: "NightclubOrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) @@ -21,7 +22,7 @@ class PaymentOrderNightclub(PaymentBase, table=True): class PaymentOrderQSR(PaymentBase, table=True): __tablename__ = "payment_source_qsr" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) retry_count: int = Field(default=0) last_attempt_time: Optional[datetime] = Field(default=None) order: "QSROrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) @@ -29,7 +30,7 @@ class PaymentOrderQSR(PaymentBase, table=True): class PaymentOrderRestaurant(PaymentBase, table=True): __tablename__ = "payment_source_restaurant" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) retry_count: int = Field(default=0) last_attempt_time: Optional[datetime] = Field(default=None) order: "RestaurantOrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) @@ -37,7 +38,7 @@ class PaymentOrderRestaurant(PaymentBase, table=True): class PaymentEvent(PaymentBase, table=True): __tablename__ = "payment_event" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - event_booking_id: Optional[int] = Field(default=None, foreign_key="event_booking.id") + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + event_booking_id: Optional[uuid.UUID] = Field(default=None, foreign_key="event_booking.id") event_booking: Optional["EventBooking"] = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) user: Optional["UserPublic"] = Relationship(back_populates="event_payments") diff --git a/backend/app/models/pickup_location.py b/backend/app/models/pickup_location.py index 0c04c29009..ae20d52174 100644 --- a/backend/app/models/pickup_location.py +++ b/backend/app/models/pickup_location.py @@ -1,11 +1,12 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List class PickupLocation(SQLModel, table=True): __tablename__ = "pickup_location" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - nightclub_id: int = Field(foreign_key="nightclub.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) name: str = Field(nullable=False) description: Optional[str] = Field(default=None) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2e94164461..38ff0d4674 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -6,23 +6,18 @@ import uuid from pydantic import EmailStr -# if TYPE_CHECKING: -# from .venue import QSR, Foodcourt, Restaurant, Nightclub - - # Shared properties class UserBase(SQLModel): email: EmailStr = Field(unique=True, nullable=True, index=True, max_length=255) - phone_number: Optional[str] = Field(unique=True, index=True,default=None) + phone_number: Optional[str] = Field(unique=True, nullable=False,index=True,default=None) is_active: bool = True is_superuser: bool = False full_name: str | None = Field(default=None, max_length=255) + refresh_token: str = Field(nullable=True) - -class UserPublic(SQLModel, table=True): +class UserPublic(UserBase, table=True): __tablename__ = "user_public" - - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) date_of_birth: Optional[datetime] = Field(default=None) gender: Optional[str] = Field(default=None) registration_date: datetime = Field(nullable=False) @@ -46,7 +41,7 @@ class UserPublic(SQLModel, table=True): class UserBusiness(UserBase, table=True): __tablename__ = "user_business" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) registration_date: datetime = Field(nullable=False) # Relationships @@ -66,22 +61,3 @@ class UserBusiness(UserBase, table=True): back_populates="managing_users", link_model=NightclubUserBusinessLink ) - -# JSON payload containing access token -class Token(SQLModel): - access_token: str - token_type: str = "bearer" - - -# Contents of JWT token -class TokenPayload(SQLModel): - sub: str | None = None - - -class NewPassword(SQLModel): - token: str - new_password: str = Field(min_length=8, max_length=40) - -# Generic message -class Message(SQLModel): - message: str diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index a478123e36..0bca9f9eb1 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -1,3 +1,4 @@ +import uuid from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List, TYPE_CHECKING @@ -20,8 +21,8 @@ class VenueBase(SQLModel): qr_url: Optional[str] = Field(default=None) class NightclubUserBusinessLink(SQLModel, table=True): - nightclub_id: int = Field(foreign_key="nightclub.id", primary_key=True) - user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", primary_key=True) + user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) class NightclubBase(VenueBase): pass @@ -29,7 +30,7 @@ class NightclubBase(VenueBase): class Nightclub(NightclubBase, table=True): __tablename__ = "nightclub" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: Optional[uuid.UUID] = Field(default=None, primary_key=True, index=True) # Relationships events: List["Event"] = Relationship(back_populates="nightclub") club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") @@ -43,8 +44,8 @@ class Nightclub(NightclubBase, table=True): ) class RestaurantUserBusinessLink(SQLModel, table=True): - restaurant_id: int = Field(foreign_key="restaurant.id", primary_key=True) - user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + restaurant_id: uuid.UUID = Field(foreign_key="restaurant.id", primary_key=True) + user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) class RestaurantBase(VenueBase): pass @@ -52,7 +53,7 @@ class RestaurantBase(VenueBase): class Restaurant(RestaurantBase, table=True): __tablename__ = "restaurant" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships menu: List["RestaurantMenu"] = Relationship(back_populates="restaurant") orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") @@ -62,8 +63,8 @@ class Restaurant(RestaurantBase, table=True): ) class QSRUserBusinessLink(SQLModel, table=True): - qsr_id: int = Field(foreign_key="qsr.id", primary_key=True) - user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + qsr_id: uuid.UUID = Field(foreign_key="qsr.id", primary_key=True) + user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) class QSRBase(VenueBase): pass @@ -71,8 +72,8 @@ class QSRBase(VenueBase): class QSR(QSRBase, table=True): __tablename__ = "qsr" - id: Optional[int] = Field(default=None, primary_key=True, index=True) - foodcourt_id: Optional[int] = Field(default=None, foreign_key="foodcourt.id") + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + foodcourt_id: Optional[uuid.UUID] = Field(default=None, foreign_key="foodcourt.id") # Relationships foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") menu: List["QSRMenu"] = Relationship(back_populates="qsr") @@ -83,8 +84,8 @@ class QSR(QSRBase, table=True): ) class FoodcourtUserBusinessLink(SQLModel, table=True): - foodcourt_id: int = Field(foreign_key="foodcourt.id", primary_key=True) - user_business_id: int = Field(foreign_key="user_business.id", primary_key=True) + foodcourt_id: uuid.UUID = Field(foreign_key="foodcourt.id", primary_key=True) + user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) class FoodcourtBase(VenueBase): pass @@ -92,7 +93,7 @@ class FoodcourtBase(VenueBase): class Foodcourt(FoodcourtBase, table=True): __tablename__ = "foodcourt" - id: Optional[int] = Field(default=None, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships qsrs: List["QSR"] = Relationship(back_populates="foodcourt") managing_users: List["UserBusiness"] = Relationship( diff --git a/backend/app/schema/auth.py b/backend/app/schema/auth.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/schema/menu.py b/backend/app/schema/menu.py index 1c9de2e3cf..56614da53f 100644 --- a/backend/app/schema/menu.py +++ b/backend/app/schema/menu.py @@ -1,10 +1,11 @@ from typing import List, Optional +import uuid from app.models.menu import NightclubMenuBase, QSRMenuBase, RestaurantMenuBase from app.models.menu_item import MenuItemBase from app.models.menu_category import MenuCategoryBase class QSRMenuRead(QSRMenuBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True @@ -13,7 +14,7 @@ class Config: from_attributes = True class RestaurantMenuRead(RestaurantMenuBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True @@ -22,18 +23,18 @@ class Config: from_attributes = True class MenuItemRead(MenuItemBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True class MenuCategoryRead(MenuCategoryBase): - id: Optional[int] + id: Optional[uuid.UUID] menu_items: List[MenuItemRead] = [] class Config: from_attributes = True class NightclubMenuRead(NightclubMenuBase): - id: Optional[int] + id: Optional[uuid.UUID] categories: List[MenuCategoryRead] = [] class Config: from_attributes = True diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index f413a8bc04..44a5219610 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -1,4 +1,5 @@ from typing import Optional +import uuid from app.models.venue import FoodcourtBase, NightclubBase, QSRBase, RestaurantBase class RestaurantRead(RestaurantBase): @@ -11,7 +12,7 @@ class Config: from_attributes = True class NightclubRead(NightclubBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True @@ -20,7 +21,7 @@ class Config: from_attributes = True class QSRRead(QSRBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True @@ -30,7 +31,7 @@ class Config: class FoodcourtRead(FoodcourtBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True diff --git a/backend/poetry.lock b/backend/poetry.lock index 277d1e2c1b..c17311b829 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,5 +1,20 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aioredis" +version = "1.3.1" +description = "asyncio (PEP 3156) Redis support" +optional = false +python-versions = "*" +files = [ + {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, + {file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"}, +] + +[package.dependencies] +async-timeout = "*" +hiredis = "*" + [[package]] name = "alembic" version = "1.13.2" @@ -52,34 +67,165 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "argon2-cffi" +version = "23.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, + {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[package.extras] +dev = ["argon2-cffi[tests,typing]", "tox (>4)"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] +tests = ["hypothesis", "pytest"] +typing = ["mypy"] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.6" +files = [ + {file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"}, + {file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"}, +] + +[package.dependencies] +cffi = ">=1.0.1" + +[package.extras] +dev = ["cogapp", "pre-commit", "pytest", "wheel"] +tests = ["pytest"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "asyncpg" +version = "0.29.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169"}, + {file = "asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb"}, + {file = "asyncpg-0.29.0-cp310-cp310-win32.whl", hash = "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449"}, + {file = "asyncpg-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b"}, + {file = "asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675"}, + {file = "asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175"}, + {file = "asyncpg-0.29.0-cp312-cp312-win32.whl", hash = "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02"}, + {file = "asyncpg-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9"}, + {file = "asyncpg-0.29.0-cp38-cp38-win32.whl", hash = "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408"}, + {file = "asyncpg-0.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c"}, + {file = "asyncpg-0.29.0-cp39-cp39-win32.whl", hash = "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2"}, + {file = "asyncpg-0.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8"}, + {file = "asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""} + +[package.extras] +docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] + [[package]] name = "bcrypt" -version = "4.0.1" +version = "4.1.2" description = "Modern password hashing for your software and your servers" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"}, - {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"}, - {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"}, - {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"}, - {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"}, - {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"}, + {file = "bcrypt-4.1.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:ac621c093edb28200728a9cca214d7e838529e557027ef0581685909acd28b5e"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea505c97a5c465ab8c3ba75c0805a102ce526695cd6818c6de3b1a38f6f60da1"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57fa9442758da926ed33a91644649d3e340a71e2d0a5a8de064fb621fd5a3326"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb3bd3321517916696233b5e0c67fd7d6281f0ef48e66812db35fc963a422a1c"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6cad43d8c63f34b26aef462b6f5e44fdcf9860b723d2453b5d391258c4c8e966"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:44290ccc827d3a24604f2c8bcd00d0da349e336e6503656cb8192133e27335e2"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:732b3920a08eacf12f93e6b04ea276c489f1c8fb49344f564cca2adb663b3e4c"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c28973decf4e0e69cee78c68e30a523be441972c826703bb93099868a8ff5b5"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b8df79979c5bae07f1db22dcc49cc5bccf08a0380ca5c6f391cbb5790355c0b0"}, + {file = "bcrypt-4.1.2-cp37-abi3-win32.whl", hash = "sha256:fbe188b878313d01b7718390f31528be4010fed1faa798c5a1d0469c9c48c369"}, + {file = "bcrypt-4.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:9800ae5bd5077b13725e2e3934aa3c9c37e49d3ea3d06318010aa40f54c63551"}, + {file = "bcrypt-4.1.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:71b8be82bc46cedd61a9f4ccb6c1a493211d031415a34adde3669ee1b0afbb63"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e3c6642077b0c8092580c819c1684161262b2e30c4f45deb000c38947bf483"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387e7e1af9a4dd636b9505a465032f2f5cb8e61ba1120e79a0e1cd0b512f3dfc"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f70d9c61f9c4ca7d57f3bfe88a5ccf62546ffbadf3681bb1e268d9d2e41c91a7"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2a298db2a8ab20056120b45e86c00a0a5eb50ec4075b6142db35f593b97cb3fb"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ba55e40de38a24e2d78d34c2d36d6e864f93e0d79d0b6ce915e4335aa81d01b1"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3566a88234e8de2ccae31968127b0ecccbb4cddb629da744165db72b58d88ca4"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b90e216dc36864ae7132cb151ffe95155a37a14e0de3a8f64b49655dd959ff9c"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:69057b9fc5093ea1ab00dd24ede891f3e5e65bee040395fb1e66ee196f9c9b4a"}, + {file = "bcrypt-4.1.2-cp39-abi3-win32.whl", hash = "sha256:02d9ef8915f72dd6daaef40e0baeef8a017ce624369f09754baf32bb32dba25f"}, + {file = "bcrypt-4.1.2-cp39-abi3-win_amd64.whl", hash = "sha256:be3ab1071662f6065899fe08428e45c16aa36e28bc42921c4901a191fda6ee42"}, + {file = "bcrypt-4.1.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d75fc8cd0ba23f97bae88a6ec04e9e5351ff3c6ad06f38fe32ba50cbd0d11946"}, + {file = "bcrypt-4.1.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:a97e07e83e3262599434816f631cc4c7ca2aa8e9c072c1b1a7fec2ae809a1d2d"}, + {file = "bcrypt-4.1.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e51c42750b7585cee7892c2614be0d14107fad9581d1738d954a262556dd1aab"}, + {file = "bcrypt-4.1.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba4e4cc26610581a6329b3937e02d319f5ad4b85b074846bf4fef8a8cf51e7bb"}, + {file = "bcrypt-4.1.2.tar.gz", hash = "sha256:33313a1200a3ae90b75587ceac502b048b840fc69e7f7a0905b5f87fac7a1258"}, ] [package.extras] @@ -108,6 +254,85 @@ files = [ {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.4.0" @@ -318,6 +543,55 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "43.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "cssselect" version = "1.2.0" @@ -380,13 +654,13 @@ wmi = ["wmi (>=1.5.1)"] [[package]] name = "email-validator" -version = "2.2.0" +version = "2.1.2" description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" files = [ - {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, - {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, + {file = "email_validator-2.1.2-py3-none-any.whl", hash = "sha256:d89f6324e13b1e39889eab7f9ca2f91dc9aebb6fa50a6d8bd4329ab50f251115"}, + {file = "email_validator-2.1.2.tar.gz", hash = "sha256:14c0f3d343c4beda37400421b39fa411bbe33a75df20825df73ad53e06a9f04c"}, ] [package.dependencies] @@ -445,6 +719,45 @@ typing-extensions = ">=4.8.0" [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "fastapi-cache" +version = "0.1.0" +description = "FastAPI simple cache" +optional = false +python-versions = "*" +files = [ + {file = "fastapi-cache-0.1.0.tar.gz", hash = "sha256:1f57e6e666672c84e3dd5d4141ec808d5339d158e10c87a87eb9ce11ff8b1735"}, +] + +[package.dependencies] +aioredis = "1.3.1" + +[[package]] +name = "fastapi-users" +version = "13.0.0" +description = "Ready-to-use and customizable users management for FastAPI" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi_users-13.0.0-py3-none-any.whl", hash = "sha256:e6246529e3080a5b50e5afeed1e996663b661f1dc791a1ac478925cb5bfc0fa0"}, + {file = "fastapi_users-13.0.0.tar.gz", hash = "sha256:b397c815b7051c8fd4b560fbeee707acd28e00bd3e8f25c292ad158a1e47e884"}, +] + +[package.dependencies] +email-validator = ">=1.1.0,<2.2" +fastapi = ">=0.65.2" +httpx-oauth = {version = ">=0.13", optional = true, markers = "extra == \"oauth\""} +makefun = ">=1.11.2,<2.0.0" +pwdlib = {version = "0.2.0", extras = ["argon2", "bcrypt"]} +pyjwt = {version = "2.8.0", extras = ["crypto"]} +python-multipart = "0.0.9" + +[package.extras] +beanie = ["fastapi-users-db-beanie (>=3.0.0)"] +oauth = ["httpx-oauth (>=0.13)"] +redis = ["redis (>=4.3.3,<6.0.0)"] +sqlalchemy = ["fastapi-users-db-sqlalchemy (>=6.0.0)"] + [[package]] name = "filelock" version = "3.15.4" @@ -564,6 +877,109 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "hiredis" +version = "3.0.0" +description = "Python wrapper for hiredis" +optional = false +python-versions = ">=3.8" +files = [ + {file = "hiredis-3.0.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:4b182791c41c5eb1d9ed736f0ff81694b06937ca14b0d4dadde5dadba7ff6dae"}, + {file = "hiredis-3.0.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:13c275b483a052dd645eb2cb60d6380f1f5215e4c22d6207e17b86be6dd87ffa"}, + {file = "hiredis-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1018cc7f12824506f165027eabb302735b49e63af73eb4d5450c66c88f47026"}, + {file = "hiredis-3.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83a29cc7b21b746cb6a480189e49f49b2072812c445e66a9e38d2004d496b81c"}, + {file = "hiredis-3.0.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e241fab6332e8fb5f14af00a4a9c6aefa22f19a336c069b7ddbf28ef8341e8d6"}, + {file = "hiredis-3.0.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1fb8de899f0145d6c4d5d4bd0ee88a78eb980a7ffabd51e9889251b8f58f1785"}, + {file = "hiredis-3.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b23291951959141173eec10f8573538e9349fa27f47a0c34323d1970bf891ee5"}, + {file = "hiredis-3.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e421ac9e4b5efc11705a0d5149e641d4defdc07077f748667f359e60dc904420"}, + {file = "hiredis-3.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77c8006c12154c37691b24ff293c077300c22944018c3ff70094a33e10c1d795"}, + {file = "hiredis-3.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:41afc0d3c18b59eb50970479a9c0e5544fb4b95e3a79cf2fbaece6ddefb926fe"}, + {file = "hiredis-3.0.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:04ccae6dcd9647eae6025425ab64edb4d79fde8b9e6e115ebfabc6830170e3b2"}, + {file = "hiredis-3.0.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fe91d62b0594db5ea7d23fc2192182b1a7b6973f628a9b8b2e0a42a2be721ac6"}, + {file = "hiredis-3.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:99516d99316062824a24d145d694f5b0d030c80da693ea6f8c4ecf71a251d8bb"}, + {file = "hiredis-3.0.0-cp310-cp310-win32.whl", hash = "sha256:562eaf820de045eb487afaa37e6293fe7eceb5b25e158b5a1974b7e40bf04543"}, + {file = "hiredis-3.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a1c81c89ed765198da27412aa21478f30d54ef69bf5e4480089d9c3f77b8f882"}, + {file = "hiredis-3.0.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:4664dedcd5933364756d7251a7ea86d60246ccf73a2e00912872dacbfcef8978"}, + {file = "hiredis-3.0.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:47de0bbccf4c8a9f99d82d225f7672b9dd690d8fd872007b933ef51a302c9fa6"}, + {file = "hiredis-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e43679eca508ba8240d016d8cca9d27342d70184773c15bea78a23c87a1922f1"}, + {file = "hiredis-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13c345e7278c210317e77e1934b27b61394fee0dec2e8bd47e71570900f75823"}, + {file = "hiredis-3.0.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00018f22f38530768b73ea86c11f47e8d4df65facd4e562bd78773bd1baef35e"}, + {file = "hiredis-3.0.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ea3a86405baa8eb0d3639ced6926ad03e07113de54cb00fd7510cb0db76a89d"}, + {file = "hiredis-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c073848d2b1d5561f3903879ccf4e1a70c9b1e7566c7bdcc98d082fa3e7f0a1d"}, + {file = "hiredis-3.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a8dffb5f5b3415a4669d25de48b617fd9d44b0bccfc4c2ab24b06406ecc9ecb"}, + {file = "hiredis-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22c17c96143c2a62dfd61b13803bc5de2ac526b8768d2141c018b965d0333b66"}, + {file = "hiredis-3.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3ece960008dab66c6b8bb3a1350764677ee7c74ccd6270aaf1b1caf9ccebb46"}, + {file = "hiredis-3.0.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f75999ae00a920f7dce6ecae76fa5e8674a3110e5a75f12c7a2c75ae1af53396"}, + {file = "hiredis-3.0.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e069967cbd5e1900aafc4b5943888f6d34937fc59bf8918a1a546cb729b4b1e4"}, + {file = "hiredis-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0aacc0a78e1d94d843a6d191f224a35893e6bdfeb77a4a89264155015c65f126"}, + {file = "hiredis-3.0.0-cp311-cp311-win32.whl", hash = "sha256:719c32147ba29528cb451f037bf837dcdda4ff3ddb6cdb12c4216b0973174718"}, + {file = "hiredis-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:bdc144d56333c52c853c31b4e2e52cfbdb22d3da4374c00f5f3d67c42158970f"}, + {file = "hiredis-3.0.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:484025d2eb8f6348f7876fc5a2ee742f568915039fcb31b478fd5c242bb0fe3a"}, + {file = "hiredis-3.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:fcdb552ffd97151dab8e7bc3ab556dfa1512556b48a367db94b5c20253a35ee1"}, + {file = "hiredis-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bb6f9fd92f147ba11d338ef5c68af4fd2908739c09e51f186e1d90958c68cc1"}, + {file = "hiredis-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa86bf9a0ed339ec9e8a9a9d0ae4dccd8671625c83f9f9f2640729b15e07fbfd"}, + {file = "hiredis-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e194a0d5df9456995d8f510eab9f529213e7326af6b94770abf8f8b7952ddcaa"}, + {file = "hiredis-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a1df39d74ec507d79c7a82c8063eee60bf80537cdeee652f576059b9cdd15c"}, + {file = "hiredis-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f91456507427ba36fd81b2ca11053a8e112c775325acc74e993201ea912d63e9"}, + {file = "hiredis-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9862db92ef67a8a02e0d5370f07d380e14577ecb281b79720e0d7a89aedb9ee5"}, + {file = "hiredis-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d10fcd9e0eeab835f492832b2a6edb5940e2f1230155f33006a8dfd3bd2c94e4"}, + {file = "hiredis-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:48727d7d405d03977d01885f317328dc21d639096308de126c2c4e9950cbd3c9"}, + {file = "hiredis-3.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e0bb6102ebe2efecf8a3292c6660a0e6fac98176af6de67f020bea1c2343717"}, + {file = "hiredis-3.0.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:df274e3abb4df40f4c7274dd3e587dfbb25691826c948bc98d5fead019dfb001"}, + {file = "hiredis-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:034925b5fb514f7b11aac38cd55b3fd7e9d3af23bd6497f3f20aa5b8ba58e232"}, + {file = "hiredis-3.0.0-cp312-cp312-win32.whl", hash = "sha256:120f2dda469b28d12ccff7c2230225162e174657b49cf4cd119db525414ae281"}, + {file = "hiredis-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e584fe5f4e6681d8762982be055f1534e0170f6308a7a90f58d737bab12ff6a8"}, + {file = "hiredis-3.0.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:122171ff47d96ed8dd4bba6c0e41d8afaba3e8194949f7720431a62aa29d8895"}, + {file = "hiredis-3.0.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:ba9fc605ac558f0de67463fb588722878641e6fa1dabcda979e8e69ff581d0bd"}, + {file = "hiredis-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a631e2990b8be23178f655cae8ac6c7422af478c420dd54e25f2e26c29e766f1"}, + {file = "hiredis-3.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63482db3fadebadc1d01ad33afa6045ebe2ea528eb77ccaabd33ee7d9c2bad48"}, + {file = "hiredis-3.0.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f669212c390eebfbe03c4e20181f5970b82c5d0a0ad1df1785f7ffbe7d61150"}, + {file = "hiredis-3.0.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a49ef161739f8018c69b371528bdb47d7342edfdee9ddc75a4d8caddf45a6e"}, + {file = "hiredis-3.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98a152052b8878e5e43a2e3a14075218adafc759547c98668a21e9485882696c"}, + {file = "hiredis-3.0.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50a196af0ce657fcde9bf8a0bbe1032e22c64d8fcec2bc926a35e7ff68b3a166"}, + {file = "hiredis-3.0.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f2f312eef8aafc2255e3585dcf94d5da116c43ef837db91db9ecdc1bc930072d"}, + {file = "hiredis-3.0.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:6ca41fa40fa019cde42c21add74aadd775e71458051a15a352eabeb12eb4d084"}, + {file = "hiredis-3.0.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:6eecb343c70629f5af55a8b3e53264e44fa04e155ef7989de13668a0cb102a90"}, + {file = "hiredis-3.0.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:c3fdad75e7837a475900a1d3a5cc09aa024293c3b0605155da2d42f41bc0e482"}, + {file = "hiredis-3.0.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8854969e7480e8d61ed7549eb232d95082a743e94138d98d7222ba4e9f7ecacd"}, + {file = "hiredis-3.0.0-cp38-cp38-win32.whl", hash = "sha256:f114a6c86edbf17554672b050cce72abf489fe58d583c7921904d5f1c9691605"}, + {file = "hiredis-3.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:7d99b91e42217d7b4b63354b15b41ce960e27d216783e04c4a350224d55842a4"}, + {file = "hiredis-3.0.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:4c6efcbb5687cf8d2aedcc2c3ed4ac6feae90b8547427d417111194873b66b06"}, + {file = "hiredis-3.0.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5b5cff42a522a0d81c2ae7eae5e56d0ee7365e0c4ad50c4de467d8957aff4414"}, + {file = "hiredis-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:82f794d564f4bc76b80c50b03267fe5d6589e93f08e66b7a2f674faa2fa76ebc"}, + {file = "hiredis-3.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7a4c1791d7aa7e192f60fe028ae409f18ccdd540f8b1e6aeb0df7816c77e4a4"}, + {file = "hiredis-3.0.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2537b2cd98192323fce4244c8edbf11f3cac548a9d633dbbb12b48702f379f4"}, + {file = "hiredis-3.0.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fed69bbaa307040c62195a269f82fc3edf46b510a17abb6b30a15d7dab548df"}, + {file = "hiredis-3.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869f6d5537d243080f44253491bb30aa1ec3c21754003b3bddeadedeb65842b0"}, + {file = "hiredis-3.0.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d435ae89073d7cd51e6b6bf78369c412216261c9c01662e7008ff00978153729"}, + {file = "hiredis-3.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:204b79b30a0e6be0dc2301a4d385bb61472809f09c49f400497f1cdd5a165c66"}, + {file = "hiredis-3.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ea635101b739c12effd189cc19b2671c268abb03013fd1f6321ca29df3ca625"}, + {file = "hiredis-3.0.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f359175197fd833c8dd7a8c288f1516be45415bb5c939862ab60c2918e1e1943"}, + {file = "hiredis-3.0.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac6d929cb33dd12ad3424b75725975f0a54b5b12dbff95f2a2d660c510aa106d"}, + {file = "hiredis-3.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:100431e04d25a522ef2c3b94f294c4219c4de3bfc7d557b6253296145a144c11"}, + {file = "hiredis-3.0.0-cp39-cp39-win32.whl", hash = "sha256:e1a9c14ae9573d172dc050a6f63a644457df5d01ec4d35a6a0f097f812930f83"}, + {file = "hiredis-3.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:54a6dd7b478e6eb01ce15b3bb5bf771e108c6c148315bf194eb2ab776a3cac4d"}, + {file = "hiredis-3.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:50da7a9edf371441dfcc56288d790985ee9840d982750580710a9789b8f4a290"}, + {file = "hiredis-3.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9b285ef6bf1581310b0d5e8f6ce64f790a1c40e89c660e1320b35f7515433672"}, + {file = "hiredis-3.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dcfa684966f25b335072115de2f920228a3c2caf79d4bfa2b30f6e4f674a948"}, + {file = "hiredis-3.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a41be8af1fd78ca97bc948d789a09b730d1e7587d07ca53af05758f31f4b985d"}, + {file = "hiredis-3.0.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:038756db735e417ab36ee6fd7725ce412385ed2bd0767e8179a4755ea11b804f"}, + {file = "hiredis-3.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fcecbd39bd42cef905c0b51c9689c39d0cc8b88b1671e7f40d4fb213423aef3a"}, + {file = "hiredis-3.0.0-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a131377493a59fb0f5eaeb2afd49c6540cafcfba5b0b3752bed707be9e7c4eaf"}, + {file = "hiredis-3.0.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d22c53f0ec5c18ecb3d92aa9420563b1c5d657d53f01356114978107b00b860"}, + {file = "hiredis-3.0.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8a91e9520fbc65a799943e5c970ffbcd67905744d8becf2e75f9f0a5e8414f0"}, + {file = "hiredis-3.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dc8043959b50141df58ab4f398e8ae84c6f9e673a2c9407be65fc789138f4a6"}, + {file = "hiredis-3.0.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51b99cfac514173d7b8abdfe10338193e8a0eccdfe1870b646009d2fb7cbe4b5"}, + {file = "hiredis-3.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:fa1fcad89d8a41d8dc10b1e54951ec1e161deabd84ed5a2c95c3c7213bdb3514"}, + {file = "hiredis-3.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:898636a06d9bf575d2c594129085ad6b713414038276a4bfc5db7646b8a5be78"}, + {file = "hiredis-3.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:466f836dbcf86de3f9692097a7a01533dc9926986022c6617dc364a402b265c5"}, + {file = "hiredis-3.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23142a8af92a13fc1e3f2ca1d940df3dcf2af1d176be41fe8d89e30a837a0b60"}, + {file = "hiredis-3.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:793c80a3d6b0b0e8196a2d5de37a08330125668c8012922685e17aa9108c33ac"}, + {file = "hiredis-3.0.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:467d28112c7faa29b7db743f40803d927c8591e9da02b6ce3d5fadc170a542a2"}, + {file = "hiredis-3.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:dc384874a719c767b50a30750f937af18842ee5e288afba95a5a3ed703b1515a"}, + {file = "hiredis-3.0.0.tar.gz", hash = "sha256:fed8581ae26345dea1f1e0d1a96e05041a727a45e7d8d459164583e23c6ac441"}, +] + [[package]] name = "httpcore" version = "1.0.5" @@ -657,6 +1073,20 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +[[package]] +name = "httpx-oauth" +version = "0.15.1" +description = "Async OAuth client using HTTPX" +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx_oauth-0.15.1-py3-none-any.whl", hash = "sha256:89b45f250e93e42bbe9631adf349cab0e3d3ced958c07e06651735198d1bdf00"}, + {file = "httpx_oauth-0.15.1.tar.gz", hash = "sha256:4094cf0938fc7252b5f5dfd62cd1ab5aee2fcb6734e621942ee17d1af4806b74"}, +] + +[package.dependencies] +httpx = ">=0.18,<1.0.0" + [[package]] name = "identify" version = "2.6.0" @@ -868,6 +1298,17 @@ html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] source = ["Cython (>=3.0.10)"] +[[package]] +name = "makefun" +version = "1.15.4" +description = "Small library to dynamically create python functions." +optional = false +python-versions = "*" +files = [ + {file = "makefun-1.15.4-py2.py3-none-any.whl", hash = "sha256:945d078a7e01a903f2cbef738b33e0ebc52b8d35fb7e20c528ed87b5c80db5b7"}, + {file = "makefun-1.15.4.tar.gz", hash = "sha256:9f9b9904e7c397759374a88f4c57781fbab2a458dec78df4b3ee6272cd9fb010"}, +] + [[package]] name = "mako" version = "1.3.5" @@ -1036,6 +1477,23 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "otplessauthsdk" +version = "0.3.3" +description = "otpless-auth-sdk" +optional = false +python-versions = ">=3" +files = [ + {file = "OTPLessAuthSDK-0.3.3-py3-none-any.whl", hash = "sha256:7f698e19a7af3501d69b1b8ca8cf952bcaaa6d3a783a7cfc390c7e0c65dfdd7f"}, + {file = "OTPLessAuthSDK-0.3.3.tar.gz", hash = "sha256:46126c5f6a5009d44b7f7adaf7e67567930de5be530a2f0edb49d8f764cfae22"}, +] + +[package.dependencies] +cryptography = "*" +PyJWT = "*" +requests = "*" +rsa = "*" + [[package]] name = "packaging" version = "24.1" @@ -1246,6 +1704,47 @@ files = [ {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, ] +[[package]] +name = "pwdlib" +version = "0.2.0" +description = "Modern password hashing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pwdlib-0.2.0-py3-none-any.whl", hash = "sha256:be53812012ab66795a57ac9393a59716ae7c2b60841ed453eb1262017fdec144"}, + {file = "pwdlib-0.2.0.tar.gz", hash = "sha256:b1bdafc064310eb6d3d07144a210267063ab4f45ac73a97be948e6589f74e861"}, +] + +[package.dependencies] +argon2-cffi = {version = "23.1.0", optional = true, markers = "extra == \"argon2\""} +bcrypt = {version = "4.1.2", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (==23.1.0)"] +bcrypt = ["bcrypt (==4.1.2)"] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.8.2" @@ -1400,12 +1899,29 @@ files = [ {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, ] +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + [package.extras] crypto = ["cryptography (>=3.4.0)"] dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pyotp" +version = "2.9.0" +description = "Python One Time Password Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, + {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, +] + +[package.extras] +test = ["coverage", "mypy", "ruff", "wheel"] + [[package]] name = "pytest" version = "7.4.4" @@ -1458,17 +1974,17 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.7" +version = "0.0.9" description = "A streaming multipart parser for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.7-py3-none-any.whl", hash = "sha256:b1fef9a53b74c795e2347daac8c54b252d9e0df9c619712691c1cc8021bd3c49"}, - {file = "python_multipart-0.0.7.tar.gz", hash = "sha256:288a6c39b06596c1b988bb6794c6fbc80e6c369e35e5062637df256bee0c9af9"}, + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, ] [package.extras] -dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==2.2.0)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] [[package]] name = "pyyaml" @@ -1530,6 +2046,24 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "redis" +version = "5.0.8" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"}, + {file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "requests" version = "2.32.3" @@ -1551,6 +2085,20 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "ruff" version = "0.2.2" @@ -2103,4 +2651,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "24f7dbbb95269a10076da119e82d56137ea116600ce3c448296bef29fae8ef0e" +content-hash = "9aec6433c23cc584d6596ce89b702915229b2289f1fb219afb5bd5a82dfed066" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 400f0760e6..3c3d984c8f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,7 +8,7 @@ authors = ["Admin "] python = "^3.10" uvicorn = {extras = ["standard"], version = "^0.24.0.post1"} fastapi = "^0.109.1" -python-multipart = "^0.0.7" +python-multipart = "0.0.9" email-validator = "^2.1.0.post1" passlib = {extras = ["bcrypt"], version = "^1.7.4"} tenacity = "^8.2.3" @@ -24,10 +24,16 @@ httpx = "^0.25.1" psycopg = {extras = ["binary"], version = "^3.1.13"} sqlmodel = "^0.0.21" # Pin bcrypt until passlib supports the latest -bcrypt = "4.0.1" +bcrypt = "4.1.2" pydantic-settings = "^2.2.1" sentry-sdk = {extras = ["fastapi"], version = "^1.40.6"} pyjwt = "^2.8.0" +fastapi-cache = {extras = ["redis"], version = "^0.1.0"} +pyotp = "^2.9.0" +redis = "^5.0.8" +fastapi-users = {extras = ["oauth"], version = "^13.0.0"} +asyncpg = "^0.29.0" +otplessauthsdk = "^0.3.3" [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" diff --git a/copier.yml b/copier.yml index 5db3891c8d..f53c66bb2f 100644 --- a/copier.yml +++ b/copier.yml @@ -74,7 +74,6 @@ _exclude: - poetry.lock - .cache - .venv - # Frontend # Logs - logs - "*.log" diff --git a/deployment.md b/deployment.md index 6bcbe40259..a0b8b0db17 100644 --- a/deployment.md +++ b/deployment.md @@ -284,8 +284,6 @@ Traefik UI: `https://traefik.fastapi-project.example.com` ### Production -Frontend: `https://fastapi-project.example.com` - Backend API docs: `https://fastapi-project.example.com/docs` Backend API base URL: `https://fastapi-project.example.com/api/` @@ -294,8 +292,6 @@ Adminer: `https://adminer.fastapi-project.example.com` ### Staging -Frontend: `https://staging.fastapi-project.example.com` - Backend API docs: `https://staging.fastapi-project.example.com/docs` Backend API base URL: `https://staging.fastapi-project.example.com/api/` diff --git a/development.md b/development.md index 60f814751a..b4753b3902 100644 --- a/development.md +++ b/development.md @@ -146,10 +146,6 @@ The production or staging URLs would use these same paths, but with your own dom Development URLs, for local development. -Frontend: http://localhost - -Backend: http://localhost/api/ - Automatic Interactive Docs (Swagger UI): http://localhost/docs Automatic Alternative Docs (ReDoc): http://localhost/redoc @@ -162,8 +158,6 @@ Traefik UI: http://localhost:8090 Development URLs, for local development. -Frontend: http://localhost.tiangolo.com - Backend: http://localhost.tiangolo.com/api/ Automatic Interactive Docs (Swagger UI): http://localhost.tiangolo.com/docs diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 418b535ab6..15f669bb68 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -74,14 +74,6 @@ services: - "1080:1080" - "1025:1025" - frontend: - restart: "no" - build: - context: ./frontend - args: - - VITE_API_URL=http://${DOMAIN?Variable not set} - - NODE_ENV=development - networks: traefik-public: # For local dev, don't expect an external Traefik network diff --git a/docker-compose.yml b/docker-compose.yml index 09de10ddb9..42a4754b23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,59 +72,21 @@ services: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.constraint-label=traefik-public - - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=80 - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=(Host(`${DOMAIN?Variable not set}`) || Host(`www.${DOMAIN?Variable not set}`)) && (PathPrefix(`/api`) || PathPrefix(`/docs`) || PathPrefix(`/redoc`)) - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=(Host(`${DOMAIN?Variable not set}`) || Host(`www.${DOMAIN?Variable not set}`)) && (PathPrefix(`/api`) || PathPrefix(`/docs`) || PathPrefix(`/redoc`)) - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le - - # Define Traefik Middleware to handle domain with and without "www" to redirect to only one - traefik.http.middlewares.${STACK_NAME?Variable not set}-www-redirect.redirectregex.regex=^http(s)?://www.(${DOMAIN?Variable not set})/(.*) - # Redirect a domain with www to non-www - traefik.http.middlewares.${STACK_NAME?Variable not set}-www-redirect.redirectregex.replacement=http$${1}://${DOMAIN?Variable not set}/$${3} - - # Enable www redirection for HTTP and HTTPS - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect,${STACK_NAME?Variable not set}-www-redirect - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.middlewares=${STACK_NAME?Variable not set}-www-redirect - frontend: - image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' - restart: always - networks: - - traefik-public - - default - build: - context: ./frontend - args: - - VITE_API_URL=https://${DOMAIN?Variable not set} - - NODE_ENV=production - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public - - - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 - - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`${DOMAIN?Variable not set}`) || Host(`www.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http - - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`${DOMAIN?Variable not set}`) || Host(`www.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le - - # Enable www redirection for HTTP and HTTPS - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.middlewares=${STACK_NAME?Variable not set}-www-redirect - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect,${STACK_NAME?Variable not set}-www-redirect volumes: app-db-data: networks: traefik-public: - # Allow setting it to false for testing - external: true + external: true \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore deleted file mode 100644 index f06235c460..0000000000 --- a/frontend/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist diff --git a/frontend/.env b/frontend/.env deleted file mode 100644 index f829bd1979..0000000000 --- a/frontend/.env +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=http://localhost diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index dfc4015cce..0000000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local -openapi.json - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ diff --git a/frontend/.nvmrc b/frontend/.nvmrc deleted file mode 100644 index 209e3ef4b6..0000000000 --- a/frontend/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -20 diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 8728c7b029..0000000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Stage 0, "build-stage", based on Node.js, to build and compile the frontend -FROM node:20 AS build-stage - -WORKDIR /app - -COPY package*.json /app/ - -RUN npm install - -COPY ./ /app/ - -ARG VITE_API_URL=${VITE_API_URL} - -RUN npm run build - - -# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx -FROM nginx:1 - -COPY --from=build-stage /app/dist/ /usr/share/nginx/html - -COPY ./nginx.conf /etc/nginx/conf.d/default.conf -COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 9a01970fda..0000000000 --- a/frontend/README.md +++ /dev/null @@ -1,160 +0,0 @@ -# FastAPI Project - Frontend - -The frontend is built with [Vite](https://vitejs.dev/), [React](https://reactjs.org/), [TypeScript](https://www.typescriptlang.org/), [TanStack Query](https://tanstack.com/query), [TanStack Router](https://tanstack.com/router) and [Chakra UI](https://chakra-ui.com/). - -## Frontend development - -Before you begin, ensure that you have either the Node Version Manager (nvm) or Fast Node Manager (fnm) installed on your system. - -* To install fnm follow the [official fnm guide](https://github.com/Schniz/fnm#installation). If you prefer nvm, you can install it using the [official nvm guide](https://github.com/nvm-sh/nvm#installing-and-updating). - -* After installing either nvm or fnm, proceed to the `frontend` directory: - -```bash -cd frontend -``` -* If the Node.js version specified in the `.nvmrc` file isn't installed on your system, you can install it using the appropriate command: - -```bash -# If using fnm -fnm install - -# If using nvm -nvm install -``` - -* Once the installation is complete, switch to the installed version: - -```bash -# If using fnm -fnm use - -# If using nvm -nvm use -``` - -* Within the `frontend` directory, install the necessary NPM packages: - -```bash -npm install -``` - -* And start the live server with the following `npm` script: - -```bash -npm run dev -``` - -* Then open your browser at http://localhost:5173/. - -Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload. - -Check the file `package.json` to see other available options. - -### Removing the frontend - -If you are developing an API-only app and want to remove the frontend, you can do it easily: - -* Remove the `./frontend` directory. - -* In the `docker-compose.yml` file, remove the whole service / section `frontend`. - -* In the `docker-compose.override.yml` file, remove the whole service / section `frontend`. - -Done, you have a frontend-less (api-only) app. 🤓 - ---- - -If you want, you can also remove the `FRONTEND` environment variables from: - -* `.env` -* `./scripts/*.sh` - -But it would be only to clean them up, leaving them won't really have any effect either way. - -## Generate Client - -### Automatically - -* Activate the backend virtual environment. -* From the top level project directory, run the script: - -```bash -./scripts/generate-frontend-client.sh -``` - -* Commit the changes. - -### Manually - -* Start the Docker Compose stack. - -* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory. - -* To simplify the names in the generated frontend client code, modify the `openapi.json` file by running the following script: - -```bash -node modify-openapi-operationids.js -``` - -* To generate the frontend client, run: - -```bash -npm run generate-client -``` - -* Commit the changes. - -Notice that everytime the backend changes (changing the OpenAPI schema), you should follow these steps again to update the frontend client. - -## Using a Remote API - -If you want to use a remote API, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file: - -```env -VITE_API_URL=https://my-remote-api.example.com -``` - -Then, when you run the frontend, it will use that URL as the base URL for the API. - -## Code Structure - -The frontend code is structured as follows: - -* `frontend/src` - The main frontend code. -* `frontend/src/assets` - Static assets. -* `frontend/src/client` - The generated OpenAPI client. -* `frontend/src/components` - The different components of the frontend. -* `frontend/src/hooks` - Custom hooks. -* `frontend/src/routes` - The different routes of the frontend which include the pages. -* `theme.tsx` - The Chakra UI custom theme. - -## End-to-End Testing with Playwright - -The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command: - -```bash -docker compose up -d -``` - -Then, you can run the tests with the following command: - -```bash -npx playwright test -``` - -You can also run your tests in UI mode to see the browser and interact with it running: - -```bash -npx playwright test --ui -``` - -To stop and remove the Docker Compose stack and clean the data created in tests, use the following command: - -```bash -docker compose down -v -``` - -To update the tests, navigate to the tests directory and modify the existing test files or add new ones as needed. - -For more information on writing and running Playwright tests, refer to the official [Playwright documentation](https://playwright.dev/docs/intro). \ No newline at end of file diff --git a/frontend/biome.json b/frontend/biome.json deleted file mode 100644 index a06315dc2a..0000000000 --- a/frontend/biome.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.6.1/schema.json", - "organizeImports": { - "enabled": true - }, - "files": { - "ignore": [ - "node_modules", - "src/routeTree.gen.ts", - "playwright.config.ts", - "playwright-report" - ] - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "off", - "noArrayIndexKey": "off" - }, - "style": { - "noNonNullAssertion": "off" - } - } - }, - "formatter": { - "indentStyle": "space" - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "asNeeded" - } - } -} diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 57621a268b..0000000000 --- a/frontend/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Full Stack FastAPI Project - - - -
- - - diff --git a/frontend/modify-openapi-operationids.js b/frontend/modify-openapi-operationids.js deleted file mode 100644 index b22fd17f9e..0000000000 --- a/frontend/modify-openapi-operationids.js +++ /dev/null @@ -1,36 +0,0 @@ -import * as fs from "node:fs" - -async function modifyOpenAPIFile(filePath) { - try { - const data = await fs.promises.readFile(filePath) - const openapiContent = JSON.parse(data) - - const paths = openapiContent.paths - for (const pathKey of Object.keys(paths)) { - const pathData = paths[pathKey] - for (const method of Object.keys(pathData)) { - const operation = pathData[method] - if (operation.tags && operation.tags.length > 0) { - const tag = operation.tags[0] - const operationId = operation.operationId - const toRemove = `${tag}-` - if (operationId.startsWith(toRemove)) { - const newOperationId = operationId.substring(toRemove.length) - operation.operationId = newOperationId - } - } - } - } - - await fs.promises.writeFile( - filePath, - JSON.stringify(openapiContent, null, 2), - ) - console.log("File successfully modified") - } catch (err) { - console.error("Error:", err) - } -} - -const filePath = "./openapi.json" -modifyOpenAPIFile(filePath) diff --git a/frontend/nginx-backend-not-found.conf b/frontend/nginx-backend-not-found.conf deleted file mode 100644 index f6fea66358..0000000000 --- a/frontend/nginx-backend-not-found.conf +++ /dev/null @@ -1,9 +0,0 @@ -location /api { - return 404; -} -location /docs { - return 404; -} -location /redoc { - return 404; -} diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index ba4d9aad6c..0000000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,11 +0,0 @@ -server { - listen 80; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri /index.html =404; - } - - include /etc/nginx/extra-conf.d/*.conf; -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 9560ff9f69..0000000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,6478 +0,0 @@ -{ - "name": "frontend", - "version": "0.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "frontend", - "version": "0.0.0", - "dependencies": { - "@chakra-ui/icons": "2.1.1", - "@chakra-ui/react": "2.8.2", - "@emotion/react": "11.11.3", - "@emotion/styled": "11.11.0", - "@tanstack/react-query": "^5.28.14", - "@tanstack/react-query-devtools": "^5.28.14", - "@tanstack/react-router": "1.19.1", - "axios": "1.7.4", - "form-data": "4.0.0", - "framer-motion": "10.16.16", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.13", - "react-hook-form": "7.49.3", - "react-icons": "5.0.1" - }, - "devDependencies": { - "@biomejs/biome": "1.6.1", - "@hey-api/openapi-ts": "^0.34.1", - "@playwright/test": "^1.45.2", - "@tanstack/router-devtools": "1.19.1", - "@tanstack/router-vite-plugin": "1.19.0", - "@types/node": "^20.10.5", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.2.15", - "@vitejs/plugin-react-swc": "^3.5.0", - "dotenv": "^16.4.5", - "typescript": "^5.2.2", - "vite": "^5.0.13" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dependencies": { - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@biomejs/biome": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.6.1.tgz", - "integrity": "sha512-SILQvA2S0XeaOuu1bivv6fQmMo7zMfr2xqDEN+Sz78pGbAKZnGmg0emsXjQWoBY/RVm9kPCgX+aGEpZZTYaM7w==", - "dev": true, - "hasInstallScript": true, - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.6.1", - "@biomejs/cli-darwin-x64": "1.6.1", - "@biomejs/cli-linux-arm64": "1.6.1", - "@biomejs/cli-linux-arm64-musl": "1.6.1", - "@biomejs/cli-linux-x64": "1.6.1", - "@biomejs/cli-linux-x64-musl": "1.6.1", - "@biomejs/cli-win32-arm64": "1.6.1", - "@biomejs/cli-win32-x64": "1.6.1" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.6.1.tgz", - "integrity": "sha512-KlvY00iB9T/vFi4m/GXxEyYkYnYy6aw06uapzUIIdiMMj7I/pmZu7CsZlzWdekVD0j+SsQbxdZMsb0wPhnRSsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.6.1.tgz", - "integrity": "sha512-jP4E8TXaQX5e3nvRJSzB+qicZrdIDCrjR0sSb1DaDTx4JPZH5WXq/BlTqAyWi3IijM+IYMjWqAAK4kOHsSCzxw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.6.1.tgz", - "integrity": "sha512-nxD1UyX3bWSl/RSKlib/JsOmt+652/9yieogdSC/UTLgVCZYOF7u8L/LK7kAa0Y4nA8zSPavAQTgko7mHC2ObA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.1.tgz", - "integrity": "sha512-YdkDgFecdHJg7PJxAMaZIixVWGB6St4yH08BHagO0fEhNNiY8cAKEVo2mcXlsnEiTMpeSEAY9VxLUrVT3IVxpw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.6.1.tgz", - "integrity": "sha512-BYAzenlMF3QdngjNFw9QVBXKGNzeecqwF3pwDgUGEvU7OJpn1/lyVkJVxYPtVGRNdjQ9e6l/s8NjKuBpW/ZR4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.1.tgz", - "integrity": "sha512-aSISIDmxq04NNy7tm4x9rBk2vH0ub2VDIE4outEmdC2LBtEJoINiphlZagx/FvjbsqUfygent9QUSn0oREnAXg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.6.1.tgz", - "integrity": "sha512-/eCHQKZ1kEawUpkSuXq4urtxMsD1P1678OPG3zNKt3ru16AqqspLdO3jzBe3k74xCPYnQ36e9Yqc97Mo0qgPtg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.6.1.tgz", - "integrity": "sha512-5TUZbzBwnDLFxLVGEPsorNi6eC2Gt+z4Oei9Qvq0M/4c4/mjZ96ABgwao/tMxf4ZBr/qyy2YdvF+gX9Rc+xC0A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.*" - } - }, - "node_modules/@chakra-ui/accordion": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/accordion/-/accordion-2.3.1.tgz", - "integrity": "sha512-FSXRm8iClFyU+gVaXisOSEw0/4Q+qZbFRiuhIAkVU6Boj0FxAMrlo9a8AV5TuF77rgaHytCdHk0Ng+cyUijrag==", - "dependencies": { - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/transition": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/alert": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/alert/-/alert-2.2.2.tgz", - "integrity": "sha512-jHg4LYMRNOJH830ViLuicjb3F+v6iriE/2G5T+Sd0Hna04nukNJ1MxUmBPE+vI22me2dIflfelu2v9wdB6Pojw==", - "dependencies": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/spinner": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/anatomy": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/anatomy/-/anatomy-2.2.2.tgz", - "integrity": "sha512-MV6D4VLRIHr4PkW4zMyqfrNS1mPlCTiCXwvYGtDFQYr+xHFfonhAuf9WjsSc0nyp2m0OdkSLnzmVKkZFLo25Tg==" - }, - "node_modules/@chakra-ui/avatar": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/avatar/-/avatar-2.3.0.tgz", - "integrity": "sha512-8gKSyLfygnaotbJbDMHDiJoF38OHXUYVme4gGxZ1fLnQEdPVEaIWfH+NndIjOM0z8S+YEFnT9KyGMUtvPrBk3g==", - "dependencies": { - "@chakra-ui/image": "2.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/breadcrumb": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/breadcrumb/-/breadcrumb-2.2.0.tgz", - "integrity": "sha512-4cWCG24flYBxjruRi4RJREWTGF74L/KzI2CognAW/d/zWR0CjiScuJhf37Am3LFbCySP6WSoyBOtTIoTA4yLEA==", - "dependencies": { - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/breakpoint-utils": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@chakra-ui/breakpoint-utils/-/breakpoint-utils-2.0.8.tgz", - "integrity": "sha512-Pq32MlEX9fwb5j5xx8s18zJMARNHlQZH2VH1RZgfgRDpp7DcEgtRW5AInfN5CfqdHLO1dGxA7I3MqEuL5JnIsA==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "node_modules/@chakra-ui/button": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/button/-/button-2.1.0.tgz", - "integrity": "sha512-95CplwlRKmmUXkdEp/21VkEWgnwcx2TOBG6NfYlsuLBDHSLlo5FKIiE2oSi4zXc4TLcopGcWPNcm/NDaSC5pvA==", - "dependencies": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/spinner": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/card": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/card/-/card-2.2.0.tgz", - "integrity": "sha512-xUB/k5MURj4CtPAhdSoXZidUbm8j3hci9vnc+eZJVDqhDOShNlD6QeniQNRPRys4lWAQLCbFcrwL29C8naDi6g==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/checkbox": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/checkbox/-/checkbox-2.3.2.tgz", - "integrity": "sha512-85g38JIXMEv6M+AcyIGLh7igNtfpAN6KGQFYxY9tBj0eWvWk4NKQxvqqyVta0bSAyIl1rixNIIezNpNWk2iO4g==", - "dependencies": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/visually-hidden": "2.2.0", - "@zag-js/focus-visible": "0.16.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/clickable": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/clickable/-/clickable-2.1.0.tgz", - "integrity": "sha512-flRA/ClPUGPYabu+/GLREZVZr9j2uyyazCAUHAdrTUEdDYCr31SVGhgh7dgKdtq23bOvAQJpIJjw/0Bs0WvbXw==", - "dependencies": { - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/close-button": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/close-button/-/close-button-2.1.1.tgz", - "integrity": "sha512-gnpENKOanKexswSVpVz7ojZEALl2x5qjLYNqSQGbxz+aP9sOXPfUS56ebyBrre7T7exuWGiFeRwnM0oVeGPaiw==", - "dependencies": { - "@chakra-ui/icon": "3.2.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/color-mode": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/color-mode/-/color-mode-2.2.0.tgz", - "integrity": "sha512-niTEA8PALtMWRI9wJ4LL0CSBDo8NBfLNp4GD6/0hstcm3IlbBHTVKxN6HwSaoNYfphDQLxCjT4yG+0BJA5tFpg==", - "dependencies": { - "@chakra-ui/react-use-safe-layout-effect": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/control-box": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/control-box/-/control-box-2.1.0.tgz", - "integrity": "sha512-gVrRDyXFdMd8E7rulL0SKeoljkLQiPITFnsyMO8EFHNZ+AHt5wK4LIguYVEq88APqAGZGfHFWXr79RYrNiE3Mg==", - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/counter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/counter/-/counter-2.1.0.tgz", - "integrity": "sha512-s6hZAEcWT5zzjNz2JIWUBzRubo9la/oof1W7EKZVVfPYHERnl5e16FmBC79Yfq8p09LQ+aqFKm/etYoJMMgghw==", - "dependencies": { - "@chakra-ui/number-utils": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/css-reset": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/css-reset/-/css-reset-2.3.0.tgz", - "integrity": "sha512-cQwwBy5O0jzvl0K7PLTLgp8ijqLPKyuEMiDXwYzl95seD3AoeuoCLyzZcJtVqaUZ573PiBdAbY/IlZcwDOItWg==", - "peerDependencies": { - "@emotion/react": ">=10.0.35", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/descendant": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/descendant/-/descendant-3.1.0.tgz", - "integrity": "sha512-VxCIAir08g5w27klLyi7PVo8BxhW4tgU/lxQyujkmi4zx7hT9ZdrcQLAted/dAa+aSIZ14S1oV0Q9lGjsAdxUQ==", - "dependencies": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/dom-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/dom-utils/-/dom-utils-2.1.0.tgz", - "integrity": "sha512-ZmF2qRa1QZ0CMLU8M1zCfmw29DmPNtfjR9iTo74U5FPr3i1aoAh7fbJ4qAlZ197Xw9eAW28tvzQuoVWeL5C7fQ==" - }, - "node_modules/@chakra-ui/editable": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/editable/-/editable-3.1.0.tgz", - "integrity": "sha512-j2JLrUL9wgg4YA6jLlbU88370eCRyor7DZQD9lzpY95tSOXpTljeg3uF9eOmDnCs6fxp3zDWIfkgMm/ExhcGTg==", - "dependencies": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-focus-on-pointer-down": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/event-utils": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@chakra-ui/event-utils/-/event-utils-2.0.8.tgz", - "integrity": "sha512-IGM/yGUHS+8TOQrZGpAKOJl/xGBrmRYJrmbHfUE7zrG3PpQyXvbLDP1M+RggkCFVgHlJi2wpYIf0QtQlU0XZfw==" - }, - "node_modules/@chakra-ui/focus-lock": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/focus-lock/-/focus-lock-2.1.0.tgz", - "integrity": "sha512-EmGx4PhWGjm4dpjRqM4Aa+rCWBxP+Rq8Uc/nAVnD4YVqkEhBkrPTpui2lnjsuxqNaZ24fIAZ10cF1hlpemte/w==", - "dependencies": { - "@chakra-ui/dom-utils": "2.1.0", - "react-focus-lock": "^2.9.4" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/form-control": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/form-control/-/form-control-2.2.0.tgz", - "integrity": "sha512-wehLC1t4fafCVJ2RvJQT2jyqsAwX7KymmiGqBu7nQoQz8ApTkGABWpo/QwDh3F/dBLrouHDoOvGmYTqft3Mirw==", - "dependencies": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/hooks": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/hooks/-/hooks-2.2.1.tgz", - "integrity": "sha512-RQbTnzl6b1tBjbDPf9zGRo9rf/pQMholsOudTxjy4i9GfTfz6kgp5ValGjQm2z7ng6Z31N1cnjZ1AlSzQ//ZfQ==", - "dependencies": { - "@chakra-ui/react-utils": "2.0.12", - "@chakra-ui/utils": "2.0.15", - "compute-scroll-into-view": "3.0.3", - "copy-to-clipboard": "3.3.3" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/icon": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/icon/-/icon-3.2.0.tgz", - "integrity": "sha512-xxjGLvlX2Ys4H0iHrI16t74rG9EBcpFvJ3Y3B7KMQTrnW34Kf7Da/UC8J67Gtx85mTHW020ml85SVPKORWNNKQ==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/icons": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/icons/-/icons-2.1.1.tgz", - "integrity": "sha512-3p30hdo4LlRZTT5CwoAJq3G9fHI0wDc0pBaMHj4SUn0yomO+RcDRlzhdXqdr5cVnzax44sqXJVnf3oQG0eI+4g==", - "dependencies": { - "@chakra-ui/icon": "3.2.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/image": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/image/-/image-2.1.0.tgz", - "integrity": "sha512-bskumBYKLiLMySIWDGcz0+D9Th0jPvmX6xnRMs4o92tT3Od/bW26lahmV2a2Op2ItXeCmRMY+XxJH5Gy1i46VA==", - "dependencies": { - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/input": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/input/-/input-2.1.2.tgz", - "integrity": "sha512-GiBbb3EqAA8Ph43yGa6Mc+kUPjh4Spmxp1Pkelr8qtudpc3p2PJOOebLpd90mcqw8UePPa+l6YhhPtp6o0irhw==", - "dependencies": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/object-utils": "2.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/layout": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/layout/-/layout-2.3.1.tgz", - "integrity": "sha512-nXuZ6WRbq0WdgnRgLw+QuxWAHuhDtVX8ElWqcTK+cSMFg/52eVP47czYBE5F35YhnoW2XBwfNoNgZ7+e8Z01Rg==", - "dependencies": { - "@chakra-ui/breakpoint-utils": "2.0.8", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/object-utils": "2.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/lazy-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@chakra-ui/lazy-utils/-/lazy-utils-2.0.5.tgz", - "integrity": "sha512-UULqw7FBvcckQk2n3iPO56TMJvDsNv0FKZI6PlUNJVaGsPbsYxK/8IQ60vZgaTVPtVcjY6BE+y6zg8u9HOqpyg==" - }, - "node_modules/@chakra-ui/live-region": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/live-region/-/live-region-2.1.0.tgz", - "integrity": "sha512-ZOxFXwtaLIsXjqnszYYrVuswBhnIHHP+XIgK1vC6DePKtyK590Wg+0J0slDwThUAd4MSSIUa/nNX84x1GMphWw==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/media-query": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/media-query/-/media-query-3.3.0.tgz", - "integrity": "sha512-IsTGgFLoICVoPRp9ykOgqmdMotJG0CnPsKvGQeSFOB/dZfIujdVb14TYxDU4+MURXry1MhJ7LzZhv+Ml7cr8/g==", - "dependencies": { - "@chakra-ui/breakpoint-utils": "2.0.8", - "@chakra-ui/react-env": "3.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/menu": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/menu/-/menu-2.2.1.tgz", - "integrity": "sha512-lJS7XEObzJxsOwWQh7yfG4H8FzFPRP5hVPN/CL+JzytEINCSBvsCDHrYPQGp7jzpCi8vnTqQQGQe0f8dwnXd2g==", - "dependencies": { - "@chakra-ui/clickable": "2.1.0", - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-animation-state": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-disclosure": "2.1.0", - "@chakra-ui/react-use-focus-effect": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-outside-click": "2.2.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/transition": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/modal": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/modal/-/modal-2.3.1.tgz", - "integrity": "sha512-TQv1ZaiJMZN+rR9DK0snx/OPwmtaGH1HbZtlYt4W4s6CzyK541fxLRTjIXfEzIGpvNW+b6VFuFjbcR78p4DEoQ==", - "dependencies": { - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/focus-lock": "2.1.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/transition": "2.1.0", - "aria-hidden": "^1.2.3", - "react-remove-scroll": "^2.5.6" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@chakra-ui/number-input": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/number-input/-/number-input-2.1.2.tgz", - "integrity": "sha512-pfOdX02sqUN0qC2ysuvgVDiws7xZ20XDIlcNhva55Jgm095xjm8eVdIBfNm3SFbSUNxyXvLTW/YQanX74tKmuA==", - "dependencies": { - "@chakra-ui/counter": "2.1.0", - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0", - "@chakra-ui/react-use-interval": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/number-utils": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@chakra-ui/number-utils/-/number-utils-2.0.7.tgz", - "integrity": "sha512-yOGxBjXNvLTBvQyhMDqGU0Oj26s91mbAlqKHiuw737AXHt0aPllOthVUqQMeaYLwLCjGMg0jtI7JReRzyi94Dg==" - }, - "node_modules/@chakra-ui/object-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/object-utils/-/object-utils-2.1.0.tgz", - "integrity": "sha512-tgIZOgLHaoti5PYGPTwK3t/cqtcycW0owaiOXoZOcpwwX/vlVb+H1jFsQyWiiwQVPt9RkoSLtxzXamx+aHH+bQ==" - }, - "node_modules/@chakra-ui/pin-input": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/pin-input/-/pin-input-2.1.0.tgz", - "integrity": "sha512-x4vBqLStDxJFMt+jdAHHS8jbh294O53CPQJoL4g228P513rHylV/uPscYUHrVJXRxsHfRztQO9k45jjTYaPRMw==", - "dependencies": { - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/popover": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/popover/-/popover-2.2.1.tgz", - "integrity": "sha512-K+2ai2dD0ljvJnlrzesCDT9mNzLifE3noGKZ3QwLqd/K34Ym1W/0aL1ERSynrcG78NKoXS54SdEzkhCZ4Gn/Zg==", - "dependencies": { - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-animation-state": "2.1.0", - "@chakra-ui/react-use-disclosure": "2.1.0", - "@chakra-ui/react-use-focus-effect": "2.1.0", - "@chakra-ui/react-use-focus-on-pointer-down": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/popper": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/popper/-/popper-3.1.0.tgz", - "integrity": "sha512-ciDdpdYbeFG7og6/6J8lkTFxsSvwTdMLFkpVylAF6VNC22jssiWfquj2eyD4rJnzkRFPvIWJq8hvbfhsm+AjSg==", - "dependencies": { - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@popperjs/core": "^2.9.3" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/portal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/portal/-/portal-2.1.0.tgz", - "integrity": "sha512-9q9KWf6SArEcIq1gGofNcFPSWEyl+MfJjEUg/un1SMlQjaROOh3zYr+6JAwvcORiX7tyHosnmWC3d3wI2aPSQg==", - "dependencies": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@chakra-ui/progress": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/progress/-/progress-2.2.0.tgz", - "integrity": "sha512-qUXuKbuhN60EzDD9mHR7B67D7p/ZqNS2Aze4Pbl1qGGZfulPW0PY8Rof32qDtttDQBkzQIzFGE8d9QpAemToIQ==", - "dependencies": { - "@chakra-ui/react-context": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/provider": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/provider/-/provider-2.4.2.tgz", - "integrity": "sha512-w0Tef5ZCJK1mlJorcSjItCSbyvVuqpvyWdxZiVQmE6fvSJR83wZof42ux0+sfWD+I7rHSfj+f9nzhNaEWClysw==", - "dependencies": { - "@chakra-ui/css-reset": "2.3.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-env": "3.1.0", - "@chakra-ui/system": "2.6.2", - "@chakra-ui/utils": "2.0.15" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0", - "@emotion/styled": "^11.0.0", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@chakra-ui/radio": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/radio/-/radio-2.1.2.tgz", - "integrity": "sha512-n10M46wJrMGbonaghvSRnZ9ToTv/q76Szz284gv4QUWvyljQACcGrXIONUnQ3BIwbOfkRqSk7Xl/JgZtVfll+w==", - "dependencies": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@zag-js/focus-visible": "0.16.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.8.2.tgz", - "integrity": "sha512-Hn0moyxxyCDKuR9ywYpqgX8dvjqwu9ArwpIb9wHNYjnODETjLwazgNIliCVBRcJvysGRiV51U2/JtJVrpeCjUQ==", - "dependencies": { - "@chakra-ui/accordion": "2.3.1", - "@chakra-ui/alert": "2.2.2", - "@chakra-ui/avatar": "2.3.0", - "@chakra-ui/breadcrumb": "2.2.0", - "@chakra-ui/button": "2.1.0", - "@chakra-ui/card": "2.2.0", - "@chakra-ui/checkbox": "2.3.2", - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/control-box": "2.1.0", - "@chakra-ui/counter": "2.1.0", - "@chakra-ui/css-reset": "2.3.0", - "@chakra-ui/editable": "3.1.0", - "@chakra-ui/focus-lock": "2.1.0", - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/hooks": "2.2.1", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/image": "2.1.0", - "@chakra-ui/input": "2.1.2", - "@chakra-ui/layout": "2.3.1", - "@chakra-ui/live-region": "2.1.0", - "@chakra-ui/media-query": "3.3.0", - "@chakra-ui/menu": "2.2.1", - "@chakra-ui/modal": "2.3.1", - "@chakra-ui/number-input": "2.1.2", - "@chakra-ui/pin-input": "2.1.0", - "@chakra-ui/popover": "2.2.1", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/progress": "2.2.0", - "@chakra-ui/provider": "2.4.2", - "@chakra-ui/radio": "2.1.2", - "@chakra-ui/react-env": "3.1.0", - "@chakra-ui/select": "2.1.2", - "@chakra-ui/skeleton": "2.1.0", - "@chakra-ui/skip-nav": "2.1.0", - "@chakra-ui/slider": "2.1.0", - "@chakra-ui/spinner": "2.1.0", - "@chakra-ui/stat": "2.1.1", - "@chakra-ui/stepper": "2.3.1", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/switch": "2.1.2", - "@chakra-ui/system": "2.6.2", - "@chakra-ui/table": "2.1.0", - "@chakra-ui/tabs": "3.0.0", - "@chakra-ui/tag": "3.1.1", - "@chakra-ui/textarea": "2.1.2", - "@chakra-ui/theme": "3.3.1", - "@chakra-ui/theme-utils": "2.0.21", - "@chakra-ui/toast": "7.0.2", - "@chakra-ui/tooltip": "2.3.1", - "@chakra-ui/transition": "2.1.0", - "@chakra-ui/utils": "2.0.15", - "@chakra-ui/visually-hidden": "2.2.0" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0", - "@emotion/styled": "^11.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@chakra-ui/react-children-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-children-utils/-/react-children-utils-2.0.6.tgz", - "integrity": "sha512-QVR2RC7QsOsbWwEnq9YduhpqSFnZGvjjGREV8ygKi8ADhXh93C8azLECCUVgRJF2Wc+So1fgxmjLcbZfY2VmBA==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-context": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-context/-/react-context-2.1.0.tgz", - "integrity": "sha512-iahyStvzQ4AOwKwdPReLGfDesGG+vWJfEsn0X/NoGph/SkN+HXtv2sCfYFFR9k7bb+Kvc6YfpLlSuLvKMHi2+w==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-env": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-env/-/react-env-3.1.0.tgz", - "integrity": "sha512-Vr96GV2LNBth3+IKzr/rq1IcnkXv+MLmwjQH6C8BRtn3sNskgDFD5vLkVXcEhagzZMCh8FR3V/bzZPojBOyNhw==", - "dependencies": { - "@chakra-ui/react-use-safe-layout-effect": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-types/-/react-types-2.0.7.tgz", - "integrity": "sha512-12zv2qIZ8EHwiytggtGvo4iLT0APris7T0qaAWqzpUGS0cdUtR8W+V1BJ5Ocq+7tA6dzQ/7+w5hmXih61TuhWQ==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-animation-state": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-animation-state/-/react-use-animation-state-2.1.0.tgz", - "integrity": "sha512-CFZkQU3gmDBwhqy0vC1ryf90BVHxVN8cTLpSyCpdmExUEtSEInSCGMydj2fvn7QXsz/za8JNdO2xxgJwxpLMtg==", - "dependencies": { - "@chakra-ui/dom-utils": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-callback-ref": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-callback-ref/-/react-use-callback-ref-2.1.0.tgz", - "integrity": "sha512-efnJrBtGDa4YaxDzDE90EnKD3Vkh5a1t3w7PhnRQmsphLy3g2UieasoKTlT2Hn118TwDjIv5ZjHJW6HbzXA9wQ==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-controllable-state": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-controllable-state/-/react-use-controllable-state-2.1.0.tgz", - "integrity": "sha512-QR/8fKNokxZUs4PfxjXuwl0fj/d71WPrmLJvEpCTkHjnzu7LnYvzoe2wB867IdooQJL0G1zBxl0Dq+6W1P3jpg==", - "dependencies": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-disclosure": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-disclosure/-/react-use-disclosure-2.1.0.tgz", - "integrity": "sha512-Ax4pmxA9LBGMyEZJhhUZobg9C0t3qFE4jVF1tGBsrLDcdBeLR9fwOogIPY9Hf0/wqSlAryAimICbr5hkpa5GSw==", - "dependencies": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-event-listener": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-event-listener/-/react-use-event-listener-2.1.0.tgz", - "integrity": "sha512-U5greryDLS8ISP69DKDsYcsXRtAdnTQT+jjIlRYZ49K/XhUR/AqVZCK5BkR1spTDmO9H8SPhgeNKI70ODuDU/Q==", - "dependencies": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-focus-effect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-focus-effect/-/react-use-focus-effect-2.1.0.tgz", - "integrity": "sha512-xzVboNy7J64xveLcxTIJ3jv+lUJKDwRM7Szwn9tNzUIPD94O3qwjV7DDCUzN2490nSYDF4OBMt/wuDBtaR3kUQ==", - "dependencies": { - "@chakra-ui/dom-utils": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-focus-on-pointer-down": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-focus-on-pointer-down/-/react-use-focus-on-pointer-down-2.1.0.tgz", - "integrity": "sha512-2jzrUZ+aiCG/cfanrolsnSMDykCAbv9EK/4iUyZno6BYb3vziucmvgKuoXbMPAzWNtwUwtuMhkby8rc61Ue+Lg==", - "dependencies": { - "@chakra-ui/react-use-event-listener": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-interval": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-interval/-/react-use-interval-2.1.0.tgz", - "integrity": "sha512-8iWj+I/+A0J08pgEXP1J1flcvhLBHkk0ln7ZvGIyXiEyM6XagOTJpwNhiu+Bmk59t3HoV/VyvyJTa+44sEApuw==", - "dependencies": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-latest-ref": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-latest-ref/-/react-use-latest-ref-2.1.0.tgz", - "integrity": "sha512-m0kxuIYqoYB0va9Z2aW4xP/5b7BzlDeWwyXCH6QpT2PpW3/281L3hLCm1G0eOUcdVlayqrQqOeD6Mglq+5/xoQ==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-merge-refs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-merge-refs/-/react-use-merge-refs-2.1.0.tgz", - "integrity": "sha512-lERa6AWF1cjEtWSGjxWTaSMvneccnAVH4V4ozh8SYiN9fSPZLlSG3kNxfNzdFvMEhM7dnP60vynF7WjGdTgQbQ==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-outside-click": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-outside-click/-/react-use-outside-click-2.2.0.tgz", - "integrity": "sha512-PNX+s/JEaMneijbgAM4iFL+f3m1ga9+6QK0E5Yh4s8KZJQ/bLwZzdhMz8J/+mL+XEXQ5J0N8ivZN28B82N1kNw==", - "dependencies": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-pan-event": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-pan-event/-/react-use-pan-event-2.1.0.tgz", - "integrity": "sha512-xmL2qOHiXqfcj0q7ZK5s9UjTh4Gz0/gL9jcWPA6GVf+A0Od5imEDa/Vz+533yQKWiNSm1QGrIj0eJAokc7O4fg==", - "dependencies": { - "@chakra-ui/event-utils": "2.0.8", - "@chakra-ui/react-use-latest-ref": "2.1.0", - "framesync": "6.1.2" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-previous": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-previous/-/react-use-previous-2.1.0.tgz", - "integrity": "sha512-pjxGwue1hX8AFcmjZ2XfrQtIJgqbTF3Qs1Dy3d1krC77dEsiCUbQ9GzOBfDc8pfd60DrB5N2tg5JyHbypqh0Sg==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-safe-layout-effect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-safe-layout-effect/-/react-use-safe-layout-effect-2.1.0.tgz", - "integrity": "sha512-Knbrrx/bcPwVS1TorFdzrK/zWA8yuU/eaXDkNj24IrKoRlQrSBFarcgAEzlCHtzuhufP3OULPkELTzz91b0tCw==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-size": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-size/-/react-use-size-2.1.0.tgz", - "integrity": "sha512-tbLqrQhbnqOjzTaMlYytp7wY8BW1JpL78iG7Ru1DlV4EWGiAmXFGvtnEt9HftU0NJ0aJyjgymkxfVGI55/1Z4A==", - "dependencies": { - "@zag-js/element-size": "0.10.5" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-timeout": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-timeout/-/react-use-timeout-2.1.0.tgz", - "integrity": "sha512-cFN0sobKMM9hXUhyCofx3/Mjlzah6ADaEl/AXl5Y+GawB5rgedgAcu2ErAgarEkwvsKdP6c68CKjQ9dmTQlJxQ==", - "dependencies": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-use-update-effect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-update-effect/-/react-use-update-effect-2.1.0.tgz", - "integrity": "sha512-ND4Q23tETaR2Qd3zwCKYOOS1dfssojPLJMLvUtUbW5M9uW1ejYWgGUobeAiOVfSplownG8QYMmHTP86p/v0lbA==", - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/react-utils": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-utils/-/react-utils-2.0.12.tgz", - "integrity": "sha512-GbSfVb283+YA3kA8w8xWmzbjNWk14uhNpntnipHCftBibl0lxtQ9YqMFQLwuFOO0U2gYVocszqqDWX+XNKq9hw==", - "dependencies": { - "@chakra-ui/utils": "2.0.15" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "node_modules/@chakra-ui/select": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/select/-/select-2.1.2.tgz", - "integrity": "sha512-ZwCb7LqKCVLJhru3DXvKXpZ7Pbu1TDZ7N0PdQ0Zj1oyVLJyrpef1u9HR5u0amOpqcH++Ugt0f5JSmirjNlctjA==", - "dependencies": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/shared-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@chakra-ui/shared-utils/-/shared-utils-2.0.5.tgz", - "integrity": "sha512-4/Wur0FqDov7Y0nCXl7HbHzCg4aq86h+SXdoUeuCMD3dSj7dpsVnStLYhng1vxvlbUnLpdF4oz5Myt3i/a7N3Q==" - }, - "node_modules/@chakra-ui/skeleton": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/skeleton/-/skeleton-2.1.0.tgz", - "integrity": "sha512-JNRuMPpdZGd6zFVKjVQ0iusu3tXAdI29n4ZENYwAJEMf/fN0l12sVeirOxkJ7oEL0yOx2AgEYFSKdbcAgfUsAQ==", - "dependencies": { - "@chakra-ui/media-query": "3.3.0", - "@chakra-ui/react-use-previous": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/skip-nav": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/skip-nav/-/skip-nav-2.1.0.tgz", - "integrity": "sha512-Hk+FG+vadBSH0/7hwp9LJnLjkO0RPGnx7gBJWI4/SpoJf3e4tZlWYtwGj0toYY4aGKl93jVghuwGbDBEMoHDug==", - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/slider": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/slider/-/slider-2.1.0.tgz", - "integrity": "sha512-lUOBcLMCnFZiA/s2NONXhELJh6sY5WtbRykPtclGfynqqOo47lwWJx+VP7xaeuhDOPcWSSecWc9Y1BfPOCz9cQ==", - "dependencies": { - "@chakra-ui/number-utils": "2.0.7", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-latest-ref": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-pan-event": "2.1.0", - "@chakra-ui/react-use-size": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/spinner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/spinner/-/spinner-2.1.0.tgz", - "integrity": "sha512-hczbnoXt+MMv/d3gE+hjQhmkzLiKuoTo42YhUG7Bs9OSv2lg1fZHW1fGNRFP3wTi6OIbD044U1P9HK+AOgFH3g==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/stat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/stat/-/stat-2.1.1.tgz", - "integrity": "sha512-LDn0d/LXQNbAn2KaR3F1zivsZCewY4Jsy1qShmfBMKwn6rI8yVlbvu6SiA3OpHS0FhxbsZxQI6HefEoIgtqY6Q==", - "dependencies": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/stepper": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/stepper/-/stepper-2.3.1.tgz", - "integrity": "sha512-ky77lZbW60zYkSXhYz7kbItUpAQfEdycT0Q4bkHLxfqbuiGMf8OmgZOQkOB9uM4v0zPwy2HXhe0vq4Dd0xa55Q==", - "dependencies": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/styled-system": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz", - "integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5", - "csstype": "^3.1.2", - "lodash.mergewith": "4.6.2" - } - }, - "node_modules/@chakra-ui/switch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/switch/-/switch-2.1.2.tgz", - "integrity": "sha512-pgmi/CC+E1v31FcnQhsSGjJnOE2OcND4cKPyTE+0F+bmGm48Q/b5UmKD9Y+CmZsrt/7V3h8KNczowupfuBfIHA==", - "dependencies": { - "@chakra-ui/checkbox": "2.3.2", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/system": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz", - "integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==", - "dependencies": { - "@chakra-ui/color-mode": "2.2.0", - "@chakra-ui/object-utils": "2.1.0", - "@chakra-ui/react-utils": "2.0.12", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/theme-utils": "2.0.21", - "@chakra-ui/utils": "2.0.15", - "react-fast-compare": "3.2.2" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0", - "@emotion/styled": "^11.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/table": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/table/-/table-2.1.0.tgz", - "integrity": "sha512-o5OrjoHCh5uCLdiUb0Oc0vq9rIAeHSIRScc2ExTC9Qg/uVZl2ygLrjToCaKfaaKl1oQexIeAcZDKvPG8tVkHyQ==", - "dependencies": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/tabs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/tabs/-/tabs-3.0.0.tgz", - "integrity": "sha512-6Mlclp8L9lqXmsGWF5q5gmemZXOiOYuh0SGT/7PgJVNPz3LXREXlXg2an4MBUD8W5oTkduCX+3KTMCwRrVrDYw==", - "dependencies": { - "@chakra-ui/clickable": "2.1.0", - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/tag": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/tag/-/tag-3.1.1.tgz", - "integrity": "sha512-Bdel79Dv86Hnge2PKOU+t8H28nm/7Y3cKd4Kfk9k3lOpUh4+nkSGe58dhRzht59lEqa4N9waCgQiBdkydjvBXQ==", - "dependencies": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/textarea": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/textarea/-/textarea-2.1.2.tgz", - "integrity": "sha512-ip7tvklVCZUb2fOHDb23qPy/Fr2mzDOGdkrpbNi50hDCiV4hFX02jdQJdi3ydHZUyVgZVBKPOJ+lT9i7sKA2wA==", - "dependencies": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/theme": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/theme/-/theme-3.3.1.tgz", - "integrity": "sha512-Hft/VaT8GYnItGCBbgWd75ICrIrIFrR7lVOhV/dQnqtfGqsVDlrztbSErvMkoPKt0UgAkd9/o44jmZ6X4U2nZQ==", - "dependencies": { - "@chakra-ui/anatomy": "2.2.2", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/theme-tools": "2.1.2" - }, - "peerDependencies": { - "@chakra-ui/styled-system": ">=2.8.0" - } - }, - "node_modules/@chakra-ui/theme-tools": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/theme-tools/-/theme-tools-2.1.2.tgz", - "integrity": "sha512-Qdj8ajF9kxY4gLrq7gA+Azp8CtFHGO9tWMN2wfF9aQNgG9AuMhPrUzMq9AMQ0MXiYcgNq/FD3eegB43nHVmXVA==", - "dependencies": { - "@chakra-ui/anatomy": "2.2.2", - "@chakra-ui/shared-utils": "2.0.5", - "color2k": "^2.0.2" - }, - "peerDependencies": { - "@chakra-ui/styled-system": ">=2.0.0" - } - }, - "node_modules/@chakra-ui/theme-utils": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/@chakra-ui/theme-utils/-/theme-utils-2.0.21.tgz", - "integrity": "sha512-FjH5LJbT794r0+VSCXB3lT4aubI24bLLRWB+CuRKHijRvsOg717bRdUN/N1fEmEpFnRVrbewttWh/OQs0EWpWw==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/theme": "3.3.1", - "lodash.mergewith": "4.6.2" - } - }, - "node_modules/@chakra-ui/toast": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/toast/-/toast-7.0.2.tgz", - "integrity": "sha512-yvRP8jFKRs/YnkuE41BVTq9nB2v/KDRmje9u6dgDmE5+1bFt3bwjdf9gVbif4u5Ve7F7BGk5E093ARRVtvLvXA==", - "dependencies": { - "@chakra-ui/alert": "2.2.2", - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-timeout": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/theme": "3.3.1" - }, - "peerDependencies": { - "@chakra-ui/system": "2.6.2", - "framer-motion": ">=4.0.0", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@chakra-ui/tooltip": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/tooltip/-/tooltip-2.3.1.tgz", - "integrity": "sha512-Rh39GBn/bL4kZpuEMPPRwYNnccRCL+w9OqamWHIB3Qboxs6h8cOyXfIdGxjo72lvhu1QI/a4KFqkM3St+WfC0A==", - "dependencies": { - "@chakra-ui/dom-utils": "2.1.0", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-disclosure": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "framer-motion": ">=4.0.0", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@chakra-ui/transition": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/transition/-/transition-2.1.0.tgz", - "integrity": "sha512-orkT6T/Dt+/+kVwJNy7zwJ+U2xAZ3EU7M3XCs45RBvUnZDr/u9vdmaM/3D/rOpmQJWgQBwKPJleUXrYWUagEDQ==", - "dependencies": { - "@chakra-ui/shared-utils": "2.0.5" - }, - "peerDependencies": { - "framer-motion": ">=4.0.0", - "react": ">=18" - } - }, - "node_modules/@chakra-ui/utils": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@chakra-ui/utils/-/utils-2.0.15.tgz", - "integrity": "sha512-El4+jL0WSaYYs+rJbuYFDbjmfCcfGDmRY95GO4xwzit6YAPZBLcR65rOEwLps+XWluZTy1xdMrusg/hW0c1aAA==", - "dependencies": { - "@types/lodash.mergewith": "4.6.7", - "css-box-model": "1.2.1", - "framesync": "6.1.2", - "lodash.mergewith": "4.6.2" - } - }, - "node_modules/@chakra-ui/visually-hidden": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/visually-hidden/-/visually-hidden-2.2.0.tgz", - "integrity": "sha512-KmKDg01SrQ7VbTD3+cPWf/UfpF5MSwm3v7MWi0n5t8HnnadT13MF0MJCDSXbBWnzLv1ZKJ6zlyAOeARWX+DpjQ==", - "peerDependencies": { - "@chakra-ui/system": ">=2.0.0", - "react": ">=18" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", - "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/serialize": "^1.1.2", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@emotion/cache": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", - "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", - "dependencies": { - "@emotion/memoize": "^0.8.1", - "@emotion/sheet": "^1.2.2", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", - "dependencies": { - "@emotion/memoize": "^0.8.1" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" - }, - "node_modules/@emotion/react": { - "version": "11.11.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", - "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", - "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", - "dependencies": { - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/unitless": "^0.8.1", - "@emotion/utils": "^1.2.1", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" - }, - "node_modules/@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.8.tgz", - "integrity": "sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz", - "integrity": "sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.8.tgz", - "integrity": "sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz", - "integrity": "sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz", - "integrity": "sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz", - "integrity": "sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz", - "integrity": "sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz", - "integrity": "sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz", - "integrity": "sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz", - "integrity": "sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz", - "integrity": "sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz", - "integrity": "sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz", - "integrity": "sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz", - "integrity": "sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz", - "integrity": "sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz", - "integrity": "sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz", - "integrity": "sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz", - "integrity": "sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz", - "integrity": "sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz", - "integrity": "sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz", - "integrity": "sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz", - "integrity": "sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@hey-api/openapi-ts": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.34.1.tgz", - "integrity": "sha512-7Ak+0nvf4Nhzk04tXGg6h4eM7lnWRgfjCPmMl2MyXrhS5urxd3Bg/PhtpB84u18wnwcM4rIeCUlTwDDQ/OB3NQ==", - "dev": true, - "dependencies": { - "@apidevtools/json-schema-ref-parser": "11.5.4", - "camelcase": "8.0.0", - "commander": "12.0.0", - "handlebars": "4.7.8" - }, - "bin": { - "openapi-ts": "bin/index.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/@hey-api/openapi-ts/node_modules/@apidevtools/json-schema-ref-parser": { - "version": "11.5.4", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.5.4.tgz", - "integrity": "sha512-o2fsypTGU0WxRxbax8zQoHiIB4dyrkwYfcm8TxZ+bx9pCzcWZbQtiMqpgBvWA/nJ2TrGjK5adCLfTH8wUeU/Wg==", - "dev": true, - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.15", - "js-yaml": "^4.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/philsturgeon" - } - }, - "node_modules/@hey-api/openapi-ts/node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@hey-api/openapi-ts/node_modules/commander": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", - "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true - }, - "node_modules/@playwright/test": { - "version": "1.45.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz", - "integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==", - "dev": true, - "dependencies": { - "playwright": "1.45.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.6.1.tgz", - "integrity": "sha512-0WQ0ouLejaUCRsL93GD4uft3rOmB8qoQMU05Kb8CmMtMBe7XUDLAltxVZI1q6byNqEtU7N1ZX1Vw5lIpgulLQA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.6.1.tgz", - "integrity": "sha512-1TKm25Rn20vr5aTGGZqo6E4mzPicCUD79k17EgTLAsXc1zysyi4xXKACfUbwyANEPAEIxkzwue6JZ+stYzWUTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.6.1.tgz", - "integrity": "sha512-cEXJQY/ZqMACb+nxzDeX9IPLAg7S94xouJJCNVE5BJM8JUEP4HeTF+ti3cmxWeSJo+5D+o8Tc0UAWUkfENdeyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.6.1.tgz", - "integrity": "sha512-LoSU9Xu56isrkV2jLldcKspJ7sSXmZWkAxg7sW/RfF7GS4F5/v4EiqKSMCFbZtDu2Nc1gxxFdQdKwkKS4rwxNg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.6.1.tgz", - "integrity": "sha512-EfI3hzYAy5vFNDqpXsNxXcgRDcFHUWSx5nnRSCKwXuQlI5J9dD84g2Usw81n3FLBNsGCegKGwwTVsSKK9cooSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.6.1.tgz", - "integrity": "sha512-9lhc4UZstsegbNLhH0Zu6TqvDfmhGzuCWtcTFXY10VjLLUe4Mr0Ye2L3rrtHaDd/J5+tFMEuo5LTCSCMXWfUKw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.6.1.tgz", - "integrity": "sha512-FfoOK1yP5ksX3wwZ4Zk1NgyGHZyuRhf99j64I5oEmirV8EFT7+OhUZEnP+x17lcP/QHJNWGsoJwrz4PJ9fBEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.6.1.tgz", - "integrity": "sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.6.1.tgz", - "integrity": "sha512-RkJVNVRM+piYy87HrKmhbexCHg3A6Z6MU0W9GHnJwBQNBeyhCJG9KDce4SAMdicQnpURggSvtbGo9xAWOfSvIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.6.1.tgz", - "integrity": "sha512-v2FVT6xfnnmTe3W9bJXl6r5KwJglMK/iRlkKiIFfO6ysKs0rDgz7Cwwf3tjldxQUrHL9INT/1r4VA0n9L/F1vQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.6.1.tgz", - "integrity": "sha512-YEeOjxRyEjqcWphH9dyLbzgkF8wZSKAKUkldRY6dgNR5oKs2LZazqGB41cWJ4Iqqcy9/zqYgmzBkRoVz3Q9MLw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz", - "integrity": "sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@swc/core": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.100.tgz", - "integrity": "sha512-7dKgTyxJjlrMwFZYb1auj3Xq0D8ZBe+5oeIgfMlRU05doXZypYJe0LAk0yjj3WdbwYzpF+T1PLxwTWizI0pckw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.100", - "@swc/core-darwin-x64": "1.3.100", - "@swc/core-linux-arm64-gnu": "1.3.100", - "@swc/core-linux-arm64-musl": "1.3.100", - "@swc/core-linux-x64-gnu": "1.3.100", - "@swc/core-linux-x64-musl": "1.3.100", - "@swc/core-win32-arm64-msvc": "1.3.100", - "@swc/core-win32-ia32-msvc": "1.3.100", - "@swc/core-win32-x64-msvc": "1.3.100" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz", - "integrity": "sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.100.tgz", - "integrity": "sha512-KF/MXrnH1nakm1wbt4XV8FS7kvqD9TGmVxeJ0U4bbvxXMvzeYUurzg3AJUTXYmXDhH/VXOYJE5N5RkwZZPs5iA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.100.tgz", - "integrity": "sha512-p8hikNnAEJrw5vHCtKiFT4hdlQxk1V7vqPmvUDgL/qe2menQDK/i12tbz7/3BEQ4UqUPnvwpmVn2d19RdEMNxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.100.tgz", - "integrity": "sha512-BWx/0EeY89WC4q3AaIaBSGfQxkYxIlS3mX19dwy2FWJs/O+fMvF9oLk/CyJPOZzbp+1DjGeeoGFuDYpiNO91JA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.100.tgz", - "integrity": "sha512-XUdGu3dxAkjsahLYnm8WijPfKebo+jHgHphDxaW0ovI6sTdmEGFDew7QzKZRlbYL2jRkUuuKuDGvD6lO5frmhA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.100.tgz", - "integrity": "sha512-PhoXKf+f0OaNW/GCuXjJ0/KfK9EJX7z2gko+7nVnEA0p3aaPtbP6cq1Ubbl6CMoPL+Ci3gZ7nYumDqXNc3CtLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.100.tgz", - "integrity": "sha512-PwLADZN6F9cXn4Jw52FeP/MCLVHm8vwouZZSOoOScDtihjY495SSjdPnlosMaRSR4wJQssGwiD/4MbpgQPqbAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.100.tgz", - "integrity": "sha512-0f6nicKSLlDKlyPRl2JEmkpBV4aeDfRQg6n8mPqgL7bliZIcDahG0ej+HxgNjZfS3e0yjDxsNRa6sAqWU2Z60A==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.100.tgz", - "integrity": "sha512-b7J0rPoMkRTa3XyUGt8PwCaIBuYWsL2DqbirrQKRESzgCvif5iNpqaM6kjIjI/5y5q1Ycv564CB51YDpiS8EtQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", - "integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", - "dev": true - }, - "node_modules/@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true - }, - "node_modules/@tanstack/history": { - "version": "1.15.13", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.15.13.tgz", - "integrity": "sha512-ToaeMtK5S4YaxCywAlYexc7KPFN0esjyTZ4vXzJhXEWAkro9iHgh7m/4ozPJb7oTo65WkHWX0W9GjcZbInSD8w==", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.28.13", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.28.13.tgz", - "integrity": "sha512-C3+CCOcza+mrZ7LglQbjeYEOTEC3LV0VN0eYaIN6GvqAZ8Foegdgch7n6QYPtT4FuLae5ALy+m+ZMEKpD6tMCQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/query-devtools": { - "version": "5.28.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.28.10.tgz", - "integrity": "sha512-5UN629fKa5/1K/2Pd26gaU7epxRrYiT1gy+V+pW5K6hnf1DeUKK3pANSb2eHKlecjIKIhTwyF7k9XdyE2gREvQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.28.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.28.14.tgz", - "integrity": "sha512-cZqt03Igb3I9tM72qNX5TAAmeYl75Z+k4Mv92VkXIXc2hCrv0fIywd7GN3JV1BBJl4mr7Cc+OOKKOPy8sNVOkA==", - "dependencies": { - "@tanstack/query-core": "5.28.13" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18.0.0" - } - }, - "node_modules/@tanstack/react-query-devtools": { - "version": "5.28.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.28.14.tgz", - "integrity": "sha512-4CrFBI1O5wibV1ZdGAnBMmTuc7SiShhxWubxRMyIloeEioxs3DQkFbouGBea5nexuwIxAkvhUB8khpPnNjhxMw==", - "dependencies": { - "@tanstack/query-devtools": "5.28.10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/react-query": "^5.28.14", - "react": "^18.0.0" - } - }, - "node_modules/@tanstack/react-router": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.19.1.tgz", - "integrity": "sha512-a4Xf074qo2fQLmSi8PTncEFn8XakaH3+DT7Dted4OPClzQFS+c6yU3HONVNAsuYWZ7lDK1HMKoHPDFbnHPEWvA==", - "dependencies": { - "@tanstack/history": "1.15.13", - "@tanstack/react-store": "^0.2.1", - "tiny-invariant": "^1.3.1", - "tiny-warning": "^1.0.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/@tanstack/react-store": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.2.1.tgz", - "integrity": "sha512-tEbMCQjbeVw9KOP/202LfqZMSNAVi6zYkkp1kBom8nFuMx/965Hzes3+6G6b/comCwVxoJU8Gg9IrcF8yRPthw==", - "dependencies": { - "@tanstack/store": "0.1.3", - "use-sync-external-store": "^1.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/@tanstack/router-devtools": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.19.1.tgz", - "integrity": "sha512-l560JHnffcDccSTo/sOtB+gKvtgaWYpOKOu9MyvswN9XB2pt752UFFIN1Yt/Gsp2Iooq/FcYlYnEPHb4GFzalg==", - "dev": true, - "dependencies": { - "@tanstack/react-router": "1.19.1", - "clsx": "^2.1.0", - "date-fns": "^2.29.1", - "goober": "^2.1.14" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/@tanstack/router-generator": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.19.0.tgz", - "integrity": "sha512-vFF8Q7SdyygiYC7lfJ83GRif0vcxjak9SAcgtX/w7TLR0O+qdxRXFPvhKTQQXH6vVezy5Au9bSaSI2EgDD1ubA==", - "dev": true, - "dependencies": { - "prettier": "^3.1.1", - "zod": "^3.22.4" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/router-vite-plugin": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.19.0.tgz", - "integrity": "sha512-yvvQnJ7JvqsnxAFqwiHhNTV2n1jKkidjc+XbgS2aNnEHC0aHnYH2ygPlmmfiVD7PMO7x64PdI5e12TzY/aKoFA==", - "dev": true, - "dependencies": { - "@tanstack/router-generator": "1.19.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/store": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.1.3.tgz", - "integrity": "sha512-GnolmC8Fr4mvsHE1fGQmR3Nm0eBO3KnZjDU0a+P3TeQNM/dDscFGxtA7p31NplQNW3KwBw4t1RVFmz0VeKLxcw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==" - }, - "node_modules/@types/lodash.mergewith": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/@types/lodash.mergewith/-/lodash.mergewith-4.6.7.tgz", - "integrity": "sha512-3m+lkO5CLRRYU0fhGRp7zbsGi6+BZj0uTVSwvcKU+nSlhjA9/QRNfuSGnD2mX6hQA7ZbmcCkzk5h4ZYGOtk14A==", - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/node": { - "version": "20.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", - "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.11", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "devOptional": true - }, - "node_modules/@types/react": { - "version": "18.2.39", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz", - "integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==", - "devOptional": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.2.17", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz", - "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "devOptional": true - }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.5.0.tgz", - "integrity": "sha512-1PrOvAaDpqlCV+Up8RkAh9qaiUjoDUcjtttyhXDKw53XA6Ve16SOp6cCOpRs8Dj8DqUQs6eTW5YkLcLJjrXAig==", - "dev": true, - "dependencies": { - "@swc/core": "^1.3.96" - }, - "peerDependencies": { - "vite": "^4 || ^5" - } - }, - "node_modules/@zag-js/dom-query": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.16.0.tgz", - "integrity": "sha512-Oqhd6+biWyKnhKwFFuZrrf6lxBz2tX2pRQe6grUnYwO6HJ8BcbqZomy2lpOdr+3itlaUqx+Ywj5E5ZZDr/LBfQ==" - }, - "node_modules/@zag-js/element-size": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@zag-js/element-size/-/element-size-0.10.5.tgz", - "integrity": "sha512-uQre5IidULANvVkNOBQ1tfgwTQcGl4hliPSe69Fct1VfYb2Fd0jdAcGzqQgPhfrXFpR62MxLPB7erxJ/ngtL8w==" - }, - "node_modules/@zag-js/focus-visible": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-0.16.0.tgz", - "integrity": "sha512-a7U/HSopvQbrDU4GLerpqiMcHKEkQkNPeDZJWz38cw/6Upunh41GjHetq5TB84hxyCaDzJ6q2nEdNoBQfC0FKA==", - "dependencies": { - "@zag-js/dom-query": "0.16.0" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", - "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/color2k": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", - "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/compute-scroll-into-view": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.0.3.tgz", - "integrity": "sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==" - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" - }, - "node_modules/copy-to-clipboard": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", - "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", - "dependencies": { - "toggle-selection": "^1.0.6" - } - }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/css-box-model": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", - "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", - "dependencies": { - "tiny-invariant": "^1.0.6" - } - }, - "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" - }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/esbuild": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.8.tgz", - "integrity": "sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.19.8", - "@esbuild/android-arm64": "0.19.8", - "@esbuild/android-x64": "0.19.8", - "@esbuild/darwin-arm64": "0.19.8", - "@esbuild/darwin-x64": "0.19.8", - "@esbuild/freebsd-arm64": "0.19.8", - "@esbuild/freebsd-x64": "0.19.8", - "@esbuild/linux-arm": "0.19.8", - "@esbuild/linux-arm64": "0.19.8", - "@esbuild/linux-ia32": "0.19.8", - "@esbuild/linux-loong64": "0.19.8", - "@esbuild/linux-mips64el": "0.19.8", - "@esbuild/linux-ppc64": "0.19.8", - "@esbuild/linux-riscv64": "0.19.8", - "@esbuild/linux-s390x": "0.19.8", - "@esbuild/linux-x64": "0.19.8", - "@esbuild/netbsd-x64": "0.19.8", - "@esbuild/openbsd-x64": "0.19.8", - "@esbuild/sunos-x64": "0.19.8", - "@esbuild/win32-arm64": "0.19.8", - "@esbuild/win32-ia32": "0.19.8", - "@esbuild/win32-x64": "0.19.8" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" - }, - "node_modules/focus-lock": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.3.tgz", - "integrity": "sha512-hfXkZha7Xt4RQtrL1HBfspAuIj89Y0fb6GX0dfJilb8S2G/lvL4akPAcHq6xoD2NuZnDMCnZL/zQesMyeu6Psg==", - "dependencies": { - "tslib": "^2.0.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/framer-motion": { - "version": "10.16.16", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.16.tgz", - "integrity": "sha512-je6j91rd7NmUX7L1XHouwJ4v3R+SO4umso2LUcgOct3rHZ0PajZ80ETYZTajzEXEl9DlKyzjyt4AvGQ+lrebOw==", - "dependencies": { - "tslib": "^2.4.0" - }, - "optionalDependencies": { - "@emotion/is-prop-valid": "^0.8.2" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "dependencies": { - "@emotion/memoize": "0.7.4" - } - }, - "node_modules/framer-motion/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, - "node_modules/framesync": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.1.2.tgz", - "integrity": "sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==", - "dependencies": { - "tslib": "2.4.0" - } - }, - "node_modules/framesync/node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/goober": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", - "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", - "dev": true, - "peerDependencies": { - "csstype": "^3.0.10" - } - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/playwright": { - "version": "1.45.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz", - "integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==", - "dev": true, - "dependencies": { - "playwright-core": "1.45.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.45.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz", - "integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-clientside-effect": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", - "integrity": "sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==", - "dependencies": { - "@babel/runtime": "^7.12.13" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, - "node_modules/react-error-boundary": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", - "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "peerDependencies": { - "react": ">=16.13.1" - } - }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" - }, - "node_modules/react-focus-lock": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.11.1.tgz", - "integrity": "sha512-IXLwnTBrLTlKTpASZXqqXJ8oymWrgAlOfuuDYN4XCuN1YJ72dwX198UCaF1QqGUk5C3QOnlMik//n3ufcfe8Ig==", - "dependencies": { - "@babel/runtime": "^7.0.0", - "focus-lock": "^1.3.2", - "prop-types": "^15.6.2", - "react-clientside-effect": "^1.2.6", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-hook-form": { - "version": "7.49.3", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.49.3.tgz", - "integrity": "sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==", - "engines": { - "node": ">=18", - "pnpm": "8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18" - } - }, - "node_modules/react-icons": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz", - "integrity": "sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz", - "integrity": "sha512-3cqjOqg6s0XbOjWvmasmqHch+RLxIEk2r/70rzGXuz3iIGQsQheEQyqYCBb5EECoD01Vo2SIbDqW4paLeLTASw==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/rollup": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.6.1.tgz", - "integrity": "sha512-jZHaZotEHQaHLgKr8JnQiDT1rmatjgKlMekyksz+yk9jt/8z9quNjnKNRoaM0wd9DC2QKXjmWWuDYtM3jfF8pQ==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.6.1", - "@rollup/rollup-android-arm64": "4.6.1", - "@rollup/rollup-darwin-arm64": "4.6.1", - "@rollup/rollup-darwin-x64": "4.6.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.6.1", - "@rollup/rollup-linux-arm64-gnu": "4.6.1", - "@rollup/rollup-linux-arm64-musl": "4.6.1", - "@rollup/rollup-linux-x64-gnu": "4.6.1", - "@rollup/rollup-linux-x64-musl": "4.6.1", - "@rollup/rollup-win32-arm64-msvc": "4.6.1", - "@rollup/rollup-win32-ia32-msvc": "4.6.1", - "@rollup/rollup-win32-x64-msvc": "4.6.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, - "node_modules/toggle-selection": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", - "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "node_modules/typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uglify-js": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", - "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", - "dev": true, - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "node_modules/use-callback-ref": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.1.tgz", - "integrity": "sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/vite": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.13.tgz", - "integrity": "sha512-/9ovhv2M2dGTuA+dY93B9trfyWMDRQw2jdVBhHNP6wr0oF34wG2i/N55801iZIpgUpnHDm4F/FabGQLyc+eOgg==", - "dev": true, - "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "requires": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "requires": { - "@babel/types": "^7.22.15" - } - }, - "@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==" - }, - "@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" - }, - "@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "requires": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", - "requires": { - "regenerator-runtime": "^0.14.0" - } - }, - "@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", - "requires": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - } - }, - "@biomejs/biome": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.6.1.tgz", - "integrity": "sha512-SILQvA2S0XeaOuu1bivv6fQmMo7zMfr2xqDEN+Sz78pGbAKZnGmg0emsXjQWoBY/RVm9kPCgX+aGEpZZTYaM7w==", - "dev": true, - "requires": { - "@biomejs/cli-darwin-arm64": "1.6.1", - "@biomejs/cli-darwin-x64": "1.6.1", - "@biomejs/cli-linux-arm64": "1.6.1", - "@biomejs/cli-linux-arm64-musl": "1.6.1", - "@biomejs/cli-linux-x64": "1.6.1", - "@biomejs/cli-linux-x64-musl": "1.6.1", - "@biomejs/cli-win32-arm64": "1.6.1", - "@biomejs/cli-win32-x64": "1.6.1" - } - }, - "@biomejs/cli-darwin-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.6.1.tgz", - "integrity": "sha512-KlvY00iB9T/vFi4m/GXxEyYkYnYy6aw06uapzUIIdiMMj7I/pmZu7CsZlzWdekVD0j+SsQbxdZMsb0wPhnRSsg==", - "dev": true, - "optional": true - }, - "@biomejs/cli-darwin-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.6.1.tgz", - "integrity": "sha512-jP4E8TXaQX5e3nvRJSzB+qicZrdIDCrjR0sSb1DaDTx4JPZH5WXq/BlTqAyWi3IijM+IYMjWqAAK4kOHsSCzxw==", - "dev": true, - "optional": true - }, - "@biomejs/cli-linux-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.6.1.tgz", - "integrity": "sha512-nxD1UyX3bWSl/RSKlib/JsOmt+652/9yieogdSC/UTLgVCZYOF7u8L/LK7kAa0Y4nA8zSPavAQTgko7mHC2ObA==", - "dev": true, - "optional": true - }, - "@biomejs/cli-linux-arm64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.1.tgz", - "integrity": "sha512-YdkDgFecdHJg7PJxAMaZIixVWGB6St4yH08BHagO0fEhNNiY8cAKEVo2mcXlsnEiTMpeSEAY9VxLUrVT3IVxpw==", - "dev": true, - "optional": true - }, - "@biomejs/cli-linux-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.6.1.tgz", - "integrity": "sha512-BYAzenlMF3QdngjNFw9QVBXKGNzeecqwF3pwDgUGEvU7OJpn1/lyVkJVxYPtVGRNdjQ9e6l/s8NjKuBpW/ZR4Q==", - "dev": true, - "optional": true - }, - "@biomejs/cli-linux-x64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.1.tgz", - "integrity": "sha512-aSISIDmxq04NNy7tm4x9rBk2vH0ub2VDIE4outEmdC2LBtEJoINiphlZagx/FvjbsqUfygent9QUSn0oREnAXg==", - "dev": true, - "optional": true - }, - "@biomejs/cli-win32-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.6.1.tgz", - "integrity": "sha512-/eCHQKZ1kEawUpkSuXq4urtxMsD1P1678OPG3zNKt3ru16AqqspLdO3jzBe3k74xCPYnQ36e9Yqc97Mo0qgPtg==", - "dev": true, - "optional": true - }, - "@biomejs/cli-win32-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.6.1.tgz", - "integrity": "sha512-5TUZbzBwnDLFxLVGEPsorNi6eC2Gt+z4Oei9Qvq0M/4c4/mjZ96ABgwao/tMxf4ZBr/qyy2YdvF+gX9Rc+xC0A==", - "dev": true, - "optional": true - }, - "@chakra-ui/accordion": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/accordion/-/accordion-2.3.1.tgz", - "integrity": "sha512-FSXRm8iClFyU+gVaXisOSEw0/4Q+qZbFRiuhIAkVU6Boj0FxAMrlo9a8AV5TuF77rgaHytCdHk0Ng+cyUijrag==", - "requires": { - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/transition": "2.1.0" - } - }, - "@chakra-ui/alert": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/alert/-/alert-2.2.2.tgz", - "integrity": "sha512-jHg4LYMRNOJH830ViLuicjb3F+v6iriE/2G5T+Sd0Hna04nukNJ1MxUmBPE+vI22me2dIflfelu2v9wdB6Pojw==", - "requires": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/spinner": "2.1.0" - } - }, - "@chakra-ui/anatomy": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/anatomy/-/anatomy-2.2.2.tgz", - "integrity": "sha512-MV6D4VLRIHr4PkW4zMyqfrNS1mPlCTiCXwvYGtDFQYr+xHFfonhAuf9WjsSc0nyp2m0OdkSLnzmVKkZFLo25Tg==" - }, - "@chakra-ui/avatar": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/avatar/-/avatar-2.3.0.tgz", - "integrity": "sha512-8gKSyLfygnaotbJbDMHDiJoF38OHXUYVme4gGxZ1fLnQEdPVEaIWfH+NndIjOM0z8S+YEFnT9KyGMUtvPrBk3g==", - "requires": { - "@chakra-ui/image": "2.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/breadcrumb": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/breadcrumb/-/breadcrumb-2.2.0.tgz", - "integrity": "sha512-4cWCG24flYBxjruRi4RJREWTGF74L/KzI2CognAW/d/zWR0CjiScuJhf37Am3LFbCySP6WSoyBOtTIoTA4yLEA==", - "requires": { - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/breakpoint-utils": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@chakra-ui/breakpoint-utils/-/breakpoint-utils-2.0.8.tgz", - "integrity": "sha512-Pq32MlEX9fwb5j5xx8s18zJMARNHlQZH2VH1RZgfgRDpp7DcEgtRW5AInfN5CfqdHLO1dGxA7I3MqEuL5JnIsA==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/button": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/button/-/button-2.1.0.tgz", - "integrity": "sha512-95CplwlRKmmUXkdEp/21VkEWgnwcx2TOBG6NfYlsuLBDHSLlo5FKIiE2oSi4zXc4TLcopGcWPNcm/NDaSC5pvA==", - "requires": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/spinner": "2.1.0" - } - }, - "@chakra-ui/card": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/card/-/card-2.2.0.tgz", - "integrity": "sha512-xUB/k5MURj4CtPAhdSoXZidUbm8j3hci9vnc+eZJVDqhDOShNlD6QeniQNRPRys4lWAQLCbFcrwL29C8naDi6g==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/checkbox": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/checkbox/-/checkbox-2.3.2.tgz", - "integrity": "sha512-85g38JIXMEv6M+AcyIGLh7igNtfpAN6KGQFYxY9tBj0eWvWk4NKQxvqqyVta0bSAyIl1rixNIIezNpNWk2iO4g==", - "requires": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/visually-hidden": "2.2.0", - "@zag-js/focus-visible": "0.16.0" - } - }, - "@chakra-ui/clickable": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/clickable/-/clickable-2.1.0.tgz", - "integrity": "sha512-flRA/ClPUGPYabu+/GLREZVZr9j2uyyazCAUHAdrTUEdDYCr31SVGhgh7dgKdtq23bOvAQJpIJjw/0Bs0WvbXw==", - "requires": { - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/close-button": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/close-button/-/close-button-2.1.1.tgz", - "integrity": "sha512-gnpENKOanKexswSVpVz7ojZEALl2x5qjLYNqSQGbxz+aP9sOXPfUS56ebyBrre7T7exuWGiFeRwnM0oVeGPaiw==", - "requires": { - "@chakra-ui/icon": "3.2.0" - } - }, - "@chakra-ui/color-mode": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/color-mode/-/color-mode-2.2.0.tgz", - "integrity": "sha512-niTEA8PALtMWRI9wJ4LL0CSBDo8NBfLNp4GD6/0hstcm3IlbBHTVKxN6HwSaoNYfphDQLxCjT4yG+0BJA5tFpg==", - "requires": { - "@chakra-ui/react-use-safe-layout-effect": "2.1.0" - } - }, - "@chakra-ui/control-box": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/control-box/-/control-box-2.1.0.tgz", - "integrity": "sha512-gVrRDyXFdMd8E7rulL0SKeoljkLQiPITFnsyMO8EFHNZ+AHt5wK4LIguYVEq88APqAGZGfHFWXr79RYrNiE3Mg==", - "requires": {} - }, - "@chakra-ui/counter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/counter/-/counter-2.1.0.tgz", - "integrity": "sha512-s6hZAEcWT5zzjNz2JIWUBzRubo9la/oof1W7EKZVVfPYHERnl5e16FmBC79Yfq8p09LQ+aqFKm/etYoJMMgghw==", - "requires": { - "@chakra-ui/number-utils": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/css-reset": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/css-reset/-/css-reset-2.3.0.tgz", - "integrity": "sha512-cQwwBy5O0jzvl0K7PLTLgp8ijqLPKyuEMiDXwYzl95seD3AoeuoCLyzZcJtVqaUZ573PiBdAbY/IlZcwDOItWg==", - "requires": {} - }, - "@chakra-ui/descendant": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/descendant/-/descendant-3.1.0.tgz", - "integrity": "sha512-VxCIAir08g5w27klLyi7PVo8BxhW4tgU/lxQyujkmi4zx7hT9ZdrcQLAted/dAa+aSIZ14S1oV0Q9lGjsAdxUQ==", - "requires": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0" - } - }, - "@chakra-ui/dom-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/dom-utils/-/dom-utils-2.1.0.tgz", - "integrity": "sha512-ZmF2qRa1QZ0CMLU8M1zCfmw29DmPNtfjR9iTo74U5FPr3i1aoAh7fbJ4qAlZ197Xw9eAW28tvzQuoVWeL5C7fQ==" - }, - "@chakra-ui/editable": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/editable/-/editable-3.1.0.tgz", - "integrity": "sha512-j2JLrUL9wgg4YA6jLlbU88370eCRyor7DZQD9lzpY95tSOXpTljeg3uF9eOmDnCs6fxp3zDWIfkgMm/ExhcGTg==", - "requires": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-focus-on-pointer-down": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/event-utils": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@chakra-ui/event-utils/-/event-utils-2.0.8.tgz", - "integrity": "sha512-IGM/yGUHS+8TOQrZGpAKOJl/xGBrmRYJrmbHfUE7zrG3PpQyXvbLDP1M+RggkCFVgHlJi2wpYIf0QtQlU0XZfw==" - }, - "@chakra-ui/focus-lock": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/focus-lock/-/focus-lock-2.1.0.tgz", - "integrity": "sha512-EmGx4PhWGjm4dpjRqM4Aa+rCWBxP+Rq8Uc/nAVnD4YVqkEhBkrPTpui2lnjsuxqNaZ24fIAZ10cF1hlpemte/w==", - "requires": { - "@chakra-ui/dom-utils": "2.1.0", - "react-focus-lock": "^2.9.4" - } - }, - "@chakra-ui/form-control": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/form-control/-/form-control-2.2.0.tgz", - "integrity": "sha512-wehLC1t4fafCVJ2RvJQT2jyqsAwX7KymmiGqBu7nQoQz8ApTkGABWpo/QwDh3F/dBLrouHDoOvGmYTqft3Mirw==", - "requires": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/hooks": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/hooks/-/hooks-2.2.1.tgz", - "integrity": "sha512-RQbTnzl6b1tBjbDPf9zGRo9rf/pQMholsOudTxjy4i9GfTfz6kgp5ValGjQm2z7ng6Z31N1cnjZ1AlSzQ//ZfQ==", - "requires": { - "@chakra-ui/react-utils": "2.0.12", - "@chakra-ui/utils": "2.0.15", - "compute-scroll-into-view": "3.0.3", - "copy-to-clipboard": "3.3.3" - } - }, - "@chakra-ui/icon": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/icon/-/icon-3.2.0.tgz", - "integrity": "sha512-xxjGLvlX2Ys4H0iHrI16t74rG9EBcpFvJ3Y3B7KMQTrnW34Kf7Da/UC8J67Gtx85mTHW020ml85SVPKORWNNKQ==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/icons": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/icons/-/icons-2.1.1.tgz", - "integrity": "sha512-3p30hdo4LlRZTT5CwoAJq3G9fHI0wDc0pBaMHj4SUn0yomO+RcDRlzhdXqdr5cVnzax44sqXJVnf3oQG0eI+4g==", - "requires": { - "@chakra-ui/icon": "3.2.0" - } - }, - "@chakra-ui/image": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/image/-/image-2.1.0.tgz", - "integrity": "sha512-bskumBYKLiLMySIWDGcz0+D9Th0jPvmX6xnRMs4o92tT3Od/bW26lahmV2a2Op2ItXeCmRMY+XxJH5Gy1i46VA==", - "requires": { - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/input": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/input/-/input-2.1.2.tgz", - "integrity": "sha512-GiBbb3EqAA8Ph43yGa6Mc+kUPjh4Spmxp1Pkelr8qtudpc3p2PJOOebLpd90mcqw8UePPa+l6YhhPtp6o0irhw==", - "requires": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/object-utils": "2.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/layout": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/layout/-/layout-2.3.1.tgz", - "integrity": "sha512-nXuZ6WRbq0WdgnRgLw+QuxWAHuhDtVX8ElWqcTK+cSMFg/52eVP47czYBE5F35YhnoW2XBwfNoNgZ7+e8Z01Rg==", - "requires": { - "@chakra-ui/breakpoint-utils": "2.0.8", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/object-utils": "2.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/lazy-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@chakra-ui/lazy-utils/-/lazy-utils-2.0.5.tgz", - "integrity": "sha512-UULqw7FBvcckQk2n3iPO56TMJvDsNv0FKZI6PlUNJVaGsPbsYxK/8IQ60vZgaTVPtVcjY6BE+y6zg8u9HOqpyg==" - }, - "@chakra-ui/live-region": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/live-region/-/live-region-2.1.0.tgz", - "integrity": "sha512-ZOxFXwtaLIsXjqnszYYrVuswBhnIHHP+XIgK1vC6DePKtyK590Wg+0J0slDwThUAd4MSSIUa/nNX84x1GMphWw==", - "requires": {} - }, - "@chakra-ui/media-query": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/media-query/-/media-query-3.3.0.tgz", - "integrity": "sha512-IsTGgFLoICVoPRp9ykOgqmdMotJG0CnPsKvGQeSFOB/dZfIujdVb14TYxDU4+MURXry1MhJ7LzZhv+Ml7cr8/g==", - "requires": { - "@chakra-ui/breakpoint-utils": "2.0.8", - "@chakra-ui/react-env": "3.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/menu": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/menu/-/menu-2.2.1.tgz", - "integrity": "sha512-lJS7XEObzJxsOwWQh7yfG4H8FzFPRP5hVPN/CL+JzytEINCSBvsCDHrYPQGp7jzpCi8vnTqQQGQe0f8dwnXd2g==", - "requires": { - "@chakra-ui/clickable": "2.1.0", - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-animation-state": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-disclosure": "2.1.0", - "@chakra-ui/react-use-focus-effect": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-outside-click": "2.2.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/transition": "2.1.0" - } - }, - "@chakra-ui/modal": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/modal/-/modal-2.3.1.tgz", - "integrity": "sha512-TQv1ZaiJMZN+rR9DK0snx/OPwmtaGH1HbZtlYt4W4s6CzyK541fxLRTjIXfEzIGpvNW+b6VFuFjbcR78p4DEoQ==", - "requires": { - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/focus-lock": "2.1.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/transition": "2.1.0", - "aria-hidden": "^1.2.3", - "react-remove-scroll": "^2.5.6" - } - }, - "@chakra-ui/number-input": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/number-input/-/number-input-2.1.2.tgz", - "integrity": "sha512-pfOdX02sqUN0qC2ysuvgVDiws7xZ20XDIlcNhva55Jgm095xjm8eVdIBfNm3SFbSUNxyXvLTW/YQanX74tKmuA==", - "requires": { - "@chakra-ui/counter": "2.1.0", - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0", - "@chakra-ui/react-use-interval": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/number-utils": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@chakra-ui/number-utils/-/number-utils-2.0.7.tgz", - "integrity": "sha512-yOGxBjXNvLTBvQyhMDqGU0Oj26s91mbAlqKHiuw737AXHt0aPllOthVUqQMeaYLwLCjGMg0jtI7JReRzyi94Dg==" - }, - "@chakra-ui/object-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/object-utils/-/object-utils-2.1.0.tgz", - "integrity": "sha512-tgIZOgLHaoti5PYGPTwK3t/cqtcycW0owaiOXoZOcpwwX/vlVb+H1jFsQyWiiwQVPt9RkoSLtxzXamx+aHH+bQ==" - }, - "@chakra-ui/pin-input": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/pin-input/-/pin-input-2.1.0.tgz", - "integrity": "sha512-x4vBqLStDxJFMt+jdAHHS8jbh294O53CPQJoL4g228P513rHylV/uPscYUHrVJXRxsHfRztQO9k45jjTYaPRMw==", - "requires": { - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/popover": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/popover/-/popover-2.2.1.tgz", - "integrity": "sha512-K+2ai2dD0ljvJnlrzesCDT9mNzLifE3noGKZ3QwLqd/K34Ym1W/0aL1ERSynrcG78NKoXS54SdEzkhCZ4Gn/Zg==", - "requires": { - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-animation-state": "2.1.0", - "@chakra-ui/react-use-disclosure": "2.1.0", - "@chakra-ui/react-use-focus-effect": "2.1.0", - "@chakra-ui/react-use-focus-on-pointer-down": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/popper": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/popper/-/popper-3.1.0.tgz", - "integrity": "sha512-ciDdpdYbeFG7og6/6J8lkTFxsSvwTdMLFkpVylAF6VNC22jssiWfquj2eyD4rJnzkRFPvIWJq8hvbfhsm+AjSg==", - "requires": { - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@popperjs/core": "^2.9.3" - } - }, - "@chakra-ui/portal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/portal/-/portal-2.1.0.tgz", - "integrity": "sha512-9q9KWf6SArEcIq1gGofNcFPSWEyl+MfJjEUg/un1SMlQjaROOh3zYr+6JAwvcORiX7tyHosnmWC3d3wI2aPSQg==", - "requires": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0" - } - }, - "@chakra-ui/progress": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/progress/-/progress-2.2.0.tgz", - "integrity": "sha512-qUXuKbuhN60EzDD9mHR7B67D7p/ZqNS2Aze4Pbl1qGGZfulPW0PY8Rof32qDtttDQBkzQIzFGE8d9QpAemToIQ==", - "requires": { - "@chakra-ui/react-context": "2.1.0" - } - }, - "@chakra-ui/provider": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/provider/-/provider-2.4.2.tgz", - "integrity": "sha512-w0Tef5ZCJK1mlJorcSjItCSbyvVuqpvyWdxZiVQmE6fvSJR83wZof42ux0+sfWD+I7rHSfj+f9nzhNaEWClysw==", - "requires": { - "@chakra-ui/css-reset": "2.3.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-env": "3.1.0", - "@chakra-ui/system": "2.6.2", - "@chakra-ui/utils": "2.0.15" - } - }, - "@chakra-ui/radio": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/radio/-/radio-2.1.2.tgz", - "integrity": "sha512-n10M46wJrMGbonaghvSRnZ9ToTv/q76Szz284gv4QUWvyljQACcGrXIONUnQ3BIwbOfkRqSk7Xl/JgZtVfll+w==", - "requires": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@zag-js/focus-visible": "0.16.0" - } - }, - "@chakra-ui/react": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.8.2.tgz", - "integrity": "sha512-Hn0moyxxyCDKuR9ywYpqgX8dvjqwu9ArwpIb9wHNYjnODETjLwazgNIliCVBRcJvysGRiV51U2/JtJVrpeCjUQ==", - "requires": { - "@chakra-ui/accordion": "2.3.1", - "@chakra-ui/alert": "2.2.2", - "@chakra-ui/avatar": "2.3.0", - "@chakra-ui/breadcrumb": "2.2.0", - "@chakra-ui/button": "2.1.0", - "@chakra-ui/card": "2.2.0", - "@chakra-ui/checkbox": "2.3.2", - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/control-box": "2.1.0", - "@chakra-ui/counter": "2.1.0", - "@chakra-ui/css-reset": "2.3.0", - "@chakra-ui/editable": "3.1.0", - "@chakra-ui/focus-lock": "2.1.0", - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/hooks": "2.2.1", - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/image": "2.1.0", - "@chakra-ui/input": "2.1.2", - "@chakra-ui/layout": "2.3.1", - "@chakra-ui/live-region": "2.1.0", - "@chakra-ui/media-query": "3.3.0", - "@chakra-ui/menu": "2.2.1", - "@chakra-ui/modal": "2.3.1", - "@chakra-ui/number-input": "2.1.2", - "@chakra-ui/pin-input": "2.1.0", - "@chakra-ui/popover": "2.2.1", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/progress": "2.2.0", - "@chakra-ui/provider": "2.4.2", - "@chakra-ui/radio": "2.1.2", - "@chakra-ui/react-env": "3.1.0", - "@chakra-ui/select": "2.1.2", - "@chakra-ui/skeleton": "2.1.0", - "@chakra-ui/skip-nav": "2.1.0", - "@chakra-ui/slider": "2.1.0", - "@chakra-ui/spinner": "2.1.0", - "@chakra-ui/stat": "2.1.1", - "@chakra-ui/stepper": "2.3.1", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/switch": "2.1.2", - "@chakra-ui/system": "2.6.2", - "@chakra-ui/table": "2.1.0", - "@chakra-ui/tabs": "3.0.0", - "@chakra-ui/tag": "3.1.1", - "@chakra-ui/textarea": "2.1.2", - "@chakra-ui/theme": "3.3.1", - "@chakra-ui/theme-utils": "2.0.21", - "@chakra-ui/toast": "7.0.2", - "@chakra-ui/tooltip": "2.3.1", - "@chakra-ui/transition": "2.1.0", - "@chakra-ui/utils": "2.0.15", - "@chakra-ui/visually-hidden": "2.2.0" - } - }, - "@chakra-ui/react-children-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-children-utils/-/react-children-utils-2.0.6.tgz", - "integrity": "sha512-QVR2RC7QsOsbWwEnq9YduhpqSFnZGvjjGREV8ygKi8ADhXh93C8azLECCUVgRJF2Wc+So1fgxmjLcbZfY2VmBA==", - "requires": {} - }, - "@chakra-ui/react-context": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-context/-/react-context-2.1.0.tgz", - "integrity": "sha512-iahyStvzQ4AOwKwdPReLGfDesGG+vWJfEsn0X/NoGph/SkN+HXtv2sCfYFFR9k7bb+Kvc6YfpLlSuLvKMHi2+w==", - "requires": {} - }, - "@chakra-ui/react-env": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-env/-/react-env-3.1.0.tgz", - "integrity": "sha512-Vr96GV2LNBth3+IKzr/rq1IcnkXv+MLmwjQH6C8BRtn3sNskgDFD5vLkVXcEhagzZMCh8FR3V/bzZPojBOyNhw==", - "requires": { - "@chakra-ui/react-use-safe-layout-effect": "2.1.0" - } - }, - "@chakra-ui/react-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-types/-/react-types-2.0.7.tgz", - "integrity": "sha512-12zv2qIZ8EHwiytggtGvo4iLT0APris7T0qaAWqzpUGS0cdUtR8W+V1BJ5Ocq+7tA6dzQ/7+w5hmXih61TuhWQ==", - "requires": {} - }, - "@chakra-ui/react-use-animation-state": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-animation-state/-/react-use-animation-state-2.1.0.tgz", - "integrity": "sha512-CFZkQU3gmDBwhqy0vC1ryf90BVHxVN8cTLpSyCpdmExUEtSEInSCGMydj2fvn7QXsz/za8JNdO2xxgJwxpLMtg==", - "requires": { - "@chakra-ui/dom-utils": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0" - } - }, - "@chakra-ui/react-use-callback-ref": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-callback-ref/-/react-use-callback-ref-2.1.0.tgz", - "integrity": "sha512-efnJrBtGDa4YaxDzDE90EnKD3Vkh5a1t3w7PhnRQmsphLy3g2UieasoKTlT2Hn118TwDjIv5ZjHJW6HbzXA9wQ==", - "requires": {} - }, - "@chakra-ui/react-use-controllable-state": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-controllable-state/-/react-use-controllable-state-2.1.0.tgz", - "integrity": "sha512-QR/8fKNokxZUs4PfxjXuwl0fj/d71WPrmLJvEpCTkHjnzu7LnYvzoe2wB867IdooQJL0G1zBxl0Dq+6W1P3jpg==", - "requires": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - } - }, - "@chakra-ui/react-use-disclosure": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-disclosure/-/react-use-disclosure-2.1.0.tgz", - "integrity": "sha512-Ax4pmxA9LBGMyEZJhhUZobg9C0t3qFE4jVF1tGBsrLDcdBeLR9fwOogIPY9Hf0/wqSlAryAimICbr5hkpa5GSw==", - "requires": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - } - }, - "@chakra-ui/react-use-event-listener": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-event-listener/-/react-use-event-listener-2.1.0.tgz", - "integrity": "sha512-U5greryDLS8ISP69DKDsYcsXRtAdnTQT+jjIlRYZ49K/XhUR/AqVZCK5BkR1spTDmO9H8SPhgeNKI70ODuDU/Q==", - "requires": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - } - }, - "@chakra-ui/react-use-focus-effect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-focus-effect/-/react-use-focus-effect-2.1.0.tgz", - "integrity": "sha512-xzVboNy7J64xveLcxTIJ3jv+lUJKDwRM7Szwn9tNzUIPD94O3qwjV7DDCUzN2490nSYDF4OBMt/wuDBtaR3kUQ==", - "requires": { - "@chakra-ui/dom-utils": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0" - } - }, - "@chakra-ui/react-use-focus-on-pointer-down": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-focus-on-pointer-down/-/react-use-focus-on-pointer-down-2.1.0.tgz", - "integrity": "sha512-2jzrUZ+aiCG/cfanrolsnSMDykCAbv9EK/4iUyZno6BYb3vziucmvgKuoXbMPAzWNtwUwtuMhkby8rc61Ue+Lg==", - "requires": { - "@chakra-ui/react-use-event-listener": "2.1.0" - } - }, - "@chakra-ui/react-use-interval": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-interval/-/react-use-interval-2.1.0.tgz", - "integrity": "sha512-8iWj+I/+A0J08pgEXP1J1flcvhLBHkk0ln7ZvGIyXiEyM6XagOTJpwNhiu+Bmk59t3HoV/VyvyJTa+44sEApuw==", - "requires": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - } - }, - "@chakra-ui/react-use-latest-ref": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-latest-ref/-/react-use-latest-ref-2.1.0.tgz", - "integrity": "sha512-m0kxuIYqoYB0va9Z2aW4xP/5b7BzlDeWwyXCH6QpT2PpW3/281L3hLCm1G0eOUcdVlayqrQqOeD6Mglq+5/xoQ==", - "requires": {} - }, - "@chakra-ui/react-use-merge-refs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-merge-refs/-/react-use-merge-refs-2.1.0.tgz", - "integrity": "sha512-lERa6AWF1cjEtWSGjxWTaSMvneccnAVH4V4ozh8SYiN9fSPZLlSG3kNxfNzdFvMEhM7dnP60vynF7WjGdTgQbQ==", - "requires": {} - }, - "@chakra-ui/react-use-outside-click": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-outside-click/-/react-use-outside-click-2.2.0.tgz", - "integrity": "sha512-PNX+s/JEaMneijbgAM4iFL+f3m1ga9+6QK0E5Yh4s8KZJQ/bLwZzdhMz8J/+mL+XEXQ5J0N8ivZN28B82N1kNw==", - "requires": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - } - }, - "@chakra-ui/react-use-pan-event": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-pan-event/-/react-use-pan-event-2.1.0.tgz", - "integrity": "sha512-xmL2qOHiXqfcj0q7ZK5s9UjTh4Gz0/gL9jcWPA6GVf+A0Od5imEDa/Vz+533yQKWiNSm1QGrIj0eJAokc7O4fg==", - "requires": { - "@chakra-ui/event-utils": "2.0.8", - "@chakra-ui/react-use-latest-ref": "2.1.0", - "framesync": "6.1.2" - } - }, - "@chakra-ui/react-use-previous": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-previous/-/react-use-previous-2.1.0.tgz", - "integrity": "sha512-pjxGwue1hX8AFcmjZ2XfrQtIJgqbTF3Qs1Dy3d1krC77dEsiCUbQ9GzOBfDc8pfd60DrB5N2tg5JyHbypqh0Sg==", - "requires": {} - }, - "@chakra-ui/react-use-safe-layout-effect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-safe-layout-effect/-/react-use-safe-layout-effect-2.1.0.tgz", - "integrity": "sha512-Knbrrx/bcPwVS1TorFdzrK/zWA8yuU/eaXDkNj24IrKoRlQrSBFarcgAEzlCHtzuhufP3OULPkELTzz91b0tCw==", - "requires": {} - }, - "@chakra-ui/react-use-size": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-size/-/react-use-size-2.1.0.tgz", - "integrity": "sha512-tbLqrQhbnqOjzTaMlYytp7wY8BW1JpL78iG7Ru1DlV4EWGiAmXFGvtnEt9HftU0NJ0aJyjgymkxfVGI55/1Z4A==", - "requires": { - "@zag-js/element-size": "0.10.5" - } - }, - "@chakra-ui/react-use-timeout": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-timeout/-/react-use-timeout-2.1.0.tgz", - "integrity": "sha512-cFN0sobKMM9hXUhyCofx3/Mjlzah6ADaEl/AXl5Y+GawB5rgedgAcu2ErAgarEkwvsKdP6c68CKjQ9dmTQlJxQ==", - "requires": { - "@chakra-ui/react-use-callback-ref": "2.1.0" - } - }, - "@chakra-ui/react-use-update-effect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-use-update-effect/-/react-use-update-effect-2.1.0.tgz", - "integrity": "sha512-ND4Q23tETaR2Qd3zwCKYOOS1dfssojPLJMLvUtUbW5M9uW1ejYWgGUobeAiOVfSplownG8QYMmHTP86p/v0lbA==", - "requires": {} - }, - "@chakra-ui/react-utils": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@chakra-ui/react-utils/-/react-utils-2.0.12.tgz", - "integrity": "sha512-GbSfVb283+YA3kA8w8xWmzbjNWk14uhNpntnipHCftBibl0lxtQ9YqMFQLwuFOO0U2gYVocszqqDWX+XNKq9hw==", - "requires": { - "@chakra-ui/utils": "2.0.15" - } - }, - "@chakra-ui/select": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/select/-/select-2.1.2.tgz", - "integrity": "sha512-ZwCb7LqKCVLJhru3DXvKXpZ7Pbu1TDZ7N0PdQ0Zj1oyVLJyrpef1u9HR5u0amOpqcH++Ugt0f5JSmirjNlctjA==", - "requires": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/shared-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@chakra-ui/shared-utils/-/shared-utils-2.0.5.tgz", - "integrity": "sha512-4/Wur0FqDov7Y0nCXl7HbHzCg4aq86h+SXdoUeuCMD3dSj7dpsVnStLYhng1vxvlbUnLpdF4oz5Myt3i/a7N3Q==" - }, - "@chakra-ui/skeleton": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/skeleton/-/skeleton-2.1.0.tgz", - "integrity": "sha512-JNRuMPpdZGd6zFVKjVQ0iusu3tXAdI29n4ZENYwAJEMf/fN0l12sVeirOxkJ7oEL0yOx2AgEYFSKdbcAgfUsAQ==", - "requires": { - "@chakra-ui/media-query": "3.3.0", - "@chakra-ui/react-use-previous": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/skip-nav": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/skip-nav/-/skip-nav-2.1.0.tgz", - "integrity": "sha512-Hk+FG+vadBSH0/7hwp9LJnLjkO0RPGnx7gBJWI4/SpoJf3e4tZlWYtwGj0toYY4aGKl93jVghuwGbDBEMoHDug==", - "requires": {} - }, - "@chakra-ui/slider": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/slider/-/slider-2.1.0.tgz", - "integrity": "sha512-lUOBcLMCnFZiA/s2NONXhELJh6sY5WtbRykPtclGfynqqOo47lwWJx+VP7xaeuhDOPcWSSecWc9Y1BfPOCz9cQ==", - "requires": { - "@chakra-ui/number-utils": "2.0.7", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-callback-ref": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-latest-ref": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-pan-event": "2.1.0", - "@chakra-ui/react-use-size": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0" - } - }, - "@chakra-ui/spinner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/spinner/-/spinner-2.1.0.tgz", - "integrity": "sha512-hczbnoXt+MMv/d3gE+hjQhmkzLiKuoTo42YhUG7Bs9OSv2lg1fZHW1fGNRFP3wTi6OIbD044U1P9HK+AOgFH3g==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/stat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/stat/-/stat-2.1.1.tgz", - "integrity": "sha512-LDn0d/LXQNbAn2KaR3F1zivsZCewY4Jsy1qShmfBMKwn6rI8yVlbvu6SiA3OpHS0FhxbsZxQI6HefEoIgtqY6Q==", - "requires": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/stepper": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/stepper/-/stepper-2.3.1.tgz", - "integrity": "sha512-ky77lZbW60zYkSXhYz7kbItUpAQfEdycT0Q4bkHLxfqbuiGMf8OmgZOQkOB9uM4v0zPwy2HXhe0vq4Dd0xa55Q==", - "requires": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/styled-system": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz", - "integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5", - "csstype": "^3.1.2", - "lodash.mergewith": "4.6.2" - } - }, - "@chakra-ui/switch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/switch/-/switch-2.1.2.tgz", - "integrity": "sha512-pgmi/CC+E1v31FcnQhsSGjJnOE2OcND4cKPyTE+0F+bmGm48Q/b5UmKD9Y+CmZsrt/7V3h8KNczowupfuBfIHA==", - "requires": { - "@chakra-ui/checkbox": "2.3.2", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/system": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz", - "integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==", - "requires": { - "@chakra-ui/color-mode": "2.2.0", - "@chakra-ui/object-utils": "2.1.0", - "@chakra-ui/react-utils": "2.0.12", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/theme-utils": "2.0.21", - "@chakra-ui/utils": "2.0.15", - "react-fast-compare": "3.2.2" - } - }, - "@chakra-ui/table": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/table/-/table-2.1.0.tgz", - "integrity": "sha512-o5OrjoHCh5uCLdiUb0Oc0vq9rIAeHSIRScc2ExTC9Qg/uVZl2ygLrjToCaKfaaKl1oQexIeAcZDKvPG8tVkHyQ==", - "requires": { - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/tabs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/tabs/-/tabs-3.0.0.tgz", - "integrity": "sha512-6Mlclp8L9lqXmsGWF5q5gmemZXOiOYuh0SGT/7PgJVNPz3LXREXlXg2an4MBUD8W5oTkduCX+3KTMCwRrVrDYw==", - "requires": { - "@chakra-ui/clickable": "2.1.0", - "@chakra-ui/descendant": "3.1.0", - "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/react-children-utils": "2.0.6", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-controllable-state": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/react-use-safe-layout-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/tag": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/tag/-/tag-3.1.1.tgz", - "integrity": "sha512-Bdel79Dv86Hnge2PKOU+t8H28nm/7Y3cKd4Kfk9k3lOpUh4+nkSGe58dhRzht59lEqa4N9waCgQiBdkydjvBXQ==", - "requires": { - "@chakra-ui/icon": "3.2.0", - "@chakra-ui/react-context": "2.1.0" - } - }, - "@chakra-ui/textarea": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/textarea/-/textarea-2.1.2.tgz", - "integrity": "sha512-ip7tvklVCZUb2fOHDb23qPy/Fr2mzDOGdkrpbNi50hDCiV4hFX02jdQJdi3ydHZUyVgZVBKPOJ+lT9i7sKA2wA==", - "requires": { - "@chakra-ui/form-control": "2.2.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/theme": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/theme/-/theme-3.3.1.tgz", - "integrity": "sha512-Hft/VaT8GYnItGCBbgWd75ICrIrIFrR7lVOhV/dQnqtfGqsVDlrztbSErvMkoPKt0UgAkd9/o44jmZ6X4U2nZQ==", - "requires": { - "@chakra-ui/anatomy": "2.2.2", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/theme-tools": "2.1.2" - } - }, - "@chakra-ui/theme-tools": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/theme-tools/-/theme-tools-2.1.2.tgz", - "integrity": "sha512-Qdj8ajF9kxY4gLrq7gA+Azp8CtFHGO9tWMN2wfF9aQNgG9AuMhPrUzMq9AMQ0MXiYcgNq/FD3eegB43nHVmXVA==", - "requires": { - "@chakra-ui/anatomy": "2.2.2", - "@chakra-ui/shared-utils": "2.0.5", - "color2k": "^2.0.2" - } - }, - "@chakra-ui/theme-utils": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/@chakra-ui/theme-utils/-/theme-utils-2.0.21.tgz", - "integrity": "sha512-FjH5LJbT794r0+VSCXB3lT4aubI24bLLRWB+CuRKHijRvsOg717bRdUN/N1fEmEpFnRVrbewttWh/OQs0EWpWw==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/theme": "3.3.1", - "lodash.mergewith": "4.6.2" - } - }, - "@chakra-ui/toast": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@chakra-ui/toast/-/toast-7.0.2.tgz", - "integrity": "sha512-yvRP8jFKRs/YnkuE41BVTq9nB2v/KDRmje9u6dgDmE5+1bFt3bwjdf9gVbif4u5Ve7F7BGk5E093ARRVtvLvXA==", - "requires": { - "@chakra-ui/alert": "2.2.2", - "@chakra-ui/close-button": "2.1.1", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-context": "2.1.0", - "@chakra-ui/react-use-timeout": "2.1.0", - "@chakra-ui/react-use-update-effect": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.9.2", - "@chakra-ui/theme": "3.3.1" - } - }, - "@chakra-ui/tooltip": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@chakra-ui/tooltip/-/tooltip-2.3.1.tgz", - "integrity": "sha512-Rh39GBn/bL4kZpuEMPPRwYNnccRCL+w9OqamWHIB3Qboxs6h8cOyXfIdGxjo72lvhu1QI/a4KFqkM3St+WfC0A==", - "requires": { - "@chakra-ui/dom-utils": "2.1.0", - "@chakra-ui/popper": "3.1.0", - "@chakra-ui/portal": "2.1.0", - "@chakra-ui/react-types": "2.0.7", - "@chakra-ui/react-use-disclosure": "2.1.0", - "@chakra-ui/react-use-event-listener": "2.1.0", - "@chakra-ui/react-use-merge-refs": "2.1.0", - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/transition": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/transition/-/transition-2.1.0.tgz", - "integrity": "sha512-orkT6T/Dt+/+kVwJNy7zwJ+U2xAZ3EU7M3XCs45RBvUnZDr/u9vdmaM/3D/rOpmQJWgQBwKPJleUXrYWUagEDQ==", - "requires": { - "@chakra-ui/shared-utils": "2.0.5" - } - }, - "@chakra-ui/utils": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@chakra-ui/utils/-/utils-2.0.15.tgz", - "integrity": "sha512-El4+jL0WSaYYs+rJbuYFDbjmfCcfGDmRY95GO4xwzit6YAPZBLcR65rOEwLps+XWluZTy1xdMrusg/hW0c1aAA==", - "requires": { - "@types/lodash.mergewith": "4.6.7", - "css-box-model": "1.2.1", - "framesync": "6.1.2", - "lodash.mergewith": "4.6.2" - } - }, - "@chakra-ui/visually-hidden": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/visually-hidden/-/visually-hidden-2.2.0.tgz", - "integrity": "sha512-KmKDg01SrQ7VbTD3+cPWf/UfpF5MSwm3v7MWi0n5t8HnnadT13MF0MJCDSXbBWnzLv1ZKJ6zlyAOeARWX+DpjQ==", - "requires": {} - }, - "@emotion/babel-plugin": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", - "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", - "requires": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/serialize": "^1.1.2", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - }, - "dependencies": { - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" - } - } - }, - "@emotion/cache": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", - "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", - "requires": { - "@emotion/memoize": "^0.8.1", - "@emotion/sheet": "^1.2.2", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", - "stylis": "4.2.0" - } - }, - "@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" - }, - "@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", - "requires": { - "@emotion/memoize": "^0.8.1" - } - }, - "@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" - }, - "@emotion/react": { - "version": "11.11.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", - "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", - "requires": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", - "hoist-non-react-statics": "^3.3.1" - } - }, - "@emotion/serialize": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", - "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", - "requires": { - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/unitless": "^0.8.1", - "@emotion/utils": "^1.2.1", - "csstype": "^3.0.2" - } - }, - "@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" - }, - "@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", - "requires": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" - } - }, - "@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" - }, - "@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", - "requires": {} - }, - "@emotion/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" - }, - "@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" - }, - "@esbuild/android-arm": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.8.tgz", - "integrity": "sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz", - "integrity": "sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.8.tgz", - "integrity": "sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz", - "integrity": "sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz", - "integrity": "sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz", - "integrity": "sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz", - "integrity": "sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz", - "integrity": "sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz", - "integrity": "sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz", - "integrity": "sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz", - "integrity": "sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz", - "integrity": "sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz", - "integrity": "sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz", - "integrity": "sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz", - "integrity": "sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz", - "integrity": "sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz", - "integrity": "sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz", - "integrity": "sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ==", - "dev": true, - "optional": true - }, - "@esbuild/sunos-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz", - "integrity": "sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w==", - "dev": true, - "optional": true - }, - "@esbuild/win32-arm64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz", - "integrity": "sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg==", - "dev": true, - "optional": true - }, - "@esbuild/win32-ia32": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz", - "integrity": "sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw==", - "dev": true, - "optional": true - }, - "@esbuild/win32-x64": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz", - "integrity": "sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA==", - "dev": true, - "optional": true - }, - "@hey-api/openapi-ts": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.34.1.tgz", - "integrity": "sha512-7Ak+0nvf4Nhzk04tXGg6h4eM7lnWRgfjCPmMl2MyXrhS5urxd3Bg/PhtpB84u18wnwcM4rIeCUlTwDDQ/OB3NQ==", - "dev": true, - "requires": { - "@apidevtools/json-schema-ref-parser": "11.5.4", - "camelcase": "8.0.0", - "commander": "12.0.0", - "handlebars": "4.7.8" - }, - "dependencies": { - "@apidevtools/json-schema-ref-parser": { - "version": "11.5.4", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.5.4.tgz", - "integrity": "sha512-o2fsypTGU0WxRxbax8zQoHiIB4dyrkwYfcm8TxZ+bx9pCzcWZbQtiMqpgBvWA/nJ2TrGjK5adCLfTH8wUeU/Wg==", - "dev": true, - "requires": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.15", - "js-yaml": "^4.1.0" - } - }, - "camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "dev": true - }, - "commander": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", - "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", - "dev": true - } - } - }, - "@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true - }, - "@playwright/test": { - "version": "1.45.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz", - "integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==", - "dev": true, - "requires": { - "playwright": "1.45.2" - } - }, - "@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" - }, - "@rollup/rollup-android-arm-eabi": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.6.1.tgz", - "integrity": "sha512-0WQ0ouLejaUCRsL93GD4uft3rOmB8qoQMU05Kb8CmMtMBe7XUDLAltxVZI1q6byNqEtU7N1ZX1Vw5lIpgulLQA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-android-arm64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.6.1.tgz", - "integrity": "sha512-1TKm25Rn20vr5aTGGZqo6E4mzPicCUD79k17EgTLAsXc1zysyi4xXKACfUbwyANEPAEIxkzwue6JZ+stYzWUTA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-arm64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.6.1.tgz", - "integrity": "sha512-cEXJQY/ZqMACb+nxzDeX9IPLAg7S94xouJJCNVE5BJM8JUEP4HeTF+ti3cmxWeSJo+5D+o8Tc0UAWUkfENdeyw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-x64": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.6.1.tgz", - "integrity": "sha512-LoSU9Xu56isrkV2jLldcKspJ7sSXmZWkAxg7sW/RfF7GS4F5/v4EiqKSMCFbZtDu2Nc1gxxFdQdKwkKS4rwxNg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.6.1.tgz", - "integrity": "sha512-EfI3hzYAy5vFNDqpXsNxXcgRDcFHUWSx5nnRSCKwXuQlI5J9dD84g2Usw81n3FLBNsGCegKGwwTVsSKK9cooSQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-gnu": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.6.1.tgz", - "integrity": "sha512-9lhc4UZstsegbNLhH0Zu6TqvDfmhGzuCWtcTFXY10VjLLUe4Mr0Ye2L3rrtHaDd/J5+tFMEuo5LTCSCMXWfUKw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-musl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.6.1.tgz", - "integrity": "sha512-FfoOK1yP5ksX3wwZ4Zk1NgyGHZyuRhf99j64I5oEmirV8EFT7+OhUZEnP+x17lcP/QHJNWGsoJwrz4PJ9fBEXw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-gnu": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.6.1.tgz", - "integrity": "sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-musl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.6.1.tgz", - "integrity": "sha512-RkJVNVRM+piYy87HrKmhbexCHg3A6Z6MU0W9GHnJwBQNBeyhCJG9KDce4SAMdicQnpURggSvtbGo9xAWOfSvIQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-arm64-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.6.1.tgz", - "integrity": "sha512-v2FVT6xfnnmTe3W9bJXl6r5KwJglMK/iRlkKiIFfO6ysKs0rDgz7Cwwf3tjldxQUrHL9INT/1r4VA0n9L/F1vQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-ia32-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.6.1.tgz", - "integrity": "sha512-YEeOjxRyEjqcWphH9dyLbzgkF8wZSKAKUkldRY6dgNR5oKs2LZazqGB41cWJ4Iqqcy9/zqYgmzBkRoVz3Q9MLw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-x64-msvc": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz", - "integrity": "sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A==", - "dev": true, - "optional": true - }, - "@swc/core": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.100.tgz", - "integrity": "sha512-7dKgTyxJjlrMwFZYb1auj3Xq0D8ZBe+5oeIgfMlRU05doXZypYJe0LAk0yjj3WdbwYzpF+T1PLxwTWizI0pckw==", - "dev": true, - "requires": { - "@swc/core-darwin-arm64": "1.3.100", - "@swc/core-darwin-x64": "1.3.100", - "@swc/core-linux-arm64-gnu": "1.3.100", - "@swc/core-linux-arm64-musl": "1.3.100", - "@swc/core-linux-x64-gnu": "1.3.100", - "@swc/core-linux-x64-musl": "1.3.100", - "@swc/core-win32-arm64-msvc": "1.3.100", - "@swc/core-win32-ia32-msvc": "1.3.100", - "@swc/core-win32-x64-msvc": "1.3.100", - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - } - }, - "@swc/core-darwin-arm64": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz", - "integrity": "sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw==", - "dev": true, - "optional": true - }, - "@swc/core-darwin-x64": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.100.tgz", - "integrity": "sha512-KF/MXrnH1nakm1wbt4XV8FS7kvqD9TGmVxeJ0U4bbvxXMvzeYUurzg3AJUTXYmXDhH/VXOYJE5N5RkwZZPs5iA==", - "dev": true, - "optional": true - }, - "@swc/core-linux-arm64-gnu": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.100.tgz", - "integrity": "sha512-p8hikNnAEJrw5vHCtKiFT4hdlQxk1V7vqPmvUDgL/qe2menQDK/i12tbz7/3BEQ4UqUPnvwpmVn2d19RdEMNxw==", - "dev": true, - "optional": true - }, - "@swc/core-linux-arm64-musl": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.100.tgz", - "integrity": "sha512-BWx/0EeY89WC4q3AaIaBSGfQxkYxIlS3mX19dwy2FWJs/O+fMvF9oLk/CyJPOZzbp+1DjGeeoGFuDYpiNO91JA==", - "dev": true, - "optional": true - }, - "@swc/core-linux-x64-gnu": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.100.tgz", - "integrity": "sha512-XUdGu3dxAkjsahLYnm8WijPfKebo+jHgHphDxaW0ovI6sTdmEGFDew7QzKZRlbYL2jRkUuuKuDGvD6lO5frmhA==", - "dev": true, - "optional": true - }, - "@swc/core-linux-x64-musl": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.100.tgz", - "integrity": "sha512-PhoXKf+f0OaNW/GCuXjJ0/KfK9EJX7z2gko+7nVnEA0p3aaPtbP6cq1Ubbl6CMoPL+Ci3gZ7nYumDqXNc3CtLQ==", - "dev": true, - "optional": true - }, - "@swc/core-win32-arm64-msvc": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.100.tgz", - "integrity": "sha512-PwLADZN6F9cXn4Jw52FeP/MCLVHm8vwouZZSOoOScDtihjY495SSjdPnlosMaRSR4wJQssGwiD/4MbpgQPqbAw==", - "dev": true, - "optional": true - }, - "@swc/core-win32-ia32-msvc": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.100.tgz", - "integrity": "sha512-0f6nicKSLlDKlyPRl2JEmkpBV4aeDfRQg6n8mPqgL7bliZIcDahG0ej+HxgNjZfS3e0yjDxsNRa6sAqWU2Z60A==", - "dev": true, - "optional": true - }, - "@swc/core-win32-x64-msvc": { - "version": "1.3.100", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.100.tgz", - "integrity": "sha512-b7J0rPoMkRTa3XyUGt8PwCaIBuYWsL2DqbirrQKRESzgCvif5iNpqaM6kjIjI/5y5q1Ycv564CB51YDpiS8EtQ==", - "dev": true, - "optional": true - }, - "@swc/counter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", - "integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", - "dev": true - }, - "@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true - }, - "@tanstack/history": { - "version": "1.15.13", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.15.13.tgz", - "integrity": "sha512-ToaeMtK5S4YaxCywAlYexc7KPFN0esjyTZ4vXzJhXEWAkro9iHgh7m/4ozPJb7oTo65WkHWX0W9GjcZbInSD8w==" - }, - "@tanstack/query-core": { - "version": "5.28.13", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.28.13.tgz", - "integrity": "sha512-C3+CCOcza+mrZ7LglQbjeYEOTEC3LV0VN0eYaIN6GvqAZ8Foegdgch7n6QYPtT4FuLae5ALy+m+ZMEKpD6tMCQ==" - }, - "@tanstack/query-devtools": { - "version": "5.28.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.28.10.tgz", - "integrity": "sha512-5UN629fKa5/1K/2Pd26gaU7epxRrYiT1gy+V+pW5K6hnf1DeUKK3pANSb2eHKlecjIKIhTwyF7k9XdyE2gREvQ==" - }, - "@tanstack/react-query": { - "version": "5.28.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.28.14.tgz", - "integrity": "sha512-cZqt03Igb3I9tM72qNX5TAAmeYl75Z+k4Mv92VkXIXc2hCrv0fIywd7GN3JV1BBJl4mr7Cc+OOKKOPy8sNVOkA==", - "requires": { - "@tanstack/query-core": "5.28.13" - } - }, - "@tanstack/react-query-devtools": { - "version": "5.28.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.28.14.tgz", - "integrity": "sha512-4CrFBI1O5wibV1ZdGAnBMmTuc7SiShhxWubxRMyIloeEioxs3DQkFbouGBea5nexuwIxAkvhUB8khpPnNjhxMw==", - "requires": { - "@tanstack/query-devtools": "5.28.10" - } - }, - "@tanstack/react-router": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.19.1.tgz", - "integrity": "sha512-a4Xf074qo2fQLmSi8PTncEFn8XakaH3+DT7Dted4OPClzQFS+c6yU3HONVNAsuYWZ7lDK1HMKoHPDFbnHPEWvA==", - "requires": { - "@tanstack/history": "1.15.13", - "@tanstack/react-store": "^0.2.1", - "tiny-invariant": "^1.3.1", - "tiny-warning": "^1.0.3" - } - }, - "@tanstack/react-store": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.2.1.tgz", - "integrity": "sha512-tEbMCQjbeVw9KOP/202LfqZMSNAVi6zYkkp1kBom8nFuMx/965Hzes3+6G6b/comCwVxoJU8Gg9IrcF8yRPthw==", - "requires": { - "@tanstack/store": "0.1.3", - "use-sync-external-store": "^1.2.0" - } - }, - "@tanstack/router-devtools": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.19.1.tgz", - "integrity": "sha512-l560JHnffcDccSTo/sOtB+gKvtgaWYpOKOu9MyvswN9XB2pt752UFFIN1Yt/Gsp2Iooq/FcYlYnEPHb4GFzalg==", - "dev": true, - "requires": { - "@tanstack/react-router": "1.19.1", - "clsx": "^2.1.0", - "date-fns": "^2.29.1", - "goober": "^2.1.14" - } - }, - "@tanstack/router-generator": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.19.0.tgz", - "integrity": "sha512-vFF8Q7SdyygiYC7lfJ83GRif0vcxjak9SAcgtX/w7TLR0O+qdxRXFPvhKTQQXH6vVezy5Au9bSaSI2EgDD1ubA==", - "dev": true, - "requires": { - "prettier": "^3.1.1", - "zod": "^3.22.4" - } - }, - "@tanstack/router-vite-plugin": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.19.0.tgz", - "integrity": "sha512-yvvQnJ7JvqsnxAFqwiHhNTV2n1jKkidjc+XbgS2aNnEHC0aHnYH2ygPlmmfiVD7PMO7x64PdI5e12TzY/aKoFA==", - "dev": true, - "requires": { - "@tanstack/router-generator": "1.19.0" - } - }, - "@tanstack/store": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.1.3.tgz", - "integrity": "sha512-GnolmC8Fr4mvsHE1fGQmR3Nm0eBO3KnZjDU0a+P3TeQNM/dDscFGxtA7p31NplQNW3KwBw4t1RVFmz0VeKLxcw==" - }, - "@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==" - }, - "@types/lodash.mergewith": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/@types/lodash.mergewith/-/lodash.mergewith-4.6.7.tgz", - "integrity": "sha512-3m+lkO5CLRRYU0fhGRp7zbsGi6+BZj0uTVSwvcKU+nSlhjA9/QRNfuSGnD2mX6hQA7ZbmcCkzk5h4ZYGOtk14A==", - "requires": { - "@types/lodash": "*" - } - }, - "@types/node": { - "version": "20.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", - "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", - "dev": true, - "requires": { - "undici-types": "~5.26.4" - } - }, - "@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" - }, - "@types/prop-types": { - "version": "15.7.11", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "devOptional": true - }, - "@types/react": { - "version": "18.2.39", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz", - "integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==", - "devOptional": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/react-dom": { - "version": "18.2.17", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz", - "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "devOptional": true - }, - "@vitejs/plugin-react-swc": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.5.0.tgz", - "integrity": "sha512-1PrOvAaDpqlCV+Up8RkAh9qaiUjoDUcjtttyhXDKw53XA6Ve16SOp6cCOpRs8Dj8DqUQs6eTW5YkLcLJjrXAig==", - "dev": true, - "requires": { - "@swc/core": "^1.3.96" - } - }, - "@zag-js/dom-query": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.16.0.tgz", - "integrity": "sha512-Oqhd6+biWyKnhKwFFuZrrf6lxBz2tX2pRQe6grUnYwO6HJ8BcbqZomy2lpOdr+3itlaUqx+Ywj5E5ZZDr/LBfQ==" - }, - "@zag-js/element-size": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@zag-js/element-size/-/element-size-0.10.5.tgz", - "integrity": "sha512-uQre5IidULANvVkNOBQ1tfgwTQcGl4hliPSe69Fct1VfYb2Fd0jdAcGzqQgPhfrXFpR62MxLPB7erxJ/ngtL8w==" - }, - "@zag-js/focus-visible": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-0.16.0.tgz", - "integrity": "sha512-a7U/HSopvQbrDU4GLerpqiMcHKEkQkNPeDZJWz38cw/6Upunh41GjHetq5TB84hxyCaDzJ6q2nEdNoBQfC0FKA==", - "requires": { - "@zag-js/dom-query": "0.16.0" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "aria-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", - "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", - "requires": { - "tslib": "^2.0.0" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", - "requires": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "requires": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, - "clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", - "dev": true - }, - "color2k": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", - "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==" - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "compute-scroll-into-view": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.0.3.tgz", - "integrity": "sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==" - }, - "convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" - }, - "copy-to-clipboard": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", - "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", - "requires": { - "toggle-selection": "^1.0.6" - } - }, - "cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "css-box-model": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", - "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", - "requires": { - "tiny-invariant": "^1.0.6" - } - }, - "csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" - }, - "date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "requires": { - "@babel/runtime": "^7.21.0" - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "esbuild": { - "version": "0.19.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.8.tgz", - "integrity": "sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w==", - "dev": true, - "requires": { - "@esbuild/android-arm": "0.19.8", - "@esbuild/android-arm64": "0.19.8", - "@esbuild/android-x64": "0.19.8", - "@esbuild/darwin-arm64": "0.19.8", - "@esbuild/darwin-x64": "0.19.8", - "@esbuild/freebsd-arm64": "0.19.8", - "@esbuild/freebsd-x64": "0.19.8", - "@esbuild/linux-arm": "0.19.8", - "@esbuild/linux-arm64": "0.19.8", - "@esbuild/linux-ia32": "0.19.8", - "@esbuild/linux-loong64": "0.19.8", - "@esbuild/linux-mips64el": "0.19.8", - "@esbuild/linux-ppc64": "0.19.8", - "@esbuild/linux-riscv64": "0.19.8", - "@esbuild/linux-s390x": "0.19.8", - "@esbuild/linux-x64": "0.19.8", - "@esbuild/netbsd-x64": "0.19.8", - "@esbuild/openbsd-x64": "0.19.8", - "@esbuild/sunos-x64": "0.19.8", - "@esbuild/win32-arm64": "0.19.8", - "@esbuild/win32-ia32": "0.19.8", - "@esbuild/win32-x64": "0.19.8" - } - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" - }, - "find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" - }, - "focus-lock": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.3.tgz", - "integrity": "sha512-hfXkZha7Xt4RQtrL1HBfspAuIj89Y0fb6GX0dfJilb8S2G/lvL4akPAcHq6xoD2NuZnDMCnZL/zQesMyeu6Psg==", - "requires": { - "tslib": "^2.0.3" - } - }, - "follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" - }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "framer-motion": { - "version": "10.16.16", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.16.tgz", - "integrity": "sha512-je6j91rd7NmUX7L1XHouwJ4v3R+SO4umso2LUcgOct3rHZ0PajZ80ETYZTajzEXEl9DlKyzjyt4AvGQ+lrebOw==", - "requires": { - "@emotion/is-prop-valid": "^0.8.2", - "tslib": "^2.4.0" - }, - "dependencies": { - "@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "requires": { - "@emotion/memoize": "0.7.4" - } - }, - "@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - } - } - }, - "framesync": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.1.2.tgz", - "integrity": "sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==", - "requires": { - "tslib": "2.4.0" - }, - "dependencies": { - "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - } - } - }, - "fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, - "goober": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", - "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", - "dev": true, - "requires": {} - }, - "handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "requires": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "uglify-js": "^3.1.4", - "wordwrap": "^1.0.0" - } - }, - "hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", - "requires": { - "function-bind": "^1.1.2" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "requires": { - "react-is": "^16.7.0" - } - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, - "is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "requires": { - "hasown": "^2.0.0" - } - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true - }, - "nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true - }, - "neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "playwright": { - "version": "1.45.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz", - "integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==", - "dev": true, - "requires": { - "fsevents": "2.3.2", - "playwright-core": "1.45.2" - }, - "dependencies": { - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - } - } - }, - "playwright-core": { - "version": "1.45.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz", - "integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==", - "dev": true - }, - "postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, - "requires": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "requires": { - "loose-envify": "^1.1.0" - } - }, - "react-clientside-effect": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", - "integrity": "sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==", - "requires": { - "@babel/runtime": "^7.12.13" - } - }, - "react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "requires": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" - } - }, - "react-error-boundary": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", - "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==", - "requires": { - "@babel/runtime": "^7.12.5" - } - }, - "react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" - }, - "react-focus-lock": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.11.1.tgz", - "integrity": "sha512-IXLwnTBrLTlKTpASZXqqXJ8oymWrgAlOfuuDYN4XCuN1YJ72dwX198UCaF1QqGUk5C3QOnlMik//n3ufcfe8Ig==", - "requires": { - "@babel/runtime": "^7.0.0", - "focus-lock": "^1.3.2", - "prop-types": "^15.6.2", - "react-clientside-effect": "^1.2.6", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-hook-form": { - "version": "7.49.3", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.49.3.tgz", - "integrity": "sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==", - "requires": {} - }, - "react-icons": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz", - "integrity": "sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==", - "requires": {} - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "requires": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-remove-scroll-bar": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz", - "integrity": "sha512-3cqjOqg6s0XbOjWvmasmqHch+RLxIEk2r/70rzGXuz3iIGQsQheEQyqYCBb5EECoD01Vo2SIbDqW4paLeLTASw==", - "requires": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - } - }, - "react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "requires": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - } - }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, - "resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "requires": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - }, - "rollup": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.6.1.tgz", - "integrity": "sha512-jZHaZotEHQaHLgKr8JnQiDT1rmatjgKlMekyksz+yk9jt/8z9quNjnKNRoaM0wd9DC2QKXjmWWuDYtM3jfF8pQ==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.6.1", - "@rollup/rollup-android-arm64": "4.6.1", - "@rollup/rollup-darwin-arm64": "4.6.1", - "@rollup/rollup-darwin-x64": "4.6.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.6.1", - "@rollup/rollup-linux-arm64-gnu": "4.6.1", - "@rollup/rollup-linux-arm64-musl": "4.6.1", - "@rollup/rollup-linux-x64-gnu": "4.6.1", - "@rollup/rollup-linux-x64-musl": "4.6.1", - "@rollup/rollup-win32-arm64-msvc": "4.6.1", - "@rollup/rollup-win32-ia32-msvc": "4.6.1", - "@rollup/rollup-win32-x64-msvc": "4.6.1", - "fsevents": "~2.3.2" - } - }, - "scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "requires": { - "loose-envify": "^1.1.0" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true - }, - "stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" - }, - "tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" - }, - "toggle-selection": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", - "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" - }, - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true - }, - "uglify-js": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", - "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", - "dev": true, - "optional": true - }, - "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "use-callback-ref": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.1.tgz", - "integrity": "sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==", - "requires": { - "tslib": "^2.0.0" - } - }, - "use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "requires": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - } - }, - "use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} - }, - "vite": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.13.tgz", - "integrity": "sha512-/9ovhv2M2dGTuA+dY93B9trfyWMDRQw2jdVBhHNP6wr0oF34wG2i/N55801iZIpgUpnHDm4F/FabGQLyc+eOgg==", - "dev": true, - "requires": { - "esbuild": "^0.19.3", - "fsevents": "~2.3.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" - } - }, - "wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - }, - "zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dev": true - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 1a7a547f68..0000000000 --- a/frontend/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "biome check --apply-unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./", - "preview": "vite preview", - "generate-client": "openapi-ts --input ./openapi.json --output ./src/client --client axios --exportSchemas true" - }, - "dependencies": { - "@chakra-ui/icons": "2.1.1", - "@chakra-ui/react": "2.8.2", - "@emotion/react": "11.11.3", - "@emotion/styled": "11.11.0", - "@tanstack/react-query": "^5.28.14", - "@tanstack/react-query-devtools": "^5.28.14", - "@tanstack/react-router": "1.19.1", - "axios": "1.7.4", - "form-data": "4.0.0", - "framer-motion": "10.16.16", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.13", - "react-hook-form": "7.49.3", - "react-icons": "5.0.1" - }, - "devDependencies": { - "@biomejs/biome": "1.6.1", - "@hey-api/openapi-ts": "^0.34.1", - "@playwright/test": "^1.45.2", - "@tanstack/router-devtools": "1.19.1", - "@tanstack/router-vite-plugin": "1.19.0", - "@types/node": "^20.10.5", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.2.15", - "@vitejs/plugin-react-swc": "^3.5.0", - "dotenv": "^16.4.5", - "typescript": "^5.2.2", - "vite": "^5.0.13" - } -} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts deleted file mode 100644 index dcdd6fec81..0000000000 --- a/frontend/playwright.config.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:5173', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { name: 'setup', testMatch: /.*\.setup\.ts/ }, - - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['setup'], - }, - - // { - // name: 'firefox', - // use: { - // ...devices['Desktop Firefox'], - // storageState: 'playwright/.auth/user.json', - // }, - // dependencies: ['setup'], - // }, - - // { - // name: 'webkit', - // use: { - // ...devices['Desktop Safari'], - // storageState: 'playwright/.auth/user.json', - // }, - // dependencies: ['setup'], - // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - webServer: { - command: 'npm run dev', - url: 'http://localhost:5173', - reuseExistingServer: !process.env.CI, - }, -}); diff --git a/frontend/public/assets/images/fastapi-logo.svg b/frontend/public/assets/images/fastapi-logo.svg deleted file mode 100644 index d3dad4bec8..0000000000 --- a/frontend/public/assets/images/fastapi-logo.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - image/svg+xml - - - - - - - - - - - - - - diff --git a/frontend/public/assets/images/favicon.png b/frontend/public/assets/images/favicon.png deleted file mode 100644 index e5b7c3ada7d093d237711560e03f6a841a4a41b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5043 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 - readonly cookies?: Record - readonly headers?: Record - readonly query?: Record - readonly formData?: Record - readonly body?: any - readonly mediaType?: string - readonly responseHeader?: string - readonly errors?: Record -} diff --git a/frontend/src/client/core/ApiResult.ts b/frontend/src/client/core/ApiResult.ts deleted file mode 100644 index f88b8c64f1..0000000000 --- a/frontend/src/client/core/ApiResult.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ApiResult = { - readonly body: TData - readonly ok: boolean - readonly status: number - readonly statusText: string - readonly url: string -} diff --git a/frontend/src/client/core/CancelablePromise.ts b/frontend/src/client/core/CancelablePromise.ts deleted file mode 100644 index f47db79eae..0000000000 --- a/frontend/src/client/core/CancelablePromise.ts +++ /dev/null @@ -1,126 +0,0 @@ -export class CancelError extends Error { - constructor(message: string) { - super(message) - this.name = "CancelError" - } - - public get isCancelled(): boolean { - return true - } -} - -export interface OnCancel { - readonly isResolved: boolean - readonly isRejected: boolean - readonly isCancelled: boolean - - (cancelHandler: () => void): void -} - -export class CancelablePromise implements Promise { - private _isResolved: boolean - private _isRejected: boolean - private _isCancelled: boolean - readonly cancelHandlers: (() => void)[] - readonly promise: Promise - private _resolve?: (value: T | PromiseLike) => void - private _reject?: (reason?: unknown) => void - - constructor( - executor: ( - resolve: (value: T | PromiseLike) => void, - reject: (reason?: unknown) => void, - onCancel: OnCancel, - ) => void, - ) { - this._isResolved = false - this._isRejected = false - this._isCancelled = false - this.cancelHandlers = [] - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve - this._reject = reject - - const onResolve = (value: T | PromiseLike): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return - } - this._isResolved = true - if (this._resolve) this._resolve(value) - } - - const onReject = (reason?: unknown): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return - } - this._isRejected = true - if (this._reject) this._reject(reason) - } - - const onCancel = (cancelHandler: () => void): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return - } - this.cancelHandlers.push(cancelHandler) - } - - Object.defineProperty(onCancel, "isResolved", { - get: (): boolean => this._isResolved, - }) - - Object.defineProperty(onCancel, "isRejected", { - get: (): boolean => this._isRejected, - }) - - Object.defineProperty(onCancel, "isCancelled", { - get: (): boolean => this._isCancelled, - }) - - return executor(onResolve, onReject, onCancel as OnCancel) - }) - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise" - } - - public then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.promise.then(onFulfilled, onRejected) - } - - public catch( - onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, - ): Promise { - return this.promise.catch(onRejected) - } - - public finally(onFinally?: (() => void) | null): Promise { - return this.promise.finally(onFinally) - } - - public cancel(): void { - if (this._isResolved || this._isRejected || this._isCancelled) { - return - } - this._isCancelled = true - if (this.cancelHandlers.length) { - try { - for (const cancelHandler of this.cancelHandlers) { - cancelHandler() - } - } catch (error) { - console.warn("Cancellation threw an error", error) - return - } - } - this.cancelHandlers.length = 0 - if (this._reject) this._reject(new CancelError("Request aborted")) - } - - public get isCancelled(): boolean { - return this._isCancelled - } -} diff --git a/frontend/src/client/core/OpenAPI.ts b/frontend/src/client/core/OpenAPI.ts deleted file mode 100644 index 746df5e61d..0000000000 --- a/frontend/src/client/core/OpenAPI.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { AxiosRequestConfig, AxiosResponse } from "axios" -import type { ApiRequestOptions } from "./ApiRequestOptions" -import type { TResult } from "./types" - -type Headers = Record -type Middleware = (value: T) => T | Promise -type Resolver = (options: ApiRequestOptions) => Promise - -export class Interceptors { - _fns: Middleware[] - - constructor() { - this._fns = [] - } - - eject(fn: Middleware) { - const index = this._fns.indexOf(fn) - if (index !== -1) { - this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)] - } - } - - use(fn: Middleware) { - this._fns = [...this._fns, fn] - } -} - -export type OpenAPIConfig = { - BASE: string - CREDENTIALS: "include" | "omit" | "same-origin" - ENCODE_PATH?: ((path: string) => string) | undefined - HEADERS?: Headers | Resolver | undefined - PASSWORD?: string | Resolver | undefined - RESULT?: TResult - TOKEN?: string | Resolver | undefined - USERNAME?: string | Resolver | undefined - VERSION: string - WITH_CREDENTIALS: boolean - interceptors: { - request: Interceptors - response: Interceptors - } -} - -export const OpenAPI: OpenAPIConfig = { - BASE: "", - CREDENTIALS: "include", - ENCODE_PATH: undefined, - HEADERS: undefined, - PASSWORD: undefined, - RESULT: "body", - TOKEN: undefined, - USERNAME: undefined, - VERSION: "0.1.0", - WITH_CREDENTIALS: false, - interceptors: { request: new Interceptors(), response: new Interceptors() }, -} diff --git a/frontend/src/client/core/request.ts b/frontend/src/client/core/request.ts deleted file mode 100644 index 99d38b46f1..0000000000 --- a/frontend/src/client/core/request.ts +++ /dev/null @@ -1,376 +0,0 @@ -import axios from "axios" -import type { - AxiosError, - AxiosRequestConfig, - AxiosResponse, - AxiosInstance, -} from "axios" - -import { ApiError } from "./ApiError" -import type { ApiRequestOptions } from "./ApiRequestOptions" -import type { ApiResult } from "./ApiResult" -import { CancelablePromise } from "./CancelablePromise" -import type { OnCancel } from "./CancelablePromise" -import type { OpenAPIConfig } from "./OpenAPI" - -export const isString = (value: unknown): value is string => { - return typeof value === "string" -} - -export const isStringWithValue = (value: unknown): value is string => { - return isString(value) && value !== "" -} - -export const isBlob = (value: any): value is Blob => { - return value instanceof Blob -} - -export const isFormData = (value: unknown): value is FormData => { - return value instanceof FormData -} - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300 -} - -export const base64 = (str: string): string => { - try { - return btoa(str) - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString("base64") - } -} - -export const getQueryString = (params: Record): string => { - const qs: string[] = [] - - const append = (key: string, value: unknown) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) - } - - const encodePair = (key: string, value: unknown) => { - if (value === undefined || value === null) { - return - } - - if (Array.isArray(value)) { - value.forEach((v) => encodePair(key, v)) - } else if (typeof value === "object") { - Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)) - } else { - append(key, value) - } - } - - Object.entries(params).forEach(([key, value]) => encodePair(key, value)) - - return qs.length ? `?${qs.join("&")}` : "" -} - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI - - const path = options.url - .replace("{api-version}", config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])) - } - return substring - }) - - const url = config.BASE + path - return options.query ? url + getQueryString(options.query) : url -} - -export const getFormData = ( - options: ApiRequestOptions, -): FormData | undefined => { - if (options.formData) { - const formData = new FormData() - - const process = (key: string, value: unknown) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value) - } else { - formData.append(key, JSON.stringify(value)) - } - } - - Object.entries(options.formData) - .filter(([, value]) => value !== undefined && value !== null) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => process(key, v)) - } else { - process(key, value) - } - }) - - return formData - } - return undefined -} - -type Resolver = (options: ApiRequestOptions) => Promise - -export const resolve = async ( - options: ApiRequestOptions, - resolver?: T | Resolver, -): Promise => { - if (typeof resolver === "function") { - return (resolver as Resolver)(options) - } - return resolver -} - -export const getHeaders = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, -): Promise> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]) - - const headers = Object.entries({ - Accept: "application/json", - ...additionalHeaders, - ...options.headers, - }) - .filter(([, value]) => value !== undefined && value !== null) - .reduce( - (headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), - {} as Record, - ) - - if (isStringWithValue(token)) { - headers["Authorization"] = `Bearer ${token}` - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`) - headers["Authorization"] = `Basic ${credentials}` - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType - } else if (isBlob(options.body)) { - headers["Content-Type"] = options.body.type || "application/octet-stream" - } else if (isString(options.body)) { - headers["Content-Type"] = "text/plain" - } else if (!isFormData(options.body)) { - headers["Content-Type"] = "application/json" - } - } else if (options.formData !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType - } - } - - return headers -} - -export const getRequestBody = (options: ApiRequestOptions): unknown => { - if (options.body) { - return options.body - } - return undefined -} - -export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: unknown, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance, -): Promise> => { - const controller = new AbortController() - - let requestConfig: AxiosRequestConfig = { - data: body ?? formData, - headers, - method: options.method, - signal: controller.signal, - url, - withCredentials: config.WITH_CREDENTIALS, - } - - onCancel(() => controller.abort()) - - for (const fn of config.interceptors.request._fns) { - requestConfig = await fn(requestConfig) - } - - try { - return await axiosClient.request(requestConfig) - } catch (error) { - const axiosError = error as AxiosError - if (axiosError.response) { - return axiosError.response - } - throw error - } -} - -export const getResponseHeader = ( - response: AxiosResponse, - responseHeader?: string, -): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader] - if (isString(content)) { - return content - } - } - return undefined -} - -export const getResponseBody = (response: AxiosResponse): unknown => { - if (response.status !== 204) { - return response.data - } - return undefined -} - -export const catchErrorCodes = ( - options: ApiRequestOptions, - result: ApiResult, -): void => { - const errors: Record = { - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Payload Too Large", - 414: "URI Too Long", - 415: "Unsupported Media Type", - 416: "Range Not Satisfiable", - 417: "Expectation Failed", - 418: "Im a teapot", - 421: "Misdirected Request", - 422: "Unprocessable Content", - 423: "Locked", - 424: "Failed Dependency", - 425: "Too Early", - 426: "Upgrade Required", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 506: "Variant Also Negotiates", - 507: "Insufficient Storage", - 508: "Loop Detected", - 510: "Not Extended", - 511: "Network Authentication Required", - ...options.errors, - } - - const error = errors[result.status] - if (error) { - throw new ApiError(options, result, error) - } - - if (!result.ok) { - const errorStatus = result.status ?? "unknown" - const errorStatusText = result.statusText ?? "unknown" - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2) - } catch (e) { - return undefined - } - })() - - throw new ApiError( - options, - result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`, - ) - } -} - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise - * @throws ApiError - */ -export const request = ( - config: OpenAPIConfig, - options: ApiRequestOptions, - axiosClient: AxiosInstance = axios, -): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options) - const formData = getFormData(options) - const body = getRequestBody(options) - const headers = await getHeaders(config, options) - - if (!onCancel.isCancelled) { - let response = await sendRequest( - config, - options, - url, - body, - formData, - headers, - onCancel, - axiosClient, - ) - - for (const fn of config.interceptors.response._fns) { - response = await fn(response) - } - - const responseBody = getResponseBody(response) - const responseHeader = getResponseHeader( - response, - options.responseHeader, - ) - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - } - - catchErrorCodes(options, result) - - resolve(result.body) - } - } catch (error) { - reject(error) - } - }) -} diff --git a/frontend/src/client/core/types.ts b/frontend/src/client/core/types.ts deleted file mode 100644 index 199c08d3df..0000000000 --- a/frontend/src/client/core/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApiResult } from "./ApiResult" - -export type TResult = "body" | "raw" - -export type TApiResponse = Exclude< - T, - "raw" -> extends never - ? ApiResult - : ApiResult["body"] - -export type TConfig = { - _result?: T -} diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts deleted file mode 100644 index adf1d0cabf..0000000000 --- a/frontend/src/client/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { ApiError } from "./core/ApiError" -export { CancelablePromise, CancelError } from "./core/CancelablePromise" -export { OpenAPI } from "./core/OpenAPI" -export type { OpenAPIConfig } from "./core/OpenAPI" - -export * from "./models" -export * from "./schemas" -export * from "./services" diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts deleted file mode 100644 index 2c8074ddd6..0000000000 --- a/frontend/src/client/models.ts +++ /dev/null @@ -1,99 +0,0 @@ -export type Body_login_login_access_token = { - grant_type?: string | null - username: string - password: string - scope?: string - client_id?: string | null - client_secret?: string | null -} - -export type HTTPValidationError = { - detail?: Array -} - -export type ItemCreate = { - title: string - description?: string | null -} - -export type ItemPublic = { - title: string - description?: string | null - id: string - owner_id: string -} - -export type ItemUpdate = { - title?: string | null - description?: string | null -} - -export type ItemsPublic = { - data: Array - count: number -} - -export type Message = { - message: string -} - -export type NewPassword = { - token: string - new_password: string -} - -export type Token = { - access_token: string - token_type?: string -} - -export type UpdatePassword = { - current_password: string - new_password: string -} - -export type UserCreate = { - email: string - is_active?: boolean - is_superuser?: boolean - full_name?: string | null - password: string -} - -export type UserPublic = { - email: string - is_active?: boolean - is_superuser?: boolean - full_name?: string | null - id: string -} - -export type UserRegister = { - email: string - password: string - full_name?: string | null -} - -export type UserUpdate = { - email?: string | null - is_active?: boolean - is_superuser?: boolean - full_name?: string | null - password?: string | null -} - -export type UserUpdateMe = { - full_name?: string | null - email?: string | null -} - -export type UsersPublic = { - data: Array - count: number -} - -export type ValidationError = { - loc: Array - msg: string - type: string -} diff --git a/frontend/src/client/schemas.ts b/frontend/src/client/schemas.ts deleted file mode 100644 index 9e92efd106..0000000000 --- a/frontend/src/client/schemas.ts +++ /dev/null @@ -1,444 +0,0 @@ -export const $Body_login_login_access_token = { - properties: { - grant_type: { - type: "any-of", - contains: [ - { - type: "string", - pattern: "password", - }, - { - type: "null", - }, - ], - }, - username: { - type: "string", - isRequired: true, - }, - password: { - type: "string", - isRequired: true, - }, - scope: { - type: "string", - default: "", - }, - client_id: { - type: "any-of", - contains: [ - { - type: "string", - }, - { - type: "null", - }, - ], - }, - client_secret: { - type: "any-of", - contains: [ - { - type: "string", - }, - { - type: "null", - }, - ], - }, - }, -} as const - -export const $HTTPValidationError = { - properties: { - detail: { - type: "array", - contains: { - type: "ValidationError", - }, - }, - }, -} as const - -export const $ItemCreate = { - properties: { - title: { - type: "string", - isRequired: true, - maxLength: 255, - minLength: 1, - }, - description: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - }, -} as const - -export const $ItemPublic = { - properties: { - title: { - type: "string", - isRequired: true, - maxLength: 255, - minLength: 1, - }, - description: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - id: { - type: "string", - isRequired: true, - format: "uuid", - }, - owner_id: { - type: "string", - isRequired: true, - format: "uuid", - }, - }, -} as const - -export const $ItemUpdate = { - properties: { - title: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - minLength: 1, - }, - { - type: "null", - }, - ], - }, - description: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - }, -} as const - -export const $ItemsPublic = { - properties: { - data: { - type: "array", - contains: { - type: "ItemPublic", - }, - isRequired: true, - }, - count: { - type: "number", - isRequired: true, - }, - }, -} as const - -export const $Message = { - properties: { - message: { - type: "string", - isRequired: true, - }, - }, -} as const - -export const $NewPassword = { - properties: { - token: { - type: "string", - isRequired: true, - }, - new_password: { - type: "string", - isRequired: true, - maxLength: 40, - minLength: 8, - }, - }, -} as const - -export const $Token = { - properties: { - access_token: { - type: "string", - isRequired: true, - }, - token_type: { - type: "string", - default: "bearer", - }, - }, -} as const - -export const $UpdatePassword = { - properties: { - current_password: { - type: "string", - isRequired: true, - maxLength: 40, - minLength: 8, - }, - new_password: { - type: "string", - isRequired: true, - maxLength: 40, - minLength: 8, - }, - }, -} as const - -export const $UserCreate = { - properties: { - email: { - type: "string", - isRequired: true, - format: "email", - maxLength: 255, - }, - is_active: { - type: "boolean", - default: true, - }, - is_superuser: { - type: "boolean", - default: false, - }, - full_name: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - password: { - type: "string", - isRequired: true, - maxLength: 40, - minLength: 8, - }, - }, -} as const - -export const $UserPublic = { - properties: { - email: { - type: "string", - isRequired: true, - format: "email", - maxLength: 255, - }, - is_active: { - type: "boolean", - default: true, - }, - is_superuser: { - type: "boolean", - default: false, - }, - full_name: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - id: { - type: "string", - isRequired: true, - format: "uuid", - }, - }, -} as const - -export const $UserRegister = { - properties: { - email: { - type: "string", - isRequired: true, - format: "email", - maxLength: 255, - }, - password: { - type: "string", - isRequired: true, - maxLength: 40, - minLength: 8, - }, - full_name: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - }, -} as const - -export const $UserUpdate = { - properties: { - email: { - type: "any-of", - contains: [ - { - type: "string", - format: "email", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - is_active: { - type: "boolean", - default: true, - }, - is_superuser: { - type: "boolean", - default: false, - }, - full_name: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - password: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 40, - minLength: 8, - }, - { - type: "null", - }, - ], - }, - }, -} as const - -export const $UserUpdateMe = { - properties: { - full_name: { - type: "any-of", - contains: [ - { - type: "string", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - email: { - type: "any-of", - contains: [ - { - type: "string", - format: "email", - maxLength: 255, - }, - { - type: "null", - }, - ], - }, - }, -} as const - -export const $UsersPublic = { - properties: { - data: { - type: "array", - contains: { - type: "UserPublic", - }, - isRequired: true, - }, - count: { - type: "number", - isRequired: true, - }, - }, -} as const - -export const $ValidationError = { - properties: { - loc: { - type: "array", - contains: { - type: "any-of", - contains: [ - { - type: "string", - }, - { - type: "number", - }, - ], - }, - isRequired: true, - }, - msg: { - type: "string", - isRequired: true, - }, - type: { - type: "string", - isRequired: true, - }, - }, -} as const diff --git a/frontend/src/client/services.ts b/frontend/src/client/services.ts deleted file mode 100644 index b99e4ac515..0000000000 --- a/frontend/src/client/services.ts +++ /dev/null @@ -1,517 +0,0 @@ -import type { CancelablePromise } from "./core/CancelablePromise" -import { OpenAPI } from "./core/OpenAPI" -import { request as __request } from "./core/request" - -import type { - Body_login_login_access_token, - Message, - NewPassword, - Token, - UserPublic, - UpdatePassword, - UserCreate, - UserRegister, - UsersPublic, - UserUpdate, - UserUpdateMe, - ItemCreate, - ItemPublic, - ItemsPublic, - ItemUpdate, -} from "./models" - -export type TDataLoginAccessToken = { - formData: Body_login_login_access_token -} -export type TDataRecoverPassword = { - email: string -} -export type TDataResetPassword = { - requestBody: NewPassword -} -export type TDataRecoverPasswordHtmlContent = { - email: string -} - -export class LoginService { - /** - * Login Access Token - * OAuth2 compatible token login, get an access token for future requests - * @returns Token Successful Response - * @throws ApiError - */ - public static loginAccessToken( - data: TDataLoginAccessToken, - ): CancelablePromise { - const { formData } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/login/access-token", - formData: formData, - mediaType: "application/x-www-form-urlencoded", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Test Token - * Test access token - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static testToken(): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/login/test-token", - }) - } - - /** - * Recover Password - * Password Recovery - * @returns Message Successful Response - * @throws ApiError - */ - public static recoverPassword( - data: TDataRecoverPassword, - ): CancelablePromise { - const { email } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/password-recovery/{email}", - path: { - email, - }, - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Reset Password - * Reset password - * @returns Message Successful Response - * @throws ApiError - */ - public static resetPassword( - data: TDataResetPassword, - ): CancelablePromise { - const { requestBody } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/reset-password/", - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Recover Password Html Content - * HTML Content for Password Recovery - * @returns string Successful Response - * @throws ApiError - */ - public static recoverPasswordHtmlContent( - data: TDataRecoverPasswordHtmlContent, - ): CancelablePromise { - const { email } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/password-recovery-html-content/{email}", - path: { - email, - }, - errors: { - 422: `Validation Error`, - }, - }) - } -} - -export type TDataReadUsers = { - limit?: number - skip?: number -} -export type TDataCreateUser = { - requestBody: UserCreate -} -export type TDataUpdateUserMe = { - requestBody: UserUpdateMe -} -export type TDataUpdatePasswordMe = { - requestBody: UpdatePassword -} -export type TDataRegisterUser = { - requestBody: UserRegister -} -export type TDataReadUserById = { - userId: string -} -export type TDataUpdateUser = { - requestBody: UserUpdate - userId: string -} -export type TDataDeleteUser = { - userId: string -} - -export class UsersService { - /** - * Read Users - * Retrieve users. - * @returns UsersPublic Successful Response - * @throws ApiError - */ - public static readUsers( - data: TDataReadUsers = {}, - ): CancelablePromise { - const { limit = 100, skip = 0 } = data - return __request(OpenAPI, { - method: "GET", - url: "/api/v1/users/", - query: { - skip, - limit, - }, - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Create User - * Create new user. - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static createUser( - data: TDataCreateUser, - ): CancelablePromise { - const { requestBody } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/users/", - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Read User Me - * Get current user. - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static readUserMe(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/api/v1/users/me", - }) - } - - /** - * Delete User Me - * Delete own user. - * @returns Message Successful Response - * @throws ApiError - */ - public static deleteUserMe(): CancelablePromise { - return __request(OpenAPI, { - method: "DELETE", - url: "/api/v1/users/me", - }) - } - - /** - * Update User Me - * Update own user. - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static updateUserMe( - data: TDataUpdateUserMe, - ): CancelablePromise { - const { requestBody } = data - return __request(OpenAPI, { - method: "PATCH", - url: "/api/v1/users/me", - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Update Password Me - * Update own password. - * @returns Message Successful Response - * @throws ApiError - */ - public static updatePasswordMe( - data: TDataUpdatePasswordMe, - ): CancelablePromise { - const { requestBody } = data - return __request(OpenAPI, { - method: "PATCH", - url: "/api/v1/users/me/password", - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Register User - * Create new user without the need to be logged in. - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static registerUser( - data: TDataRegisterUser, - ): CancelablePromise { - const { requestBody } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/users/signup", - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Read User By Id - * Get a specific user by id. - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static readUserById( - data: TDataReadUserById, - ): CancelablePromise { - const { userId } = data - return __request(OpenAPI, { - method: "GET", - url: "/api/v1/users/{user_id}", - path: { - user_id: userId, - }, - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Update User - * Update a user. - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static updateUser( - data: TDataUpdateUser, - ): CancelablePromise { - const { requestBody, userId } = data - return __request(OpenAPI, { - method: "PATCH", - url: "/api/v1/users/{user_id}", - path: { - user_id: userId, - }, - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Delete User - * Delete a user. - * @returns Message Successful Response - * @throws ApiError - */ - public static deleteUser(data: TDataDeleteUser): CancelablePromise { - const { userId } = data - return __request(OpenAPI, { - method: "DELETE", - url: "/api/v1/users/{user_id}", - path: { - user_id: userId, - }, - errors: { - 422: `Validation Error`, - }, - }) - } -} - -export type TDataTestEmail = { - emailTo: string -} - -export class UtilsService { - /** - * Test Email - * Test emails. - * @returns Message Successful Response - * @throws ApiError - */ - public static testEmail(data: TDataTestEmail): CancelablePromise { - const { emailTo } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/utils/test-email/", - query: { - email_to: emailTo, - }, - errors: { - 422: `Validation Error`, - }, - }) - } -} - -export type TDataReadItems = { - limit?: number - skip?: number -} -export type TDataCreateItem = { - requestBody: ItemCreate -} -export type TDataReadItem = { - id: string -} -export type TDataUpdateItem = { - id: string - requestBody: ItemUpdate -} -export type TDataDeleteItem = { - id: string -} - -export class ItemsService { - /** - * Read Items - * Retrieve items. - * @returns ItemsPublic Successful Response - * @throws ApiError - */ - public static readItems( - data: TDataReadItems = {}, - ): CancelablePromise { - const { limit = 100, skip = 0 } = data - return __request(OpenAPI, { - method: "GET", - url: "/api/v1/items/", - query: { - skip, - limit, - }, - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Create Item - * Create new item. - * @returns ItemPublic Successful Response - * @throws ApiError - */ - public static createItem( - data: TDataCreateItem, - ): CancelablePromise { - const { requestBody } = data - return __request(OpenAPI, { - method: "POST", - url: "/api/v1/items/", - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Read Item - * Get item by ID. - * @returns ItemPublic Successful Response - * @throws ApiError - */ - public static readItem(data: TDataReadItem): CancelablePromise { - const { id } = data - return __request(OpenAPI, { - method: "GET", - url: "/api/v1/items/{id}", - path: { - id, - }, - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Update Item - * Update an item. - * @returns ItemPublic Successful Response - * @throws ApiError - */ - public static updateItem( - data: TDataUpdateItem, - ): CancelablePromise { - const { id, requestBody } = data - return __request(OpenAPI, { - method: "PUT", - url: "/api/v1/items/{id}", - path: { - id, - }, - body: requestBody, - mediaType: "application/json", - errors: { - 422: `Validation Error`, - }, - }) - } - - /** - * Delete Item - * Delete an item. - * @returns Message Successful Response - * @throws ApiError - */ - public static deleteItem(data: TDataDeleteItem): CancelablePromise { - const { id } = data - return __request(OpenAPI, { - method: "DELETE", - url: "/api/v1/items/{id}", - path: { - id, - }, - errors: { - 422: `Validation Error`, - }, - }) - } -} diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx deleted file mode 100644 index a24a18a78e..0000000000 --- a/frontend/src/components/Admin/AddUser.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { - Button, - Checkbox, - Flex, - FormControl, - FormErrorMessage, - FormLabel, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { type UserCreate, UsersService } from "../../client" -import type { ApiError } from "../../client/core/ApiError" -import useCustomToast from "../../hooks/useCustomToast" -import { emailPattern, handleError } from "../../utils" - -interface AddUserProps { - isOpen: boolean - onClose: () => void -} - -interface UserCreateForm extends UserCreate { - confirm_password: string -} - -const AddUser = ({ isOpen, onClose }: AddUserProps) => { - const queryClient = useQueryClient() - const showToast = useCustomToast() - const { - register, - handleSubmit, - reset, - getValues, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - email: "", - full_name: "", - password: "", - confirm_password: "", - is_superuser: false, - is_active: false, - }, - }) - - const mutation = useMutation({ - mutationFn: (data: UserCreate) => - UsersService.createUser({ requestBody: data }), - onSuccess: () => { - showToast("Success!", "User created successfully.", "success") - reset() - onClose() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["users"] }) - }, - }) - - const onSubmit: SubmitHandler = (data) => { - mutation.mutate(data) - } - - return ( - <> - - - - Add User - - - - Email - - {errors.email && ( - {errors.email.message} - )} - - - Full name - - {errors.full_name && ( - {errors.full_name.message} - )} - - - Set Password - - {errors.password && ( - {errors.password.message} - )} - - - Confirm Password - - value === getValues().password || - "The passwords do not match", - })} - placeholder="Password" - type="password" - /> - {errors.confirm_password && ( - - {errors.confirm_password.message} - - )} - - - - - Is superuser? - - - - - Is active? - - - - - - - - - - - - ) -} - -export default AddUser diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx deleted file mode 100644 index d7885ab174..0000000000 --- a/frontend/src/components/Admin/EditUser.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - Button, - Checkbox, - Flex, - FormControl, - FormErrorMessage, - FormLabel, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { - type ApiError, - type UserPublic, - type UserUpdate, - UsersService, -} from "../../client" -import useCustomToast from "../../hooks/useCustomToast" -import { emailPattern, handleError } from "../../utils" - -interface EditUserProps { - user: UserPublic - isOpen: boolean - onClose: () => void -} - -interface UserUpdateForm extends UserUpdate { - confirm_password: string -} - -const EditUser = ({ user, isOpen, onClose }: EditUserProps) => { - const queryClient = useQueryClient() - const showToast = useCustomToast() - - const { - register, - handleSubmit, - reset, - getValues, - formState: { errors, isSubmitting, isDirty }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: user, - }) - - const mutation = useMutation({ - mutationFn: (data: UserUpdateForm) => - UsersService.updateUser({ userId: user.id, requestBody: data }), - onSuccess: () => { - showToast("Success!", "User updated successfully.", "success") - onClose() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["users"] }) - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - if (data.password === "") { - data.password = undefined - } - mutation.mutate(data) - } - - const onCancel = () => { - reset() - onClose() - } - - return ( - <> - - - - Edit User - - - - Email - - {errors.email && ( - {errors.email.message} - )} - - - Full name - - - - Set Password - - {errors.password && ( - {errors.password.message} - )} - - - Confirm Password - - value === getValues().password || - "The passwords do not match", - })} - placeholder="Password" - type="password" - /> - {errors.confirm_password && ( - - {errors.confirm_password.message} - - )} - - - - - Is superuser? - - - - - Is active? - - - - - - - - - - - - - ) -} - -export default EditUser diff --git a/frontend/src/components/Common/ActionsMenu.tsx b/frontend/src/components/Common/ActionsMenu.tsx deleted file mode 100644 index 4ff94ee3ea..0000000000 --- a/frontend/src/components/Common/ActionsMenu.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { - Button, - Menu, - MenuButton, - MenuItem, - MenuList, - useDisclosure, -} from "@chakra-ui/react" -import { BsThreeDotsVertical } from "react-icons/bs" -import { FiEdit, FiTrash } from "react-icons/fi" - -import type { ItemPublic, UserPublic } from "../../client" -import EditUser from "../Admin/EditUser" -import EditItem from "../Items/EditItem" -import Delete from "./DeleteAlert" - -interface ActionsMenuProps { - type: string - value: ItemPublic | UserPublic - disabled?: boolean -} - -const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => { - const editUserModal = useDisclosure() - const deleteModal = useDisclosure() - - return ( - <> - - } - variant="unstyled" - /> - - } - > - Edit {type} - - } - color="ui.danger" - > - Delete {type} - - - {type === "User" ? ( - - ) : ( - - )} - - - - ) -} - -export default ActionsMenu diff --git a/frontend/src/components/Common/DeleteAlert.tsx b/frontend/src/components/Common/DeleteAlert.tsx deleted file mode 100644 index 1528fc5fe1..0000000000 --- a/frontend/src/components/Common/DeleteAlert.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Button, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import React from "react" -import { useForm } from "react-hook-form" - -import { ItemsService, UsersService } from "../../client" -import useCustomToast from "../../hooks/useCustomToast" - -interface DeleteProps { - type: string - id: string - isOpen: boolean - onClose: () => void -} - -const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => { - const queryClient = useQueryClient() - const showToast = useCustomToast() - const cancelRef = React.useRef(null) - const { - handleSubmit, - formState: { isSubmitting }, - } = useForm() - - const deleteEntity = async (id: string) => { - if (type === "Item") { - await ItemsService.deleteItem({ id: id }) - } else if (type === "User") { - await UsersService.deleteUser({ userId: id }) - } else { - throw new Error(`Unexpected type: ${type}`) - } - } - - const mutation = useMutation({ - mutationFn: deleteEntity, - onSuccess: () => { - showToast( - "Success", - `The ${type.toLowerCase()} was deleted successfully.`, - "success", - ) - onClose() - }, - onError: () => { - showToast( - "An error occurred.", - `An error occurred while deleting the ${type.toLowerCase()}.`, - "error", - ) - }, - onSettled: () => { - queryClient.invalidateQueries({ - queryKey: [type === "Item" ? "items" : "users"], - }) - }, - }) - - const onSubmit = async () => { - mutation.mutate(id) - } - - return ( - <> - - - - Delete {type} - - - {type === "User" && ( - - All items associated with this user will also be{" "} - permantly deleted. - - )} - Are you sure? You will not be able to undo this action. - - - - - - - - - - - ) -} - -export default Delete diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx deleted file mode 100644 index 2aba31c362..0000000000 --- a/frontend/src/components/Common/Navbar.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ComponentType, ElementType } from "react" - -import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react" -import { FaPlus } from "react-icons/fa" - -interface NavbarProps { - type: string - addModalAs: ComponentType | ElementType -} - -const Navbar = ({ type, addModalAs }: NavbarProps) => { - const addModal = useDisclosure() - - const AddModal = addModalAs - return ( - <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} - - - - - ) -} - -export default Navbar diff --git a/frontend/src/components/Common/NotFound.tsx b/frontend/src/components/Common/NotFound.tsx deleted file mode 100644 index 66ea559c01..0000000000 --- a/frontend/src/components/Common/NotFound.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button, Container, Text } from "@chakra-ui/react" -import { Link } from "@tanstack/react-router" - -const NotFound = () => { - return ( - <> - - - 404 - - Oops! - Page not found. - - - - ) -} - -export default NotFound diff --git a/frontend/src/components/Common/Sidebar.tsx b/frontend/src/components/Common/Sidebar.tsx deleted file mode 100644 index 3cc522cc57..0000000000 --- a/frontend/src/components/Common/Sidebar.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { - Box, - Drawer, - DrawerBody, - DrawerCloseButton, - DrawerContent, - DrawerOverlay, - Flex, - IconButton, - Image, - Text, - useColorModeValue, - useDisclosure, -} from "@chakra-ui/react" -import { useQueryClient } from "@tanstack/react-query" -import { FiLogOut, FiMenu } from "react-icons/fi" - -import Logo from "/assets/images/fastapi-logo.svg" -import type { UserPublic } from "../../client" -import useAuth from "../../hooks/useAuth" -import SidebarItems from "./SidebarItems" - -const Sidebar = () => { - const queryClient = useQueryClient() - const bgColor = useColorModeValue("ui.light", "ui.dark") - const textColor = useColorModeValue("ui.dark", "ui.light") - const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate") - const currentUser = queryClient.getQueryData(["currentUser"]) - const { isOpen, onOpen, onClose } = useDisclosure() - const { logout } = useAuth() - - const handleLogout = async () => { - logout() - } - - return ( - <> - {/* Mobile */} - } - /> - - - - - - - - logo - - - - Log out - - - {currentUser?.email && ( - - Logged in as: {currentUser.email} - - )} - - - - - - {/* Desktop */} - - - - Logo - - - {currentUser?.email && ( - - Logged in as: {currentUser.email} - - )} - - - - ) -} - -export default Sidebar diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx deleted file mode 100644 index 929e8f785e..0000000000 --- a/frontend/src/components/Common/SidebarItems.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react" -import { useQueryClient } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" -import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" - -import type { UserPublic } from "../../client" - -const items = [ - { icon: FiHome, title: "Dashboard", path: "/" }, - { icon: FiBriefcase, title: "Items", path: "/items" }, - { icon: FiSettings, title: "User Settings", path: "/settings" }, -] - -interface SidebarItemsProps { - onClose?: () => void -} - -const SidebarItems = ({ onClose }: SidebarItemsProps) => { - const queryClient = useQueryClient() - const textColor = useColorModeValue("ui.main", "ui.light") - const bgActive = useColorModeValue("#E2E8F0", "#4A5568") - const currentUser = queryClient.getQueryData(["currentUser"]) - - const finalItems = currentUser?.is_superuser - ? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }] - : items - - const listItems = finalItems.map(({ icon, title, path }) => ( - - - {title} - - )) - - return ( - <> - {listItems} - - ) -} - -export default SidebarItems diff --git a/frontend/src/components/Common/UserMenu.tsx b/frontend/src/components/Common/UserMenu.tsx deleted file mode 100644 index e3d54ac26b..0000000000 --- a/frontend/src/components/Common/UserMenu.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { - Box, - IconButton, - Menu, - MenuButton, - MenuItem, - MenuList, -} from "@chakra-ui/react" -import { Link } from "@tanstack/react-router" -import { FaUserAstronaut } from "react-icons/fa" -import { FiLogOut, FiUser } from "react-icons/fi" - -import useAuth from "../../hooks/useAuth" - -const UserMenu = () => { - const { logout } = useAuth() - - const handleLogout = async () => { - logout() - } - - return ( - <> - {/* Desktop */} - - - } - bg="ui.main" - isRound - data-testid="user-menu" - /> - - } as={Link} to="settings"> - My profile - - } - onClick={handleLogout} - color="ui.danger" - fontWeight="bold" - > - Log out - - - - - - ) -} - -export default UserMenu diff --git a/frontend/src/components/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx deleted file mode 100644 index fa5682da3f..0000000000 --- a/frontend/src/components/Items/AddItem.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Button, - FormControl, - FormErrorMessage, - FormLabel, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { type ApiError, type ItemCreate, ItemsService } from "../../client" -import useCustomToast from "../../hooks/useCustomToast" -import { handleError } from "../../utils" - -interface AddItemProps { - isOpen: boolean - onClose: () => void -} - -const AddItem = ({ isOpen, onClose }: AddItemProps) => { - const queryClient = useQueryClient() - const showToast = useCustomToast() - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - title: "", - description: "", - }, - }) - - const mutation = useMutation({ - mutationFn: (data: ItemCreate) => - ItemsService.createItem({ requestBody: data }), - onSuccess: () => { - showToast("Success!", "Item created successfully.", "success") - reset() - onClose() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["items"] }) - }, - }) - - const onSubmit: SubmitHandler = (data) => { - mutation.mutate(data) - } - - return ( - <> - - - - Add Item - - - - Title - - {errors.title && ( - {errors.title.message} - )} - - - Description - - - - - - - - - - - - ) -} - -export default AddItem diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx deleted file mode 100644 index 3d40cdc03a..0000000000 --- a/frontend/src/components/Items/EditItem.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { - Button, - FormControl, - FormErrorMessage, - FormLabel, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { - type ApiError, - type ItemPublic, - type ItemUpdate, - ItemsService, -} from "../../client" -import useCustomToast from "../../hooks/useCustomToast" -import { handleError } from "../../utils" - -interface EditItemProps { - item: ItemPublic - isOpen: boolean - onClose: () => void -} - -const EditItem = ({ item, isOpen, onClose }: EditItemProps) => { - const queryClient = useQueryClient() - const showToast = useCustomToast() - const { - register, - handleSubmit, - reset, - formState: { isSubmitting, errors, isDirty }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: item, - }) - - const mutation = useMutation({ - mutationFn: (data: ItemUpdate) => - ItemsService.updateItem({ id: item.id, requestBody: data }), - onSuccess: () => { - showToast("Success!", "Item updated successfully.", "success") - onClose() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["items"] }) - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) - } - - const onCancel = () => { - reset() - onClose() - } - - return ( - <> - - - - Edit Item - - - - Title - - {errors.title && ( - {errors.title.message} - )} - - - Description - - - - - - - - - - - ) -} - -export default EditItem diff --git a/frontend/src/components/UserSettings/Appearance.tsx b/frontend/src/components/UserSettings/Appearance.tsx deleted file mode 100644 index a2ab4b0a60..0000000000 --- a/frontend/src/components/UserSettings/Appearance.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { - Badge, - Container, - Heading, - Radio, - RadioGroup, - Stack, - useColorMode, -} from "@chakra-ui/react" - -const Appearance = () => { - const { colorMode, toggleColorMode } = useColorMode() - - return ( - <> - - - Appearance - - - - {/* TODO: Add system default option */} - - Light Mode - - Default - - - - Dark Mode - - - - - - ) -} -export default Appearance diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx deleted file mode 100644 index 73217939fc..0000000000 --- a/frontend/src/components/UserSettings/ChangePassword.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { - Box, - Button, - Container, - FormControl, - FormErrorMessage, - FormLabel, - Heading, - Input, - useColorModeValue, -} from "@chakra-ui/react" -import { useMutation } from "@tanstack/react-query" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { type ApiError, type UpdatePassword, UsersService } from "../../client" -import useCustomToast from "../../hooks/useCustomToast" -import { confirmPasswordRules, handleError, passwordRules } from "../../utils" - -interface UpdatePasswordForm extends UpdatePassword { - confirm_password: string -} - -const ChangePassword = () => { - const color = useColorModeValue("inherit", "ui.light") - const showToast = useCustomToast() - const { - register, - handleSubmit, - reset, - getValues, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - }) - - const mutation = useMutation({ - mutationFn: (data: UpdatePassword) => - UsersService.updatePasswordMe({ requestBody: data }), - onSuccess: () => { - showToast("Success!", "Password updated successfully.", "success") - reset() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) - } - - return ( - <> - - - Change Password - - - - - Current Password - - - {errors.current_password && ( - - {errors.current_password.message} - - )} - - - Set Password - - {errors.new_password && ( - {errors.new_password.message} - )} - - - Confirm Password - - {errors.confirm_password && ( - - {errors.confirm_password.message} - - )} - - - - - - ) -} -export default ChangePassword diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx deleted file mode 100644 index 7ca3b92c95..0000000000 --- a/frontend/src/components/UserSettings/DeleteAccount.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { - Button, - Container, - Heading, - Text, - useDisclosure, -} from "@chakra-ui/react" - -import DeleteConfirmation from "./DeleteConfirmation" - -const DeleteAccount = () => { - const confirmationModal = useDisclosure() - - return ( - <> - - - Delete Account - - - Permanently delete your data and everything associated with your - account. - - - - - - ) -} -export default DeleteAccount diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx deleted file mode 100644 index 5bbdcdd6c7..0000000000 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Button, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import React from "react" -import { useForm } from "react-hook-form" - -import { type ApiError, UsersService } from "../../client" -import useAuth from "../../hooks/useAuth" -import useCustomToast from "../../hooks/useCustomToast" -import { handleError } from "../../utils" - -interface DeleteProps { - isOpen: boolean - onClose: () => void -} - -const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => { - const queryClient = useQueryClient() - const showToast = useCustomToast() - const cancelRef = React.useRef(null) - const { - handleSubmit, - formState: { isSubmitting }, - } = useForm() - const { logout } = useAuth() - - const mutation = useMutation({ - mutationFn: () => UsersService.deleteUserMe(), - onSuccess: () => { - showToast( - "Success", - "Your account has been successfully deleted.", - "success", - ) - logout() - onClose() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["currentUser"] }) - }, - }) - - const onSubmit = async () => { - mutation.mutate() - } - - return ( - <> - - - - Confirmation Required - - - All your account data will be{" "} - permanently deleted. If you are sure, please - click "Confirm" to proceed. This action cannot be - undone. - - - - - - - - - - - ) -} - -export default DeleteConfirmation diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx deleted file mode 100644 index d066a846a6..0000000000 --- a/frontend/src/components/UserSettings/UserInformation.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { - Box, - Button, - Container, - Flex, - FormControl, - FormErrorMessage, - FormLabel, - Heading, - Input, - Text, - useColorModeValue, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useState } from "react" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { - type ApiError, - type UserPublic, - type UserUpdateMe, - UsersService, -} from "../../client" -import useAuth from "../../hooks/useAuth" -import useCustomToast from "../../hooks/useCustomToast" -import { emailPattern, handleError } from "../../utils" - -const UserInformation = () => { - const queryClient = useQueryClient() - const color = useColorModeValue("inherit", "ui.light") - const showToast = useCustomToast() - const [editMode, setEditMode] = useState(false) - const { user: currentUser } = useAuth() - const { - register, - handleSubmit, - reset, - getValues, - formState: { isSubmitting, errors, isDirty }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - full_name: currentUser?.full_name, - email: currentUser?.email, - }, - }) - - const toggleEditMode = () => { - setEditMode(!editMode) - } - - const mutation = useMutation({ - mutationFn: (data: UserUpdateMe) => - UsersService.updateUserMe({ requestBody: data }), - onSuccess: () => { - showToast("Success!", "User updated successfully.", "success") - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - onSettled: () => { - queryClient.invalidateQueries() - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) - } - - const onCancel = () => { - reset() - toggleEditMode() - } - - return ( - <> - - - User Information - - - - - Full name - - {editMode ? ( - - ) : ( - - {currentUser?.full_name || "N/A"} - - )} - - - - Email - - {editMode ? ( - - ) : ( - - {currentUser?.email} - - )} - {errors.email && ( - {errors.email.message} - )} - - - - {editMode && ( - - )} - - - - - ) -} - -export default UserInformation diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts deleted file mode 100644 index 76b0abdfd3..0000000000 --- a/frontend/src/hooks/useAuth.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { useNavigate } from "@tanstack/react-router" -import { useState } from "react" - -import { AxiosError } from "axios" -import { - type Body_login_login_access_token as AccessToken, - type ApiError, - LoginService, - type UserPublic, - type UserRegister, - UsersService, -} from "../client" -import useCustomToast from "./useCustomToast" - -const isLoggedIn = () => { - return localStorage.getItem("access_token") !== null -} - -const useAuth = () => { - const [error, setError] = useState(null) - const navigate = useNavigate() - const showToast = useCustomToast() - const queryClient = useQueryClient() - const { data: user, isLoading } = useQuery({ - queryKey: ["currentUser"], - queryFn: UsersService.readUserMe, - enabled: isLoggedIn(), - }) - - const signUpMutation = useMutation({ - mutationFn: (data: UserRegister) => - UsersService.registerUser({ requestBody: data }), - - onSuccess: () => { - navigate({ to: "/login" }) - showToast( - "Account created.", - "Your account has been created successfully.", - "success", - ) - }, - onError: (err: ApiError) => { - let errDetail = (err.body as any)?.detail - - if (err instanceof AxiosError) { - errDetail = err.message - } - - showToast("Something went wrong.", errDetail, "error") - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["users"] }) - }, - }) - - const login = async (data: AccessToken) => { - const response = await LoginService.loginAccessToken({ - formData: data, - }) - localStorage.setItem("access_token", response.access_token) - } - - const loginMutation = useMutation({ - mutationFn: login, - onSuccess: () => { - navigate({ to: "/" }) - }, - onError: (err: ApiError) => { - let errDetail = (err.body as any)?.detail - - if (err instanceof AxiosError) { - errDetail = err.message - } - - if (Array.isArray(errDetail)) { - errDetail = "Something went wrong" - } - - setError(errDetail) - }, - }) - - const logout = () => { - localStorage.removeItem("access_token") - navigate({ to: "/login" }) - } - - return { - signUpMutation, - loginMutation, - logout, - user, - isLoading, - error, - resetError: () => setError(null), - } -} - -export { isLoggedIn } -export default useAuth diff --git a/frontend/src/hooks/useCustomToast.ts b/frontend/src/hooks/useCustomToast.ts deleted file mode 100644 index 06bc8a6ab8..0000000000 --- a/frontend/src/hooks/useCustomToast.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useToast } from "@chakra-ui/react" -import { useCallback } from "react" - -const useCustomToast = () => { - const toast = useToast() - - const showToast = useCallback( - (title: string, description: string, status: "success" | "error") => { - toast({ - title, - description, - status, - isClosable: true, - position: "bottom-right", - }) - }, - [toast], - ) - - return showToast -} - -export default useCustomToast diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index afc904538b..0000000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ChakraProvider } from "@chakra-ui/react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { RouterProvider, createRouter } from "@tanstack/react-router" -import ReactDOM from "react-dom/client" -import { routeTree } from "./routeTree.gen" - -import { StrictMode } from "react" -import { OpenAPI } from "./client" -import theme from "./theme" - -OpenAPI.BASE = import.meta.env.VITE_API_URL -OpenAPI.TOKEN = async () => { - return localStorage.getItem("access_token") || "" -} - -const queryClient = new QueryClient() - -const router = createRouter({ routeTree }) -declare module "@tanstack/react-router" { - interface Register { - router: typeof router - } -} - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - - , -) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts deleted file mode 100644 index 0e78c9ba20..0000000000 --- a/frontend/src/routeTree.gen.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* prettier-ignore-start */ - -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - -// This file is auto-generated by TanStack Router - -// Import Routes - -import { Route as rootRoute } from './routes/__root' -import { Route as SignupImport } from './routes/signup' -import { Route as ResetPasswordImport } from './routes/reset-password' -import { Route as RecoverPasswordImport } from './routes/recover-password' -import { Route as LoginImport } from './routes/login' -import { Route as LayoutImport } from './routes/_layout' -import { Route as LayoutIndexImport } from './routes/_layout/index' -import { Route as LayoutSettingsImport } from './routes/_layout/settings' -import { Route as LayoutItemsImport } from './routes/_layout/items' -import { Route as LayoutAdminImport } from './routes/_layout/admin' - -// Create/Update Routes - -const SignupRoute = SignupImport.update({ - path: '/signup', - getParentRoute: () => rootRoute, -} as any) - -const ResetPasswordRoute = ResetPasswordImport.update({ - path: '/reset-password', - getParentRoute: () => rootRoute, -} as any) - -const RecoverPasswordRoute = RecoverPasswordImport.update({ - path: '/recover-password', - getParentRoute: () => rootRoute, -} as any) - -const LoginRoute = LoginImport.update({ - path: '/login', - getParentRoute: () => rootRoute, -} as any) - -const LayoutRoute = LayoutImport.update({ - id: '/_layout', - getParentRoute: () => rootRoute, -} as any) - -const LayoutIndexRoute = LayoutIndexImport.update({ - path: '/', - getParentRoute: () => LayoutRoute, -} as any) - -const LayoutSettingsRoute = LayoutSettingsImport.update({ - path: '/settings', - getParentRoute: () => LayoutRoute, -} as any) - -const LayoutItemsRoute = LayoutItemsImport.update({ - path: '/items', - getParentRoute: () => LayoutRoute, -} as any) - -const LayoutAdminRoute = LayoutAdminImport.update({ - path: '/admin', - getParentRoute: () => LayoutRoute, -} as any) - -// Populate the FileRoutesByPath interface - -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/_layout': { - preLoaderRoute: typeof LayoutImport - parentRoute: typeof rootRoute - } - '/login': { - preLoaderRoute: typeof LoginImport - parentRoute: typeof rootRoute - } - '/recover-password': { - preLoaderRoute: typeof RecoverPasswordImport - parentRoute: typeof rootRoute - } - '/reset-password': { - preLoaderRoute: typeof ResetPasswordImport - parentRoute: typeof rootRoute - } - '/signup': { - preLoaderRoute: typeof SignupImport - parentRoute: typeof rootRoute - } - '/_layout/admin': { - preLoaderRoute: typeof LayoutAdminImport - parentRoute: typeof LayoutImport - } - '/_layout/items': { - preLoaderRoute: typeof LayoutItemsImport - parentRoute: typeof LayoutImport - } - '/_layout/settings': { - preLoaderRoute: typeof LayoutSettingsImport - parentRoute: typeof LayoutImport - } - '/_layout/': { - preLoaderRoute: typeof LayoutIndexImport - parentRoute: typeof LayoutImport - } - } -} - -// Create and export the route tree - -export const routeTree = rootRoute.addChildren([ - LayoutRoute.addChildren([ - LayoutAdminRoute, - LayoutItemsRoute, - LayoutSettingsRoute, - LayoutIndexRoute, - ]), - LoginRoute, - RecoverPasswordRoute, - ResetPasswordRoute, - SignupRoute, -]) - -/* prettier-ignore-end */ diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx deleted file mode 100644 index 5da6383f2a..0000000000 --- a/frontend/src/routes/__root.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Outlet, createRootRoute } from "@tanstack/react-router" -import React, { Suspense } from "react" - -import NotFound from "../components/Common/NotFound" - -const loadDevtools = () => - Promise.all([ - import("@tanstack/router-devtools"), - import("@tanstack/react-query-devtools"), - ]).then(([routerDevtools, reactQueryDevtools]) => { - return { - default: () => ( - <> - - - - ), - } - }) - -const TanStackDevtools = - process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools) - -export const Route = createRootRoute({ - component: () => ( - <> - - - - - - ), - notFoundComponent: () => , -}) diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx deleted file mode 100644 index 9a6cfa3b81..0000000000 --- a/frontend/src/routes/_layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Flex, Spinner } from "@chakra-ui/react" -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router" - -import Sidebar from "../components/Common/Sidebar" -import UserMenu from "../components/Common/UserMenu" -import useAuth, { isLoggedIn } from "../hooks/useAuth" - -export const Route = createFileRoute("/_layout")({ - component: Layout, - beforeLoad: async () => { - if (!isLoggedIn()) { - throw redirect({ - to: "/login", - }) - } - }, -}) - -function Layout() { - const { isLoading } = useAuth() - - return ( - - - {isLoading ? ( - - - - ) : ( - - )} - - - ) -} diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx deleted file mode 100644 index 644653ff79..0000000000 --- a/frontend/src/routes/_layout/admin.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { - Badge, - Box, - Button, - Container, - Flex, - Heading, - SkeletonText, - Table, - TableContainer, - Tbody, - Td, - Th, - Thead, - Tr, -} from "@chakra-ui/react" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { useEffect } from "react" -import { z } from "zod" - -import { type UserPublic, UsersService } from "../../client" -import AddUser from "../../components/Admin/AddUser" -import ActionsMenu from "../../components/Common/ActionsMenu" -import Navbar from "../../components/Common/Navbar" - -const usersSearchSchema = z.object({ - page: z.number().catch(1), -}) - -export const Route = createFileRoute("/_layout/admin")({ - component: Admin, - validateSearch: (search) => usersSearchSchema.parse(search), -}) - -const PER_PAGE = 5 - -function getUsersQueryOptions({ page }: { page: number }) { - return { - queryFn: () => - UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), - queryKey: ["users", { page }], - } -} - -function UsersTable() { - const queryClient = useQueryClient() - const currentUser = queryClient.getQueryData(["currentUser"]) - const { page } = Route.useSearch() - const navigate = useNavigate({ from: Route.fullPath }) - const setPage = (page: number) => - navigate({ search: (prev) => ({ ...prev, page }) }) - - const { - data: users, - isPending, - isPlaceholderData, - } = useQuery({ - ...getUsersQueryOptions({ page }), - placeholderData: (prevData) => prevData, - }) - - const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE - const hasPreviousPage = page > 1 - - useEffect(() => { - if (hasNextPage) { - queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 })) - } - }, [page, queryClient, hasNextPage]) - - return ( - <> - - - - - - - - - - - - {isPending ? ( - - - {new Array(4).fill(null).map((_, index) => ( - - ))} - - - ) : ( - - {users?.data.map((user) => ( - - - - - - - - ))} - - )} -
Full nameEmailRoleStatusActions
- -
- {user.full_name || "N/A"} - {currentUser?.id === user.id && ( - - You - - )} - - {user.email} - {user.is_superuser ? "Superuser" : "User"} - - - {user.is_active ? "Active" : "Inactive"} - - - -
-
- - - Page {page} - - - - ) -} - -function Admin() { - return ( - - - Users Management - - - - - - ) -} diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx deleted file mode 100644 index 80cc934083..0000000000 --- a/frontend/src/routes/_layout/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, Container, Text } from "@chakra-ui/react" -import { createFileRoute } from "@tanstack/react-router" - -import useAuth from "../../hooks/useAuth" - -export const Route = createFileRoute("/_layout/")({ - component: Dashboard, -}) - -function Dashboard() { - const { user: currentUser } = useAuth() - - return ( - <> - - - - Hi, {currentUser?.full_name || currentUser?.email} 👋🏼 - - Welcome back, nice to see you again! - - - - ) -} diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx deleted file mode 100644 index 174fa83c9b..0000000000 --- a/frontend/src/routes/_layout/items.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { - Button, - Container, - Flex, - Heading, - SkeletonText, - Table, - TableContainer, - Tbody, - Td, - Th, - Thead, - Tr, -} from "@chakra-ui/react" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { useEffect } from "react" -import { z } from "zod" - -import { ItemsService } from "../../client" -import ActionsMenu from "../../components/Common/ActionsMenu" -import Navbar from "../../components/Common/Navbar" -import AddItem from "../../components/Items/AddItem" - -const itemsSearchSchema = z.object({ - page: z.number().catch(1), -}) - -export const Route = createFileRoute("/_layout/items")({ - component: Items, - validateSearch: (search) => itemsSearchSchema.parse(search), -}) - -const PER_PAGE = 5 - -function getItemsQueryOptions({ page }: { page: number }) { - return { - queryFn: () => - ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), - queryKey: ["items", { page }], - } -} - -function ItemsTable() { - const queryClient = useQueryClient() - const { page } = Route.useSearch() - const navigate = useNavigate({ from: Route.fullPath }) - const setPage = (page: number) => - navigate({ search: (prev) => ({ ...prev, page }) }) - - const { - data: items, - isPending, - isPlaceholderData, - } = useQuery({ - ...getItemsQueryOptions({ page }), - placeholderData: (prevData) => prevData, - }) - - const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE - const hasPreviousPage = page > 1 - - useEffect(() => { - if (hasNextPage) { - queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 })) - } - }, [page, queryClient, hasNextPage]) - - return ( - <> - - - - - - - - - - - {isPending ? ( - - - {new Array(4).fill(null).map((_, index) => ( - - ))} - - - ) : ( - - {items?.data.map((item) => ( - - - - - - - ))} - - )} -
IDTitleDescriptionActions
- -
{item.id} - {item.title} - - {item.description || "N/A"} - - -
-
- - - Page {page} - - - - ) -} - -function Items() { - return ( - - - Items Management - - - - - - ) -} diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx deleted file mode 100644 index 68266c6b9a..0000000000 --- a/frontend/src/routes/_layout/settings.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - Container, - Heading, - Tab, - TabList, - TabPanel, - TabPanels, - Tabs, -} from "@chakra-ui/react" -import { useQueryClient } from "@tanstack/react-query" -import { createFileRoute } from "@tanstack/react-router" - -import type { UserPublic } from "../../client" -import Appearance from "../../components/UserSettings/Appearance" -import ChangePassword from "../../components/UserSettings/ChangePassword" -import DeleteAccount from "../../components/UserSettings/DeleteAccount" -import UserInformation from "../../components/UserSettings/UserInformation" - -const tabsConfig = [ - { title: "My profile", component: UserInformation }, - { title: "Password", component: ChangePassword }, - { title: "Appearance", component: Appearance }, - { title: "Danger zone", component: DeleteAccount }, -] - -export const Route = createFileRoute("/_layout/settings")({ - component: UserSettings, -}) - -function UserSettings() { - const queryClient = useQueryClient() - const currentUser = queryClient.getQueryData(["currentUser"]) - const finalTabs = currentUser?.is_superuser - ? tabsConfig.slice(0, 3) - : tabsConfig - - return ( - - - User Settings - - - - {finalTabs.map((tab, index) => ( - {tab.title} - ))} - - - {finalTabs.map((tab, index) => ( - - - - ))} - - - - ) -} diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx deleted file mode 100644 index 20a9be6564..0000000000 --- a/frontend/src/routes/login.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons" -import { - Button, - Container, - FormControl, - FormErrorMessage, - Icon, - Image, - Input, - InputGroup, - InputRightElement, - Link, - Text, - useBoolean, -} from "@chakra-ui/react" -import { - Link as RouterLink, - createFileRoute, - redirect, -} from "@tanstack/react-router" -import { type SubmitHandler, useForm } from "react-hook-form" - -import Logo from "/assets/images/fastapi-logo.svg" -import type { Body_login_login_access_token as AccessToken } from "../client" -import useAuth, { isLoggedIn } from "../hooks/useAuth" -import { emailPattern } from "../utils" - -export const Route = createFileRoute("/login")({ - component: Login, - beforeLoad: async () => { - if (isLoggedIn()) { - throw redirect({ - to: "/", - }) - } - }, -}) - -function Login() { - const [show, setShow] = useBoolean() - const { loginMutation, error, resetError } = useAuth() - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - username: "", - password: "", - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - if (isSubmitting) return - - resetError() - - try { - await loginMutation.mutateAsync(data) - } catch { - // error is handled by useAuth hook - } - } - - return ( - <> - - FastAPI logo - - - {errors.username && ( - {errors.username.message} - )} - - - - - - - {show ? : } - - - - {error && {error}} - - - Forgot password? - - - - Don't have an account?{" "} - - Sign up - - - - - ) -} diff --git a/frontend/src/routes/recover-password.tsx b/frontend/src/routes/recover-password.tsx deleted file mode 100644 index 5716728bbb..0000000000 --- a/frontend/src/routes/recover-password.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { - Button, - Container, - FormControl, - FormErrorMessage, - Heading, - Input, - Text, -} from "@chakra-ui/react" -import { useMutation } from "@tanstack/react-query" -import { createFileRoute, redirect } from "@tanstack/react-router" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { type ApiError, LoginService } from "../client" -import { isLoggedIn } from "../hooks/useAuth" -import useCustomToast from "../hooks/useCustomToast" -import { emailPattern, handleError } from "../utils" - -interface FormData { - email: string -} - -export const Route = createFileRoute("/recover-password")({ - component: RecoverPassword, - beforeLoad: async () => { - if (isLoggedIn()) { - throw redirect({ - to: "/", - }) - } - }, -}) - -function RecoverPassword() { - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm() - const showToast = useCustomToast() - - const recoverPassword = async (data: FormData) => { - await LoginService.recoverPassword({ - email: data.email, - }) - } - - const mutation = useMutation({ - mutationFn: recoverPassword, - onSuccess: () => { - showToast( - "Email sent.", - "We sent an email with a link to get back into your account.", - "success", - ) - reset() - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) - } - - return ( - - - Password Recovery - - - A password recovery email will be sent to the registered account. - - - - {errors.email && ( - {errors.email.message} - )} - - - - ) -} diff --git a/frontend/src/routes/reset-password.tsx b/frontend/src/routes/reset-password.tsx deleted file mode 100644 index f5ee763a3e..0000000000 --- a/frontend/src/routes/reset-password.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { - Button, - Container, - FormControl, - FormErrorMessage, - FormLabel, - Heading, - Input, - Text, -} from "@chakra-ui/react" -import { useMutation } from "@tanstack/react-query" -import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" -import { type SubmitHandler, useForm } from "react-hook-form" - -import { type ApiError, LoginService, type NewPassword } from "../client" -import { isLoggedIn } from "../hooks/useAuth" -import useCustomToast from "../hooks/useCustomToast" -import { confirmPasswordRules, handleError, passwordRules } from "../utils" - -interface NewPasswordForm extends NewPassword { - confirm_password: string -} - -export const Route = createFileRoute("/reset-password")({ - component: ResetPassword, - beforeLoad: async () => { - if (isLoggedIn()) { - throw redirect({ - to: "/", - }) - } - }, -}) - -function ResetPassword() { - const { - register, - handleSubmit, - getValues, - reset, - formState: { errors }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - new_password: "", - }, - }) - const showToast = useCustomToast() - const navigate = useNavigate() - - const resetPassword = async (data: NewPassword) => { - const token = new URLSearchParams(window.location.search).get("token") - if (!token) return - await LoginService.resetPassword({ - requestBody: { new_password: data.new_password, token: token }, - }) - } - - const mutation = useMutation({ - mutationFn: resetPassword, - onSuccess: () => { - showToast("Success!", "Password updated successfully.", "success") - reset() - navigate({ to: "/login" }) - }, - onError: (err: ApiError) => { - handleError(err, showToast) - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) - } - - return ( - - - Reset Password - - - Please enter your new password and confirm it to reset your password. - - - Set Password - - {errors.new_password && ( - {errors.new_password.message} - )} - - - Confirm Password - - {errors.confirm_password && ( - {errors.confirm_password.message} - )} - - - - ) -} diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx deleted file mode 100644 index b021e73698..0000000000 --- a/frontend/src/routes/signup.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - Button, - Container, - Flex, - FormControl, - FormErrorMessage, - FormLabel, - Image, - Input, - Link, - Text, -} from "@chakra-ui/react" -import { - Link as RouterLink, - createFileRoute, - redirect, -} from "@tanstack/react-router" -import { type SubmitHandler, useForm } from "react-hook-form" - -import Logo from "/assets/images/fastapi-logo.svg" -import type { UserRegister } from "../client" -import useAuth, { isLoggedIn } from "../hooks/useAuth" -import { confirmPasswordRules, emailPattern, passwordRules } from "../utils" - -export const Route = createFileRoute("/signup")({ - component: SignUp, - beforeLoad: async () => { - if (isLoggedIn()) { - throw redirect({ - to: "/", - }) - } - }, -}) - -interface UserRegisterForm extends UserRegister { - confirm_password: string -} - -function SignUp() { - const { signUpMutation } = useAuth() - const { - register, - handleSubmit, - getValues, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - email: "", - full_name: "", - password: "", - confirm_password: "", - }, - }) - - const onSubmit: SubmitHandler = (data) => { - signUpMutation.mutate(data) - } - - return ( - <> - - - FastAPI logo - - - Full Name - - - {errors.full_name && ( - {errors.full_name.message} - )} - - - - Email - - - {errors.email && ( - {errors.email.message} - )} - - - - Password - - - {errors.password && ( - {errors.password.message} - )} - - - - Confirm Password - - - - {errors.confirm_password && ( - - {errors.confirm_password.message} - - )} - - - - Already have an account?{" "} - - Log In - - - - - - ) -} - -export default SignUp diff --git a/frontend/src/theme.tsx b/frontend/src/theme.tsx deleted file mode 100644 index 71675dddca..0000000000 --- a/frontend/src/theme.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { extendTheme } from "@chakra-ui/react" - -const disabledStyles = { - _disabled: { - backgroundColor: "ui.main", - }, -} - -const theme = extendTheme({ - colors: { - ui: { - main: "#009688", - secondary: "#EDF2F7", - success: "#48BB78", - danger: "#E53E3E", - light: "#FAFAFA", - dark: "#1A202C", - darkSlate: "#252D3D", - dim: "#A0AEC0", - }, - }, - components: { - Button: { - variants: { - primary: { - backgroundColor: "ui.main", - color: "ui.light", - _hover: { - backgroundColor: "#00766C", - }, - _disabled: { - ...disabledStyles, - _hover: { - ...disabledStyles, - }, - }, - }, - danger: { - backgroundColor: "ui.danger", - color: "ui.light", - _hover: { - backgroundColor: "#E32727", - }, - }, - }, - }, - Tabs: { - variants: { - enclosed: { - tab: { - _selected: { - color: "ui.main", - }, - }, - }, - }, - }, - }, -}) - -export default theme diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts deleted file mode 100644 index 99f906303c..0000000000 --- a/frontend/src/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ApiError } from "./client" - -export const emailPattern = { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: "Invalid email address", -} - -export const namePattern = { - value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/, - message: "Invalid name", -} - -export const passwordRules = (isRequired = true) => { - const rules: any = { - minLength: { - value: 8, - message: "Password must be at least 8 characters", - }, - } - - if (isRequired) { - rules.required = "Password is required" - } - - return rules -} - -export const confirmPasswordRules = ( - getValues: () => any, - isRequired = true, -) => { - const rules: any = { - validate: (value: string) => { - const password = getValues().password || getValues().new_password - return value === password ? true : "The passwords do not match" - }, - } - - if (isRequired) { - rules.required = "Password confirmation is required" - } - - return rules -} - -export const handleError = (err: ApiError, showToast: any) => { - const errDetail = (err.body as any)?.detail - let errorMessage = errDetail || "Something went wrong." - if (Array.isArray(errDetail) && errDetail.length > 0) { - errorMessage = errDetail[0].msg - } - showToast("Error", errorMessage, "error") -} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/frontend/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/tests/auth.setup.ts b/frontend/tests/auth.setup.ts deleted file mode 100644 index 3882f4f810..0000000000 --- a/frontend/tests/auth.setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { test as setup } from "@playwright/test" -import { firstSuperuser, firstSuperuserPassword } from "./config.ts" - -const authFile = "playwright/.auth/user.json" - -setup("authenticate", async ({ page }) => { - await page.goto("/login") - await page.getByPlaceholder("Email").fill(firstSuperuser) - await page.getByPlaceholder("Password").fill(firstSuperuserPassword) - await page.getByRole("button", { name: "Log In" }).click() - await page.waitForURL("/") - await page.context().storageState({ path: authFile }) -}) diff --git a/frontend/tests/config.ts b/frontend/tests/config.ts deleted file mode 100644 index 188cb367e3..0000000000 --- a/frontend/tests/config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import path from "node:path" -import { fileURLToPath } from "node:url" -import dotenv from "dotenv" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -dotenv.config({ path: path.join(__dirname, "../../.env") }) - -const { FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD } = process.env - -if (typeof FIRST_SUPERUSER !== "string") { - throw new Error("Environment variable FIRST_SUPERUSER is undefined") -} - -if (typeof FIRST_SUPERUSER_PASSWORD !== "string") { - throw new Error("Environment variable FIRST_SUPERUSER_PASSWORD is undefined") -} - -export const firstSuperuser = FIRST_SUPERUSER as string -export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string diff --git a/frontend/tests/login.spec.ts b/frontend/tests/login.spec.ts deleted file mode 100644 index 97c2284f40..0000000000 --- a/frontend/tests/login.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type Page, expect, test } from "@playwright/test" -import { firstSuperuser, firstSuperuserPassword } from "./config.ts" -import { randomPassword } from "./utils/random.ts" - -test.use({ storageState: { cookies: [], origins: [] } }) - -type OptionsType = { - exact?: boolean -} - -const fillForm = async (page: Page, email: string, password: string) => { - await page.getByPlaceholder("Email").fill(email) - await page.getByPlaceholder("Password", { exact: true }).fill(password) -} - -const verifyInput = async ( - page: Page, - placeholder: string, - options?: OptionsType, -) => { - const input = page.getByPlaceholder(placeholder, options) - await expect(input).toBeVisible() - await expect(input).toHaveText("") - await expect(input).toBeEditable() -} - -test("Inputs are visible, empty and editable", async ({ page }) => { - await page.goto("/login") - - await verifyInput(page, "Email") - await verifyInput(page, "Password", { exact: true }) -}) - -test("Log In button is visible", async ({ page }) => { - await page.goto("/login") - - await expect(page.getByRole("button", { name: "Log In" })).toBeVisible() -}) - -test("Forgot Password link is visible", async ({ page }) => { - await page.goto("/login") - - await expect( - page.getByRole("link", { name: "Forgot password?" }), - ).toBeVisible() -}) - -test("Log in with valid email and password ", async ({ page }) => { - await page.goto("/login") - - await fillForm(page, firstSuperuser, firstSuperuserPassword) - await page.getByRole("button", { name: "Log In" }).click() - - await page.waitForURL("/") - - await expect( - page.getByText("Welcome back, nice to see you again!"), - ).toBeVisible() -}) - -test("Log in with invalid email", async ({ page }) => { - await page.goto("/login") - - await fillForm(page, "invalidemail", firstSuperuserPassword) - await page.getByRole("button", { name: "Log In" }).click() - - await expect(page.getByText("Invalid email address")).toBeVisible() -}) - -test("Log in with invalid password", async ({ page }) => { - const password = randomPassword() - - await page.goto("/login") - await fillForm(page, firstSuperuser, password) - await page.getByRole("button", { name: "Log In" }).click() - - await expect(page.getByText("Incorrect email or password")).toBeVisible() -}) - -// Log out - -test("Successful log out", async ({ page }) => { - await page.goto("/login") - - await fillForm(page, firstSuperuser, firstSuperuserPassword) - await page.getByRole("button", { name: "Log In" }).click() - - await page.waitForURL("/") - - await expect( - page.getByText("Welcome back, nice to see you again!"), - ).toBeVisible() - - await page.getByTestId("user-menu").click() - await page.getByRole("menuitem", { name: "Log out" }).click() - await page.waitForURL("/login") -}) - -test("Logged-out user cannot access protected routes", async ({ page }) => { - await page.goto("/login") - - await fillForm(page, firstSuperuser, firstSuperuserPassword) - await page.getByRole("button", { name: "Log In" }).click() - - await page.waitForURL("/") - - await expect( - page.getByText("Welcome back, nice to see you again!"), - ).toBeVisible() - - await page.getByTestId("user-menu").click() - await page.getByRole("menuitem", { name: "Log out" }).click() - await page.waitForURL("/login") - - await page.goto("/settings") - await page.waitForURL("/login") -}) diff --git a/frontend/tests/reset-password.spec.ts b/frontend/tests/reset-password.spec.ts deleted file mode 100644 index 88ec798791..0000000000 --- a/frontend/tests/reset-password.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect, test } from "@playwright/test" -import { findLastEmail } from "./utils/mailcatcher" -import { randomEmail, randomPassword } from "./utils/random" -import { logInUser, signUpNewUser } from "./utils/user" - -test.use({ storageState: { cookies: [], origins: [] } }) - -test("Password Recovery title is visible", async ({ page }) => { - await page.goto("/recover-password") - - await expect( - page.getByRole("heading", { name: "Password Recovery" }), - ).toBeVisible() -}) - -test("Input is visible, empty and editable", async ({ page }) => { - await page.goto("/recover-password") - - await expect(page.getByPlaceholder("Email")).toBeVisible() - await expect(page.getByPlaceholder("Email")).toHaveText("") - await expect(page.getByPlaceholder("Email")).toBeEditable() -}) - -test("Continue button is visible", async ({ page }) => { - await page.goto("/recover-password") - - await expect(page.getByRole("button", { name: "Continue" })).toBeVisible() -}) - -test("User can reset password successfully using the link", async ({ - page, - request, -}) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const newPassword = randomPassword() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - await page.goto("/recover-password") - await page.getByPlaceholder("Email").fill(email) - - await page.getByRole("button", { name: "Continue" }).click() - - const emailData = await findLastEmail({ - request, - filter: (e) => e.recipients.includes(`<${email}>`), - timeout: 5000, - }) - - await page.goto(`http://localhost:1080/messages/${emailData.id}.html`) - - const selector = 'a[href*="/reset-password?token="]' - - let url = await page.getAttribute(selector, "href") - - // TODO: update var instead of doing a replace - url = url!.replace("http://localhost/", "http://localhost:5173/") - - // Set the new password and confirm it - await page.goto(url) - - await page.getByLabel("Set Password").fill(newPassword) - await page.getByLabel("Confirm Password").fill(newPassword) - await page.getByRole("button", { name: "Reset Password" }).click() - await expect(page.getByText("Password updated successfully")).toBeVisible() - - // Check if the user is able to login with the new password - await logInUser(page, email, newPassword) -}) - -test("Expired or invalid reset link", async ({ page }) => { - const password = randomPassword() - const invalidUrl = "/reset-password?token=invalidtoken" - - await page.goto(invalidUrl) - - await page.getByLabel("Set Password").fill(password) - await page.getByLabel("Confirm Password").fill(password) - await page.getByRole("button", { name: "Reset Password" }).click() - - await expect(page.getByText("Invalid token")).toBeVisible() -}) - -test("Weak new password validation", async ({ page, request }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const weakPassword = "123" - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - await page.goto("/recover-password") - await page.getByPlaceholder("Email").fill(email) - await page.getByRole("button", { name: "Continue" }).click() - - const emailData = await findLastEmail({ - request, - filter: (e) => e.recipients.includes(`<${email}>`), - timeout: 5000, - }) - - await page.goto(`http://localhost:1080/messages/${emailData.id}.html`) - - const selector = 'a[href*="/reset-password?token="]' - let url = await page.getAttribute(selector, "href") - url = url!.replace("http://localhost/", "http://localhost:5173/") - - // Set a weak new password - await page.goto(url) - await page.getByLabel("Set Password").fill(weakPassword) - await page.getByLabel("Confirm Password").fill(weakPassword) - await page.getByRole("button", { name: "Reset Password" }).click() - - await expect( - page.getByText("Password must be at least 8 characters"), - ).toBeVisible() -}) diff --git a/frontend/tests/sign-up.spec.ts b/frontend/tests/sign-up.spec.ts deleted file mode 100644 index a666123280..0000000000 --- a/frontend/tests/sign-up.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { type Page, expect, test } from "@playwright/test" - -import { randomEmail, randomPassword } from "./utils/random" - -test.use({ storageState: { cookies: [], origins: [] } }) - -type OptionsType = { - exact?: boolean -} - -const fillForm = async ( - page: Page, - full_name: string, - email: string, - password: string, - confirm_password: string, -) => { - await page.getByPlaceholder("Full Name").fill(full_name) - await page.getByPlaceholder("Email").fill(email) - await page.getByPlaceholder("Password", { exact: true }).fill(password) - await page.getByPlaceholder("Repeat Password").fill(confirm_password) -} - -const verifyInput = async ( - page: Page, - placeholder: string, - options?: OptionsType, -) => { - const input = page.getByPlaceholder(placeholder, options) - await expect(input).toBeVisible() - await expect(input).toHaveText("") - await expect(input).toBeEditable() -} - -test("Inputs are visible, empty and editable", async ({ page }) => { - await page.goto("/signup") - - await verifyInput(page, "Full Name") - await verifyInput(page, "Email") - await verifyInput(page, "Password", { exact: true }) - await verifyInput(page, "Repeat Password") -}) - -test("Sign Up button is visible", async ({ page }) => { - await page.goto("/signup") - - await expect(page.getByRole("button", { name: "Sign Up" })).toBeVisible() -}) - -test("Log In link is visible", async ({ page }) => { - await page.goto("/signup") - - await expect(page.getByRole("link", { name: "Log In" })).toBeVisible() -}) - -test("Sign up with valid name, email, and password", async ({ page }) => { - const full_name = "Test User" - const email = randomEmail() - const password = randomPassword() - - await page.goto("/signup") - await fillForm(page, full_name, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() -}) - -test("Sign up with invalid email", async ({ page }) => { - await page.goto("/signup") - - await fillForm( - page, - "Playwright Test", - "invalid-email", - "changethis", - "changethis", - ) - await page.getByRole("button", { name: "Sign Up" }).click() - - await expect(page.getByText("Invalid email address")).toBeVisible() -}) - -test("Sign up with existing email", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - - // Sign up with an email - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() - - // Sign up again with the same email - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() - - await page - .getByText("The user with this email already exists in the system") - .click() -}) - -test("Sign up with weak password", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = "weak" - - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() - - await expect( - page.getByText("Password must be at least 8 characters"), - ).toBeVisible() -}) - -test("Sign up with mismatched passwords", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const password2 = randomPassword() - - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password2) - await page.getByRole("button", { name: "Sign Up" }).click() - - await expect(page.getByText("Passwords do not match")).toBeVisible() -}) - -test("Sign up with missing full name", async ({ page }) => { - const fullName = "" - const email = randomEmail() - const password = randomPassword() - - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() - - await expect(page.getByText("Full Name is required")).toBeVisible() -}) - -test("Sign up with missing email", async ({ page }) => { - const fullName = "Test User" - const email = "" - const password = randomPassword() - - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() - - await expect(page.getByText("Email is required")).toBeVisible() -}) - -test("Sign up with missing password", async ({ page }) => { - const fullName = "" - const email = randomEmail() - const password = "" - - await page.goto("/signup") - - await fillForm(page, fullName, email, password, password) - await page.getByRole("button", { name: "Sign Up" }).click() - - await expect(page.getByText("Password is required")).toBeVisible() -}) diff --git a/frontend/tests/user-settings.spec.ts b/frontend/tests/user-settings.spec.ts deleted file mode 100644 index a3a8a27490..0000000000 --- a/frontend/tests/user-settings.spec.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { expect, test } from "@playwright/test" -import { firstSuperuser, firstSuperuserPassword } from "./config.ts" -import { randomEmail, randomPassword } from "./utils/random" -import { logInUser, logOutUser, signUpNewUser } from "./utils/user" - -const tabs = ["My profile", "Password", "Appearance"] - -// User Information - -test("My profile tab is active by default", async ({ page }) => { - await page.goto("/settings") - await expect(page.getByRole("tab", { name: "My profile" })).toHaveAttribute( - "aria-selected", - "true", - ) -}) - -test("All tabs are visible", async ({ page }) => { - await page.goto("/settings") - for (const tab of tabs) { - await expect(page.getByRole("tab", { name: tab })).toBeVisible() - } -}) - -test.describe("Edit user full name and email successfully", () => { - test.use({ storageState: { cookies: [], origins: [] } }) - - test("Edit user name with a valid name", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const updatedName = "Test User 2" - const password = randomPassword() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() - await page.getByRole("button", { name: "Edit" }).click() - await page.getByLabel("Full name").fill(updatedName) - await page.getByRole("button", { name: "Save" }).click() - await expect(page.getByText("User updated successfully")).toBeVisible() - // Check if the new name is displayed on the page - await expect( - page.getByLabel("My profile").getByText(updatedName, { exact: true }), - ).toBeVisible() - }) - - test("Edit user email with a valid email", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const updatedEmail = randomEmail() - const password = randomPassword() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() - await page.getByRole("button", { name: "Edit" }).click() - await page.getByLabel("Email").fill(updatedEmail) - await page.getByRole("button", { name: "Save" }).click() - await expect(page.getByText("User updated successfully")).toBeVisible() - await expect( - page.getByLabel("My profile").getByText(updatedEmail, { exact: true }), - ).toBeVisible() - }) -}) - -test.describe("Edit user with invalid data", () => { - test.use({ storageState: { cookies: [], origins: [] } }) - - test("Edit user email with an invalid email", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const invalidEmail = "" - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() - await page.getByRole("button", { name: "Edit" }).click() - await page.getByLabel("Email").fill(invalidEmail) - await page.locator("body").click() - await expect(page.getByText("Email is required")).toBeVisible() - }) - - test("Cancel edit action restores original name", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const updatedName = "Test User" - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() - await page.getByRole("button", { name: "Edit" }).click() - await page.getByLabel("Full name").fill(updatedName) - await page.getByRole("button", { name: "Cancel" }).first().click() - await expect( - page.getByLabel("My profile").getByText(fullName, { exact: true }), - ).toBeVisible() - }) - - test("Cancel edit action restores original email", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const updatedEmail = randomEmail() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() - await page.getByRole("button", { name: "Edit" }).click() - await page.getByLabel("Email").fill(updatedEmail) - await page.getByRole("button", { name: "Cancel" }).first().click() - await expect( - page.getByLabel("My profile").getByText(email, { exact: true }), - ).toBeVisible() - }) -}) - -// Change Password - -test.describe("Change password successfully", () => { - test.use({ storageState: { cookies: [], origins: [] } }) - - test("Update password successfully", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const NewPassword = randomPassword() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "Password" }).click() - await page.getByLabel("Current Password*").fill(password) - await page.getByLabel("Set Password*").fill(NewPassword) - await page.getByLabel("Confirm Password*").fill(NewPassword) - await page.getByRole("button", { name: "Save" }).click() - await expect(page.getByText("Password updated successfully.")).toBeVisible() - - await logOutUser(page) - - // Check if the user can log in with the new password - await logInUser(page, email, NewPassword) - }) -}) - -test.describe("Change password with invalid data", () => { - test.use({ storageState: { cookies: [], origins: [] } }) - - test("Update password with weak passwords", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const weakPassword = "weak" - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "Password" }).click() - await page.getByLabel("Current Password*").fill(password) - await page.getByLabel("Set Password*").fill(weakPassword) - await page.getByLabel("Confirm Password*").fill(weakPassword) - await expect( - page.getByText("Password must be at least 8 characters"), - ).toBeVisible() - }) - - test("New password and confirmation password do not match", async ({ - page, - }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - const newPassword = randomPassword() - const confirmPassword = randomPassword() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "Password" }).click() - await page.getByLabel("Current Password*").fill(password) - await page.getByLabel("Set Password*").fill(newPassword) - await page.getByLabel("Confirm Password*").fill(confirmPassword) - await page.getByRole("button", { name: "Save" }).click() - await expect(page.getByText("Passwords do not match")).toBeVisible() - }) - - test("Current password and new password are the same", async ({ page }) => { - const fullName = "Test User" - const email = randomEmail() - const password = randomPassword() - - // Sign up a new user - await signUpNewUser(page, fullName, email, password) - - // Log in the user - await logInUser(page, email, password) - - await page.goto("/settings") - await page.getByRole("tab", { name: "Password" }).click() - await page.getByLabel("Current Password*").fill(password) - await page.getByLabel("Set Password*").fill(password) - await page.getByLabel("Confirm Password*").fill(password) - await page.getByRole("button", { name: "Save" }).click() - await expect( - page.getByText("New password cannot be the same as the current one"), - ).toBeVisible() - }) -}) - -// Appearance - -test("Appearance tab is visible", async ({ page }) => { - await page.goto("/settings") - await page.getByRole("tab", { name: "Appearance" }).click() - await expect(page.getByLabel("Appearance")).toBeVisible() -}) - -test("User can switch from light mode to dark mode", async ({ page }) => { - await page.goto("/settings") - await page.getByRole("tab", { name: "Appearance" }).click() - await page.getByLabel("Appearance").locator("span").nth(3).click() - const isDarkMode = await page.evaluate(() => - document.body.classList.contains("chakra-ui-dark"), - ) - expect(isDarkMode).toBe(true) -}) - -test("User can switch from dark mode to light mode", async ({ page }) => { - await page.goto("/settings") - await page.getByRole("tab", { name: "Appearance" }).click() - await page.getByLabel("Appearance").locator("span").first().click() - const isLightMode = await page.evaluate(() => - document.body.classList.contains("chakra-ui-light"), - ) - expect(isLightMode).toBe(true) -}) - -test("Selected mode is preserved across sessions", async ({ page }) => { - await page.goto("/settings") - await page.getByRole("tab", { name: "Appearance" }).click() - await page.getByLabel("Appearance").locator("span").nth(3).click() - - await logOutUser(page) - - await logInUser(page, firstSuperuser, firstSuperuserPassword) - const isDarkMode = await page.evaluate(() => - document.body.classList.contains("chakra-ui-dark"), - ) - expect(isDarkMode).toBe(true) -}) diff --git a/frontend/tests/utils/mailcatcher.ts b/frontend/tests/utils/mailcatcher.ts deleted file mode 100644 index 601ce434fb..0000000000 --- a/frontend/tests/utils/mailcatcher.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { APIRequestContext } from "@playwright/test" - -type Email = { - id: number - recipients: string[] - subject: string -} - -async function findEmail({ - request, - filter, -}: { request: APIRequestContext; filter?: (email: Email) => boolean }) { - const response = await request.get("http://localhost:1080/messages") - - let emails = await response.json() - - if (filter) { - emails = emails.filter(filter) - } - - const email = emails[emails.length - 1] - - if (email) { - return email as Email - } - - return null -} - -export function findLastEmail({ - request, - filter, - timeout = 5000, -}: { - request: APIRequestContext - filter?: (email: Email) => boolean - timeout?: number -}) { - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Timeout while trying to get latest email")), - timeout, - ), - ) - - const checkEmails = async () => { - while (true) { - const emailData = await findEmail({ request, filter }) - - if (emailData) { - return emailData - } - // Wait for 100ms before checking again - await new Promise((resolve) => setTimeout(resolve, 100)) - } - } - - return Promise.race([timeoutPromise, checkEmails()]) -} diff --git a/frontend/tests/utils/random.ts b/frontend/tests/utils/random.ts deleted file mode 100644 index d96f0833ce..0000000000 --- a/frontend/tests/utils/random.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const randomEmail = () => - `test_${Math.random().toString(36).substring(7)}@example.com` - -export const randomTeamName = () => - `Team ${Math.random().toString(36).substring(7)}` - -export const randomPassword = () => `${Math.random().toString(36).substring(2)}` - -export const slugify = (text: string) => - text - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^\w-]+/g, "") diff --git a/frontend/tests/utils/user.ts b/frontend/tests/utils/user.ts deleted file mode 100644 index 8fcfd26cb5..0000000000 --- a/frontend/tests/utils/user.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type Page, expect } from "@playwright/test" - -export async function signUpNewUser( - page: Page, - name: string, - email: string, - password: string, -) { - await page.goto("/signup") - - await page.getByPlaceholder("Full Name").fill(name) - await page.getByPlaceholder("Email").fill(email) - await page.getByPlaceholder("Password", { exact: true }).fill(password) - await page.getByPlaceholder("Repeat Password").fill(password) - await page.getByRole("button", { name: "Sign Up" }).click() - await expect( - page.getByText("Your account has been created successfully"), - ).toBeVisible() - await page.goto("/login") -} - -export async function logInUser(page: Page, email: string, password: string) { - await page.goto("/login") - - await page.getByPlaceholder("Email").fill(email) - await page.getByPlaceholder("Password", { exact: true }).fill(password) - await page.getByRole("button", { name: "Log In" }).click() - await page.waitForURL("/") - await expect( - page.getByText("Welcome back, nice to see you again!"), - ).toBeVisible() -} - -export async function logOutUser(page: Page) { - await page.getByTestId("user-menu").click() - await page.getByRole("menuitem", { name: "Log out" }).click() - await page.goto("/login") -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index baadbb9fb1..0000000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src", "*.ts", "**/*.ts"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json deleted file mode 100644 index 42872c59f5..0000000000 --- a/frontend/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 572745b8cf..0000000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TanStackRouterVite } from "@tanstack/router-vite-plugin" -import react from "@vitejs/plugin-react-swc" -import { defineConfig } from "vite" - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react(), TanStackRouterVite()], -}) diff --git a/scripts/build-push.sh b/scripts/build-push.sh deleted file mode 100644 index 3fa3aa7e6b..0000000000 --- a/scripts/build-push.sh +++ /dev/null @@ -1,10 +0,0 @@ -#! /usr/bin/env sh - -# Exit in case of error -set -e - -TAG=${TAG?Variable not set} \ -FRONTEND_ENV=${FRONTEND_ENV-production} \ -sh ./scripts/build.sh - -docker-compose -f docker-compose.yml push diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100644 index 21528c538e..0000000000 --- a/scripts/build.sh +++ /dev/null @@ -1,10 +0,0 @@ -#! /usr/bin/env sh - -# Exit in case of error -set -e - -TAG=${TAG?Variable not set} \ -FRONTEND_ENV=${FRONTEND_ENV-production} \ -docker-compose \ --f docker-compose.yml \ -build diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh deleted file mode 100644 index 1327ee6fd1..0000000000 --- a/scripts/generate-client.sh +++ /dev/null @@ -1,8 +0,0 @@ -#! /usr/bin/env bash - -PYTHONPATH=backend python -c "import app.main; import json; print(json.dumps(app.main.app.openapi()))" > openapi.json -node frontend/modify-openapi-operationids.js -mv openapi.json frontend/ -cd frontend -npm run generate-client -npx biome format --write ./src/client From 786ef0c60b9b017e90a74209b795eea041ee5725 Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Thu, 10 Oct 2024 21:06:03 +0530 Subject: [PATCH 04/25] Added QR model and scanning with deep link --- ...75a3d9f95aa_added_qr_codes_to_foodcourt.py | 29 ++ ...e4249d074a6_added_qr_codes_to_foodcourt.py | 41 +++ backend/app/api/deps.py | 26 +- backend/app/api/main.py | 5 +- backend/app/api/routes/items.py | 0 backend/app/api/routes/qrcode.py | 112 ++++++++ backend/app/api/routes/users.py | 78 +++-- backend/app/api/routes/venues.py | 67 +++-- backend/app/core/db.py | 2 +- backend/app/core/security.py | 2 +- backend/app/crud.py | 48 +++- backend/app/main.py | 3 + backend/app/models/base_model.py | 7 + backend/app/models/qrcode.py | 39 +++ backend/app/models/venue.py | 9 +- backend/app/schema/qrcode.py | 25 ++ backend/app/schema/venue.py | 2 +- backend/app/static/landing_page.html | 48 ++++ backend/poetry.lock | 272 +++++++++++++++++- backend/pyproject.toml | 2 + 20 files changed, 735 insertions(+), 82 deletions(-) create mode 100644 backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py create mode 100644 backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py delete mode 100644 backend/app/api/routes/items.py create mode 100644 backend/app/api/routes/qrcode.py create mode 100644 backend/app/models/base_model.py create mode 100644 backend/app/models/qrcode.py create mode 100644 backend/app/schema/qrcode.py create mode 100644 backend/app/static/landing_page.html diff --git a/backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py b/backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py new file mode 100644 index 0000000000..28e957474a --- /dev/null +++ b/backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py @@ -0,0 +1,29 @@ +"""added qr_codes to foodcourt + +Revision ID: 375a3d9f95aa +Revises: fe4249d074a6 +Create Date: 2024-10-05 07:58:40.774421 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '375a3d9f95aa' +down_revision = 'fe4249d074a6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py b/backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py new file mode 100644 index 0000000000..64bb0b66c4 --- /dev/null +++ b/backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py @@ -0,0 +1,41 @@ +"""added qr_codes to foodcourt + +Revision ID: fe4249d074a6 +Revises: a392ecf44925 +Create Date: 2024-10-05 07:51:04.040718 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'fe4249d074a6' +down_revision = 'a392ecf44925' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('qr_codes', + sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('foodcourt_id', sa.Uuid(), nullable=True), + sa.Column('qsr_id', sa.Uuid(), nullable=True), + sa.Column('nightclub_id', sa.Uuid(), nullable=True), + sa.Column('restaurant_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), + sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('qr_codes') + # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index fbffad4295..5f65d667e8 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -50,11 +50,13 @@ async def get_current_user( credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], session: SessionDep ) -> Union[UserPublic, UserBusiness]: + # print('credentials.credentials ', credentials.credentials) try: + print('credentials.credentials ', credentials.credentials) # Verify and decode the token (ensure this is as fast as possible) token_data = get_jwt_payload(credentials.credentials) user_id = token_data.sub - + print('hhuuuser_id ', user_id) # Query both user types in a single call, using a union if possible user = ( session.query(UserPublic) @@ -88,10 +90,10 @@ async def get_current_user( # Dependency to get the business user async def get_business_user( - credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), - session: Session = Depends(SessionDep) + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + session: SessionDep ) -> UserBusiness: - current_user = await get_current_user(credentials.credentials, session) + current_user = await get_current_user(credentials, session) if not isinstance(current_user, UserBusiness): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -101,10 +103,10 @@ async def get_business_user( # Dependency to get the superuser async def get_super_user( - credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), - session: Session = Depends(SessionDep) -) -> UserPublic: - current_user = await get_current_user(credentials, session) + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + session: SessionDep +) -> UserBusiness: + current_user = await get_business_user(credentials, session) if not current_user.is_superuser: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -114,10 +116,12 @@ async def get_super_user( # Dependency to get any public user async def get_public_user( - credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), - session: Session = Depends(SessionDep) + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + session: SessionDep ) -> UserPublic: - current_user = await get_current_user(credentials.credentials, session) + print('credentials.credentials ', credentials.credentials) + current_user = await get_current_user(credentials, session) + # print('current_user ', current_user) if not isinstance(current_user, UserPublic): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 9cf1e07260..9a9f6d5a88 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,10 +1,11 @@ from fastapi import APIRouter -from app.api.routes import venues, menu, users, login +from app.api.routes import venues, menu, users, login,qrcode api_router = APIRouter() api_router.include_router(venues.router, prefix="/venues", tags=["venues"]) api_router.include_router(menu.router, prefix="/menu", tags=["menu"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) -api_router.include_router(login.router, prefix="/login", tags=["login"]) +api_router.include_router(login.router, tags=["login"]) +api_router.include_router(qrcode.router, tags=["qrcode"]) diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/api/routes/qrcode.py b/backend/app/api/routes/qrcode.py new file mode 100644 index 0000000000..cd02362b4b --- /dev/null +++ b/backend/app/api/routes/qrcode.py @@ -0,0 +1,112 @@ +from pathlib import Path +import uuid +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from jinja2 import Template +from sqlalchemy import UUID +from sqlmodel import select +from typing import List +from app.api.deps import SessionDep +from app.models.qrcode import QRCode # Ensure you have this import for your model +from app.schema.qrcode import QRCodeCreate, QRCodeRead # Ensure you have these imports for your schemas +from app.crud import ( + get_all_records, + get_record_by_id, + create_record, + update_record, + patch_record, + delete_record +) + +router = APIRouter() + +# Get all QR codes +@router.get("/qr_codes/", response_model=List[QRCodeRead]) +async def read_qr_codes(session: SessionDep): + # Retrieve all QR codes + return get_all_records(session, QRCode) + +# Get a specific QR code +@router.get("/qr_codes/{qr_code_id}", response_model=QRCodeRead) +async def read_qr_code(qr_code_id: uuid.UUID, session: SessionDep): + """ + Retrieve a specific QR code by ID. + """ + return get_record_by_id(session, QRCode, qr_code_id) + +# Create a new QR code +@router.post("/qr_codes/", response_model=QRCodeRead) +async def create_qr_code(qr_code: QRCodeCreate, session: SessionDep): + """ + Create a new QR code. + """ + return create_record(session, QRCode, qr_code) + +# Update a QR code +@router.put("/qr_codes/{qr_code_id}", response_model=QRCodeRead) +async def update_qr_code(qr_code_id: uuid.UUID, updated_qr_code: QRCodeCreate, session: SessionDep): + """ + Update an existing QR code. + """ + return update_record(session, QRCode, qr_code_id, updated_qr_code) + +# PATCH a QR code for partial updates +@router.patch("/qr_codes/{qr_code_id}", response_model=QRCodeRead) +async def patch_qr_code(qr_code_id: uuid.UUID, updated_qr_code: QRCodeCreate, session: SessionDep): + """ + Partially update an existing QR code. + """ + return patch_record(session, QRCode, qr_code_id, updated_qr_code) + +# Delete a QR code +@router.delete("/qr_codes/{qr_code_id}", response_model=None) +async def delete_qr_code(qr_code_id: uuid.UUID, session: SessionDep): + """ + Delete a QR code by ID. + """ + return delete_record(session, QRCode, qr_code_id) + +@router.get("/scan/{qr_id}", response_class=HTMLResponse) +async def scan_qr_code(qr_id: uuid.UUID, session: SessionDep): + """ + Scan a QR code to determine its associated venue and redirect appropriately. + """ + # Retrieve the QR code from the database + qr_code_result = session.execute(select(QRCode).where(QRCode.id == qr_id)).one_or_none() + + if not qr_code_result: + raise HTTPException(status_code=404, detail="QR code not found.") + + qr_code = qr_code_result[0] + # Determine the venue type and ID from the QR code + venue_type, venue_id = None, None + + if qr_code.foodcourt_id: + venue_type = "foodcourt" + venue_id = qr_code.foodcourt_id + elif qr_code.qsr_id: + venue_type = "qsr" + venue_id = qr_code.qsr_id + elif qr_code.nightclub_id: + venue_type = "nightclub" + venue_id = qr_code.nightclub_id + elif qr_code.restaurant_id: + venue_type = "restaurant" + venue_id = qr_code.restaurant_id + + if not venue_type or not venue_id: + raise HTTPException(status_code=400, detail="No associated venue found.") + + # Load the landing page HTML template + template_path = Path(__file__).parent.parent.parent / "static" / "landing_page.html" + print('template_path:', template_path) + try: + template_str = template_path.read_text() + except FileNotFoundError: + raise HTTPException(status_code=500, detail="Landing page template not found.") + + # Use string.Template to replace placeholders in the HTML + template = Template(template_str) + html_content = template.render(venueId=venue_id, venueType=venue_type) + return HTMLResponse(content=html_content) \ No newline at end of file diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index a8118434f9..b41f6f6a77 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,7 +1,7 @@ -from typing import List +from typing import List, Union import uuid from fastapi import APIRouter, Query, HTTPException, Depends -from app.api.deps import SessionDep +from app.api.deps import SessionDep, get_business_user, get_current_user, get_public_user, get_super_user from app.models import UserBusiness, UserPublic from app.crud import get_all_records, get_record_by_id, create_record, update_record, delete_record, patch_record @@ -11,7 +11,8 @@ async def read_user_businesses( session: SessionDep, skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100) + limit: int = Query(10, le=100), + current_user: UserBusiness = Depends(get_super_user) ): """ Retrieve a paginated list of user businesses. @@ -23,7 +24,8 @@ async def read_user_businesses( @router.get("/user-businesses/{user_business_id}", response_model=UserBusiness) async def read_user_business( user_business_id: uuid.UUID, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): """ Retrieve a single user business by ID. @@ -31,10 +33,23 @@ async def read_user_business( """ return get_record_by_id(session, UserBusiness, user_business_id) +@router.get("/me/user-businesses/{user_business_id}", response_model=UserBusiness) +async def read_user_business_me( + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) +): + """ + Retrieve a single user business by ID. + - **user_business_id**: The ID of the user business to retrieve + """ + return get_record_by_id(session, UserBusiness, current_user.id) + + @router.post("/user-businesses/", response_model=UserBusiness) async def create_user_business( user_business: UserBusiness, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): """ Create a new user business. @@ -44,21 +59,22 @@ async def create_user_business( @router.put("/user-businesses/{user_business_id}", response_model=UserBusiness) async def update_user_business( - user_business_id: uuid.UUID, user_business: UserBusiness, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_current_user) ): """ Update an existing user business. - **user_business_id**: The ID of the user business to update - **user_business**: The updated user business data """ - return update_record(session, UserBusiness, user_business_id, user_business) + return update_record(session, UserBusiness, current_user.id, user_business) @router.delete("/user-businesses/{user_business_id}", response_model=UserBusiness) async def delete_user_business( user_business_id: uuid.UUID, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): """ Delete a user business by ID. @@ -67,23 +83,25 @@ async def delete_user_business( delete_record(session, UserBusiness, user_business_id) return {"message": f"UserBusiness with ID {user_business_id} has been deleted."} -@router.get("/user-publics/", response_model=List[UserPublic]) -async def read_user_publics( +@router.get("/user-public/", response_model=List[UserPublic]) +async def read_user_public( session: SessionDep, skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100) + limit: int = Query(10, le=100), + current_user: UserBusiness = Depends(get_business_user) ): """ - Retrieve a paginated list of user publics. + Retrieve a paginated list of user public. - **skip**: The page number (starting from 0) - **limit**: The number of items per page """ return get_all_records(session, UserPublic, skip=skip, limit=limit) -@router.get("/user-publics/{user_public_id}", response_model=UserPublic) +@router.get("/user-public/{user_public_id}", response_model=UserPublic) async def read_user_public( user_public_id: uuid.UUID, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): """ Retrieve a single user public by ID. @@ -91,34 +109,36 @@ async def read_user_public( """ return get_record_by_id(session, UserPublic, user_public_id) -@router.post("/user-publics/", response_model=UserPublic) -async def create_user_public( - user_public: UserPublic, - session: SessionDep +@router.get("/me/user-public/", response_model=UserPublic) +async def read_user_public_me( + session: SessionDep, + current_user: UserPublic = Depends(get_public_user) ): + print("current_user : ",current_user) """ - Create a new user public. - - **user_public**: The user public data to create + Retrieve a single user public by ID. + - **user_public_id**: The ID of the user public to retrieve """ - return create_record(session, UserPublic, user_public) + return get_record_by_id(session, UserPublic, current_user.id) -@router.put("/user-publics/{user_public_id}", response_model=UserPublic) -async def update_user_public( - user_public_id: uuid.UUID, +@router.put("/me/user-public/", response_model=UserPublic) +async def update_user_public_me( user_public: UserPublic, - session: SessionDep + session: SessionDep, + current_user: UserPublic = Depends(get_public_user) ): """ Update an existing user public. - **user_public_id**: The ID of the user public to update - **user_public**: The updated user public data """ - return update_record(session, UserPublic, user_public_id, user_public) + return update_record(session, UserPublic, current_user.id , user_public) -@router.delete("/user-publics/{user_public_id}", response_model=UserPublic) +@router.delete("/user-public/{user_public_id}", response_model=UserPublic) async def delete_user_public( user_public_id: uuid.UUID, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): """ Delete a user public by ID. diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py index 1d4cc10d5a..2709baa4c8 100644 --- a/backend/app/api/routes/venues.py +++ b/backend/app/api/routes/venues.py @@ -5,7 +5,7 @@ from typing import List, Union from app.models.venue import Nightclub, Restaurant, QSR, Foodcourt -from app.api.deps import SessionDep, get_current_user +from app.api.deps import SessionDep, get_business_user, get_current_user, get_super_user from app.crud import ( get_all_records, get_record_by_id, @@ -34,7 +34,10 @@ async def read_nightclubs( return nightclubs @router.get("/nightclubs/{venue_id}", response_model=NightclubRead) -async def read_nightclub(venue_id: uuid.UUID, session: SessionDep ): +async def read_nightclub( + venue_id: uuid.UUID, session: SessionDep, + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) +): nightclub = get_record_by_id(session, Nightclub, venue_id) if not nightclub: raise HTTPException(status_code=404, detail="Nightclub not found") @@ -43,7 +46,8 @@ async def read_nightclub(venue_id: uuid.UUID, session: SessionDep ): @router.post("/nightclubs/", response_model=NightclubRead) async def create_nightclub( nightclub: NightclubCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return create_record(session, Nightclub, nightclub) @@ -51,15 +55,16 @@ async def create_nightclub( async def update_nightclub( venue_id: uuid.UUID, updated_nightclub: NightclubCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return update_record(session, Nightclub, venue_id, updated_nightclub) @router.delete("/nightclubs/{venue_id}", response_model=None) async def delete_nightclub( venue_id: uuid.UUID, - session: SessionDep - + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): return delete_record(session, Nightclub, venue_id) @@ -67,7 +72,8 @@ async def delete_nightclub( async def read_restaurants( session: SessionDep, skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100) + limit: int = Query(10, le=100), + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) ): """ Retrieve a paginated list of restaurants. @@ -78,7 +84,9 @@ async def read_restaurants( return restaurants @router.get("/restaurants/{venue_id}", response_model=RestaurantRead) -async def read_restaurant(venue_id: uuid.UUID, session: SessionDep ): +async def read_restaurant( + venue_id: uuid.UUID, session: SessionDep, + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user)): restaurant = get_record_by_id(session, Restaurant, venue_id) if not restaurant: raise HTTPException(status_code=404, detail="restaurant not found") @@ -87,7 +95,8 @@ async def read_restaurant(venue_id: uuid.UUID, session: SessionDep ): @router.post("/restaurants/", response_model=RestaurantRead) async def create_restaurant( restaurant: RestaurantCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return create_record(session, Restaurant, restaurant) @@ -95,15 +104,16 @@ async def create_restaurant( async def update_restaurant( venue_id: uuid.UUID, updated_restaurant: RestaurantCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return update_record(session, Restaurant, venue_id, updated_restaurant) @router.delete("/restaurants/{venue_id}", response_model=None) async def delete_restaurant( venue_id: uuid.UUID, - session: SessionDep - + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): return delete_record(session, Restaurant, venue_id) @@ -111,7 +121,8 @@ async def delete_restaurant( async def read_qsrs( session: SessionDep, skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100) + limit: int = Query(10, le=100), + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) ): """ Retrieve a paginated list of qsrs. @@ -122,7 +133,9 @@ async def read_qsrs( return qsrs @router.get("/qsrs/{venue_id}", response_model=QSRRead) -async def read_qsr(venue_id: uuid.UUID, session: SessionDep ): +async def read_qsr( + venue_id: uuid.UUID, session: SessionDep , + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user)): qsr = get_record_by_id(session, QSR, venue_id) if not qsr: raise HTTPException(status_code=404, detail="QSR not found") @@ -131,7 +144,8 @@ async def read_qsr(venue_id: uuid.UUID, session: SessionDep ): @router.post("/qsrs/", response_model=QSRRead) async def create_qsr( qsr: QSRCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return create_record(session, QSR, qsr) @@ -139,15 +153,16 @@ async def create_qsr( async def update_qsr( venue_id: uuid.UUID, updated_qsr: QSRCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return update_record(session, QSR, venue_id, updated_qsr) @router.delete("/qsrs/{venue_id}", response_model=None) async def delete_qsr( venue_id: uuid.UUID, - session: SessionDep - + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): return delete_record(session, QSR, venue_id) @@ -155,7 +170,8 @@ async def delete_qsr( async def read_foodcourts( session: SessionDep, skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100) + limit: int = Query(10, le=100), + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) ): """ Retrieve a paginated list of foodcourts. @@ -166,7 +182,9 @@ async def read_foodcourts( return foodcourts @router.get("/foodcourts/{venue_id}", response_model=FoodcourtRead) -async def read_foodcourt(venue_id: uuid.UUID, session: SessionDep ): +async def read_foodcourt( + venue_id: uuid.UUID, session: SessionDep , + current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user)): foodcourt = get_record_by_id(session, Foodcourt, venue_id) if not foodcourt: raise HTTPException(status_code=404, detail="foodcourt not found") @@ -175,7 +193,8 @@ async def read_foodcourt(venue_id: uuid.UUID, session: SessionDep ): @router.post("/foodcourts/", response_model=FoodcourtRead) async def create_foodcourt( foodcourt: FoodcourtCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) ): return create_record(session, Foodcourt, foodcourt) @@ -183,14 +202,16 @@ async def create_foodcourt( async def update_foodcourt( venue_id: uuid.UUID, updated_foodcourt: FoodcourtCreate, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user), ): return update_record(session, Foodcourt, venue_id, updated_foodcourt) @router.delete("/foodcourts/{venue_id}", response_model=None) async def delete_foodcourt( venue_id: uuid.UUID, - session: SessionDep + session: SessionDep, + current_user: UserBusiness = Depends(get_super_user) ): return delete_record(session, Foodcourt, venue_id) \ No newline at end of file diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 7032ecb698..e8eb7671d2 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlmodel import Session, create_engine, select +from sqlmodel import Session, create_engine from app.models.user import UserBusiness from app.core.config import settings diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 8a08e1882b..0e0046cd04 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -12,7 +12,7 @@ def get_jwt_payload(token: str) -> TokenModel: try: # Decode the token using the secret key and algorithm payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - + print('payload ', payload) # Create and return a TokenModel instance with the decoded payload return TokenModel(sub=payload.get("sub"), exp=payload.get("exp")) diff --git a/backend/app/crud.py b/backend/app/crud.py index 8870556a44..b541fa78ea 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,3 +1,4 @@ +import datetime import uuid from fastapi import HTTPException from sqlmodel import SQLModel, Session, select @@ -16,8 +17,8 @@ def get_all_records( """ try: statement = select(model).offset(skip).limit(limit) - result = session.exec(statement) - return result.all() + result = session.execute(statement).scalars().all() + return result except Exception as e: raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} records: {str(e)}") @@ -33,7 +34,7 @@ def get_record_by_id( """ try: statement = select(model).where(model.id == record_id) - result = session.exec(statement).first() + result = session.execute(statement).scalars().first() if not result: raise HTTPException(status_code=404, detail=f"{model.__name__} with ID {record_id} not found.") return result @@ -41,17 +42,26 @@ def get_record_by_id( raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} record: {str(e)}") # Function to create a new record +# Create a new record def create_record( session: Session, model: Type[SQLModel], obj_in: SQLModel ) -> SQLModel: """ - Create a new record. + Create a new record with automatic `created_at` and `updated_at` timestamps. - **session**: Database session - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - **obj_in**: Data to create the new record """ try: - obj = model(**obj_in.dict()) + # Prepare the data for creation + obj_data = obj_in.model_dump() + + # Set `created_at` and `updated_at` timestamps + obj_data['created_at'] = datetime.utcnow() + obj_data['updated_at'] = datetime.utcnow() + + # Create the new object + obj = model(**obj_data) session.add(obj) session.commit() session.refresh(obj) @@ -60,22 +70,33 @@ def create_record( session.rollback() raise HTTPException(status_code=500, detail=f"Error creating {model.__name__}: {str(e)}") -# Function to update an existing record + +# Update an existing record def update_record( session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel ) -> SQLModel: """ - Update an existing record. + Update an existing record with automatic `updated_at` timestamp. - **session**: Database session - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - **record_id**: ID of the record to update - **obj_in**: Data to update the record """ try: + # Retrieve the existing record obj = get_record_by_id(session, model, record_id) - obj_data = obj_in.dict(exclude_unset=True) + + # Convert incoming data + obj_data = obj_in.model_dump() + + # Update the fields for field, value in obj_data.items(): setattr(obj, field, value) + + # Set `updated_at` to the current time + obj.updated_at = datetime.utcnow() + + # Commit the changes session.add(obj) session.commit() session.refresh(obj) @@ -86,11 +107,13 @@ def update_record( session.rollback() raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") + +# Partially update an existing record def patch_record( session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel ) -> SQLModel: """ - Partially update an existing record. + Partially update an existing record with automatic `updated_at` timestamp. - **session**: Database session - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR) - **record_id**: ID of the record to update @@ -100,13 +123,16 @@ def patch_record( # Get the existing record from the database obj = get_record_by_id(session, model, record_id) - # Convert the incoming data, excluding any unset values - obj_data = obj_in.dict(exclude_unset=True) + # Convert the incoming data + obj_data = obj_in.model_dump() # Update the fields on the object for field, value in obj_data.items(): setattr(obj, field, value) + # Set `updated_at` to the current time + obj.updated_at = datetime.utcnow() + # Commit the changes session.add(obj) session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 4c252a1722..35b4454ea3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +from fastapi.staticfiles import StaticFiles import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute @@ -20,6 +21,8 @@ def custom_generate_unique_id(route: APIRoute) -> str: generate_unique_id_function=custom_generate_unique_id, ) +# app.mount("/static", StaticFiles(directory="app/static"), name="static") + # Set all CORS enabled origins if settings.BACKEND_CORS_ORIGINS: app.add_middleware( diff --git a/backend/app/models/base_model.py b/backend/app/models/base_model.py new file mode 100644 index 0000000000..6ef666c69a --- /dev/null +++ b/backend/app/models/base_model.py @@ -0,0 +1,7 @@ +from sqlmodel import SQLModel, Field +from datetime import datetime +from typing import Optional + +class BaseTimeModel(SQLModel): + created_at: Optional[datetime] = Field(default_factory=datetime.now(datetime.timezone.utc), nullable=False) + updated_at: Optional[datetime] = Field(default_factory=datetime.now(datetime.timezone.utc), nullable=False) \ No newline at end of file diff --git a/backend/app/models/qrcode.py b/backend/app/models/qrcode.py new file mode 100644 index 0000000000..9e549c41ef --- /dev/null +++ b/backend/app/models/qrcode.py @@ -0,0 +1,39 @@ +import uuid +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +from pydantic import model_validator, ValidationError + +class QRBase(SQLModel): + table_number: Optional[str] = Field(default=None, nullable=True) + +class QRCode(QRBase, table=True): + __tablename__ = "qr_codes" + id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) + + # References + foodcourt_id: Optional[uuid.UUID] = Field(default=None, foreign_key="foodcourt.id") + qsr_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr.id") + nightclub_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub.id") + restaurant_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant.id") + + # Relationships + foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qr_codes") + qsr: Optional["QSR"] = Relationship(back_populates="qr_codes") + nightclub: Optional["Nightclub"] = Relationship(back_populates="qr_codes") + restaurant: Optional["Restaurant"] = Relationship(back_populates="qr_codes") + + # Custom model validator to ensure exactly one foreign key is present + @model_validator(mode="before") + def check_one_foreign_key(cls, values): + foodcourt_id = values.get("foodcourt_id") + qsr_id = values.get("qsr_id") + nightclub_id = values.get("nightclub_id") + restaurant_id = values.get("restaurant_id") + + # Count how many of the foreign key fields are set + count = sum(v is not None for v in [foodcourt_id, qsr_id, nightclub_id, restaurant_id]) + + if count != 1: + raise ValueError("Exactly one of the following must be set: foodcourt_id, qsr_id, nightclub_id, restaurant_id.") + + return values diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index 0bca9f9eb1..0ebb4630df 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -1,4 +1,5 @@ import uuid +from app.models.qrcode import QRCode from sqlmodel import SQLModel, Field, Relationship from typing import Optional, List, TYPE_CHECKING @@ -30,7 +31,7 @@ class NightclubBase(VenueBase): class Nightclub(NightclubBase, table=True): __tablename__ = "nightclub" - id: Optional[uuid.UUID] = Field(default=None, primary_key=True, index=True) + id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Relationships events: List["Event"] = Relationship(back_populates="nightclub") club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") @@ -42,6 +43,7 @@ class Nightclub(NightclubBase, table=True): back_populates="managed_nightclubs", link_model=NightclubUserBusinessLink ) + qr_codes: List[QRCode] = Relationship(back_populates="nightclub") class RestaurantUserBusinessLink(SQLModel, table=True): restaurant_id: uuid.UUID = Field(foreign_key="restaurant.id", primary_key=True) @@ -61,6 +63,7 @@ class Restaurant(RestaurantBase, table=True): back_populates="managed_restaurants", link_model=RestaurantUserBusinessLink ) + qr_codes: List[QRCode] = Relationship(back_populates="restaurant") class QSRUserBusinessLink(SQLModel, table=True): qsr_id: uuid.UUID = Field(foreign_key="qsr.id", primary_key=True) @@ -82,6 +85,7 @@ class QSR(QSRBase, table=True): back_populates="managed_qsrs", link_model=QSRUserBusinessLink ) + qr_codes: List[QRCode] = Relationship(back_populates="qsr") class FoodcourtUserBusinessLink(SQLModel, table=True): foodcourt_id: uuid.UUID = Field(foreign_key="foodcourt.id", primary_key=True) @@ -99,4 +103,5 @@ class Foodcourt(FoodcourtBase, table=True): managing_users: List["UserBusiness"] = Relationship( back_populates="managed_foodcourts", link_model=FoodcourtUserBusinessLink - ) \ No newline at end of file + ) + qr_codes: List[QRCode] = Relationship(back_populates="foodcourt") \ No newline at end of file diff --git a/backend/app/schema/qrcode.py b/backend/app/schema/qrcode.py new file mode 100644 index 0000000000..eaa725f165 --- /dev/null +++ b/backend/app/schema/qrcode.py @@ -0,0 +1,25 @@ +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + +class QRCodeBase(BaseModel): + table_number: Optional[str] = Field(default=None, nullable=True) + foodcourt_id: Optional[UUID] = Field(default=None) + qsr_id: Optional[UUID] = Field(default=None) + nightclub_id: Optional[UUID] = Field(default=None) + restaurant_id: Optional[UUID] = Field(default=None) + +class QRCodeCreate(QRCodeBase): + """ + Schema for creating a new QR code. + Only one of foodcourt_id, qsr_id, nightclub_id, or restaurant_id should be present. + """ + + class Config: + from_attributes = True + +class QRCodeRead(QRCodeBase): + id: UUID # Automatically added by the model + + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index 44a5219610..cfefbda373 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -3,7 +3,7 @@ from app.models.venue import FoodcourtBase, NightclubBase, QSRBase, RestaurantBase class RestaurantRead(RestaurantBase): - id: Optional[int] + id: Optional[uuid.UUID] class Config: from_attributes = True diff --git a/backend/app/static/landing_page.html b/backend/app/static/landing_page.html new file mode 100644 index 0000000000..2bed0ef319 --- /dev/null +++ b/backend/app/static/landing_page.html @@ -0,0 +1,48 @@ + + + + + + Redirecting... + + + +

Redirecting...

+ + \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock index c17311b829..66347a213e 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "aioredis" version = "1.3.1" @@ -15,6 +26,20 @@ files = [ async-timeout = "*" hiredis = "*" +[[package]] +name = "aiosqlite" +version = "0.17.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231"}, + {file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"}, +] + +[package.dependencies] +typing_extensions = ">=3.7.2" + [[package]] name = "alembic" version = "1.13.2" @@ -192,6 +217,20 @@ async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""} docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] +[[package]] +name = "babel" +version = "2.16.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + [[package]] name = "bcrypt" version = "4.1.2" @@ -719,6 +758,29 @@ typing-extensions = ">=4.8.0" [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "fastapi-admin" +version = "1.0.4" +description = "A fast admin dashboard based on FastAPI and TortoiseORM with tabler ui, inspired by Django admin." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "fastapi-admin-1.0.4.tar.gz", hash = "sha256:d45cb23a23fc2bbffb0e8adfb1362350a1fe808f47912253c1f067f7d7745d88"}, + {file = "fastapi_admin-1.0.4-py3-none-any.whl", hash = "sha256:0b00dd2d72bbb5af50847cd4c49261aab999eeba7d8371786b822dce4b97ef0d"}, +] + +[package.dependencies] +aiofiles = "*" +aioredis = "*" +Babel = "*" +bcrypt = "*" +fastapi = "*" +jinja2 = "*" +pendulum = "*" +python-multipart = "*" +tortoise-orm = "*" +uvicorn = {version = "*", extras = ["standard"]} + [[package]] name = "fastapi-cache" version = "0.1.0" @@ -1123,6 +1185,28 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "iso8601" +version = "1.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.6.2,<4.0" +files = [ + {file = "iso8601-1.1.0-py3-none-any.whl", hash = "sha256:8400e90141bf792bce2634df533dc57e3bee19ea120a87bebcd3da89a58ad73f"}, + {file = "iso8601-1.1.0.tar.gz", hash = "sha256:32811e7b81deee2063ea6d2e94f8819a86d1f3811e49d23623a41fa832bef03f"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.4" @@ -1525,6 +1609,105 @@ bcrypt = ["bcrypt (>=3.1.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] totp = ["cryptography"] +[[package]] +name = "pendulum" +version = "3.0.0" +description = "Python datetimes made easy" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pendulum-3.0.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2cf9e53ef11668e07f73190c805dbdf07a1939c3298b78d5a9203a86775d1bfd"}, + {file = "pendulum-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fb551b9b5e6059377889d2d878d940fd0bbb80ae4810543db18e6f77b02c5ef6"}, + {file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c58227ac260d5b01fc1025176d7b31858c9f62595737f350d22124a9a3ad82d"}, + {file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60fb6f415fea93a11c52578eaa10594568a6716602be8430b167eb0d730f3332"}, + {file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b69f6b4dbcb86f2c2fe696ba991e67347bcf87fe601362a1aba6431454b46bde"}, + {file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:138afa9c373ee450ede206db5a5e9004fd3011b3c6bbe1e57015395cd076a09f"}, + {file = "pendulum-3.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:83d9031f39c6da9677164241fd0d37fbfc9dc8ade7043b5d6d62f56e81af8ad2"}, + {file = "pendulum-3.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0c2308af4033fa534f089595bcd40a95a39988ce4059ccd3dc6acb9ef14ca44a"}, + {file = "pendulum-3.0.0-cp310-none-win_amd64.whl", hash = "sha256:9a59637cdb8462bdf2dbcb9d389518c0263799189d773ad5c11db6b13064fa79"}, + {file = "pendulum-3.0.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3725245c0352c95d6ca297193192020d1b0c0f83d5ee6bb09964edc2b5a2d508"}, + {file = "pendulum-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6c035f03a3e565ed132927e2c1b691de0dbf4eb53b02a5a3c5a97e1a64e17bec"}, + {file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597e66e63cbd68dd6d58ac46cb7a92363d2088d37ccde2dae4332ef23e95cd00"}, + {file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99a0f8172e19f3f0c0e4ace0ad1595134d5243cf75985dc2233e8f9e8de263ca"}, + {file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77d8839e20f54706aed425bec82a83b4aec74db07f26acd039905d1237a5e1d4"}, + {file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afde30e8146292b059020fbc8b6f8fd4a60ae7c5e6f0afef937bbb24880bdf01"}, + {file = "pendulum-3.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:660434a6fcf6303c4efd36713ca9212c753140107ee169a3fc6c49c4711c2a05"}, + {file = "pendulum-3.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dee9e5a48c6999dc1106eb7eea3e3a50e98a50651b72c08a87ee2154e544b33e"}, + {file = "pendulum-3.0.0-cp311-none-win_amd64.whl", hash = "sha256:d4cdecde90aec2d67cebe4042fd2a87a4441cc02152ed7ed8fb3ebb110b94ec4"}, + {file = "pendulum-3.0.0-cp311-none-win_arm64.whl", hash = "sha256:773c3bc4ddda2dda9f1b9d51fe06762f9200f3293d75c4660c19b2614b991d83"}, + {file = "pendulum-3.0.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:409e64e41418c49f973d43a28afe5df1df4f1dd87c41c7c90f1a63f61ae0f1f7"}, + {file = "pendulum-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a38ad2121c5ec7c4c190c7334e789c3b4624798859156b138fcc4d92295835dc"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fde4d0b2024b9785f66b7f30ed59281bd60d63d9213cda0eb0910ead777f6d37"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b2c5675769fb6d4c11238132962939b960fcb365436b6d623c5864287faa319"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8af95e03e066826f0f4c65811cbee1b3123d4a45a1c3a2b4fc23c4b0dff893b5"}, + {file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2165a8f33cb15e06c67070b8afc87a62b85c5a273e3aaa6bc9d15c93a4920d6f"}, + {file = "pendulum-3.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ad5e65b874b5e56bd942546ea7ba9dd1d6a25121db1c517700f1c9de91b28518"}, + {file = "pendulum-3.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17fe4b2c844bbf5f0ece69cfd959fa02957c61317b2161763950d88fed8e13b9"}, + {file = "pendulum-3.0.0-cp312-none-win_amd64.whl", hash = "sha256:78f8f4e7efe5066aca24a7a57511b9c2119f5c2b5eb81c46ff9222ce11e0a7a5"}, + {file = "pendulum-3.0.0-cp312-none-win_arm64.whl", hash = "sha256:28f49d8d1e32aae9c284a90b6bb3873eee15ec6e1d9042edd611b22a94ac462f"}, + {file = "pendulum-3.0.0-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d4e2512f4e1a4670284a153b214db9719eb5d14ac55ada5b76cbdb8c5c00399d"}, + {file = "pendulum-3.0.0-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:3d897eb50883cc58d9b92f6405245f84b9286cd2de6e8694cb9ea5cb15195a32"}, + {file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e169cc2ca419517f397811bbe4589cf3cd13fca6dc38bb352ba15ea90739ebb"}, + {file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f17c3084a4524ebefd9255513692f7e7360e23c8853dc6f10c64cc184e1217ab"}, + {file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:826d6e258052715f64d05ae0fc9040c0151e6a87aae7c109ba9a0ed930ce4000"}, + {file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2aae97087872ef152a0c40e06100b3665d8cb86b59bc8471ca7c26132fccd0f"}, + {file = "pendulum-3.0.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ac65eeec2250d03106b5e81284ad47f0d417ca299a45e89ccc69e36130ca8bc7"}, + {file = "pendulum-3.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a5346d08f3f4a6e9e672187faa179c7bf9227897081d7121866358af369f44f9"}, + {file = "pendulum-3.0.0-cp37-none-win_amd64.whl", hash = "sha256:235d64e87946d8f95c796af34818c76e0f88c94d624c268693c85b723b698aa9"}, + {file = "pendulum-3.0.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:6a881d9c2a7f85bc9adafcfe671df5207f51f5715ae61f5d838b77a1356e8b7b"}, + {file = "pendulum-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d7762d2076b9b1cb718a6631ad6c16c23fc3fac76cbb8c454e81e80be98daa34"}, + {file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e8e36a8130819d97a479a0e7bf379b66b3b1b520e5dc46bd7eb14634338df8c"}, + {file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7dc843253ac373358ffc0711960e2dd5b94ab67530a3e204d85c6e8cb2c5fa10"}, + {file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a78ad3635d609ceb1e97d6aedef6a6a6f93433ddb2312888e668365908c7120"}, + {file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a137e9e0d1f751e60e67d11fc67781a572db76b2296f7b4d44554761049d6"}, + {file = "pendulum-3.0.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c95984037987f4a457bb760455d9ca80467be792236b69d0084f228a8ada0162"}, + {file = "pendulum-3.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d29c6e578fe0f893766c0d286adbf0b3c726a4e2341eba0917ec79c50274ec16"}, + {file = "pendulum-3.0.0-cp38-none-win_amd64.whl", hash = "sha256:deaba8e16dbfcb3d7a6b5fabdd5a38b7c982809567479987b9c89572df62e027"}, + {file = "pendulum-3.0.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b11aceea5b20b4b5382962b321dbc354af0defe35daa84e9ff3aae3c230df694"}, + {file = "pendulum-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a90d4d504e82ad236afac9adca4d6a19e4865f717034fc69bafb112c320dcc8f"}, + {file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:825799c6b66e3734227756fa746cc34b3549c48693325b8b9f823cb7d21b19ac"}, + {file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad769e98dc07972e24afe0cff8d365cb6f0ebc7e65620aa1976fcfbcadc4c6f3"}, + {file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6fc26907eb5fb8cc6188cc620bc2075a6c534d981a2f045daa5f79dfe50d512"}, + {file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c717eab1b6d898c00a3e0fa7781d615b5c5136bbd40abe82be100bb06df7a56"}, + {file = "pendulum-3.0.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3ddd1d66d1a714ce43acfe337190be055cdc221d911fc886d5a3aae28e14b76d"}, + {file = "pendulum-3.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:822172853d7a9cf6da95d7b66a16c7160cb99ae6df55d44373888181d7a06edc"}, + {file = "pendulum-3.0.0-cp39-none-win_amd64.whl", hash = "sha256:840de1b49cf1ec54c225a2a6f4f0784d50bd47f68e41dc005b7f67c7d5b5f3ae"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3b1f74d1e6ffe5d01d6023870e2ce5c2191486928823196f8575dcc786e107b1"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:729e9f93756a2cdfa77d0fc82068346e9731c7e884097160603872686e570f07"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e586acc0b450cd21cbf0db6bae386237011b75260a3adceddc4be15334689a9a"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22e7944ffc1f0099a79ff468ee9630c73f8c7835cd76fdb57ef7320e6a409df4"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fa30af36bd8e50686846bdace37cf6707bdd044e5cb6e1109acbad3277232e04"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:440215347b11914ae707981b9a57ab9c7b6983ab0babde07063c6ee75c0dc6e7"}, + {file = "pendulum-3.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:314c4038dc5e6a52991570f50edb2f08c339debdf8cea68ac355b32c4174e820"}, + {file = "pendulum-3.0.0-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5acb1d386337415f74f4d1955c4ce8d0201978c162927d07df8eb0692b2d8533"}, + {file = "pendulum-3.0.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a789e12fbdefaffb7b8ac67f9d8f22ba17a3050ceaaa635cd1cc4645773a4b1e"}, + {file = "pendulum-3.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:860aa9b8a888e5913bd70d819306749e5eb488e6b99cd6c47beb701b22bdecf5"}, + {file = "pendulum-3.0.0-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5ebc65ea033ef0281368217fbf59f5cb05b338ac4dd23d60959c7afcd79a60a0"}, + {file = "pendulum-3.0.0-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d9fef18ab0386ef6a9ac7bad7e43ded42c83ff7ad412f950633854f90d59afa8"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1c134ba2f0571d0b68b83f6972e2307a55a5a849e7dac8505c715c531d2a8795"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:385680812e7e18af200bb9b4a49777418c32422d05ad5a8eb85144c4a285907b"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eec91cd87c59fb32ec49eb722f375bd58f4be790cae11c1b70fac3ee4f00da0"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4386bffeca23c4b69ad50a36211f75b35a4deb6210bdca112ac3043deb7e494a"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dfbcf1661d7146d7698da4b86e7f04814221081e9fe154183e34f4c5f5fa3bf8"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:04a1094a5aa1daa34a6b57c865b25f691848c61583fb22722a4df5699f6bf74c"}, + {file = "pendulum-3.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5b0ec85b9045bd49dd3a3493a5e7ddfd31c36a2a60da387c419fa04abcaecb23"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0a15b90129765b705eb2039062a6daf4d22c4e28d1a54fa260892e8c3ae6e157"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:bb8f6d7acd67a67d6fedd361ad2958ff0539445ef51cbe8cd288db4306503cd0"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd69b15374bef7e4b4440612915315cc42e8575fcda2a3d7586a0d88192d0c88"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc00f8110db6898360c53c812872662e077eaf9c75515d53ecc65d886eec209a"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:83a44e8b40655d0ba565a5c3d1365d27e3e6778ae2a05b69124db9e471255c4a"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1a3604e9fbc06b788041b2a8b78f75c243021e0f512447806a6d37ee5214905d"}, + {file = "pendulum-3.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:92c307ae7accebd06cbae4729f0ba9fa724df5f7d91a0964b1b972a22baa482b"}, + {file = "pendulum-3.0.0.tar.gz", hash = "sha256:5d034998dea404ec31fae27af6b22cff1708f830a1ed7353be4d1019bb9f584e"}, +] + +[package.dependencies] +python-dateutil = ">=2.6" +tzdata = ">=2020.1" + +[package.extras] +test = ["time-machine (>=2.6.0)"] + [[package]] name = "platformdirs" version = "4.2.2" @@ -1922,6 +2105,17 @@ files = [ [package.extras] test = ["coverage", "mypy", "ruff", "wheel"] +[[package]] +name = "pypika-tortoise" +version = "0.1.6" +description = "Forked from pypika and streamline just for tortoise-orm" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pypika-tortoise-0.1.6.tar.gz", hash = "sha256:d802868f479a708e3263724c7b5719a26ad79399b2a70cea065f4a4cadbebf36"}, + {file = "pypika_tortoise-0.1.6-py3-none-any.whl", hash = "sha256:2d68bbb7e377673743cff42aa1059f3a80228d411fbcae591e4465e173109fd8"}, +] + [[package]] name = "pytest" version = "7.4.4" @@ -1986,6 +2180,17 @@ files = [ [package.extras] dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -2195,6 +2400,28 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sqladmin" +version = "0.19.0" +description = "SQLAlchemy admin for FastAPI and Starlette" +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqladmin-0.19.0-py3-none-any.whl", hash = "sha256:7549df159c1a9e65d2a9bf66ddca321e4c2aafef824faa23b881fa0aa08b88bf"}, + {file = "sqladmin-0.19.0.tar.gz", hash = "sha256:edd7d1a16e61fc4edb428dc92a99e9f5b41252127a9d93637ce1d9b3eaa20877"}, +] + +[package.dependencies] +itsdangerous = {version = "*", optional = true, markers = "extra == \"full\""} +jinja2 = "*" +python-multipart = "*" +sqlalchemy = ">=1.4" +starlette = "*" +wtforms = ">=3.1,<3.2" + +[package.extras] +full = ["itsdangerous"] + [[package]] name = "sqlalchemy" version = "2.0.31" @@ -2340,6 +2567,32 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "tortoise-orm" +version = "0.21.6" +description = "Easy async ORM for python, built with relations in mind" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "tortoise_orm-0.21.6-py3-none-any.whl", hash = "sha256:98fcf07dce3396075eac36b0d2b14d2267ff875d32455e03ee15e38de2f138df"}, + {file = "tortoise_orm-0.21.6.tar.gz", hash = "sha256:0fbc718001647bf282c01eaaa360f94f1432c9281701244180703d48d58a88ec"}, +] + +[package.dependencies] +aiosqlite = ">=0.16.0,<0.18.0" +iso8601 = ">=1.0.2,<2.0.0" +pydantic = ">=2.0,<2.7.0 || >2.7.0,<3.0" +pypika-tortoise = ">=0.1.6,<0.2.0" +pytz = "*" + +[package.extras] +accel = ["ciso8601", "orjson", "uvloop"] +aiomysql = ["aiomysql"] +asyncmy = ["asyncmy (>=0.2.8,<0.3.0)"] +asyncodbc = ["asyncodbc (>=0.1.1,<0.2.0)"] +asyncpg = ["asyncpg"] +psycopg = ["psycopg[binary,pool] (>=3.0.12,<4.0.0)"] + [[package]] name = "types-passlib" version = "1.7.7.20240327" @@ -2648,7 +2901,24 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "wtforms" +version = "3.1.2" +description = "Form validation and rendering for Python web development." +optional = false +python-versions = ">=3.8" +files = [ + {file = "wtforms-3.1.2-py3-none-any.whl", hash = "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07"}, + {file = "wtforms-3.1.2.tar.gz", hash = "sha256:f8d76180d7239c94c6322f7990ae1216dae3659b7aa1cee94b6318bdffb474b9"}, +] + +[package.dependencies] +markupsafe = "*" + +[package.extras] +email = ["email-validator"] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9aec6433c23cc584d6596ce89b702915229b2289f1fb219afb5bd5a82dfed066" +content-hash = "a16e33dfc99918d37919782659083da940e7b94db8d09af90ad56eca4638e89e" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3c3d984c8f..980c4311c0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,6 +34,8 @@ redis = "^5.0.8" fastapi-users = {extras = ["oauth"], version = "^13.0.0"} asyncpg = "^0.29.0" otplessauthsdk = "^0.3.3" +fastapi-admin = "^1.0.4" +sqladmin = {extras = ["full"], version = "^0.19.0"} [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" From 4fcc1a2522326834fbc76c820ba9cf5908fc481d Mon Sep 17 00:00:00 2001 From: abhinandanmishra1 Date: Sun, 13 Oct 2024 00:33:59 +0530 Subject: [PATCH 05/25] feat: created apis for carousel posters --- backend/app/api/main.py | 3 +- backend/app/api/routes/carousel.py | 54 ++++++++++++++++++++++++++ backend/app/models/carousel_poster.py | 30 ++++++++++++++ backend/app/models/event.py | 3 +- backend/app/models/venue.py | 6 ++- backend/app/schema/carousel_poster.py | 14 +++++++ backend/app/utils/__init__.py | 1 + backend/app/utils/h3_utils.py | 7 ++++ backend/poetry.lock | 56 ++++++++++++++++++++++++++- backend/pyproject.toml | 2 +- 10 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/routes/carousel.py create mode 100644 backend/app/models/carousel_poster.py create mode 100644 backend/app/schema/carousel_poster.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/h3_utils.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 9a9f6d5a88..19e3999739 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import venues, menu, users, login,qrcode +from app.api.routes import venues, menu, users, login, qrcode, carousel api_router = APIRouter() @@ -9,3 +9,4 @@ api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(login.router, tags=["login"]) api_router.include_router(qrcode.router, tags=["qrcode"]) +api_router.include_router(carousel.router, prefix="/carousel", tags=["carousel"]) diff --git a/backend/app/api/routes/carousel.py b/backend/app/api/routes/carousel.py new file mode 100644 index 0000000000..d768939ba2 --- /dev/null +++ b/backend/app/api/routes/carousel.py @@ -0,0 +1,54 @@ +import uuid +import h3 +from fastapi import APIRouter, Depends, Path +from sqlmodel import select +from typing import List +from datetime import datetime, timezone +from app.models.carousel_poster import CarouselPoster +from app.schema.carousel_poster import CarouselPosterCreate, CarouselPosterRead +from app.api.deps import SessionDep +from app.utils import get_h3_index +from app.crud import create_record, delete_record, update_record + +router = APIRouter() + +@router.get("/poster/", response_model=List[CarouselPoster]) +async def get_carousel_posters(latitude: float, longitude: float, session: SessionDep, radius: int = 3000): + user_h3_index = get_h3_index(latitude=latitude, longitude=longitude) + + distance_in_km = radius / 1000 + k_ring_size = int(distance_in_km / 1.2) + + nearby_h3_indexes = h3.k_ring(user_h3_index, k_ring_size) + + posters = session.execute( + select(CarouselPoster) + .where(CarouselPoster.h3_index.in_(nearby_h3_indexes)) + # .where(CarouselPoster.expires_at > current_time) + ).scalars().all() + + return posters + +@router.post("/poster/", response_model=CarouselPosterRead) +async def create_carousel_poster(poster: CarouselPosterCreate, session: SessionDep): + h3_index = get_h3_index(latitude=poster.latitude, longitude=poster.longitude, resolution=9) + carousel_poster_obj = CarouselPoster( + **poster.model_dump(), + h3_index=h3_index + ) + + return create_record(session=session, model=CarouselPoster, obj_in=carousel_poster_obj) + + +@router.put("/poster/{poster_id}", response_model=CarouselPosterRead) +async def update_carousel_poster( + poster_id: uuid.UUID, + updated_data: CarouselPosterCreate, + session: SessionDep +): + return update_record(session=session, record_id=poster_id, obj_in=updated_data, model=CarouselPoster) + + +@router.delete("/poster/{poster_id}") +async def delete_carousel_poster(poster_id: uuid.UUID, session: SessionDep): + return delete_record(session=session, model=CarouselPoster, record_id=poster_id) \ No newline at end of file diff --git a/backend/app/models/carousel_poster.py b/backend/app/models/carousel_poster.py new file mode 100644 index 0000000000..ca54cf8ead --- /dev/null +++ b/backend/app/models/carousel_poster.py @@ -0,0 +1,30 @@ +import uuid +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from datetime import datetime + +class CarouselPosterBase(SQLModel): + image_url: str = Field(nullable=False) + deep_link: str = Field(nullable=False) + latitude: float = Field(nullable=False) + longitude: float = Field(nullable=False) + expires_at: datetime = Field(nullable=False) + + # Foreign keys [Optional] + event_id: Optional[uuid.UUID] = Field(default=None, foreign_key="event.id") + nightclub_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub.id") + foodcourt_id: Optional[uuid.UUID] = Field(default=None, foreign_key="foodcourt.id") + qsr_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr.id") + restaurant_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant.id") + +class CarouselPoster(CarouselPosterBase, table=True): + __tablename__ = "carousel_poster" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + h3_index: str = Field(nullable=False) + + # Relationships [Optional] + event: Optional["Event"] = Relationship(back_populates="carousel_posters") + nightclub: Optional["Nightclub"] = Relationship(back_populates="carousel_posters") + foodcourt: Optional["Foodcourt"] = Relationship(back_populates="carousel_posters") + qsr: Optional["QSR"] = Relationship(back_populates="carousel_posters") + restaurant: Optional["Restaurant"] = Relationship(back_populates="carousel_posters") \ No newline at end of file diff --git a/backend/app/models/event.py b/backend/app/models/event.py index 1a2923061e..2cdb6bf361 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -16,4 +16,5 @@ class Event(SQLModel, table=True): # Relationships nightclub: Optional["Nightclub"] = Relationship(back_populates="events") offerings: List["EventOffering"] = Relationship(back_populates="event") - event_bookings: List["EventBooking"] = Relationship(back_populates="event") \ No newline at end of file + event_bookings: List["EventBooking"] = Relationship(back_populates="event") + carousel_posters: Optional[List["CarouselPoster"]] = Relationship(back_populates="event") \ No newline at end of file diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index 0ebb4630df..e4c61c1524 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -44,6 +44,7 @@ class Nightclub(NightclubBase, table=True): link_model=NightclubUserBusinessLink ) qr_codes: List[QRCode] = Relationship(back_populates="nightclub") + carousel_posters: Optional[List["CarouselPoster"]] = Relationship(back_populates="nightclub") class RestaurantUserBusinessLink(SQLModel, table=True): restaurant_id: uuid.UUID = Field(foreign_key="restaurant.id", primary_key=True) @@ -64,6 +65,7 @@ class Restaurant(RestaurantBase, table=True): link_model=RestaurantUserBusinessLink ) qr_codes: List[QRCode] = Relationship(back_populates="restaurant") + carousel_posters: Optional[List["CarouselPoster"]] = Relationship(back_populates="restaurant") class QSRUserBusinessLink(SQLModel, table=True): qsr_id: uuid.UUID = Field(foreign_key="qsr.id", primary_key=True) @@ -86,6 +88,7 @@ class QSR(QSRBase, table=True): link_model=QSRUserBusinessLink ) qr_codes: List[QRCode] = Relationship(back_populates="qsr") + carousel_posters: Optional[List["CarouselPoster"]] = Relationship(back_populates="qsr") class FoodcourtUserBusinessLink(SQLModel, table=True): foodcourt_id: uuid.UUID = Field(foreign_key="foodcourt.id", primary_key=True) @@ -104,4 +107,5 @@ class Foodcourt(FoodcourtBase, table=True): back_populates="managed_foodcourts", link_model=FoodcourtUserBusinessLink ) - qr_codes: List[QRCode] = Relationship(back_populates="foodcourt") \ No newline at end of file + qr_codes: List[QRCode] = Relationship(back_populates="foodcourt") + carousel_posters: Optional[List["CarouselPoster"]] = Relationship(back_populates="foodcourt") \ No newline at end of file diff --git a/backend/app/schema/carousel_poster.py b/backend/app/schema/carousel_poster.py new file mode 100644 index 0000000000..2020fe3fe3 --- /dev/null +++ b/backend/app/schema/carousel_poster.py @@ -0,0 +1,14 @@ +from sqlmodel import SQLModel, Field +from typing import Optional +import uuid +from datetime import datetime +from app.models.carousel_poster import CarouselPosterBase + +class CarouselPosterCreate(CarouselPosterBase): + class Config: + from_attributes = True + +class CarouselPosterRead(CarouselPosterBase): + id: Optional[uuid.UUID] + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000000..73b91aab81 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +from .h3_utils import get_h3_index, is_within_radius \ No newline at end of file diff --git a/backend/app/utils/h3_utils.py b/backend/app/utils/h3_utils.py new file mode 100644 index 0000000000..2cbbd56055 --- /dev/null +++ b/backend/app/utils/h3_utils.py @@ -0,0 +1,7 @@ +import h3 + +def get_h3_index(latitude: float, longitude: float, resolution: int = 9) -> str: + return h3.geo_to_h3(latitude, longitude, resolution) + +def is_within_radius(user_h3_index: str, poster_h3_index: str, radius: int) -> bool: + return h3.h3_distance(user_h3_index, poster_h3_index) <= radius diff --git a/backend/poetry.lock b/backend/poetry.lock index 66347a213e..bcfb89c0df 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -939,6 +939,60 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "h3" +version = "3.7.7" +description = "Hierarchical hexagonal geospatial indexing system" +optional = false +python-versions = "*" +files = [ + {file = "h3-3.7.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:951ecc9da0bcd5091670b13636928747bc98bc76891da0fa725524ec017cd9de"}, + {file = "h3-3.7.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26b9dd605541223ef927cc913deccb236cee024b16032f4a3e4387e2791479f2"}, + {file = "h3-3.7.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:996ebb32dc26dd607af7493149f94ce316117be6f42971f7b33bbd326ec695d2"}, + {file = "h3-3.7.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa2a4aa888cd9476788b874b4e11e178293f5b86e8461c36596bf183c242d417"}, + {file = "h3-3.7.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0256e42687470c6f0044ca78fe375fe32a654be8b5a8313b4a68f52f513389c6"}, + {file = "h3-3.7.7-cp310-cp310-win_amd64.whl", hash = "sha256:a3e2bc125490f900e0513c30480722f129bab1415f23040b6cd3a3f8d5a39336"}, + {file = "h3-3.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7d59018a50cd3b6d0ff0b18a54fdfcbaf2f79c13c831842f54fd2780c4b561ea"}, + {file = "h3-3.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e74526d941c1656fe162cc63b459b61aa83a15e257e9477b1570f26c544b51a"}, + {file = "h3-3.7.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c7398dbab685fcf3fe92f7c4c5901ab258bc66f7fa05fd1da8693375a10a549"}, + {file = "h3-3.7.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d22ea488ab5fe01c94070e9a6b3222916905a4d3f7a9d33cb2298c93fa0ffd3"}, + {file = "h3-3.7.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c94836155e8169be393980fc059f06481a14dd1913bd9cba609f6f1e8864c171"}, + {file = "h3-3.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:836e74313ff55324485cd7e07783bc67df3191ec08a318035d7cd8ee0b0badab"}, + {file = "h3-3.7.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:51c2f63ef5a57e4b18ebc9c0eb56656433e280ec45ab487a514127bb6e7d6a1f"}, + {file = "h3-3.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d6e38dea47c220d9802af8e8bebc806f9f39358aee07b736191ff21e2c9921d"}, + {file = "h3-3.7.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e408342e94f558802a97bfcbe1baae2af8b1fd926ad9041d970ff9dbd0502099"}, + {file = "h3-3.7.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:644c3c84585aa4df62e81bc54fd305c4d6686324731de230b0ddbd7036ed172c"}, + {file = "h3-3.7.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bb4a3d5e82d0c89512dc71b4eac17976a29be29da250ba76bc94bc5b9e824f0e"}, + {file = "h3-3.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:2ccff5f02589e80202597ed0b9f61ebd114e262e7dd0fe88059298602898192f"}, + {file = "h3-3.7.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ef2e71b619f984e71c4bd9d128152e2c7e3e788e2d2ec571b32cef1d295ddf38"}, + {file = "h3-3.7.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb13f0213ed6da80e739355e5b62cfc81b7b1469af997be3384a6cbc3a1a750"}, + {file = "h3-3.7.7-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:701f72f703d892fb17e66b9fd7b6b2ad125e135b091eb7dd0ec11858b84d84d2"}, + {file = "h3-3.7.7-cp36-cp36m-win_amd64.whl", hash = "sha256:796622be7cb052690404c0ac03768183e51ae22505ce4a424b4537b2b7609fba"}, + {file = "h3-3.7.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bcd88a72d6aa97d0f3b3b87b7bfd9725a8909501e6cb9d0057d5b690b6bb37b0"}, + {file = "h3-3.7.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7358ba3f91193a2551c4a8d7ad7fd348e567b3a3581c9c161630029dfb23e07"}, + {file = "h3-3.7.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f34b204edc2e8f7d99a6db4ed1b5d202b7ea3ec6817d373ec432dee14efe04"}, + {file = "h3-3.7.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aa0f8ce89b5e694815ee7a5172a782d58f2652267329de7008354b110b53955"}, + {file = "h3-3.7.7-cp37-cp37m-win_amd64.whl", hash = "sha256:4c851baa1c2d4f29b01157ce2a4cdb1f3879fff5c36ff7861dad1526963a17a7"}, + {file = "h3-3.7.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6f3a9da5472820b0a4add342f96fe52f65fbb8f46984383885738517b38af69e"}, + {file = "h3-3.7.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1c57da776a3c1a01e2986b1f6a31d497ee0be8fcdbaaf9b23bb90f5a90eb8f0b"}, + {file = "h3-3.7.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a5c0c0ddd9c57694ecc3b9ba99cbef2842882f8943d6edc676a365e139dbc6d"}, + {file = "h3-3.7.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c1b5a0a652719b645387231bf6d7d4dd85150e4440a4ce72a804a10e86592ae"}, + {file = "h3-3.7.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:64f76dc827fef94e9f43f95a1daea2e11f2ad2e8c55deac072f3d59bd62412d4"}, + {file = "h3-3.7.7-cp38-cp38-win_amd64.whl", hash = "sha256:c993a36120d7f5607f24ba9e39caf715eaf9cd9d44f5d5660fd85e3f4e0c6bf7"}, + {file = "h3-3.7.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eb154d2af699870b888e10476e327c895078009d2d2a6ef2d053d7dcf0e2c270"}, + {file = "h3-3.7.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c96ad74e246bb7638d413efa8199dd4c58ee929424a4dcaadb16365195f77f87"}, + {file = "h3-3.7.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52901f14f8b6e2c82075fd52c0e70176b868f621d47b5dc93f468c510e963722"}, + {file = "h3-3.7.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9d82a0fcc647e7bab36ab2e7a7392d141edc95d113ccf972e0fb7b0ddf80a0"}, + {file = "h3-3.7.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f4417d09acb36f0452346052f576923d6e4334bff3459f217d6278d40397424"}, + {file = "h3-3.7.7-cp39-cp39-win_amd64.whl", hash = "sha256:7ae774cd43b057f68dc10c99e4522fa40ed6b32ab90b2df0025595ffa15e77a0"}, + {file = "h3-3.7.7.tar.gz", hash = "sha256:33d141c3cef0725a881771fd8cb80c06a0db84a6e4ca5c647ce095ae07c61e94"}, +] + +[package.extras] +all = ["flake8", "numpy", "pylint", "pytest", "pytest-cov"] +numpy = ["numpy"] +test = ["flake8", "pylint", "pytest", "pytest-cov"] + [[package]] name = "hiredis" version = "3.0.0" @@ -2921,4 +2975,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a16e33dfc99918d37919782659083da940e7b94db8d09af90ad56eca4638e89e" +content-hash = "be3f5a23911a1d2d631f15293704a2edb6c52602e70bf491d42e9e5b24be46a5" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 980c4311c0..45856e304b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,7 @@ tenacity = "^8.2.3" pydantic = ">2.0" emails = "^0.6" psycopg2 = "^2.9.6" - +h3 = "^3.7.7" gunicorn = "^22.0.0" jinja2 = "^3.1.4" From fae36e586035917707c6e8b327afb9073f635f2b Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Fri, 25 Oct 2024 17:25:20 +0530 Subject: [PATCH 06/25] updated model structure and schema for venue and restaurant --- ..._add_explicit_uservenueassociation_for_.py | 31 + .../50ffb0a7eef5_initial_migration.py | 35 - ...n.py => 7fd1b196213e_initial_migration.py} | 458 ++++++------- .../8c693686becd_description_of_changes.py | 37 ++ .../9e690425af2e_add_zomato_link_to_venue.py | 25 + .../app/alembic/versions/a392ecf44925_c.py | 31 - ...f9b00_updated_menu_and_category_models.py} | 12 +- ...3a3e76248_create_menu_subcategory_table.py | 57 ++ ...e4249d074a6_added_qr_codes_to_foodcourt.py | 41 -- backend/app/api/main.py | 4 +- backend/app/api/routes/menu.py | 443 ++++++++----- backend/app/api/routes/venues.py | 306 +++------ backend/app/crud.py | 172 ++--- .../email-templates/build/new_account.html | 25 - .../email-templates/build/reset_password.html | 25 - .../app/email-templates/build/test_email.html | 25 - .../app/email-templates/src/new_account.mjml | 15 - .../email-templates/src/reset_password.mjml | 17 - .../app/email-templates/src/test_email.mjml | 11 - backend/app/models/__init__.py | 7 +- backend/app/models/base_model.py | 6 +- backend/app/models/menu.py | 152 ++++- backend/app/models/menu_category.py | 40 -- backend/app/models/menu_item.py | 21 - backend/app/models/pickup_location.py | 4 +- backend/app/models/qrcode.py | 30 +- backend/app/models/user.py | 32 +- backend/app/models/venue.py | 248 ++++--- backend/app/schema/menu.py | 117 +++- backend/app/schema/menu_category.py | 27 + backend/app/schema/menu_item.py | 31 + backend/app/schema/venue.py | 94 ++- backend/app/utils.py | 169 ++--- backend/poetry.lock | 612 +++++++++++++++++- backend/pyproject.toml | 3 + 35 files changed, 2049 insertions(+), 1314 deletions(-) create mode 100644 backend/app/alembic/versions/3078d16ee962_add_explicit_uservenueassociation_for_.py delete mode 100644 backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py rename backend/app/alembic/versions/{1100c57e615e_initial_migration.py => 7fd1b196213e_initial_migration.py} (74%) create mode 100644 backend/app/alembic/versions/8c693686becd_description_of_changes.py create mode 100644 backend/app/alembic/versions/9e690425af2e_add_zomato_link_to_venue.py delete mode 100644 backend/app/alembic/versions/a392ecf44925_c.py rename backend/app/alembic/versions/{375a3d9f95aa_added_qr_codes_to_foodcourt.py => e2955dcf9b00_updated_menu_and_category_models.py} (69%) create mode 100644 backend/app/alembic/versions/ea33a3e76248_create_menu_subcategory_table.py delete mode 100644 backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py delete mode 100644 backend/app/email-templates/build/new_account.html delete mode 100644 backend/app/email-templates/build/reset_password.html delete mode 100644 backend/app/email-templates/build/test_email.html delete mode 100644 backend/app/email-templates/src/new_account.mjml delete mode 100644 backend/app/email-templates/src/reset_password.mjml delete mode 100644 backend/app/email-templates/src/test_email.mjml delete mode 100644 backend/app/models/menu_category.py delete mode 100644 backend/app/models/menu_item.py create mode 100644 backend/app/schema/menu_category.py create mode 100644 backend/app/schema/menu_item.py diff --git a/backend/app/alembic/versions/3078d16ee962_add_explicit_uservenueassociation_for_.py b/backend/app/alembic/versions/3078d16ee962_add_explicit_uservenueassociation_for_.py new file mode 100644 index 0000000000..1e37e4bab3 --- /dev/null +++ b/backend/app/alembic/versions/3078d16ee962_add_explicit_uservenueassociation_for_.py @@ -0,0 +1,31 @@ +"""Add explicit UserVenueAssociation for UserBusiness and Venue + +Revision ID: 3078d16ee962 +Revises: 8c693686becd +Create Date: 2024-10-24 14:55:49.010006 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '3078d16ee962' +down_revision = '8c693686becd' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_venue_association', sa.Column('id', sa.Uuid(), nullable=False)) + op.add_column('user_venue_association', sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_venue_association', 'role') + op.drop_column('user_venue_association', 'id') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py b/backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py deleted file mode 100644 index e5d215b7bb..0000000000 --- a/backend/app/alembic/versions/50ffb0a7eef5_initial_migration.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Initial migration - -Revision ID: 50ffb0a7eef5 -Revises: 1100c57e615e -Create Date: 2024-09-28 13:06:36.733410 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = '50ffb0a7eef5' -down_revision = '1100c57e615e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('orderitem', sa.Column('id', sa.Uuid(), nullable=False)) - op.drop_index('ix_orderitem_order_item_id', table_name='orderitem') - op.create_index(op.f('ix_orderitem_id'), 'orderitem', ['id'], unique=False) - op.drop_column('orderitem', 'order_item_id') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('orderitem', sa.Column('order_item_id', sa.UUID(), autoincrement=False, nullable=False)) - op.drop_index(op.f('ix_orderitem_id'), table_name='orderitem') - op.create_index('ix_orderitem_order_item_id', 'orderitem', ['order_item_id'], unique=False) - op.drop_column('orderitem', 'id') - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/1100c57e615e_initial_migration.py b/backend/app/alembic/versions/7fd1b196213e_initial_migration.py similarity index 74% rename from backend/app/alembic/versions/1100c57e615e_initial_migration.py rename to backend/app/alembic/versions/7fd1b196213e_initial_migration.py index b24dab34fb..0565126562 100644 --- a/backend/app/alembic/versions/1100c57e615e_initial_migration.py +++ b/backend/app/alembic/versions/7fd1b196213e_initial_migration.py @@ -1,8 +1,8 @@ """Initial migration -Revision ID: 1100c57e615e +Revision ID: 7fd1b196213e Revises: -Create Date: 2024-09-28 12:41:42.435197 +Create Date: 2024-10-23 17:54:34.693255 """ from alembic import op @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. -revision = '1100c57e615e' +revision = '7fd1b196213e' down_revision = None branch_labels = None depends_on = None @@ -19,75 +19,15 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('foodcourt', - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('latitude', sa.Float(), nullable=True), - sa.Column('longitude', sa.Float(), nullable=True), - sa.Column('capacity', sa.Integer(), nullable=True), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_rating', sa.Float(), nullable=True), - sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('avg_expense_for_two', sa.Float(), nullable=True), - sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_foodcourt_id'), 'foodcourt', ['id'], unique=False) - op.create_table('nightclub', - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('latitude', sa.Float(), nullable=True), - sa.Column('longitude', sa.Float(), nullable=True), - sa.Column('capacity', sa.Integer(), nullable=True), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_rating', sa.Float(), nullable=True), - sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('avg_expense_for_two', sa.Float(), nullable=True), - sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_nightclub_id'), 'nightclub', ['id'], unique=False) - op.create_table('restaurant', - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('latitude', sa.Float(), nullable=True), - sa.Column('longitude', sa.Float(), nullable=True), - sa.Column('capacity', sa.Integer(), nullable=True), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_rating', sa.Float(), nullable=True), - sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('avg_expense_for_two', sa.Float(), nullable=True), - sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_restaurant_id'), 'restaurant', ['id'], unique=False) op.create_table('user_business', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('is_superuser', sa.Boolean(), nullable=False), sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('id', sa.Uuid(), nullable=False), sa.Column('registration_date', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id') @@ -96,11 +36,14 @@ def upgrade(): op.create_index(op.f('ix_user_business_id'), 'user_business', ['id'], unique=False) op.create_index(op.f('ix_user_business_phone_number'), 'user_business', ['phone_number'], unique=True) op.create_table('user_public', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('is_superuser', sa.Boolean(), nullable=False), sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('id', sa.Uuid(), nullable=False), sa.Column('date_of_birth', sa.DateTime(), nullable=True), sa.Column('gender', sqlmodel.sql.sqltypes.AutoString(), nullable=True), @@ -112,54 +55,63 @@ def upgrade(): op.create_index(op.f('ix_user_public_email'), 'user_public', ['email'], unique=True) op.create_index(op.f('ix_user_public_id'), 'user_public', ['id'], unique=False) op.create_index(op.f('ix_user_public_phone_number'), 'user_public', ['phone_number'], unique=True) - op.create_table('event', + op.create_table('venue', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('nightclub_id', sa.Uuid(), nullable=False), - sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('start_time', sa.DateTime(), nullable=False), - sa.Column('end_time', sa.DateTime(), nullable=False), - sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('age_restriction', sa.Integer(), nullable=True), - sa.Column('dress_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('capacity', sa.Integer(), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_rating', sa.Float(), nullable=True), + sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('opening_time', sa.Time(), nullable=True), + sa.Column('closing_time', sa.Time(), nullable=True), + sa.Column('avg_expense_for_two', sa.Float(), nullable=True), + sa.Column('zomato_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('swiggy_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_event_id'), 'event', ['id'], unique=False) - op.create_table('foodcourtuserbusinesslink', - sa.Column('foodcourt_id', sa.Uuid(), nullable=False), - sa.Column('user_business_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), - sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), - sa.PrimaryKeyConstraint('foodcourt_id', 'user_business_id') - ) - op.create_table('group', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('nightclub_id', sa.Uuid(), nullable=True), + op.create_index(op.f('ix_venue_id'), 'venue', ['id'], unique=False) + op.create_index(op.f('ix_venue_name'), 'venue', ['name'], unique=False) + op.create_table('foodcourt', sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('admin_user_id', sa.Uuid(), nullable=False), - sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.ForeignKeyConstraint(['admin_user_id'], ['user_public.id'], ), - sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('total_qsrs', sa.Integer(), nullable=True), + sa.Column('seating_capacity', sa.Integer(), nullable=True), + sa.Column('venue_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_group_id'), 'group', ['id'], unique=False) - op.create_table('nightclub_menu', + op.create_index(op.f('ix_foodcourt_id'), 'foodcourt', ['id'], unique=False) + op.create_index(op.f('ix_foodcourt_venue_id'), 'foodcourt', ['venue_id'], unique=False) + op.create_table('menu', sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('nightclub_id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=False), sa.Column('id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_nightclub_menu_id'), 'nightclub_menu', ['id'], unique=False) - op.create_table('nightclubuserbusinesslink', - sa.Column('nightclub_id', sa.Uuid(), nullable=False), - sa.Column('user_business_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), - sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), - sa.PrimaryKeyConstraint('nightclub_id', 'user_business_id') + op.create_index(op.f('ix_menu_id'), 'menu', ['id'], unique=False) + op.create_table('nightclub', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), + sa.PrimaryKeyConstraint('id') ) + op.create_index(op.f('ix_nightclub_id'), 'nightclub', ['id'], unique=False) + op.create_index(op.f('ix_nightclub_venue_id'), 'nightclub', ['venue_id'], unique=False) op.create_table('payment_source_nightclub', sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), @@ -204,53 +156,117 @@ def upgrade(): op.create_index(op.f('ix_payment_source_restaurant_id'), 'payment_source_restaurant', ['id'], unique=False) op.create_table('pickup_location', sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('nightclub_id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_pickup_location_id'), 'pickup_location', ['id'], unique=False) - op.create_table('qsr', + op.create_table('restaurant', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=False), + sa.Column('cuisine_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_restaurant_id'), 'restaurant', ['id'], unique=False) + op.create_index(op.f('ix_restaurant_venue_id'), 'restaurant', ['venue_id'], unique=False) + op.create_table('user_venue_association', + sa.Column('user_business_id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), + sa.PrimaryKeyConstraint('user_business_id', 'venue_id') + ) + op.create_table('event', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('start_time', sa.DateTime(), nullable=False), + sa.Column('end_time', sa.DateTime(), nullable=False), + sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('age_restriction', sa.Integer(), nullable=True), + sa.Column('dress_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_event_id'), 'event', ['id'], unique=False) + op.create_table('group', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('nightclub_id', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('admin_user_id', sa.Uuid(), nullable=False), + sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['admin_user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_group_id'), 'group', ['id'], unique=False) + op.create_table('menu_category', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('menu_id', sa.Uuid(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('latitude', sa.Float(), nullable=True), - sa.Column('longitude', sa.Float(), nullable=True), - sa.Column('capacity', sa.Integer(), nullable=True), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_rating', sa.Float(), nullable=True), - sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('instagram_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('google_map_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('mobile_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('opening_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('closing_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('avg_expense_for_two', sa.Float(), nullable=True), - sa.Column('qr_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['menu_id'], ['menu.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_menu_category_id'), 'menu_category', ['id'], unique=False) + op.create_table('nightclub_order', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('order_time', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('taxes_and_charges', sa.Float(), nullable=True), + sa.Column('cover_charge_used', sa.Float(), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=True), + sa.Column('payment_id', sa.Uuid(), nullable=True), + sa.Column('pickup_location_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['payment_id'], ['payment_source_nightclub.id'], ), + sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['nightclub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_nightclub_order_id'), 'nightclub_order', ['id'], unique=False) + op.create_table('qsr', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('id', sa.Uuid(), nullable=False), sa.Column('foodcourt_id', sa.Uuid(), nullable=True), + sa.Column('drive_thru', sa.Boolean(), nullable=True), + sa.Column('venue_id', sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), sa.PrimaryKeyConstraint('id') ) + op.create_index(op.f('ix_qsr_foodcourt_id'), 'qsr', ['foodcourt_id'], unique=False) op.create_index(op.f('ix_qsr_id'), 'qsr', ['id'], unique=False) - op.create_table('restaurant_menu', - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('restaurant_id', sa.Uuid(), nullable=False), + op.create_index(op.f('ix_qsr_venue_id'), 'qsr', ['venue_id'], unique=False) + op.create_table('restaurant_order', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('pickup_location_id', sa.Uuid(), nullable=True), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('order_time', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('taxes_and_charges', sa.Float(), nullable=True), + sa.Column('cover_charge_used', sa.Float(), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), + sa.Column('venue_id', sa.Uuid(), nullable=True), + sa.Column('payment_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['payment_id'], ['payment_source_restaurant.id'], ), + sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['restaurant.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_restaurant_menu_id'), 'restaurant_menu', ['id'], unique=False) - op.create_table('restaurantuserbusinesslink', - sa.Column('restaurant_id', sa.Uuid(), nullable=False), - sa.Column('user_business_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), - sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), - sa.PrimaryKeyConstraint('restaurant_id', 'user_business_id') - ) + op.create_index(op.f('ix_restaurant_order_id'), 'restaurant_order', ['id'], unique=False) op.create_table('clubvisit', sa.Column('id', sa.Uuid(), nullable=False), sa.Column('user_id', sa.Uuid(), nullable=False), @@ -276,6 +292,13 @@ def upgrade(): sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), sa.PrimaryKeyConstraint('id') ) + op.create_table('group_nightclub_order_link', + sa.Column('group_id', sa.Uuid(), nullable=False), + sa.Column('nightclub_order_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), + sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), + sa.PrimaryKeyConstraint('group_id', 'nightclub_order_id') + ) op.create_table('group_wallet', sa.Column('id', sa.Uuid(), nullable=False), sa.Column('group_id', sa.Uuid(), nullable=False), @@ -292,36 +315,14 @@ def upgrade(): sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), sa.PrimaryKeyConstraint('group_id', 'user_id') ) - op.create_table('nightclub_order', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('order_time', sa.DateTime(), nullable=False), - sa.Column('total_amount', sa.Float(), nullable=False), - sa.Column('taxes_and_charges', sa.Float(), nullable=True), - sa.Column('cover_charge_used', sa.Float(), nullable=True), - sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + op.create_table('menu_sub_category', sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('venue_id', sa.Uuid(), nullable=True), - sa.Column('payment_id', sa.Uuid(), nullable=True), - sa.Column('pickup_location_id', sa.Uuid(), nullable=True), - sa.ForeignKeyConstraint(['payment_id'], ['payment_source_nightclub.id'], ), - sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), - sa.ForeignKeyConstraint(['venue_id'], ['nightclub.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_nightclub_order_id'), 'nightclub_order', ['id'], unique=False) - op.create_table('qsr_menu', + sa.Column('category_id', sa.Uuid(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('menu_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('qsr_id', sa.Uuid(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), + sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_qsr_menu_id'), 'qsr_menu', ['id'], unique=False) + op.create_index(op.f('ix_menu_sub_category_id'), 'menu_sub_category', ['id'], unique=False) op.create_table('qsr_order', sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('pickup_location_id', sa.Uuid(), nullable=True), @@ -342,33 +343,6 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_qsr_order_id'), 'qsr_order', ['id'], unique=False) - op.create_table('qsruserbusinesslink', - sa.Column('qsr_id', sa.Uuid(), nullable=False), - sa.Column('user_business_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), - sa.ForeignKeyConstraint(['user_business_id'], ['user_business.id'], ), - sa.PrimaryKeyConstraint('qsr_id', 'user_business_id') - ) - op.create_table('restaurant_order', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('pickup_location_id', sa.Uuid(), nullable=True), - sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('order_time', sa.DateTime(), nullable=False), - sa.Column('total_amount', sa.Float(), nullable=False), - sa.Column('taxes_and_charges', sa.Float(), nullable=True), - sa.Column('cover_charge_used', sa.Float(), nullable=True), - sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('service_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('venue_id', sa.Uuid(), nullable=True), - sa.Column('payment_id', sa.Uuid(), nullable=True), - sa.ForeignKeyConstraint(['payment_id'], ['payment_source_restaurant.id'], ), - sa.ForeignKeyConstraint(['pickup_location_id'], ['pickup_location.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user_public.id'], ), - sa.ForeignKeyConstraint(['venue_id'], ['restaurant.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_restaurant_order_id'), 'restaurant_order', ['id'], unique=False) op.create_table('event_offering', sa.Column('id', sa.Uuid(), nullable=False), sa.Column('event_id', sa.Uuid(), nullable=False), @@ -385,13 +359,6 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_event_offering_id'), 'event_offering', ['id'], unique=False) - op.create_table('group_nightclub_order_link', - sa.Column('group_id', sa.Uuid(), nullable=False), - sa.Column('nightclub_order_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), - sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), - sa.PrimaryKeyConstraint('group_id', 'nightclub_order_id') - ) op.create_table('groupwallettopup', sa.Column('id', sa.Uuid(), nullable=False), sa.Column('group_wallet_id', sa.Uuid(), nullable=False), @@ -401,18 +368,23 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_groupwallettopup_id'), 'groupwallettopup', ['id'], unique=False) - op.create_table('menu_category', - sa.Column('qsr_menu_id', sa.Uuid(), nullable=True), - sa.Column('restaurant_menu_id', sa.Uuid(), nullable=True), - sa.Column('nightclub_menu_id', sa.Uuid(), nullable=True), + op.create_table('menu_item', + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.Column('sub_category_id', sa.Uuid(), nullable=True), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('is_veg', sa.Boolean(), nullable=True), + sa.Column('ingredients', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('abv', sa.Float(), nullable=True), + sa.Column('ibu', sa.Integer(), nullable=True), sa.Column('id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['nightclub_menu_id'], ['nightclub_menu.id'], ), - sa.ForeignKeyConstraint(['qsr_menu_id'], ['qsr_menu.id'], ), - sa.ForeignKeyConstraint(['restaurant_menu_id'], ['restaurant_menu.id'], ), + sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), + sa.ForeignKeyConstraint(['sub_category_id'], ['menu_sub_category.id'], ), sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_menu_category_id'), 'menu_category', ['id'], unique=False) + op.create_index(op.f('ix_menu_item_id'), 'menu_item', ['id'], unique=False) op.create_table('payment_event', sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('source_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), @@ -427,23 +399,8 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_payment_event_id'), 'payment_event', ['id'], unique=False) - op.create_table('menu_item', - sa.Column('category_id', sa.Uuid(), nullable=False), - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('price', sa.Float(), nullable=False), - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('is_veg', sa.Boolean(), nullable=True), - sa.Column('ingredients', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('abv', sa.Float(), nullable=True), - sa.Column('ibu', sa.Integer(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_menu_item_id'), 'menu_item', ['id'], unique=False) op.create_table('orderitem', - sa.Column('order_item_id', sa.Uuid(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), sa.Column('nightclub_order_id', sa.Uuid(), nullable=True), sa.Column('restaurant_order_id', sa.Uuid(), nullable=True), sa.Column('qsr_order_id', sa.Uuid(), nullable=True), @@ -453,46 +410,52 @@ def upgrade(): sa.ForeignKeyConstraint(['nightclub_order_id'], ['nightclub_order.id'], ), sa.ForeignKeyConstraint(['qsr_order_id'], ['qsr_order.id'], ), sa.ForeignKeyConstraint(['restaurant_order_id'], ['restaurant_order.id'], ), - sa.PrimaryKeyConstraint('order_item_id') + sa.PrimaryKeyConstraint('id') ) - op.create_index(op.f('ix_orderitem_order_item_id'), 'orderitem', ['order_item_id'], unique=False) + op.create_index(op.f('ix_orderitem_id'), 'orderitem', ['id'], unique=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_orderitem_order_item_id'), table_name='orderitem') + op.drop_index(op.f('ix_orderitem_id'), table_name='orderitem') op.drop_table('orderitem') - op.drop_index(op.f('ix_menu_item_id'), table_name='menu_item') - op.drop_table('menu_item') op.drop_index(op.f('ix_payment_event_id'), table_name='payment_event') op.drop_table('payment_event') - op.drop_index(op.f('ix_menu_category_id'), table_name='menu_category') - op.drop_table('menu_category') + op.drop_index(op.f('ix_menu_item_id'), table_name='menu_item') + op.drop_table('menu_item') op.drop_index(op.f('ix_groupwallettopup_id'), table_name='groupwallettopup') op.drop_table('groupwallettopup') - op.drop_table('group_nightclub_order_link') op.drop_index(op.f('ix_event_offering_id'), table_name='event_offering') op.drop_table('event_offering') - op.drop_index(op.f('ix_restaurant_order_id'), table_name='restaurant_order') - op.drop_table('restaurant_order') - op.drop_table('qsruserbusinesslink') op.drop_index(op.f('ix_qsr_order_id'), table_name='qsr_order') op.drop_table('qsr_order') - op.drop_index(op.f('ix_qsr_menu_id'), table_name='qsr_menu') - op.drop_table('qsr_menu') - op.drop_index(op.f('ix_nightclub_order_id'), table_name='nightclub_order') - op.drop_table('nightclub_order') + op.drop_index(op.f('ix_menu_sub_category_id'), table_name='menu_sub_category') + op.drop_table('menu_sub_category') op.drop_table('groupmembers') op.drop_index(op.f('ix_group_wallet_id'), table_name='group_wallet') op.drop_table('group_wallet') + op.drop_table('group_nightclub_order_link') op.drop_table('event_booking') op.drop_table('clubvisit') - op.drop_table('restaurantuserbusinesslink') - op.drop_index(op.f('ix_restaurant_menu_id'), table_name='restaurant_menu') - op.drop_table('restaurant_menu') + op.drop_index(op.f('ix_restaurant_order_id'), table_name='restaurant_order') + op.drop_table('restaurant_order') + op.drop_index(op.f('ix_qsr_venue_id'), table_name='qsr') op.drop_index(op.f('ix_qsr_id'), table_name='qsr') + op.drop_index(op.f('ix_qsr_foodcourt_id'), table_name='qsr') op.drop_table('qsr') + op.drop_index(op.f('ix_nightclub_order_id'), table_name='nightclub_order') + op.drop_table('nightclub_order') + op.drop_index(op.f('ix_menu_category_id'), table_name='menu_category') + op.drop_table('menu_category') + op.drop_index(op.f('ix_group_id'), table_name='group') + op.drop_table('group') + op.drop_index(op.f('ix_event_id'), table_name='event') + op.drop_table('event') + op.drop_table('user_venue_association') + op.drop_index(op.f('ix_restaurant_venue_id'), table_name='restaurant') + op.drop_index(op.f('ix_restaurant_id'), table_name='restaurant') + op.drop_table('restaurant') op.drop_index(op.f('ix_pickup_location_id'), table_name='pickup_location') op.drop_table('pickup_location') op.drop_index(op.f('ix_payment_source_restaurant_id'), table_name='payment_source_restaurant') @@ -501,14 +464,17 @@ def downgrade(): op.drop_table('payment_source_qsr') op.drop_index(op.f('ix_payment_source_nightclub_id'), table_name='payment_source_nightclub') op.drop_table('payment_source_nightclub') - op.drop_table('nightclubuserbusinesslink') - op.drop_index(op.f('ix_nightclub_menu_id'), table_name='nightclub_menu') - op.drop_table('nightclub_menu') - op.drop_index(op.f('ix_group_id'), table_name='group') - op.drop_table('group') - op.drop_table('foodcourtuserbusinesslink') - op.drop_index(op.f('ix_event_id'), table_name='event') - op.drop_table('event') + op.drop_index(op.f('ix_nightclub_venue_id'), table_name='nightclub') + op.drop_index(op.f('ix_nightclub_id'), table_name='nightclub') + op.drop_table('nightclub') + op.drop_index(op.f('ix_menu_id'), table_name='menu') + op.drop_table('menu') + op.drop_index(op.f('ix_foodcourt_venue_id'), table_name='foodcourt') + op.drop_index(op.f('ix_foodcourt_id'), table_name='foodcourt') + op.drop_table('foodcourt') + op.drop_index(op.f('ix_venue_name'), table_name='venue') + op.drop_index(op.f('ix_venue_id'), table_name='venue') + op.drop_table('venue') op.drop_index(op.f('ix_user_public_phone_number'), table_name='user_public') op.drop_index(op.f('ix_user_public_id'), table_name='user_public') op.drop_index(op.f('ix_user_public_email'), table_name='user_public') @@ -517,10 +483,4 @@ def downgrade(): op.drop_index(op.f('ix_user_business_id'), table_name='user_business') op.drop_index(op.f('ix_user_business_email'), table_name='user_business') op.drop_table('user_business') - op.drop_index(op.f('ix_restaurant_id'), table_name='restaurant') - op.drop_table('restaurant') - op.drop_index(op.f('ix_nightclub_id'), table_name='nightclub') - op.drop_table('nightclub') - op.drop_index(op.f('ix_foodcourt_id'), table_name='foodcourt') - op.drop_table('foodcourt') # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/8c693686becd_description_of_changes.py b/backend/app/alembic/versions/8c693686becd_description_of_changes.py new file mode 100644 index 0000000000..3b39fcda0e --- /dev/null +++ b/backend/app/alembic/versions/8c693686becd_description_of_changes.py @@ -0,0 +1,37 @@ +"""Description of changes + +Revision ID: 8c693686becd +Revises: 9e690425af2e +Create Date: 2024-10-23 23:16:40.093380 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '8c693686becd' +down_revision = '9e690425af2e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('nightclub', sa.Column('age_limit', sa.Integer(), nullable=True)) + op.add_column('venue', sa.Column('zomato_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('venue', sa.Column('swiggy_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.drop_column('venue', 'swiggylink') + op.drop_column('venue', 'zomatolink') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venue', sa.Column('zomatolink', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('venue', sa.Column('swiggylink', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.drop_column('venue', 'swiggy_link') + op.drop_column('venue', 'zomato_link') + op.drop_column('nightclub', 'age_limit') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/9e690425af2e_add_zomato_link_to_venue.py b/backend/app/alembic/versions/9e690425af2e_add_zomato_link_to_venue.py new file mode 100644 index 0000000000..0abc04de47 --- /dev/null +++ b/backend/app/alembic/versions/9e690425af2e_add_zomato_link_to_venue.py @@ -0,0 +1,25 @@ +"""add zomato_link to venue + +Revision ID: 9e690425af2e +Revises: 7fd1b196213e +Create Date: 2024-10-23 23:11:38.666505 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '9e690425af2e' +down_revision = '7fd1b196213e' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/app/alembic/versions/a392ecf44925_c.py b/backend/app/alembic/versions/a392ecf44925_c.py deleted file mode 100644 index e38427df1f..0000000000 --- a/backend/app/alembic/versions/a392ecf44925_c.py +++ /dev/null @@ -1,31 +0,0 @@ -"""c - -Revision ID: a392ecf44925 -Revises: 50ffb0a7eef5 -Create Date: 2024-09-28 18:47:47.766797 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = 'a392ecf44925' -down_revision = '50ffb0a7eef5' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('user_business', sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) - op.add_column('user_public', sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('user_public', 'refresh_token') - op.drop_column('user_business', 'refresh_token') - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py b/backend/app/alembic/versions/e2955dcf9b00_updated_menu_and_category_models.py similarity index 69% rename from backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py rename to backend/app/alembic/versions/e2955dcf9b00_updated_menu_and_category_models.py index 28e957474a..288f13039b 100644 --- a/backend/app/alembic/versions/375a3d9f95aa_added_qr_codes_to_foodcourt.py +++ b/backend/app/alembic/versions/e2955dcf9b00_updated_menu_and_category_models.py @@ -1,8 +1,8 @@ -"""added qr_codes to foodcourt +"""Updated menu and category models -Revision ID: 375a3d9f95aa -Revises: fe4249d074a6 -Create Date: 2024-10-05 07:58:40.774421 +Revision ID: e2955dcf9b00 +Revises: ea33a3e76248 +Create Date: 2024-10-24 23:47:03.184543 """ from alembic import op @@ -11,8 +11,8 @@ # revision identifiers, used by Alembic. -revision = '375a3d9f95aa' -down_revision = 'fe4249d074a6' +revision = 'e2955dcf9b00' +down_revision = 'ea33a3e76248' branch_labels = None depends_on = None diff --git a/backend/app/alembic/versions/ea33a3e76248_create_menu_subcategory_table.py b/backend/app/alembic/versions/ea33a3e76248_create_menu_subcategory_table.py new file mode 100644 index 0000000000..12c6f31eee --- /dev/null +++ b/backend/app/alembic/versions/ea33a3e76248_create_menu_subcategory_table.py @@ -0,0 +1,57 @@ +"""create menu_subcategory table + +Revision ID: ea33a3e76248 +Revises: 3078d16ee962 +Create Date: 2024-10-24 23:25:14.486071 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'ea33a3e76248' +down_revision = '3078d16ee962' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('menu_subcategory', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_alcoholic', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_menu_subcategory_id'), 'menu_subcategory', ['id'], unique=False) + op.add_column('menu_item', sa.Column('subcategory_id', sa.Uuid(), nullable=False)) + op.drop_constraint('menu_item_category_id_fkey', 'menu_item', type_='foreignkey') + op.create_foreign_key(None, 'menu_item', 'menu_subcategory', ['subcategory_id'], ['id']) + op.drop_column('menu_item', 'category_id') + op.drop_column('menu_item', 'sub_category_id') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('menu_item', sa.Column('sub_category_id', sa.UUID(), autoincrement=False, nullable=True)) + op.add_column('menu_item', sa.Column('category_id', sa.UUID(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'menu_item', type_='foreignkey') + op.create_foreign_key('menu_item_category_id_fkey', 'menu_item', 'menu_category', ['category_id'], ['id']) + op.create_foreign_key('menu_item_sub_category_id_fkey', 'menu_item', 'menu_sub_category', ['sub_category_id'], ['id']) + op.drop_column('menu_item', 'subcategory_id') + op.create_table('menu_sub_category', + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('category_id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['menu_category.id'], name='menu_sub_category_category_id_fkey'), + sa.PrimaryKeyConstraint('id', name='menu_sub_category_pkey') + ) + op.create_index('ix_menu_sub_category_id', 'menu_sub_category', ['id'], unique=False) + op.drop_index(op.f('ix_menu_subcategory_id'), table_name='menu_subcategory') + op.drop_table('menu_subcategory') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py b/backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py deleted file mode 100644 index 64bb0b66c4..0000000000 --- a/backend/app/alembic/versions/fe4249d074a6_added_qr_codes_to_foodcourt.py +++ /dev/null @@ -1,41 +0,0 @@ -"""added qr_codes to foodcourt - -Revision ID: fe4249d074a6 -Revises: a392ecf44925 -Create Date: 2024-10-05 07:51:04.040718 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = 'fe4249d074a6' -down_revision = 'a392ecf44925' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('qr_codes', - sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('foodcourt_id', sa.Uuid(), nullable=True), - sa.Column('qsr_id', sa.Uuid(), nullable=True), - sa.Column('nightclub_id', sa.Uuid(), nullable=True), - sa.Column('restaurant_id', sa.Uuid(), nullable=True), - sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], ), - sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], ), - sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], ), - sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('qr_codes') - # ### end Alembic commands ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 9a9f6d5a88..51afe9fa1f 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -4,8 +4,8 @@ api_router = APIRouter() -api_router.include_router(venues.router, prefix="/venues", tags=["venues"]) +api_router.include_router(venues.router, prefix="/venue", tags=["venue"]) api_router.include_router(menu.router, prefix="/menu", tags=["menu"]) -api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(users.router, prefix="/user", tags=["user"]) api_router.include_router(login.router, tags=["login"]) api_router.include_router(qrcode.router, tags=["qrcode"]) diff --git a/backend/app/api/routes/menu.py b/backend/app/api/routes/menu.py index 3be858f6c3..3141c1f226 100644 --- a/backend/app/api/routes/menu.py +++ b/backend/app/api/routes/menu.py @@ -1,211 +1,322 @@ import uuid -from app.schema.menu import MenuCategoryRead, MenuItemRead, NightclubMenuCreate, NightclubMenuRead, MenuCategoryCreate, MenuItemCreate, QSRMenuCreate, QSRMenuRead, RestaurantMenuCreate, RestaurantMenuRead -from app.models.menu import NightclubMenu, QSRMenu, RestaurantMenu -from app.models.menu_category import MenuCategory -from app.models.menu_item import MenuItem -from sqlmodel import select -from fastapi import APIRouter +from app.schema.menu import MenuCategoryCreate, MenuCategoryRead, MenuCategoryUpdate, MenuCreate, MenuItemCreate, MenuItemRead, MenuItemUpdate, MenuRead, MenuSubCategoryCreate, MenuSubCategoryRead, MenuSubCategoryUpdate, MenuUpdate +from app.models.menu import Menu, MenuCategory, MenuItem, MenuSubCategory +from app.models.venue import Venue +from sqlmodel import Session,select +from fastapi import APIRouter, Depends, HTTPException from typing import List from app.api.deps import SessionDep from app.crud import ( get_record_by_id, create_record, update_record, - patch_record, delete_record ) - +from app.api.deps import get_db router = APIRouter() -# Get all menus for a nightclub -@router.get("/nightclubs/{nightclub_id}/menus/", response_model=List[NightclubMenuRead]) -async def read_nightclub_menus(nightclub_id: uuid.UUID, session: SessionDep): - """ - Retrieve all menus for a specific nightclub. - """ - menus = session.exec(select(NightclubMenu).where(NightclubMenu.nightclub_id == nightclub_id)).all() - return menus +# Get all menus of a specific venue +@router.get("/all/{venue_id}", response_model=List[MenuRead]) +async def read_menus(venue_id: uuid.UUID, db: Session = Depends(get_db)): + """ + Retrieve all menus for a specific venue. + """ + # Query the Menu table for menus associated with the specified venue + statement = select(Menu).where(Menu.venue_id == venue_id) + menus = db.execute(statement).scalars().all() # Execute the query + + if not menus: + raise HTTPException(status_code=404, detail="No menus found for this venue.") + + return [Menu.to_read_schema(menu) for menu in menus] -# Get a specific menu -@router.get("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) -async def read_nightclub_menu(menu_id: uuid.UUID, session: SessionDep): +@router.get("/menu/{menu_id}", response_model=MenuRead) +async def read_menu(menu_id: uuid.UUID, db: Session = Depends(get_db)): """ - Retrieve a specific menu by ID for a nightclub. + Retrieve a specific menu by its ID. """ - return get_record_by_id(session, NightclubMenu, menu_id) + menu = get_record_by_id(db, Menu, menu_id) + print('huhuhuh',menu) + ret = Menu.to_read_schema(menu) + return ret -# Create a new menu -@router.post("/nightclubs/menus/", response_model=NightclubMenuRead) -async def create_nightclub_menu( menu: NightclubMenuCreate, session: SessionDep): - """ - Create a new menu for a nightclub. - """ - return create_record(session, NightclubMenu, menu) -# Update a menu -@router.put("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) -async def update_nightclub_menu(menu_id: uuid.UUID, updated_menu: NightclubMenuCreate, session: SessionDep): +@router.post("/", response_model=MenuRead) +async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db)): """ - Update an existing menu for a nightclub. + Create a new menu for a specific venue. """ - return update_record(session, NightclubMenu, menu_id, updated_menu) + # Check if the venue exists + venue = get_record_by_id(db, Venue, menu_create.venue_id) + if not venue: + raise HTTPException(status_code=404, detail="Venue not found.") -# PATCH a menu for partial updates -@router.patch("/nightclubs/menus/{menu_id}", response_model=NightclubMenuRead) -async def patch_nightclub_menu(menu_id: uuid.UUID, updated_menu: NightclubMenuCreate, session: SessionDep): - """ - Partially update an existing menu for a venue (Nightclub, Restaurant, QSR). - """ - return patch_record(session, NightclubMenu, menu_id, updated_menu) + try: + # Create the Menu object + menu_instance = Menu.from_create_schema(menu_create) -# Delete a menu -@router.delete("/nightclubs/menus/{menu_id}", response_model=None) -async def delete_nightclub_menu(menu_id: uuid.UUID, session: SessionDep): - """ - Delete a menu by ID for a nightclub. - """ - return delete_record(session, NightclubMenu, menu_id) + # Use the create_record helper to save the menu to the database + created_menu = create_record(db, menu_instance) -# Get all menus for a qsr -@router.get("/qsrs/{qsr_id}/menus/", response_model=List[QSRMenuRead]) -async def read_qsr_menus(qsr_id: uuid.UUID, session: SessionDep): - """ - Retrieve all menus for a specific qsr. - """ - menus = session.exec(select(QSRMenu).where(QSRMenu.qsr_id == qsr_id)).all() - return menus + return Menu.to_read_schema(created_menu) + + except Exception as e: + db.rollback() # Rollback in case of error + raise HTTPException(status_code=400, detail=f"Error creating menu: {str(e)}") -# Get a specific menu -@router.get("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) -async def read_qsr_menu(menu_id: uuid.UUID, session: SessionDep): - """ - Retrieve a specific menu by ID for a qsr. - """ - return get_record_by_id(session, QSRMenu, menu_id) +@router.patch("/{menu_id}", response_model=MenuRead) +async def update_menu( + menu_id: uuid.UUID, + menu_update: MenuUpdate, + db: Session = Depends(get_db) +): + """ + Update an existing menu's details using a partial update (PATCH). + + :param menu_id: The ID of the menu to update. + :param menu_update: The fields to update, provided as a Pydantic model. + :param db: Active database session. + :return: The updated Menu as a response. + """ + # Retrieve the menu by its ID + menu_instance = get_record_by_id(db, Menu, menu_id) + + if not menu_instance: + raise HTTPException(status_code=404, detail="Menu not found.") + + # Update the menu using the validated fields from MenuUpdate + updated_menu = update_record(db, menu_instance, menu_update) + + return Menu.to_read_schema(updated_menu) -# Create a new menu -@router.post("/qsrs/menus/", response_model=QSRMenuRead) -async def create_qsr_menu( menu: QSRMenuCreate, session: SessionDep): +@router.delete("/{menu_id}", response_model=dict) +async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db)): """ - Create a new menu for a qsr. - """ - return create_record(session, QSRMenu, menu) + Delete a menu by its ID. -# Update a menu -@router.put("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) -async def update_qsr_menu(menu_id: uuid.UUID, updated_menu: QSRMenuCreate, session: SessionDep): - """ - Update an existing menu for a qsr. - """ - return update_record(session, QSRMenu, menu_id, updated_menu) + :param menu_id: The ID of the menu to delete. + :param db: Active database session. + :return: Confirmation message on successful deletion. + """ + menu = get_record_by_id(db, Menu, menu_id) + + if not menu: + raise HTTPException(status_code=404, detail="Menu not found.") + + delete_record(db, menu) + + return {"detail": "Menu deleted successfully."} -# PATCH a menu for partial updates -@router.patch("/qsrs/menus/{menu_id}", response_model=QSRMenuRead) -async def patch_qsr_menu(menu_id: uuid.UUID, updated_menu: QSRMenuCreate, session: SessionDep): - """ - Partially update an existing menu for a venue (QSR, Restaurant, QSR). - """ - return patch_record(session, QSRMenu, menu_id, updated_menu) +############################################################################################################## -# Delete a menu -@router.delete("/qsrs/menus/{menu_id}", response_model=None) -async def delete_qsr_menu(menu_id: uuid.UUID, session: SessionDep): - """ - Delete a menu by ID for a qsr. - """ - return delete_record(session, QSRMenu, menu_id) +@router.post("/category", response_model=MenuCategoryRead) +async def create_menu_category( + category_create: MenuCategoryCreate, + db: Session = Depends(get_db) +): + """ + Create a new menu category associated with a menu. + + :param category_create: The details for the new menu category, provided as a Pydantic model. + :param db: Active database session. + :return: The created MenuCategory as a response. + """ + # Check if the menu exists + menu = get_record_by_id(db, Menu, category_create.menu_id) + + if not menu: + raise HTTPException(status_code=404, detail="Menu not found.") + + # Create a new MenuCategory instance from the provided data + category_instance = MenuCategory.from_create_schema(category_create) + + # Persist the new category in the database + created_category = create_record(db, category_instance) + + return MenuCategory.to_read_schema(created_category) -# Get all menus for a restaurant -@router.get("/restaurants/{restaurant_id}/menus/", response_model=List[RestaurantMenuRead]) -async def read_restaurant_menus(restaurant_id: uuid.UUID, session: SessionDep): - """ - Retrieve all menus for a specific restaurant. - """ - menus = session.exec(select(RestaurantMenu).where(RestaurantMenu.restaurant_id == restaurant_id)).all() - return menus +@router.patch("/category/{category_id}", response_model=MenuCategoryRead) +async def update_menu_category( + category_id: uuid.UUID, + category_update: MenuCategoryUpdate, + db: Session = Depends(get_db) +): + """ + Update an existing menu category's details using a partial update (PATCH). + + :param category_id: The ID of the menu category to update. + :param category_update: The fields to update, provided as a Pydantic model. + :param db: Active database session. + :return: The updated MenuCategory as a response. + """ + # Retrieve the category by its ID + category_instance = get_record_by_id(db, MenuCategory, category_id) + + if not category_instance: + raise HTTPException(status_code=404, detail="Menu category not found.") + + # Update the category using the validated fields from MenuCategoryUpdate + updated_category = update_record(db, category_instance, category_update) + + return MenuCategory.to_read_schema(updated_category) -# Get a specific menu -@router.get("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) -async def read_restaurant_menu(menu_id: uuid.UUID, session: SessionDep): - """ - Retrieve a specific menu by ID for a restaurant. +@router.delete("/category/{category_id}", response_model=dict) +async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db)): """ - return get_record_by_id(session, RestaurantMenu, menu_id) + Delete a menu category by its ID. -# Create a new menu -@router.post("/restaurants/menus/", response_model=RestaurantMenuRead) -async def create_restaurant_menu( menu: RestaurantMenuCreate, session: SessionDep): - """ - Create a new menu for a restaurant. - """ - return create_record(session, RestaurantMenu, menu) + :param category_id: The ID of the category to delete. + :param db: Active database session. + :return: Confirmation message on successful deletion. + """ + category = get_record_by_id(db, MenuCategory, category_id) + + if not category: + raise HTTPException(status_code=404, detail="Category not found.") + + delete_record(db, category) + + return {"detail": "Category deleted successfully."} -# Update a menu -@router.put("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) -async def update_restaurant_menu(menu_id: uuid.UUID, updated_menu: RestaurantMenuCreate, session: SessionDep): - """ - Update an existing menu for a restaurant. - """ - return update_record(session, RestaurantMenu, menu_id, updated_menu) +############################################################################################################## -# PATCH a menu for partial updates -@router.patch("/restaurants/menus/{menu_id}", response_model=RestaurantMenuRead) -async def patch_restaurant_menu(menu_id: uuid.UUID, updated_menu: RestaurantMenuCreate, session: SessionDep): - """ - Partially update an existing menu for a venue (Restaurant, Restaurant, QSR). - """ - return patch_record(session, RestaurantMenu, menu_id, updated_menu) +@router.post("/subcategory/", response_model=MenuSubCategoryRead) +async def create_menu_subcategory( + subcategory_create: MenuSubCategoryCreate, + db: Session = Depends(get_db) +): + """ + Create a new menu subcategory associated with a category. + + :param subcategory_create: The details for the new menu subcategory, provided as a Pydantic model. + :param db: Active database session. + :return: The created MenuSubCategory as a response. + """ + # Check if the category exists + category = get_record_by_id(db, MenuCategory, subcategory_create.category_id) + + if not category: + raise HTTPException(status_code=404, detail="Category not found.") + + # Create a new MenuSubCategory instance from the provided data + subcategory_instance = MenuSubCategory.from_create_schema(subcategory_create) + + # Persist the new subcategory in the database + created_subcategory = create_record(db, subcategory_instance) + + return MenuSubCategory.to_read_schema(created_subcategory) -# Delete a menu -@router.delete("/restaurants/menus/{menu_id}", response_model=None) -async def delete_restaurant_menu(menu_id: uuid.UUID, session: SessionDep): - """ - Delete a menu by ID for a restaurant. - """ - return delete_record(session, RestaurantMenu, menu_id) +@router.patch("/subcategory/{subcategory_id}", response_model=MenuSubCategoryRead) +async def update_menu_subcategory( + subcategory_id: uuid.UUID, + subcategory_update: MenuSubCategoryUpdate, + db: Session = Depends(get_db) +): + """ + Update an existing menu subcategory. -# CRUD operations for Menu Categories + :param subcategory_id: The unique identifier for the subcategory to update. + :param subcategory_update: The details to update, provided as a Pydantic model. + :param db: Active database session. + :return: The updated MenuSubCategory as a response. + """ + # Check if the subcategory exists + subcategory = get_record_by_id(db, MenuSubCategory, subcategory_id) + + if not subcategory: + raise HTTPException(status_code=404, detail="Subcategory not found.") -# Create a new category -@router.post("/nightclubs/menus/categories/", response_model=MenuCategoryRead) -async def create_menu_category(category: MenuCategoryCreate, session: SessionDep): - return create_record(session, MenuCategory, category) + # Update the subcategory with provided data + update_data = subcategory_update.dict(exclude_unset=True) # Exclude unset fields for partial update + updated_subcategory = update_record(db, subcategory, update_data) + + return MenuSubCategory.to_read_schema(updated_subcategory) -# Update a category -@router.put("/nightclubs/menus/categories/{category_id}", response_model=MenuCategoryRead) -async def update_menu_category(category_id: uuid.UUID, updated_category: MenuCategoryCreate, session: SessionDep): - """ - Update an existing category for a specific menu. +@router.delete("/subcategory/{subcategory_id}", response_model=dict) +async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(get_db)): """ - return update_record(session, MenuCategory, category_id, updated_category) + Delete a menu subcategory by its ID. -# Delete a category -@router.delete("/nightclubs/menus/categories/{category_id}", response_model=None) -async def delete_menu_category(category_id: uuid.UUID, session: SessionDep): - """ - Delete a category by ID from a specific menu. + :param subcategory_id: The ID of the subcategory to delete. + :param db: Active database session. + :return: Confirmation message on successful deletion. + """ + subcategory = get_record_by_id(db, MenuSubCategory, subcategory_id) + + if not subcategory: + raise HTTPException(status_code=404, detail="Subcategory not found.") + + delete_record(db, subcategory) + + return {"detail": "Subcategory deleted successfully."} + +############################################################################################################## + +@router.post("/item/", response_model=MenuItemRead) +async def create_menu_item( + item_create: MenuItemCreate, + db: Session = Depends(get_db) +): """ - return delete_record(session, MenuCategory, category_id) + Create a new menu item associated with a subcategory. -# CRUD operations for Menu Items + :param item_create: The details for the new menu item, provided as a Pydantic model. + :param db: Active database session. + :return: The created MenuItem as a response. + """ + # Check if the subcategory exists + subcategory = get_record_by_id(db, MenuSubCategory, item_create.subcategory_id) + + if not subcategory: + raise HTTPException(status_code=404, detail="Subcategory not found.") + + # Create a new MenuItem instance from the provided data + item_instance = MenuItem.from_create_schema(item_create) + + # Persist the new item in the database + created_item = create_record(db, item_instance) + + return MenuItem.to_read_schema(created_item) -# Create a new item -@router.post("/nightclubs/menus/categories/items/", response_model=MenuItemRead) -async def create_menu_item(item: MenuItemCreate, session: SessionDep): - return create_record(session, MenuItem, item) +@router.patch("/item/{item_id}", response_model=MenuItemRead) +async def update_menu_item( + item_id: uuid.UUID, + item_update: MenuItemUpdate, + db: Session = Depends(get_db) +): + """ + Update an existing menu item. -# Update an item -@router.put("/nightclubs/menus/categories/items/{item_id}", response_model=MenuItemRead) -async def update_menu_item(item_id: uuid.UUID, updated_item: MenuItemCreate, session: SessionDep): - """ - Update an existing item under a specific category of a menu. - """ - return update_record(session, MenuItem, item_id, updated_item) + :param item_id: The unique identifier for the item to update. + :param item_update: The details to update, provided as a Pydantic model. + :param db: Active database session. + :return: The updated MenuItem as a response. + """ + # Check if the item exists + item = get_record_by_id(db, MenuItem, item_id) + + if not item: + raise HTTPException(status_code=404, detail="Menu item not found.") -# Delete an item -@router.delete("/nightclubs/menus/categories/items/{item_id}", response_model=None) -async def delete_menu_item(item_id: uuid.UUID, session: SessionDep): - """ - Delete an item by ID from a specific category of a menu. + # Update the item with provided data + updated_item = update_record(db, item, item_update) + + return MenuItem.to_read_schema(updated_item) + +@router.delete("/item/{item_id}", response_model=dict) +async def delete_menu_item(item_id: uuid.UUID, db: Session = Depends(get_db)): """ - return delete_record(session, MenuItem, item_id) \ No newline at end of file + Delete a menu item by its ID. + + :param item_id: The ID of the item to delete. + :param db: Active database session. + :return: Confirmation message on successful deletion. + """ + item = get_record_by_id(db, MenuItem, item_id) + + if not item: + raise HTTPException(status_code=404, detail="Menu item not found.") + + delete_record(db, item) + + return {"detail": "Menu item deleted successfully."} \ No newline at end of file diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py index 2709baa4c8..1b404e8666 100644 --- a/backend/app/api/routes/venues.py +++ b/backend/app/api/routes/venues.py @@ -1,217 +1,119 @@ -import uuid -from app.schema.venue import FoodcourtCreate, FoodcourtRead, NightclubCreate, NightclubRead, QSRCreate, QSRRead, RestaurantCreate, RestaurantRead -from app.models.user import UserBusiness, UserPublic -from fastapi import APIRouter, Depends, HTTPException, Query -from typing import List, Union - -from app.models.venue import Nightclub, Restaurant, QSR, Foodcourt -from app.api.deps import SessionDep, get_business_user, get_current_user, get_super_user +from typing import List +from fastapi import FastAPI, Depends, HTTPException, APIRouter +from sqlmodel import Session +from app.models.venue import QSR, Foodcourt, Restaurant, Nightclub, Venue +from app.schema.venue import ( + FoodcourtCreate, + QSRCreate, + RestaurantCreate, + NightclubCreate, + FoodcourtRead, + QSRRead, + RestaurantRead, + NightclubRead, +) +from app.api.deps import get_db # Assuming you have a dependency to get the database session from app.crud import ( - get_all_records, - get_record_by_id, create_record, - update_record, - delete_record + get_all_records, ) +app = FastAPI() router = APIRouter() -# CRUD operations for Nightclubs - -@router.get("/nightclubs/", response_model=List[NightclubRead]) -async def read_nightclubs( - session: SessionDep, - skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100), - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) -): - """ - Retrieve a paginated list of nightclubs. - - **skip**: The page number (starting from 0) - - **limit**: The number of items per page - """ - nightclubs = get_all_records(session, Nightclub, skip=skip, limit=limit) - return nightclubs - -@router.get("/nightclubs/{venue_id}", response_model=NightclubRead) -async def read_nightclub( - venue_id: uuid.UUID, session: SessionDep, - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) -): - nightclub = get_record_by_id(session, Nightclub, venue_id) - if not nightclub: - raise HTTPException(status_code=404, detail="Nightclub not found") - return nightclub - -@router.post("/nightclubs/", response_model=NightclubRead) -async def create_nightclub( - nightclub: NightclubCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) -): - return create_record(session, Nightclub, nightclub) - -@router.put("/nightclubs/{venue_id}", response_model=Nightclub) -async def update_nightclub( - venue_id: uuid.UUID, - updated_nightclub: NightclubCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) - ): - return update_record(session, Nightclub, venue_id, updated_nightclub) - -@router.delete("/nightclubs/{venue_id}", response_model=None) -async def delete_nightclub( - venue_id: uuid.UUID, - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) -): - return delete_record(session, Nightclub, venue_id) - -@router.get("/restaurants/", response_model=List[RestaurantRead]) -async def read_restaurants( - session: SessionDep, - skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100), - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) -): - """ - Retrieve a paginated list of restaurants. - - **skip**: The page number (starting from 0) - - **limit**: The number of items per page - """ - restaurants = get_all_records(session, Restaurant, skip=skip, limit=limit) - return restaurants - -@router.get("/restaurants/{venue_id}", response_model=RestaurantRead) -async def read_restaurant( - venue_id: uuid.UUID, session: SessionDep, - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user)): - restaurant = get_record_by_id(session, Restaurant, venue_id) - if not restaurant: - raise HTTPException(status_code=404, detail="restaurant not found") - return restaurant - -@router.post("/restaurants/", response_model=RestaurantRead) -async def create_restaurant( - restaurant: RestaurantCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) -): - return create_record(session, Restaurant, restaurant) - -@router.put("/restaurants/{venue_id}", response_model=Restaurant) -async def update_restaurant( - venue_id: uuid.UUID, - updated_restaurant: RestaurantCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) - ): - return update_record(session, Restaurant, venue_id, updated_restaurant) - -@router.delete("/restaurants/{venue_id}", response_model=None) -async def delete_restaurant( - venue_id: uuid.UUID, - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) -): - return delete_record(session, Restaurant, venue_id) - -@router.get("/qsrs/", response_model=List[QSRRead]) -async def read_qsrs( - session: SessionDep, - skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100), - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) -): - """ - Retrieve a paginated list of qsrs. - - **skip**: The page number (starting from 0) - - **limit**: The number of items per page - """ - qsrs = get_all_records(session, QSR, skip=skip, limit=limit) - return qsrs +# POST endpoint for Foodcourt +@router.post("/foodcourts/", response_model=FoodcourtRead) +def create_foodcourt(foodcourt: FoodcourtCreate, db: Session = Depends(get_db)): + try: + # Check if the venue exists + venue_instance = Venue.from_create_schema(foodcourt.venue) + create_record(db, venue_instance) # Persist the new venue + # Use the newly created venue instance + foodcourt_instance = Foodcourt.from_create_schema(venue_instance.id, foodcourt) + # Create the new Foodcourt record in the database + create_record(db, foodcourt_instance) + # Return the read schema for the created foodcourt + return foodcourt_instance.to_read_schema() + + except Exception as e: + # Rollback the session in case of any error + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error -@router.get("/qsrs/{venue_id}", response_model=QSRRead) -async def read_qsr( - venue_id: uuid.UUID, session: SessionDep , - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user)): - qsr = get_record_by_id(session, QSR, venue_id) - if not qsr: - raise HTTPException(status_code=404, detail="QSR not found") - return qsr +# GET endpoint for Foodcourt +@router.get("/foodcourts/", response_model=List[FoodcourtRead]) +def read_foodcourts(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_all_records(db, Foodcourt, skip=skip, limit=limit) +# POST endpoint for QSR @router.post("/qsrs/", response_model=QSRRead) -async def create_qsr( - qsr: QSRCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) -): - return create_record(session, QSR, qsr) - -@router.put("/qsrs/{venue_id}", response_model=QSR) -async def update_qsr( - venue_id: uuid.UUID, - updated_qsr: QSRCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) - ): - return update_record(session, QSR, venue_id, updated_qsr) +def create_qsr(qsr: QSRCreate, db: Session = Depends(get_db)): + try: + # Check if the venue exists + venue_instance = Venue.from_create_schema(qsr.venue) + create_record(db, venue_instance) # Persist the new venue + # Use the newly created venue instance + qsr_instance = QSR.from_create_schema(venue_instance.id, qsr) + # Create the new Foodcourt record in the database + create_record(db, qsr_instance) + # Return the read schema for the created foodcourt + return qsr_instance.to_read_schema() + + except Exception as e: + # Rollback the session in case of any error + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error -@router.delete("/qsrs/{venue_id}", response_model=None) -async def delete_qsr( - venue_id: uuid.UUID, - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) -): - return delete_record(session, QSR, venue_id) +# GET endpoint for QSR +@router.get("/qsrs/", response_model=List[QSRRead]) +def read_qsrs(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_all_records(db, QSR, skip=skip, limit=limit) -@router.get("/foodcourts/", response_model=List[FoodcourtRead]) -async def read_foodcourts( - session: SessionDep, - skip: int = Query(0, alias="page", ge=0), - limit: int = Query(10, le=100), - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user) -): - """ - Retrieve a paginated list of foodcourts. - - **skip**: The page number (starting from 0) - - **limit**: The number of items per page - """ - foodcourts = get_all_records(session, Foodcourt, skip=skip, limit=limit) - return foodcourts +# POST endpoint for Restaurant +@router.post("/restaurants/", response_model=RestaurantRead) +def create_restaurant(restaurant: RestaurantCreate, db: Session = Depends(get_db)): + try: + # Check if the venue exists + venue_instance = Venue.from_create_schema(restaurant.venue) + create_record(db, venue_instance) # Persist the new venue + # Use the newly created venue instance + restaurant_instance = Restaurant.from_create_schema(venue_instance.id, restaurant) + # Create the new Foodcourt record in the database + create_record(db, restaurant_instance) + # Return the read schema for the created foodcourt + return restaurant_instance.to_read_schema() + + except Exception as e: + # Rollback the session in case of any error + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error -@router.get("/foodcourts/{venue_id}", response_model=FoodcourtRead) -async def read_foodcourt( - venue_id: uuid.UUID, session: SessionDep , - current_user: Union[UserPublic, UserBusiness] = Depends(get_current_user)): - foodcourt = get_record_by_id(session, Foodcourt, venue_id) - if not foodcourt: - raise HTTPException(status_code=404, detail="foodcourt not found") - return foodcourt +# GET endpoint for Restaurant +@router.get("/restaurants/", response_model=List[RestaurantRead]) +def read_restaurants(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_all_records(db, Restaurant, skip=skip, limit=limit) -@router.post("/foodcourts/", response_model=FoodcourtRead) -async def create_foodcourt( - foodcourt: FoodcourtCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) -): - return create_record(session, Foodcourt, foodcourt) +# POST endpoint for Nightclub +@router.post("/nightclubs/", response_model=NightclubRead) +def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db)): + try: + # Check if the venue exists + venue_instance = Venue.from_create_schema(nightclub.venue) + create_record(db, venue_instance) # Persist the new venue + # Use the newly created venue instance + nightclub_instance = Nightclub.from_create_schema(venue_instance.id, nightclub) + # Create the new Foodcourt record in the database + create_record(db, nightclub_instance) + # Return the read schema for the created foodcourt + return nightclub_instance.to_read_schema() + + except Exception as e: + # Rollback the session in case of any error + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error -@router.put("/foodcourts/{venue_id}", response_model=Foodcourt) -async def update_foodcourt( - venue_id: uuid.UUID, - updated_foodcourt: FoodcourtCreate, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user), - ): - return update_record(session, Foodcourt, venue_id, updated_foodcourt) +# GET endpoint for Nightclub +@router.get("/nightclubs/", response_model=List[NightclubRead]) +def read_nightclubs(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_all_records(db, Nightclub, skip=skip, limit=limit) -@router.delete("/foodcourts/{venue_id}", response_model=None) -async def delete_foodcourt( - venue_id: uuid.UUID, - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) - -): - return delete_record(session, Foodcourt, venue_id) \ No newline at end of file diff --git a/backend/app/crud.py b/backend/app/crud.py index b541fa78ea..3f8eb2f08f 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,8 +1,10 @@ -import datetime +from datetime import datetime, timezone +import logging import uuid from fastapi import HTTPException +from pydantic import BaseModel from sqlmodel import SQLModel, Session, select -from typing import List, Type +from typing import List, Optional, Type, TypeVar # Generic CRUD function to get all records with pagination def get_all_records( @@ -23,91 +25,97 @@ def get_all_records( raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} records: {str(e)}") # Function to get a single record by ID -def get_record_by_id( - session: Session, model: Type[SQLModel], record_id: uuid.UUID -) -> SQLModel: - """ - Retrieve a single record by ID. - - **session**: Database session - - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - - **record_id**: ID of the record to retrieve +T = TypeVar('T', bound=SQLModel) + +def get_record_by_id(db: Session, model: Type[T], record_id: uuid.UUID) -> Optional[T]: """ - try: - statement = select(model).where(model.id == record_id) - result = session.execute(statement).scalars().first() - if not result: - raise HTTPException(status_code=404, detail=f"{model.__name__} with ID {record_id} not found.") - return result - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} record: {str(e)}") + Generic function to retrieve a record by its ID. + + Args: + db (Session): The database session. + model (Type[T]): The SQLModel class representing the table. + record_id (uuid.UUID): The ID of the record to retrieve. + Returns: + Optional[T]: The retrieved record if found, otherwise None. + + Raises: + HTTPException: If the record is not found, raises a 404 error. + """ + record = db.get(model, record_id) + if not record: + raise HTTPException(status_code=404, detail=f"{model.__name__} with ID {record_id} not found.") + return record # Function to create a new record # Create a new record def create_record( - session: Session, model: Type[SQLModel], obj_in: SQLModel + db: Session, + instance: SQLModel ) -> SQLModel: """ - Create a new record with automatic `created_at` and `updated_at` timestamps. - - **session**: Database session - - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - - **obj_in**: Data to create the new record + Create a new record in the database with automatic timestamp management. + + :param db: The active database session. + :param model: The SQLModel class representing the database model. + :param instance: An instance of the model to be persisted. + :return: The created instance with updated attributes. """ - try: - # Prepare the data for creation - obj_data = obj_in.model_dump() - - # Set `created_at` and `updated_at` timestamps - obj_data['created_at'] = datetime.utcnow() - obj_data['updated_at'] = datetime.utcnow() - - # Create the new object - obj = model(**obj_data) - session.add(obj) - session.commit() - session.refresh(obj) - return obj - except Exception as e: - session.rollback() - raise HTTPException(status_code=500, detail=f"Error creating {model.__name__}: {str(e)}") + # Current UTC time for timestamping + # current_time = datetime.now() + + # # Setting timestamps for the record + # instance.created_at = current_time + # instance.updated_at = current_time + # Persisting the new record in the database + db.add(instance) + db.commit() # Commit the changes + db.refresh(instance) # Refresh to load any generated attributes + + return instance # Return the created instance -# Update an existing record def update_record( - session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel + db: Session, + instance: SQLModel, + update_data: BaseModel ) -> SQLModel: """ - Update an existing record with automatic `updated_at` timestamp. - - **session**: Database session - - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - - **record_id**: ID of the record to update - - **obj_in**: Data to update the record + Update an existing record in the database, applying only the changes provided by a Pydantic model. + This approach ensures validation of input data and prevents partial updates with invalid fields. + + :param db: Active database session. + :param instance: Existing model instance to be updated. + :param update_data: Pydantic model containing the fields to update. + :return: The updated model instance with changes committed. """ try: - # Retrieve the existing record - obj = get_record_by_id(session, model, record_id) - - # Convert incoming data - obj_data = obj_in.model_dump() - - # Update the fields - for field, value in obj_data.items(): - setattr(obj, field, value) - - # Set `updated_at` to the current time - obj.updated_at = datetime.utcnow() - - # Commit the changes - session.add(obj) - session.commit() - session.refresh(obj) - return obj - except HTTPException as e: - raise e - except Exception as e: - session.rollback() - raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") + # Convert the Pydantic model to a dictionary, excluding unset fields + update_dict = update_data.dict(exclude_unset=True) + + # Apply updates to only the fields that are set in the update_data Pydantic model + for key, value in update_dict.items(): + if hasattr(instance, key): + setattr(instance, key, value) + else: + raise ValueError(f"Field '{key}' does not exist on the model.") + + # Persist the changes in a single transaction + db.add(instance) + db.commit() + db.refresh(instance) + + return instance + except ValueError as ve: + logging.error(f"Validation error: {ve}") + db.rollback() # Undo any changes in case of failure + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logging.error(f"Unexpected error during record update: {e}") + db.rollback() # Rollback any transaction in case of failure + raise HTTPException(status_code=500, detail="An internal error occurred while updating the record.") + # Partially update an existing record def patch_record( session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel @@ -146,21 +154,13 @@ def patch_record( raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") # Function to delete a record -def delete_record( - session: Session, model: Type[SQLModel], record_id: uuid.UUID -) -> None: +def delete_record(db: Session, instance: SQLModel) -> None: """ - Delete a record by ID. - - **session**: Database session - - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - - **record_id**: ID of the record to delete + Delete a record from the database. + + :param db: The active database session. + :param instance: The instance of the model to be deleted. + :return: None """ - try: - obj = get_record_by_id(session, model, record_id) - session.delete(obj) - session.commit() - except HTTPException as e: - raise e - except Exception as e: - session.rollback() - raise HTTPException(status_code=500, detail=f"Error deleting {model.__name__} with ID {record_id}: {str(e)}") \ No newline at end of file + db.delete(instance) + db.commit() \ No newline at end of file diff --git a/backend/app/email-templates/build/new_account.html b/backend/app/email-templates/build/new_account.html deleted file mode 100644 index 344505033b..0000000000 --- a/backend/app/email-templates/build/new_account.html +++ /dev/null @@ -1,25 +0,0 @@ -
{{ project_name }} - New Account
Welcome to your new account!
Here are your account details:
Username: {{ username }}
Password: {{ password }}
Go to Dashboard

\ No newline at end of file diff --git a/backend/app/email-templates/build/reset_password.html b/backend/app/email-templates/build/reset_password.html deleted file mode 100644 index 4148a5b773..0000000000 --- a/backend/app/email-templates/build/reset_password.html +++ /dev/null @@ -1,25 +0,0 @@ -
{{ project_name }} - Password Recovery
Hello {{ username }}
We've received a request to reset your password. You can do it by clicking the button below:
Reset password
Or copy and paste the following link into your browser:
This password will expire in {{ valid_hours }} hours.

If you didn't request a password recovery you can disregard this email.
\ No newline at end of file diff --git a/backend/app/email-templates/build/test_email.html b/backend/app/email-templates/build/test_email.html deleted file mode 100644 index 04d0d85092..0000000000 --- a/backend/app/email-templates/build/test_email.html +++ /dev/null @@ -1,25 +0,0 @@ -
{{ project_name }}
Test email for: {{ email }}

\ No newline at end of file diff --git a/backend/app/email-templates/src/new_account.mjml b/backend/app/email-templates/src/new_account.mjml deleted file mode 100644 index f41a3e3cf1..0000000000 --- a/backend/app/email-templates/src/new_account.mjml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - {{ project_name }} - New Account - Welcome to your new account! - Here are your account details: - Username: {{ username }} - Password: {{ password }} - Go to Dashboard - - - - - diff --git a/backend/app/email-templates/src/reset_password.mjml b/backend/app/email-templates/src/reset_password.mjml deleted file mode 100644 index 743f5d77f4..0000000000 --- a/backend/app/email-templates/src/reset_password.mjml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - {{ project_name }} - Password Recovery - Hello {{ username }} - We've received a request to reset your password. You can do it by clicking the button below: - Reset password - Or copy and paste the following link into your browser: -
{{ link }} - This password will expire in {{ valid_hours }} hours. - - If you didn't request a password recovery you can disregard this email. - - - - diff --git a/backend/app/email-templates/src/test_email.mjml b/backend/app/email-templates/src/test_email.mjml deleted file mode 100644 index 45d58d6bac..0000000000 --- a/backend/app/email-templates/src/test_email.mjml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - {{ project_name }} - Test email for: {{ email }} - - - - - diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4e4eab4cac..5cbfeb9407 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,9 +8,7 @@ from .group import Group from .group_wallet import GroupWallet from .group_wallet_topup import GroupWalletTopup -from .menu import RestaurantMenu, NightclubMenu, QSRMenu -from .menu_category import MenuCategory -from .menu_item import MenuItem +from .menu import Menu from .order import NightclubOrder, RestaurantOrder, QSROrder from .order_item import OrderItem from .pickup_location import PickupLocation @@ -21,8 +19,7 @@ # Make all models accessible when importing app.models __all__ = [ "SQLModel", "ClubVisit", "Event", "EventBooking", "EventOffering", "Group", "GroupWallet", - "GroupWalletTopup", "RestaurantMenu", "NightclubMenu", "QSRMenu", "MenuCategory", - "MenuItem", "NightclubOrder", "RestaurantOrder", "QSROrder", "OrderItem", + "GroupWalletTopup", "Menu", "NightclubOrder", "RestaurantOrder", "QSROrder", "OrderItem", "PickupLocation", "UserBusiness", "UserPublic", "Nightclub", "QSR", "Restaurant", "Foodcourt", "PaymentOrderNightclub", "PaymentOrderRestaurant", "PaymentOrderQSR", "PaymentEvent" diff --git a/backend/app/models/base_model.py b/backend/app/models/base_model.py index 6ef666c69a..a848065f9c 100644 --- a/backend/app/models/base_model.py +++ b/backend/app/models/base_model.py @@ -1,7 +1,7 @@ from sqlmodel import SQLModel, Field -from datetime import datetime +from datetime import timezone, datetime from typing import Optional class BaseTimeModel(SQLModel): - created_at: Optional[datetime] = Field(default_factory=datetime.now(datetime.timezone.utc), nullable=False) - updated_at: Optional[datetime] = Field(default_factory=datetime.now(datetime.timezone.utc), nullable=False) \ No newline at end of file + created_at: Optional[datetime] = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False) + updated_at: Optional[datetime] = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False) \ No newline at end of file diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py index 0aa46454c3..791b70fa60 100644 --- a/backend/app/models/menu.py +++ b/backend/app/models/menu.py @@ -1,43 +1,155 @@ import uuid +from app.schema.menu import MenuCategoryCreate, MenuCategoryRead, MenuCreate, MenuItemCreate, MenuItemRead, MenuRead, MenuSubCategoryCreate, MenuSubCategoryRead +from pydantic import ValidationError from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List +from typing import List, Optional -class MenuBase(SQLModel): +class Menu(SQLModel, table=True): + __tablename__ = "menu" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) name: str = Field(nullable=False) description: Optional[str] = Field(default=None) menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False) -class QSRMenuBase(MenuBase): - qsr_id: uuid.UUID = Field(foreign_key="qsr.id", nullable=False) + # Relationships + categories: List["MenuCategory"] = Relationship(back_populates="menu") + venue: "Venue" = Relationship(back_populates="menu") + + @classmethod + def from_create_schema(cls, schema: MenuCreate) -> "Menu": + return cls( + name=schema.name, + description=schema.description, + menu_type=schema.menu_type, + venue_id=schema.venue_id + ) -class QSRMenu(QSRMenuBase, table=True): - __tablename__= "qsr_menu" + @classmethod + def to_read_schema(cls, menu: "Menu") -> MenuRead: + for category in menu.categories: + print(f"lololo Category: {category}") + categories=[MenuCategory.to_read_schema(category) for category in menu.categories] + try: + ret = MenuRead( + menu_id=menu.id, + name=menu.name, + description=menu.description, + menu_type=menu.menu_type, + venue_id=menu.venue_id, + categories=categories + ) + print(ret) + except ValidationError as e: + print("Validation Error:", e.json()) +class MenuCategory(SQLModel, table=True): + __tablename__ = "menu_category" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + menu_id: uuid.UUID = Field(foreign_key="menu.id", nullable=False) + name: str = Field(nullable=False) # Relationships - qsr: "QSR" = Relationship(back_populates="menu") - categories: List["MenuCategory"] = Relationship(back_populates="qsr_menu") + menu: "Menu" = Relationship(back_populates="categories") + sub_categories: List["MenuSubCategory"] = Relationship(back_populates="category") -class RestaurantMenuBase(MenuBase): - restaurant_id: uuid.UUID = Field(foreign_key="restaurant.id", nullable=False) + @classmethod + def from_create_schema(cls, schema: MenuCategoryCreate) -> "MenuCategory": + return cls( + name=schema.name, + menu_id=schema.menu_id + ) -class RestaurantMenu(RestaurantMenuBase, table=True): - __tablename__= "restaurant_menu" + @classmethod + def to_read_schema(cls, category: "MenuCategory") -> MenuCategoryRead: + print("Input Category: %s", category) + sub_categories=[ + MenuSubCategory.to_read_schema(sub) for sub in category.sub_categories or [] + ] + sub_categories = ( + [MenuSubCategory.to_read_schema(sub) for sub in category.sub_categories] + if category.sub_categories else None + ) + print(f"Category ID: {category.id}, Menu ID: {category.menu_id}, Name: {category.name}, Subcategories: {category.sub_categories}") + print("Subcategories: %s", sub_categories) # Log subcategories + return MenuCategoryRead( + category_id=category.id, + menu_id=category.menu_id, + name=category.name, + sub_categories=sub_categories + ) + + +class MenuSubCategory(SQLModel, table=True): + __tablename__ = "menu_subcategory" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + category_id: uuid.UUID = Field(foreign_key="menu_category.id", nullable=False) + name: str = Field(nullable=False) + is_alcoholic: bool = Field(default=False) # Relationships - restaurant: "Restaurant" = Relationship(back_populates="menu") - categories: List["MenuCategory"] = Relationship(back_populates="restaurant_menu") + category: "MenuCategory" = Relationship(back_populates="sub_categories") + menu_items: List["MenuItem"] = Relationship(back_populates="subcategory") -class NightclubMenuBase(MenuBase): - nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) + @classmethod + def from_create_schema(cls, schema: "MenuSubCategoryCreate") -> "MenuSubCategory": + return cls( + name=schema.name, + category_id=schema.category_id, + is_alcoholic=schema.is_alcoholic # Include is_alcoholic in the model + ) -class NightclubMenu(NightclubMenuBase, table=True): - __tablename__= "nightclub_menu" + @classmethod + def to_read_schema(cls, subcategory: "MenuSubCategory") -> "MenuSubCategoryRead": + return MenuSubCategoryRead( + subcategory_id=subcategory.id, + name=subcategory.name, + is_alcoholic=subcategory.is_alcoholic, # Include is_alcoholic in the read schema + menu_items=[MenuItem.to_read_schema(item) for item in subcategory.menu_items] + ) + +class MenuItem(SQLModel, table=True): + __tablename__ = "menu_item" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + subcategory_id: uuid.UUID = Field(foreign_key="menu_subcategory.id",nullable=False) + name: str = Field(nullable=False) + price: float = Field(nullable=False) + description: Optional[str] = Field(default=None) + image_url: Optional[str] = Field(default=None) + is_veg: Optional[bool] = Field(default=None) + ingredients: Optional[str] = Field(default=None) + abv: Optional[float] = Field(default=None) + ibu: Optional[int] = Field(default=None) # Relationships - nightclub: "Nightclub" = Relationship(back_populates="menu") - categories: List["MenuCategory"] = Relationship(back_populates="nightclub_menu") + subcategory: Optional["MenuSubCategory"] = Relationship(back_populates="menu_items") + + @classmethod + def from_create_schema(cls, schema: MenuItemCreate) -> "MenuItem": + return cls( + subcategory_id=schema.subcategory_id, + name=schema.name, + price=schema.price, + description=schema.description, + image_url=schema.image_url, + is_veg=schema.is_veg, + ingredients=schema.ingredients, + abv=schema.abv, + ibu=schema.ibu + ) + + @classmethod + def to_read_schema(cls, item: "MenuItem") -> MenuItemRead: + return MenuItemRead( + item_id=item.id, + subcategory_id=item.subcategory_id, + name=item.name, + price=item.price, + description=item.description, + image_url=item.image_url, + is_veg=item.is_veg, + ingredients=item.ingredients, + abv=item.abv, + ibu=item.ibu + ) \ No newline at end of file diff --git a/backend/app/models/menu_category.py b/backend/app/models/menu_category.py deleted file mode 100644 index e6df4dbe0f..0000000000 --- a/backend/app/models/menu_category.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List -from pydantic import model_validator - -class MenuCategoryBase(SQLModel): - qsr_menu_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr_menu.id") - restaurant_menu_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant_menu.id") - nightclub_menu_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub_menu.id") - name: str = Field(nullable=False) - - @model_validator(mode="before") - def check_only_one_menu_id(cls, values): - # Convert to a regular dictionary - values_dict = dict(values) - print("values ", values_dict) - - # Use .get() to safely access values - qsr_menu_id = values_dict.get('qsr_menu_id') - restaurant_menu_id = values_dict.get('restaurant_menu_id') - nightclub_menu_id = values_dict.get('nightclub_menu_id') - - # Count how many of these fields are set (not None) - menu_ids = [qsr_menu_id, restaurant_menu_id, nightclub_menu_id] - if sum(id is not None for id in menu_ids) != 1: - raise ValueError("You must set exactly one of qsr_menu_id, restaurant_menu_id, or nightclub_menu_id.") - - return values - -class MenuCategory(MenuCategoryBase, table=True): - __tablename__ = "menu_category" - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - - # Relationships - menu_items: List["MenuItem"] = Relationship(back_populates="category") - - # Relationships with specific menu types - qsr_menu: Optional["QSRMenu"] = Relationship(back_populates="categories") - restaurant_menu: Optional["RestaurantMenu"] = Relationship(back_populates="categories") - nightclub_menu: Optional["NightclubMenu"] = Relationship(back_populates="categories") \ No newline at end of file diff --git a/backend/app/models/menu_item.py b/backend/app/models/menu_item.py deleted file mode 100644 index 063b2be15a..0000000000 --- a/backend/app/models/menu_item.py +++ /dev/null @@ -1,21 +0,0 @@ -import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional - -class MenuItemBase(SQLModel): - category_id: uuid.UUID = Field(foreign_key="menu_category.id", nullable=False) - name: str = Field(nullable=False) - price: float = Field(nullable=False) - description: Optional[str] = Field(default=None) - image_url: Optional[str] = Field(default=None) - is_veg: Optional[bool] = Field(default=None) - ingredients: Optional[str] = Field(default=None) - abv: Optional[float] = Field(default=None) - ibu: Optional[int] = Field(default=None) - -class MenuItem(MenuItemBase, table=True): - __tablename__="menu_item" - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - - # Relationships - category: Optional["MenuCategory"] = Relationship(back_populates="menu_items") \ No newline at end of file diff --git a/backend/app/models/pickup_location.py b/backend/app/models/pickup_location.py index ae20d52174..d2173c632c 100644 --- a/backend/app/models/pickup_location.py +++ b/backend/app/models/pickup_location.py @@ -6,7 +6,7 @@ class PickupLocation(SQLModel, table=True): __tablename__ = "pickup_location" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False) name: str = Field(nullable=False) description: Optional[str] = Field(default=None) @@ -14,4 +14,4 @@ class PickupLocation(SQLModel, table=True): orders: List["NightclubOrder"] = Relationship(back_populates="pickup_location") # Optionally, if you have a specific type of venue for PickupLocation - nightclub: Optional["Nightclub"] = Relationship(back_populates="pickup_locations") \ No newline at end of file + venue: Optional["Venue"] = Relationship(back_populates="pickup_locations") \ No newline at end of file diff --git a/backend/app/models/qrcode.py b/backend/app/models/qrcode.py index 9e549c41ef..d006749a0c 100644 --- a/backend/app/models/qrcode.py +++ b/backend/app/models/qrcode.py @@ -9,31 +9,7 @@ class QRBase(SQLModel): class QRCode(QRBase, table=True): __tablename__ = "qr_codes" id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) - - # References - foodcourt_id: Optional[uuid.UUID] = Field(default=None, foreign_key="foodcourt.id") - qsr_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr.id") - nightclub_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub.id") - restaurant_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant.id") - + venue_id: Optional[uuid.UUID] = Field(default=None, foreign_key="venue.id") + # Relationships - foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qr_codes") - qsr: Optional["QSR"] = Relationship(back_populates="qr_codes") - nightclub: Optional["Nightclub"] = Relationship(back_populates="qr_codes") - restaurant: Optional["Restaurant"] = Relationship(back_populates="qr_codes") - - # Custom model validator to ensure exactly one foreign key is present - @model_validator(mode="before") - def check_one_foreign_key(cls, values): - foodcourt_id = values.get("foodcourt_id") - qsr_id = values.get("qsr_id") - nightclub_id = values.get("nightclub_id") - restaurant_id = values.get("restaurant_id") - - # Count how many of the foreign key fields are set - count = sum(v is not None for v in [foodcourt_id, qsr_id, nightclub_id, restaurant_id]) - - if count != 1: - raise ValueError("Exactly one of the following must be set: foodcourt_id, qsr_id, nightclub_id, restaurant_id.") - - return values + venue: Optional["Venue"] = Relationship(back_populates="qr_codes") \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 38ff0d4674..90ac576a77 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,5 +1,5 @@ from app.models.group import GroupMembers -from app.models.venue import FoodcourtUserBusinessLink, NightclubUserBusinessLink, QSRUserBusinessLink, RestaurantUserBusinessLink +from app.models.base_model import BaseTimeModel from sqlmodel import SQLModel, Field, Relationship from typing import TYPE_CHECKING, Optional, List from datetime import datetime @@ -7,7 +7,7 @@ from pydantic import EmailStr # Shared properties -class UserBase(SQLModel): +class UserBase(BaseTimeModel): email: EmailStr = Field(unique=True, nullable=True, index=True, max_length=255) phone_number: Optional[str] = Field(unique=True, nullable=False,index=True,default=None) is_active: bool = True @@ -38,26 +38,22 @@ class UserPublic(UserBase, table=True): restaurant_payments: List["PaymentOrderRestaurant"] = Relationship(back_populates="user") event_payments: List["PaymentEvent"] = Relationship(back_populates="user") +class UserVenueAssociation(SQLModel, table=True): + __tablename__ = "user_venue_association" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) # Add a primary key + user_business_id: uuid.UUID = Field(foreign_key="user_business.id") + venue_id: uuid.UUID = Field(foreign_key="venue.id") + # Additional fields can be added for tracking roles, timestamps, etc. + role: Optional[str] = Field(default=None) # e.g., 'manager', 'owner' + + user_business: "UserBusiness" = Relationship(back_populates="venue_associations") + venue: "Venue" = Relationship(back_populates="user_associations") class UserBusiness(UserBase, table=True): __tablename__ = "user_business" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) registration_date: datetime = Field(nullable=False) # Relationships - managed_foodcourts: List["Foodcourt"] = Relationship( - back_populates="managing_users", - link_model=FoodcourtUserBusinessLink - ) - managed_qsrs: List["QSR"] = Relationship( - back_populates="managing_users", - link_model=QSRUserBusinessLink - ) - managed_restaurants: List["Restaurant"] = Relationship( - back_populates="managing_users", - link_model=RestaurantUserBusinessLink - ) - managed_nightclubs: List["Nightclub"] = Relationship( - back_populates="managing_users", - link_model=NightclubUserBusinessLink - ) + venue_associations: List["UserVenueAssociation"] = Relationship(back_populates="user_business") + managed_venues: List["Venue"] = Relationship(back_populates="managing_users", link_model=UserVenueAssociation) \ No newline at end of file diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index 0ebb4630df..a130ffa8c3 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -1,10 +1,15 @@ import uuid -from app.models.qrcode import QRCode +from app.models.base_model import BaseTimeModel +from app.models.user import UserVenueAssociation +from app.schema.venue import FoodcourtCreate, FoodcourtRead, NightclubCreate, NightclubRead, QSRCreate, QSRRead, RestaurantCreate, RestaurantRead, VenueCreate, VenueRead from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List, TYPE_CHECKING +from typing import Optional, List +from datetime import time -class VenueBase(SQLModel): - name: str = Field(nullable=False) +class Venue(BaseTimeModel, table=True): + __tablename__ = "venue" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Missing id field + name: str = Field(nullable=False, index=True) address: Optional[str] = Field(default=None) latitude: Optional[float] = Field(default=None) longitude: Optional[float] = Field(default=None) @@ -16,92 +21,173 @@ class VenueBase(SQLModel): google_map_link: Optional[str] = Field(default=None) mobile_number: Optional[str] = Field(default=None) email: Optional[str] = Field(default=None) - opening_time: Optional[str] = Field(default=None) - closing_time: Optional[str] = Field(default=None) + opening_time: Optional[time] = Field(default=None) + closing_time: Optional[time] = Field(default=None) avg_expense_for_two: Optional[float] = Field(default=None) - qr_url: Optional[str] = Field(default=None) - -class NightclubUserBusinessLink(SQLModel, table=True): - nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", primary_key=True) - user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) - -class NightclubBase(VenueBase): - pass - -class Nightclub(NightclubBase, table=True): - __tablename__ = "nightclub" - - id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - # Relationships - events: List["Event"] = Relationship(back_populates="nightclub") - club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") - menu: List["NightclubMenu"] = Relationship(back_populates="nightclub") - orders: List["NightclubOrder"] = Relationship(back_populates="nightclub") - pickup_locations: List["PickupLocation"] = Relationship(back_populates="nightclub") - group : List["Group"] = Relationship(back_populates="nightclubs") - managing_users: List["UserBusiness"] = Relationship( - back_populates="managed_nightclubs", - link_model=NightclubUserBusinessLink - ) - qr_codes: List[QRCode] = Relationship(back_populates="nightclub") - -class RestaurantUserBusinessLink(SQLModel, table=True): - restaurant_id: uuid.UUID = Field(foreign_key="restaurant.id", primary_key=True) - user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) - -class RestaurantBase(VenueBase): - pass - -class Restaurant(RestaurantBase, table=True): - __tablename__ = "restaurant" - + zomato_link: Optional[str] = Field(default=None) + swiggy_link: Optional[str] = Field(default=None) + + user_associations: List["UserVenueAssociation"] = Relationship(back_populates="venue") + managing_users: List["UserBusiness"] = Relationship(back_populates="managed_venues", link_model=UserVenueAssociation) + qr_codes: List["QRCode"] = Relationship(back_populates="venue") + menu: List["Menu"] = Relationship(back_populates="venue") + pickup_locations: List["PickupLocation"] = Relationship(back_populates="venue") + + # Back-references for specific venue types + foodcourt: Optional["Foodcourt"] = Relationship(back_populates="venue") + qsr: Optional["QSR"] = Relationship(back_populates="venue") + restaurant: Optional["Restaurant"] = Relationship(back_populates="venue") + nightclub: Optional["Nightclub"] = Relationship(back_populates="venue") + + @classmethod + def from_create_schema(cls, venue_create: VenueCreate) -> "Venue": + return cls( + name=venue_create.name, + capacity=venue_create.capacity, + description=venue_create.description, + instagram_handle=venue_create.instagram_handle, + instagram_token=venue_create.instagram_token, + google_map_link=venue_create.google_map_link, + mobile_number=venue_create.mobile_number, + email=venue_create.email, + opening_time=venue_create.opening_time, + closing_time=venue_create.closing_time, + avg_expense_for_two=venue_create.avg_expense_for_two, + zomato_link=venue_create.zomato_link, + swiggy_link=venue_create.swiggy_link + ) + + def to_read_schema(self) -> VenueRead: + return VenueRead( + id=self.id, + name=self.name, + address=self.address, + latitude=self.latitude, + longitude=self.longitude, + capacity=self.capacity, + description=self.description, + google_rating=self.google_rating, + instagram_handle=self.instagram_handle, + google_map_link=self.google_map_link, + mobile_number=self.mobile_number, + email=self.email, + opening_time=self.opening_time, + closing_time=self.closing_time, + avg_expense_for_two=self.avg_expense_for_two, + zomato_link=self.zomato_link, + swiggy_link=self.swiggy_link, + ) + +# Specific Venue Types +class Foodcourt(BaseTimeModel, table=True): + __tablename__ = "foodcourt" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + total_qsrs: Optional[int] = Field(default=None) # Example specific field for foodcourt + seating_capacity: Optional[int] = Field(default=None) # Specific to foodcourts + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) + # Relationships - menu: List["RestaurantMenu"] = Relationship(back_populates="restaurant") - orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") - managing_users: List["UserBusiness"] = Relationship( - back_populates="managed_restaurants", - link_model=RestaurantUserBusinessLink - ) - qr_codes: List[QRCode] = Relationship(back_populates="restaurant") - -class QSRUserBusinessLink(SQLModel, table=True): - qsr_id: uuid.UUID = Field(foreign_key="qsr.id", primary_key=True) - user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) - -class QSRBase(VenueBase): - pass + venue: Venue = Relationship(back_populates="foodcourt") + qsrs: List["QSR"] = Relationship(back_populates="foodcourt") -class QSR(QSRBase, table=True): + @classmethod + def from_create_schema(cls, venue_id: uuid ,foodcourt_create: FoodcourtCreate) -> "Foodcourt": + return cls ( + total_qsrs=foodcourt_create.total_qsrs, + seating_capacity=foodcourt_create.seating_capacity, + venue_id = venue_id + ) + + def to_read_schema(self) -> FoodcourtRead: + venue_read = self.venue.to_read_schema() + return FoodcourtRead( + id=self.id, + total_qsrs=self.total_qsrs, + seating_capacity=self.seating_capacity, + venue=venue_read, + qsrs=[qsr.to_read_schema() for qsr in self.qsrs] + ) + +class QSR(BaseTimeModel, table=True): __tablename__ = "qsr" - + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - foodcourt_id: Optional[uuid.UUID] = Field(default=None, foreign_key="foodcourt.id") - # Relationships - foodcourt: Optional["Foodcourt"] = Relationship(back_populates="qsrs") - menu: List["QSRMenu"] = Relationship(back_populates="qsr") - orders: List["QSROrder"] = Relationship(back_populates="qsr") - managing_users: List["UserBusiness"] = Relationship( - back_populates="managed_qsrs", - link_model=QSRUserBusinessLink - ) - qr_codes: List[QRCode] = Relationship(back_populates="qsr") + foodcourt_id: Optional[uuid.UUID] = Field(foreign_key="foodcourt.id", nullable=True, index=True) -class FoodcourtUserBusinessLink(SQLModel, table=True): - foodcourt_id: uuid.UUID = Field(foreign_key="foodcourt.id", primary_key=True) - user_business_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) + drive_thru: Optional[bool] = Field(default=False) # Specific field for QSR + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) -class FoodcourtBase(VenueBase): - pass + venue: Venue = Relationship(back_populates="qsr") + foodcourt: Optional[Foodcourt] = Relationship(back_populates="qsrs") + orders: List["QSROrder"] = Relationship(back_populates="qsr") + + @classmethod + def from_create_schema(cls, venue_id: uuid, qsr_create: QSRCreate) -> "QSR": + return cls( + foodcourt_id=qsr_create.foodcourt_id, + drive_thru=qsr_create.drive_thru, + venue_id=venue_id, + ) + + def to_read_schema(self) -> QSRRead: + venue_read = self.venue.to_read_schema() + return QSRRead( + id=self.id, + foodcourt_id=self.foodcourt_id, + drive_thru=self.drive_thru, + venue = venue_read + ) + +class Restaurant(BaseTimeModel, table=True): + __tablename__ = "restaurant" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) + venue: Venue = Relationship(back_populates="restaurant") + cuisine_type: Optional[str] = Field(default=None) # Example specific field for restaurant + orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") -class Foodcourt(FoodcourtBase, table=True): - __tablename__ = "foodcourt" + @classmethod + def from_create_schema(cls,venue_id, restaurant_create: RestaurantCreate) -> "Restaurant": + return cls( + venue_id=venue_id, + cuisine_type=restaurant_create.cuisine_type, + ) + + def to_read_schema(self) -> RestaurantRead: + venue_read = self.venue.to_read_schema() + return RestaurantRead( + id=self.id, + venue=venue_read, + cuisine_type=self.cuisine_type, + ) + +class Nightclub(BaseTimeModel, table=True): + __tablename__ = "nightclub" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) + age_limit: Optional[int] = Field(default=None) # Relationships - qsrs: List["QSR"] = Relationship(back_populates="foodcourt") - managing_users: List["UserBusiness"] = Relationship( - back_populates="managed_foodcourts", - link_model=FoodcourtUserBusinessLink - ) - qr_codes: List[QRCode] = Relationship(back_populates="foodcourt") \ No newline at end of file + events: List["Event"] = Relationship(back_populates="nightclub") + club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") + orders: List["NightclubOrder"] = Relationship(back_populates="nightclub") + group: List["Group"] = Relationship(back_populates="nightclubs") + venue: Venue = Relationship(back_populates="nightclub") + + + @classmethod + def from_create_schema(cls, venue_id, nightclub_create: NightclubCreate) -> "Nightclub": + return cls( + venue_id=venue_id, + age_limit=nightclub_create.age_limit, + ) + + def to_read_schema(self) -> NightclubRead: + venue_read = self.venue.to_read_schema() + return NightclubRead( + age_limit=self.age_limit, + id=self.id, + venue=venue_read, + ) \ No newline at end of file diff --git a/backend/app/schema/menu.py b/backend/app/schema/menu.py index 56614da53f..3ba083dfd6 100644 --- a/backend/app/schema/menu.py +++ b/backend/app/schema/menu.py @@ -1,52 +1,115 @@ from typing import List, Optional import uuid -from app.models.menu import NightclubMenuBase, QSRMenuBase, RestaurantMenuBase -from app.models.menu_item import MenuItemBase -from app.models.menu_category import MenuCategoryBase +from app.schema.menu_category import MenuCategoryRead +from pydantic import BaseModel, Field -class QSRMenuRead(QSRMenuBase): - id: Optional[uuid.UUID] +# Base Schemas +class MenuRead(BaseModel): + menu_id: uuid.UUID # Unique identifier for the menu + name: str # Name of the menu (could be a restaurant menu or type of menu) + description: Optional[str] = None # Description of the menu + categories: Optional[List[MenuCategoryRead]] = None # Nested list of categories + venue_id: uuid.UUID # Foreign key to the venue + menu_type: Optional[str] = None + +class MenuCreate(BaseModel): + name: str # Name of the menu (could be a restaurant menu or type of menu) + description: Optional[str] = None # Description of the menu + venue_id: uuid.UUID # Foreign key to the venue + menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") class Config: - from_attributes = True - -class QSRMenuCreate(QSRMenuBase): + from_attributes = True + +class MenuUpdate(BaseModel): + name: str # Name of the menu (could be a restaurant menu or type of menu) + description: Optional[str] = None # Description of the menu + menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") class Config: - from_attributes = True + from_attributes = True + +#################################################################################################### -class RestaurantMenuRead(RestaurantMenuBase): - id: Optional[uuid.UUID] +class MenuItemCreate(BaseModel): + subcategory_id: uuid.UUID + name: str + price: float + description: Optional[str] = None + image_url: Optional[str] = None + is_veg: Optional[bool] = None + ingredients: Optional[str] = None + abv: Optional[float] = None + ibu: Optional[int] = None class Config: from_attributes = True - -class RestaurantMenuCreate(RestaurantMenuBase): +# Response schema for a menu item +class MenuItemRead(BaseModel): + item_id: uuid.UUID + subcategory_id: uuid.UUID + name: str + price: float + description: Optional[str] = None + image_url: Optional[str] = None + is_veg: Optional[bool] = None + ingredients: Optional[str] = None + abv: Optional[float] = None + ibu: Optional[int] = None class Config: from_attributes = True + +class MenuItemUpdate(BaseModel): + name: Optional[str] = None # Name can be updated + price: Optional[float] = None # Price can be updated + description: Optional[str] = None # Description is optional and updatable + image_url: Optional[str] = None # Image URL is optional and updatable + is_veg: Optional[bool] = None # Optionally update veg/non-veg status + ingredients: Optional[str] = None # Ingredients list is optional and updatable + abv: Optional[float] = None # Alcohol by volume can be updated + ibu: Optional[int] = None # International Bitterness Units can be updated -class MenuItemRead(MenuItemBase): - id: Optional[uuid.UUID] class Config: from_attributes = True -class MenuCategoryRead(MenuCategoryBase): - id: Optional[uuid.UUID] - menu_items: List[MenuItemRead] = [] - class Config: - from_attributes = True +######################################################################################################### +class MenuCategoryRead(BaseModel): + category_id: uuid.UUID # Unique identifier for the category + name: str # Name of the category + menu_id : uuid.UUID + sub_categories: Optional[List["MenuSubCategoryRead"]] = None # List of subcategories -class NightclubMenuRead(NightclubMenuBase): - id: Optional[uuid.UUID] - categories: List[MenuCategoryRead] = [] +class MenuCategoryCreate(BaseModel): + name: str # Name of the category + menu_id: uuid.UUID class Config: from_attributes = True -class MenuItemCreate(MenuItemBase): +class MenuCategoryUpdate(BaseModel): + name: Optional[str] = None # Category name can be updated + menu_id: Optional[uuid.UUID] = None # Menu ID can be updated + class Config: from_attributes = True -class MenuCategoryCreate(MenuCategoryBase): +######################################################################################################### + +class MenuSubCategoryCreate(BaseModel): + name: str # Name of the subcategory + is_alcoholic: bool = Field(default=False) + category_id: uuid.UUID # Foreign key to the parent category class Config: from_attributes = True -class NightclubMenuCreate(NightclubMenuBase): +class MenuSubCategoryRead(BaseModel): + subcategory_id: uuid.UUID # Unique identifier for the subcategory + category_id: uuid.UUID + is_alcoholic: bool + name: str # Name of the subcategory + menu_items: Optional[List[MenuItemRead]] = [] # List of items under this subcategory + +class MenuSubCategoryUpdate(BaseModel): + name: Optional[str] = None # Optional name for update + is_alcoholic: Optional[bool] = None # Optional field for update + category_id: Optional[uuid.UUID] = None # Optional foreign key to update category + menu_items: Optional[List["MenuItemUpdate"]] = [] # Optional list for updating menu items + class Config: - from_attributes = True + from_attributes = True \ No newline at end of file diff --git a/backend/app/schema/menu_category.py b/backend/app/schema/menu_category.py new file mode 100644 index 0000000000..449accfa90 --- /dev/null +++ b/backend/app/schema/menu_category.py @@ -0,0 +1,27 @@ +from typing import List, Optional +import uuid +from app.schema.menu_item import MenuItemCreate, MenuItemRead +from pydantic import BaseModel + + +class MenuCategoryRead(BaseModel): + name: str # Name of the category + menu_items: Optional[List[MenuItemRead]] = [] # List of items under this category + sub_categories: Optional[List["MenuSubCategoryRead"]] = [] # List of subcategories + +# Forward declaration for subcategory read schema to avoid circular imports +class MenuSubCategoryRead(BaseModel): + id: uuid.UUID # Unique identifier for the subcategory + name: str # Name of the subcategory + menu_items: Optional[List[MenuItemRead]] = [] # List of items under this subcategory + +class MenuCategoryCreate(BaseModel): + name: str # Name of the category + menu_items: Optional[List[MenuItemCreate]] = [] # List of items under this category + sub_categories: Optional[List["MenuSubCategoryCreate"]] = [] # List of subcategories + +# Forward declaration for subcategory to avoid circular imports +class MenuSubCategoryCreate(BaseModel): + name: str # Name of the subcategory + category_id: uuid.UUID # Foreign key to the parent category + menu_items: Optional[List[MenuItemCreate]] = [] # List of items under this subcategory \ No newline at end of file diff --git a/backend/app/schema/menu_item.py b/backend/app/schema/menu_item.py new file mode 100644 index 0000000000..c3ccc2d876 --- /dev/null +++ b/backend/app/schema/menu_item.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import List, Optional +import uuid +from datetime import datetime + +# Request schema for creating a menu item +class MenuItemCreate(BaseModel): + category_id: uuid.UUID + subcategory_id: Optional[uuid.UUID] = None + name: str + price: float + description: Optional[str] = None + image_url: Optional[str] = None + is_veg: Optional[bool] = None + ingredients: Optional[str] = None + abv: Optional[float] = None + ibu: Optional[int] = None + +# Response schema for a menu item +class MenuItemRead(BaseModel): + id: uuid.UUID + category_id: uuid.UUID + subcategory_id: Optional[uuid.UUID] = None + name: str + price: float + description: Optional[str] = None + image_url: Optional[str] = None + is_veg: Optional[bool] = None + ingredients: Optional[str] = None + abv: Optional[float] = None + ibu: Optional[int] = None diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index cfefbda373..b724b92e5b 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -1,40 +1,102 @@ -from typing import Optional +from pydantic import BaseModel +from typing import Optional, List import uuid -from app.models.venue import FoodcourtBase, NightclubBase, QSRBase, RestaurantBase +from datetime import time -class RestaurantRead(RestaurantBase): - id: Optional[uuid.UUID] +# Venue base details (composition) +class VenueCreate(BaseModel): + name: str + capacity: Optional[int] = None + description: Optional[str] = None + instagram_handle: Optional[str] = None + instagram_token: Optional[str] = None + mobile_number: Optional[str] = None + email: Optional[str] = None + opening_time: Optional[time] = None + closing_time: Optional[time] = None + avg_expense_for_two: Optional[float] = None + zomato_link: Optional[str] = None + swiggy_link: Optional[str] = None + google_map_link : Optional[str] = None + +class FoodcourtCreate(BaseModel): + total_qsrs: Optional[int] = None + seating_capacity: Optional[int] = None + venue: VenueCreate class Config: from_attributes = True - -class RestaurantCreate(RestaurantBase): +class QSRCreate(BaseModel): + drive_thru: Optional[bool] = False + foodcourt_id: Optional[uuid.UUID] = None + venue: VenueCreate class Config: from_attributes = True -class NightclubRead(NightclubBase): - id: Optional[uuid.UUID] +# Restaurant Schemas +class RestaurantCreate(BaseModel): + cuisine_type: Optional[str] = None + venue_id: uuid.UUID + venue: VenueCreate class Config: from_attributes = True -class NightclubCreate(NightclubBase): + +# Nightclub Schemas +class NightclubCreate(BaseModel): + venue: VenueCreate + age_limit: Optional[int] = None + class Config: from_attributes = True -class QSRRead(QSRBase): - id: Optional[uuid.UUID] + +class VenueRead(BaseModel): + id: uuid.UUID + name: str + address: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + capacity: Optional[int] + description: Optional[str] + google_rating: Optional[float] + instagram_handle: Optional[str] + google_map_link: Optional[str] + mobile_number: Optional[str] + email: Optional[str] + opening_time: Optional[time] + closing_time: Optional[time] + avg_expense_for_two: Optional[float] + zomato_link: Optional[str] + swiggy_link: Optional[str] + +class FoodcourtRead(BaseModel): + id: uuid.UUID + total_qsrs: Optional[int] = None # Specific field for foodcourt + seating_capacity: Optional[int] = None # Specific to foodcourts + venue: VenueRead + qsrs: List["QSRRead"] = [] # List of QSRs in the foodcourt + class Config: from_attributes = True -class QSRCreate(QSRBase): +class QSRRead(BaseModel): + id: uuid.UUID + # Add any specific fields for QSR if needed + foodcourt_id: Optional[uuid.UUID] = None # Reference to the associated foodcourt + venue: VenueRead class Config: from_attributes = True - -class FoodcourtRead(FoodcourtBase): - id: Optional[uuid.UUID] +class RestaurantRead(BaseModel): + id: uuid.UUID + cuisine_type: Optional[str] = None + venue: VenueRead class Config: from_attributes = True -class FoodcourtCreate(FoodcourtBase): +class NightclubRead(BaseModel): + id: uuid.UUID + age_limit: Optional[int] = None + venue: VenueRead class Config: from_attributes = True \ No newline at end of file diff --git a/backend/app/utils.py b/backend/app/utils.py index d5ccf3153f..e2044c5766 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,117 +1,52 @@ -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any - -import emails # type: ignore -import jwt -from jinja2 import Template -from jwt.exceptions import InvalidTokenError - -from app.core.config import settings - - -@dataclass -class EmailData: - html_content: str - subject: str - - -def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: - template_str = ( - Path(__file__).parent / "email-templates" / "build" / template_name - ).read_text() - html_content = Template(template_str).render(context) - return html_content - - -def send_email( - *, - email_to: str, - subject: str = "", - html_content: str = "", -) -> None: - assert settings.emails_enabled, "no provided configuration for email variables" - message = emails.Message( - subject=subject, - html=html_content, - mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), - ) - smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} - if settings.SMTP_TLS: - smtp_options["tls"] = True - elif settings.SMTP_SSL: - smtp_options["ssl"] = True - if settings.SMTP_USER: - smtp_options["user"] = settings.SMTP_USER - if settings.SMTP_PASSWORD: - smtp_options["password"] = settings.SMTP_PASSWORD - response = message.send(to=email_to, smtp=smtp_options) - logging.info(f"send email result: {response}") - - -def generate_test_email(email_to: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Test email" - html_content = render_email_template( - template_name="test_email.html", - context={"project_name": settings.PROJECT_NAME, "email": email_to}, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Password recovery for user {email}" - link = f"{settings.server_host}/reset-password?token={token}" - html_content = render_email_template( - template_name="reset_password.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": email, - "email": email_to, - "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, - "link": link, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_new_account_email( - email_to: str, username: str, password: str -) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - New account for user {username}" - html_content = render_email_template( - template_name="new_account.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": username, - "password": password, - "email": email_to, - "link": settings.server_host, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_password_reset_token(email: str) -> str: - delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) - now = datetime.now(timezone.utc) - expires = now + delta - exp = expires.timestamp() - encoded_jwt = jwt.encode( - {"exp": exp, "nbf": now, "sub": email}, - settings.SECRET_KEY, - algorithm="HS256", - ) - return encoded_jwt - - -def verify_password_reset_token(token: str) -> str | None: - try: - decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) - return str(decoded_token["sub"]) - except InvalidTokenError: - return None +import re +from typing import Optional, List, Tuple +import unshortenit + +class CoordinatesNotFoundError(Exception): + """Custom exception raised when coordinates cannot be found in the Google Maps link.""" + pass + +def extract_coordinates_from_full_link(link: str) -> Optional[Tuple[float, float]]: + """ + Extracts latitude and longitude from a full Google Maps link. + + Args: + link (str): The full Google Maps URL. + + Returns: + Optional[Tuple[float, float]]: A tuple containing latitude and longitude, or None if not found. + """ + lat_long_regex = r"@([-+]?\d*\.\d+),([-+]?\d*\.\d+)" + match = re.search(lat_long_regex, link) + + if match: + latitude = float(match.group(1)) + longitude = float(match.group(2)) + return latitude, longitude + + return None + +def get_coordinates_from_google_maps(link: str) -> Optional[List[Tuple[str, float, float]]]: + """ + Retrieves latitude and longitude from a Google Maps link by unshortening it and extracting coordinates. + + Args: + link (str): The Google Maps URL. + + Raises: + CoordinatesNotFoundError: If coordinates are not found in the link. + + Returns: + Optional[List[Tuple[str, float, float]]]: A list of tuples containing the link, latitude, and longitude, + or raises an exception if coordinates are not found. + """ + # Unshorten the URL if it's a shortened link + unshortened_url = unshortenit.unshorten(link) + + # Attempt to extract coordinates from the full link + coordinates = extract_coordinates_from_full_link(unshortened_url) + if coordinates: + latitude, longitude = coordinates + return [(unshortened_url, latitude, longitude)] + + raise CoordinatesNotFoundError(f"No coordinates found in the provided link: {link}") \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock index 66347a213e..8d17ab3931 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -11,6 +11,129 @@ files = [ {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.4.3" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572"}, + {file = "aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586"}, +] + +[[package]] +name = "aiohttp" +version = "3.10.10" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.10.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be7443669ae9c016b71f402e43208e13ddf00912f47f623ee5994e12fc7d4b3f"}, + {file = "aiohttp-3.10.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b06b7843929e41a94ea09eb1ce3927865387e3e23ebe108e0d0d09b08d25be9"}, + {file = "aiohttp-3.10.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:333cf6cf8e65f6a1e06e9eb3e643a0c515bb850d470902274239fea02033e9a8"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:274cfa632350225ce3fdeb318c23b4a10ec25c0e2c880eff951a3842cf358ac1"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9e5e4a85bdb56d224f412d9c98ae4cbd032cc4f3161818f692cd81766eee65a"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b606353da03edcc71130b52388d25f9a30a126e04caef1fd637e31683033abd"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab5a5a0c7a7991d90446a198689c0535be89bbd6b410a1f9a66688f0880ec026"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:578a4b875af3e0daaf1ac6fa983d93e0bbfec3ead753b6d6f33d467100cdc67b"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8105fd8a890df77b76dd3054cddf01a879fc13e8af576805d667e0fa0224c35d"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3bcd391d083f636c06a68715e69467963d1f9600f85ef556ea82e9ef25f043f7"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fbc6264158392bad9df19537e872d476f7c57adf718944cc1e4495cbabf38e2a"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e48d5021a84d341bcaf95c8460b152cfbad770d28e5fe14a768988c461b821bc"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2609e9ab08474702cc67b7702dbb8a80e392c54613ebe80db7e8dbdb79837c68"}, + {file = "aiohttp-3.10.10-cp310-cp310-win32.whl", hash = "sha256:84afcdea18eda514c25bc68b9af2a2b1adea7c08899175a51fe7c4fb6d551257"}, + {file = "aiohttp-3.10.10-cp310-cp310-win_amd64.whl", hash = "sha256:9c72109213eb9d3874f7ac8c0c5fa90e072d678e117d9061c06e30c85b4cf0e6"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a"}, + {file = "aiohttp-3.10.10-cp311-cp311-win32.whl", hash = "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94"}, + {file = "aiohttp-3.10.10-cp311-cp311-win_amd64.whl", hash = "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205"}, + {file = "aiohttp-3.10.10-cp312-cp312-win32.whl", hash = "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628"}, + {file = "aiohttp-3.10.10-cp312-cp312-win_amd64.whl", hash = "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b"}, + {file = "aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8"}, + {file = "aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1b66ccafef7336a1e1f0e389901f60c1d920102315a56df85e49552308fc0486"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acd48d5b80ee80f9432a165c0ac8cbf9253eaddb6113269a5e18699b33958dbb"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3455522392fb15ff549d92fbf4b73b559d5e43dc522588f7eb3e54c3f38beee7"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45c3b868724137f713a38376fef8120c166d1eadd50da1855c112fe97954aed8"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da1dee8948d2137bb51fbb8a53cce6b1bcc86003c6b42565f008438b806cccd8"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5ce2ce7c997e1971b7184ee37deb6ea9922ef5163c6ee5aa3c274b05f9e12fa"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28529e08fde6f12eba8677f5a8608500ed33c086f974de68cc65ab218713a59d"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7db54c7914cc99d901d93a34704833568d86c20925b2762f9fa779f9cd2e70f"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03a42ac7895406220124c88911ebee31ba8b2d24c98507f4a8bf826b2937c7f2"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7e338c0523d024fad378b376a79faff37fafb3c001872a618cde1d322400a572"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:038f514fe39e235e9fef6717fbf944057bfa24f9b3db9ee551a7ecf584b5b480"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:64f6c17757251e2b8d885d728b6433d9d970573586a78b78ba8929b0f41d045a"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:93429602396f3383a797a2a70e5f1de5df8e35535d7806c9f91df06f297e109b"}, + {file = "aiohttp-3.10.10-cp38-cp38-win32.whl", hash = "sha256:c823bc3971c44ab93e611ab1a46b1eafeae474c0c844aff4b7474287b75fe49c"}, + {file = "aiohttp-3.10.10-cp38-cp38-win_amd64.whl", hash = "sha256:54ca74df1be3c7ca1cf7f4c971c79c2daf48d9aa65dea1a662ae18926f5bc8ce"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:01948b1d570f83ee7bbf5a60ea2375a89dfb09fd419170e7f5af029510033d24"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9fc1500fd2a952c5c8e3b29aaf7e3cc6e27e9cfc0a8819b3bce48cc1b849e4cc"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f614ab0c76397661b90b6851a030004dac502e48260ea10f2441abd2207fbcc7"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00819de9e45d42584bed046314c40ea7e9aea95411b38971082cad449392b08c"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05646ebe6b94cc93407b3bf34b9eb26c20722384d068eb7339de802154d61bc5"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:998f3bd3cfc95e9424a6acd7840cbdd39e45bc09ef87533c006f94ac47296090"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9010c31cd6fa59438da4e58a7f19e4753f7f264300cd152e7f90d4602449762"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ea7ffc6d6d6f8a11e6f40091a1040995cdff02cfc9ba4c2f30a516cb2633554"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ef9c33cc5cbca35808f6c74be11eb7f5f6b14d2311be84a15b594bd3e58b5527"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce0cdc074d540265bfeb31336e678b4e37316849d13b308607efa527e981f5c2"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:597a079284b7ee65ee102bc3a6ea226a37d2b96d0418cc9047490f231dc09fe8"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7789050d9e5d0c309c706953e5e8876e38662d57d45f936902e176d19f1c58ab"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e7f8b04d83483577fd9200461b057c9f14ced334dcb053090cea1da9c8321a91"}, + {file = "aiohttp-3.10.10-cp39-cp39-win32.whl", hash = "sha256:c02a30b904282777d872266b87b20ed8cc0d1501855e27f831320f471d54d983"}, + {file = "aiohttp-3.10.10-cp39-cp39-win_amd64.whl", hash = "sha256:edfe3341033a6b53a5c522c802deb2079eee5cbfbb0af032a55064bd65c73a23"}, + {file = "aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.12.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] + [[package]] name = "aioredis" version = "1.3.1" @@ -26,6 +149,20 @@ files = [ async-timeout = "*" hiredis = "*" +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + [[package]] name = "aiosqlite" version = "0.17.0" @@ -217,6 +354,25 @@ async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""} docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "babel" version = "2.16.0" @@ -271,6 +427,41 @@ files = [ tests = ["pytest (>=3.2.1,!=3.3.0)"] typecheck = ["mypy"] +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "bs4" +version = "0.0.2" +description = "Dummy package for Beautiful Soup (beautifulsoup4)" +optional = false +python-versions = "*" +files = [ + {file = "bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"}, + {file = "bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925"}, +] + +[package.dependencies] +beautifulsoup4 = "*" + [[package]] name = "cachetools" version = "5.4.0" @@ -836,6 +1027,92 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + [[package]] name = "greenlet" version = "3.0.3" @@ -1492,6 +1769,110 @@ files = [ {file = "more_itertools-10.3.0-py3-none-any.whl", hash = "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320"}, ] +[[package]] +name = "multidict" +version = "6.1.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.8" +files = [ + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, + {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, + {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, + {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, + {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, + {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, + {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, + {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, + {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, + {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, + {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, + {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, + {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, + {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, + {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + [[package]] name = "mypy" version = "1.11.1" @@ -1779,6 +2160,113 @@ requests = "*" dev = ["black", "flake8", "therapist", "tox", "twine", "wheel"] test = ["mock", "nose"] +[[package]] +name = "propcache" +version = "0.2.0" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.8" +files = [ + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336"}, + {file = "propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad"}, + {file = "propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b"}, + {file = "propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1"}, + {file = "propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348"}, + {file = "propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5"}, + {file = "propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544"}, + {file = "propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032"}, + {file = "propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed"}, + {file = "propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d"}, + {file = "propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798"}, + {file = "propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9"}, + {file = "propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df"}, + {file = "propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036"}, + {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"}, +] + [[package]] name = "psycopg" version = "3.2.1" @@ -2400,6 +2888,17 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "soupsieve" +version = "2.6" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, + {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, +] + [[package]] name = "sqladmin" version = "0.19.0" @@ -2626,6 +3125,21 @@ files = [ {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] +[[package]] +name = "unshortenit" +version = "0.4.0" +description = "Unshortens ad-based shorteners and any 301 redirected urls." +optional = false +python-versions = "*" +files = [ + {file = "unshortenit-0.4.0.tar.gz", hash = "sha256:ffe218acb22cd743b152e90ca3ac547a99d642333824048b6567a3e1688cf703"}, +] + +[package.dependencies] +click = ">=6.7" +lxml = ">=4.1.1" +requests = ">=2.18.4" + [[package]] name = "urllib3" version = "2.2.2" @@ -2918,7 +3432,103 @@ markupsafe = "*" [package.extras] email = ["email-validator"] +[[package]] +name = "yarl" +version = "1.15.5" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +files = [ + {file = "yarl-1.15.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6c57972a406ea0f61e3f28f2b3a780fb71fbe1d82d267afe5a2f889a83ee7e7"}, + {file = "yarl-1.15.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c3ac5bdcc1375c8ee52784adf94edbce37c471dd2100a117cfef56fe8dbc2b4"}, + {file = "yarl-1.15.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:68d21d0563d82aaf46163eac529adac301b20be3181b8a2811f7bd5615466055"}, + {file = "yarl-1.15.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7d317fb80bc17ed4b34a9aad8b80cef34bea0993654f3e8566daf323def7ef9"}, + {file = "yarl-1.15.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed9c72d5361cfd5af5ccadffa8f8077f4929640e1f938aa0f4b92c5a24996ac5"}, + {file = "yarl-1.15.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb707859218e8335447b210f41a755e7b1367c33e87add884128bba144694a7f"}, + {file = "yarl-1.15.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6563394492c96cb57f4dff0c69c63d2b28b5469c59c66f35a1e6451583cd0ab4"}, + {file = "yarl-1.15.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c2d1109c8d92059314cc34dd8f0a31f74b720dc140744923ed7ca228bf9b491"}, + {file = "yarl-1.15.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8fc727f0fb388debc771eaa7091c092bd2e8b6b4741b73354b8efadcf96d6031"}, + {file = "yarl-1.15.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:94189746c5ad62e1014a16298130e696fe593d031d442ef135fb7787b7a1f820"}, + {file = "yarl-1.15.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b06d8b05d0fafef204d635a4711283ddbf19c7c0facdc61b4b775f6e47e2d4be"}, + {file = "yarl-1.15.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:de6917946dc6bc237d4b354e38aa13a232e0c7948fdbdb160edee3862e9d735f"}, + {file = "yarl-1.15.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:34816f1d833433a16c4832562a050b0a60eac53dcb71b2032e6ebff82d74b6a7"}, + {file = "yarl-1.15.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:19e2a4b2935f95fad0949f420514c5d862f5f18058fbbfd8854f496a97d9fd87"}, + {file = "yarl-1.15.5-cp310-cp310-win32.whl", hash = "sha256:30ca64521f1a96b72886dd9e8652f16eab11891b4572dcfcfc1ad6d6ccb27abd"}, + {file = "yarl-1.15.5-cp310-cp310-win_amd64.whl", hash = "sha256:86648c53b10c53db8b967a75fb41e0c89dbec7398f6525e34af2b6c456bb0ac0"}, + {file = "yarl-1.15.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e652aa9f8dfa808bc5b2da4d1f4e286cf1d640570fdfa72ffc0c1d16ba114651"}, + {file = "yarl-1.15.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21050b6cd569980fe20ceeab4baeb900d3f7247270475e42bafe117416a5496c"}, + {file = "yarl-1.15.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18940191ec9a83bbfe63eea61c3e9d12474bb910d5613bce8fa46e84a80b75b2"}, + {file = "yarl-1.15.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a082dc948045606f62dca0228ab24f13737180b253378d6443f5b2b9ef8beefe"}, + {file = "yarl-1.15.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a843e692f9d5402b3455653f4607dc521de2385f01c5cad7ba4a87c46e2ea8d"}, + {file = "yarl-1.15.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5093a453176a4fad4f9c3006f507cf300546190bb3e27944275a37cfd6323a65"}, + {file = "yarl-1.15.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2597a589859b94d0a5e2f5d30fee95081867926e57cb751f8b44a7dd92da4e79"}, + {file = "yarl-1.15.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f5a1ca6eaabfe62718b87eac06d9a47b30cf92ffa065fee9196d3ecd24a3cf1"}, + {file = "yarl-1.15.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ac83b307cc4b8907345b52994055c6c3c2601ceb6fcb94c5ed6a93c6b4e8257"}, + {file = "yarl-1.15.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:325e2beb2cd8654b276e7686a3cd203628dd3fe32d5c616e632bc35a2901fb16"}, + {file = "yarl-1.15.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:75d04ba8ed335042328086e643e01165e0c24598216f72da709b375930ae3bdb"}, + {file = "yarl-1.15.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7abd7d15aedb3961a967cc65f8144dbbca42e3626a21c5f4f29919cf43eeafb9"}, + {file = "yarl-1.15.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:294c742a273f44511f14b03a9e06b66094dcdf4bbb75a5e23fead548fd5310ae"}, + {file = "yarl-1.15.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63d46606b20f80a6476f1044bab78e1a69c2e0747f174583e2f12fc70bad2170"}, + {file = "yarl-1.15.5-cp311-cp311-win32.whl", hash = "sha256:b1217102a455e3ac9ac293081093f21f0183e978c7692171ff669fee5296fa28"}, + {file = "yarl-1.15.5-cp311-cp311-win_amd64.whl", hash = "sha256:5848500b6a01497560969e8c3a7eb1b2570853c74a0ca6f67ebaf6064106c49b"}, + {file = "yarl-1.15.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d3309ee667f2d9c7ac9ecf44620d6b274bfdd8065b8c5019ff6795dd887b8fed"}, + {file = "yarl-1.15.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:96ce879799fee124d241ea3b84448378f638e290c49493d00b706f3fd57ec22b"}, + {file = "yarl-1.15.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c884dfa56b050f718ea3cbbfd972e29a6f07f63a7449b10d9a20d64f7eec92e2"}, + {file = "yarl-1.15.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0327081978fe186c3390dd4f73f95f825d0bb9c74967e22c2a1a87735974d8f5"}, + {file = "yarl-1.15.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:524b3bb7dff320e305bc979c65eddc0342548c56ea9241502f907853fe53c408"}, + {file = "yarl-1.15.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd56de8b645421ff09c993fdb0ee9c5a3b50d290a8f55793b500d99b34d0c1ce"}, + {file = "yarl-1.15.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c166ad987265bb343be58cdf4fbc4478cc1d81f2246d2be9a15f94393b269faa"}, + {file = "yarl-1.15.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d56980374a10c74255fcea6ebcfb0aeca7166d212ee9fd7e823ddef35fb62ad0"}, + {file = "yarl-1.15.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cbf36099a9b407e1456dbf55844743a98603fcba32d2a46fb3a698d926facf1b"}, + {file = "yarl-1.15.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d7fa4b033e2f267e37aabcc36949fa89f9f1716a723395912147f9cf3fb437c7"}, + {file = "yarl-1.15.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb129f77ddaea2d8e6e00417b8d907448de3407af4eddacca0a515574ad71493"}, + {file = "yarl-1.15.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:68e837b3edfcd037f9706157e7cb8efda832de6248c7d9e893e2638356dfae5d"}, + {file = "yarl-1.15.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5b8af4165e097ff84d9bbb97bb4f4d7f71b9c1c9565a2d0e27d93e5f92dae220"}, + {file = "yarl-1.15.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:70d074d5a96e0954fe6db81ff356f4361397da1cda3f7c127fc0902f671a087e"}, + {file = "yarl-1.15.5-cp312-cp312-win32.whl", hash = "sha256:362da97ad4360e4ef1dd24ccdd3bceb18332da7f40026a42f49b7edd686e31c3"}, + {file = "yarl-1.15.5-cp312-cp312-win_amd64.whl", hash = "sha256:9aa054d97033beac9cb9b19b7c0b8784b85b12cd17879087ca6bffba57884e02"}, + {file = "yarl-1.15.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5fadcf532fd9f6cbad71485ef8c2462dd9a91d3efc72ca01eb0970792c92552a"}, + {file = "yarl-1.15.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b7dd6983c81523f9de0ae6334c3b7a3cb33283936e0525f80c4f713f54a9bb6"}, + {file = "yarl-1.15.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcfd663dc88465ebe41c7c938bdc91c4b01cda96a0d64bf38fd66c1877323771"}, + {file = "yarl-1.15.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd529e637cd23204bd82072f6637cff7af2516ad2c132e8f3342cbc84871f7d1"}, + {file = "yarl-1.15.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b30f13fac56598474071a4f1ecd66c78fdaf2f8619042d7ca135f72dbb348cf"}, + {file = "yarl-1.15.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:44088ec0be82fba118ed29b6b429f80bf295297727adae4c257ac297e01e8bcd"}, + {file = "yarl-1.15.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:607683991bab8607e5158cd290dd8fdaa613442aeab802fe1c237d3a3eee7358"}, + {file = "yarl-1.15.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da48cdff56b01ea4282a6d04b83b07a2088351a4a3ff7aacc1e7e9b6b04b90b9"}, + {file = "yarl-1.15.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9162ea117ce8bad8ebc95b7376b4135988acd888d2cf4702f8281e3c11f8b81f"}, + {file = "yarl-1.15.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e8aa19c39cb20bfb16f0266df175a6004943122cf20707fbf0cacc21f6468a25"}, + {file = "yarl-1.15.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5d6be369488d503c8edc14e2f63d71ab2a607041ad216a8ad444fa18e8dea792"}, + {file = "yarl-1.15.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e2c674cfe4c03ad7a4d536b1f808221f0d11a360486b4b032d2557c0bd633ad"}, + {file = "yarl-1.15.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:041bafaa82b77fd4ec2826d42a55461ec86d999adf7ed9644eef7e8a9febb366"}, + {file = "yarl-1.15.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2eeb9ba53c055740cd282ae9d34eb7970d65e73a46f15adec4b0c1b0f2e55cc2"}, + {file = "yarl-1.15.5-cp313-cp313-win32.whl", hash = "sha256:73143dd279e641543da52c55652ad7b4c7c5f79e797f124f58f04cc060f14271"}, + {file = "yarl-1.15.5-cp313-cp313-win_amd64.whl", hash = "sha256:94ab1185900f43760d5487c8e49f5f1a66f864e36092f282f1813597479b9dfa"}, + {file = "yarl-1.15.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6b3d2767bd64c62909ea33525b954ba05c8f9726bfdf2141d175da4e344f19ae"}, + {file = "yarl-1.15.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:44359c52af9c383e5107f3b6301446fc8269599721fa42fafb2afb5f31a42dcb"}, + {file = "yarl-1.15.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6493da9ba5c551978c679ab04856c2cf8f79c316e8ec8c503460a135705edc3b"}, + {file = "yarl-1.15.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a6b6e95bc621c11cf9ff21012173337e789f2461ebc3b4e5bf65c74ef69adb8"}, + {file = "yarl-1.15.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7983290ede3aaa2c9620879530849532529b4dcbf5b12a0b6a91163a773eadb9"}, + {file = "yarl-1.15.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07a4b53abe85813c538b9cdbb02909ebe3734e3af466a587df516e960d500cc8"}, + {file = "yarl-1.15.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5882faa2a6e684f65ee44f18c701768749a950cbd5e72db452fc07805f6bdec0"}, + {file = "yarl-1.15.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e27861251d9c094f641d39a8a78dd2371fb9a252ea2f689d1ad353a31d46a0bc"}, + {file = "yarl-1.15.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8669a110f655c9eb22f16fb68a7d4942020aeaa09f1def584a80183e3e89953c"}, + {file = "yarl-1.15.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:10bfe0bef4cf5ea0383886beda004071faadedf2647048b9f876664284c5b60d"}, + {file = "yarl-1.15.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f7de0d4b6b4d8a77e422eb54d765255c0ec6883ee03b8fd537101633948619d7"}, + {file = "yarl-1.15.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:00bb3a559d7bd006a5302ecd7e409916939106a8cdbe31f4eb5e5b9ffcca57ea"}, + {file = "yarl-1.15.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:06ec070a2d71415f90dbe9d70af3158e7da97a128519dba2d1581156ee27fb92"}, + {file = "yarl-1.15.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b997a806846c00d1f41d6a251803732837771b2091bead7566f68820e317bfe7"}, + {file = "yarl-1.15.5-cp39-cp39-win32.whl", hash = "sha256:7825506fbee4055265528ec3532a8197ff26fc53d4978917a4c8ddbb4c1667d7"}, + {file = "yarl-1.15.5-cp39-cp39-win_amd64.whl", hash = "sha256:71730658be0b5de7c570a9795d7404c577b2313c1db370407092c66f70e04ccb"}, + {file = "yarl-1.15.5-py3-none-any.whl", hash = "sha256:625f31d6650829fba4030b4e7bdb2d69e41510dddfa29a1da27076c199521757"}, + {file = "yarl-1.15.5.tar.gz", hash = "sha256:8249147ee81c1cf4d1dc6f26ba28a1b9d92751529f83c308ad02164bb93abd0d"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.0" + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a16e33dfc99918d37919782659083da940e7b94db8d09af90ad56eca4638e89e" +content-hash = "98bf913f4679b721df205fa9a85e0e4d1a5555720d6de0298149adc1f5df2c6f" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 980c4311c0..e04879f4cb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,6 +36,9 @@ asyncpg = "^0.29.0" otplessauthsdk = "^0.3.3" fastapi-admin = "^1.0.4" sqladmin = {extras = ["full"], version = "^0.19.0"} +unshortenit = "^0.4.0" +aiohttp = "^3.10.10" +bs4 = "^0.0.2" [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" From fa6ed27af924c35af4f8a9ef6f56a851ca5bffcf Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Sat, 26 Oct 2024 21:54:23 +0530 Subject: [PATCH 07/25] Added model to schema mapper, user auth --- ...f4c_make_phone_number_nullable_in_user_.py | 51 +++++ backend/app/api/routes/login.py | 51 ++++- backend/app/api/routes/menu.py | 101 ++++++--- backend/app/api/routes/qrcode.py | 2 +- backend/app/api/routes/users.py | 2 +- backend/app/api/routes/utils.py | 0 backend/app/api/routes/venues.py | 92 ++++++++- backend/app/models/menu.py | 180 ++++++++-------- backend/app/models/user.py | 23 +-- backend/app/models/venue.py | 3 +- backend/app/schema/auth.py | 0 backend/app/schema/menu.py | 95 ++++----- backend/app/schema/menu_category.py | 27 --- backend/app/schema/menu_item.py | 31 --- backend/app/schema/venue.py | 8 +- backend/app/tests/api/routes/test_users.py | 34 +-- backend/app/tests/crud/test_user.py | 24 +-- backend/app/tests/utils/item.py | 4 +- backend/app/tests/utils/user.py | 10 +- backend/app/{crud.py => util.py} | 34 ++- backend/poetry.lock | 193 +++++++++--------- backend/pyproject.toml | 2 +- 22 files changed, 573 insertions(+), 394 deletions(-) create mode 100644 backend/app/alembic/versions/4951a24acf4c_make_phone_number_nullable_in_user_.py delete mode 100644 backend/app/api/routes/utils.py delete mode 100644 backend/app/schema/auth.py delete mode 100644 backend/app/schema/menu_category.py delete mode 100644 backend/app/schema/menu_item.py rename backend/app/{crud.py => util.py} (85%) diff --git a/backend/app/alembic/versions/4951a24acf4c_make_phone_number_nullable_in_user_.py b/backend/app/alembic/versions/4951a24acf4c_make_phone_number_nullable_in_user_.py new file mode 100644 index 0000000000..b5d6bc57bd --- /dev/null +++ b/backend/app/alembic/versions/4951a24acf4c_make_phone_number_nullable_in_user_.py @@ -0,0 +1,51 @@ +"""Make phone_number nullable in user_business + +Revision ID: 4951a24acf4c +Revises: e2955dcf9b00 +Create Date: 2024-10-26 15:19:32.622093 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '4951a24acf4c' +down_revision = 'e2955dcf9b00' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user_business', 'email', + existing_type=sa.VARCHAR(length=255), + nullable=False) + op.alter_column('user_business', 'phone_number', + existing_type=sa.VARCHAR(), + nullable=True) + op.drop_index('ix_user_business_phone_number', table_name='user_business') + op.drop_index('ix_user_public_email', table_name='user_public') + op.add_column('user_venue_association', sa.Column('user_id', sa.Uuid(), nullable=False)) + op.drop_constraint('user_venue_association_user_business_id_fkey', 'user_venue_association', type_='foreignkey') + op.create_foreign_key(None, 'user_venue_association', 'user_business', ['user_id'], ['id']) + op.drop_column('user_venue_association', 'user_business_id') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_venue_association', sa.Column('user_business_id', sa.UUID(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'user_venue_association', type_='foreignkey') + op.create_foreign_key('user_venue_association_user_business_id_fkey', 'user_venue_association', 'user_business', ['user_business_id'], ['id']) + op.drop_column('user_venue_association', 'user_id') + op.create_index('ix_user_public_email', 'user_public', ['email'], unique=True) + op.create_index('ix_user_business_phone_number', 'user_business', ['phone_number'], unique=True) + op.alter_column('user_business', 'phone_number', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('user_business', 'email', + existing_type=sa.VARCHAR(length=255), + nullable=True) + # ### end Alembic commands ### diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 5fc9ae0fc7..7b722aff5e 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -13,7 +13,56 @@ router = APIRouter() -@router.post("/verify_token", response_model=UserAuthResponse) +@router.post("/verify_token/business", response_model=UserAuthResponse) +async def business_user_google_login(request: OtplessToken, session: SessionDep): + try: + # Verify the Google token with Google + # token_info_url = "https://oauth2.googleapis.com/tokeninfo" + # async with httpx.AsyncClient() as client: + # response = await client.get(f"{token_info_url}?id_token={request.google_token}") + + # if response.status_code != 200: + # raise HTTPException(status_code=400, detail="Invalid Google token") + + # # Parse user info + # user_info = response.json() + # email = user_info.get("email") + # user_id = user_info.get("sub") # Google user ID + + # Check for user by Google ID or email + email = request.otpless_token+ "test@gmail.com" # Replace this with the actual email from the SDK response + user = session.query(UserBusiness).filter(UserBusiness.email == email).first() + + if not user: + # Create new user if not found + user = UserBusiness( + email=email, + is_active=True, + registration_date=datetime.now(timezone.utc), + ) + session.add(user) + session.commit() + session.refresh(user) + + # Create tokens + access_token = create_access_token(subject=str(user.id), expires_delta=timedelta(minutes=30)) + refresh_token = create_refresh_token(subject=str(user.id)) + + # Store refresh token + user.refresh_token = refresh_token.token + session.add(user) + session.commit() + + return UserAuthResponse( + access_token=access_token, + refresh_token=refresh_token, + issued_at=datetime.now(timezone.utc) + ) + + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/verify_token/public", response_model=UserAuthResponse) async def verify_token(request: OtplessToken, session: SessionDep): try: # Verify the token using OTPLess SDK diff --git a/backend/app/api/routes/menu.py b/backend/app/api/routes/menu.py index 3141c1f226..66499c4947 100644 --- a/backend/app/api/routes/menu.py +++ b/backend/app/api/routes/menu.py @@ -2,22 +2,25 @@ from app.schema.menu import MenuCategoryCreate, MenuCategoryRead, MenuCategoryUpdate, MenuCreate, MenuItemCreate, MenuItemRead, MenuItemUpdate, MenuRead, MenuSubCategoryCreate, MenuSubCategoryRead, MenuSubCategoryUpdate, MenuUpdate from app.models.menu import Menu, MenuCategory, MenuItem, MenuSubCategory from app.models.venue import Venue +from app.models.user import UserBusiness, UserPublic, UserVenueAssociation from sqlmodel import Session,select from fastapi import APIRouter, Depends, HTTPException from typing import List -from app.api.deps import SessionDep -from app.crud import ( +from app.api.deps import SessionDep, get_current_user +from app.util import ( get_record_by_id, create_record, update_record, - delete_record + delete_record, + check_user_permission ) from app.api.deps import get_db router = APIRouter() # Get all menus of a specific venue @router.get("/all/{venue_id}", response_model=List[MenuRead]) -async def read_menus(venue_id: uuid.UUID, db: Session = Depends(get_db)): +async def read_menus(venue_id: uuid.UUID, db: Session = Depends(get_db), + current_user: UserPublic = Depends(get_current_user)): """ Retrieve all menus for a specific venue. """ @@ -31,18 +34,19 @@ async def read_menus(venue_id: uuid.UUID, db: Session = Depends(get_db)): return [Menu.to_read_schema(menu) for menu in menus] @router.get("/menu/{menu_id}", response_model=MenuRead) -async def read_menu(menu_id: uuid.UUID, db: Session = Depends(get_db)): +async def read_menu(menu_id: uuid.UUID, db: Session = Depends(get_db), + current_user: UserPublic = Depends(get_current_user)): """ Retrieve a specific menu by its ID. """ menu = get_record_by_id(db, Menu, menu_id) - print('huhuhuh',menu) ret = Menu.to_read_schema(menu) return ret @router.post("/", response_model=MenuRead) -async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db)): +async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Create a new menu for a specific venue. """ @@ -50,7 +54,10 @@ async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db)): venue = get_record_by_id(db, Venue, menu_create.venue_id) if not venue: raise HTTPException(status_code=404, detail="Venue not found.") - + + # Check if the user has permission to create a menu for this venue + check_user_permission(db, current_user, menu_create.venue_id) + try: # Create the Menu object menu_instance = Menu.from_create_schema(menu_create) @@ -68,7 +75,8 @@ async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db)): async def update_menu( menu_id: uuid.UUID, menu_update: MenuUpdate, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user) ): """ Update an existing menu's details using a partial update (PATCH). @@ -80,6 +88,8 @@ async def update_menu( """ # Retrieve the menu by its ID menu_instance = get_record_by_id(db, Menu, menu_id) + # Check if the user has permission to update a menu for this venue + check_user_permission(db, current_user, menu_instance.venue_id) if not menu_instance: raise HTTPException(status_code=404, detail="Menu not found.") @@ -90,7 +100,8 @@ async def update_menu( return Menu.to_read_schema(updated_menu) @router.delete("/{menu_id}", response_model=dict) -async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db)): +async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Delete a menu by its ID. @@ -98,12 +109,14 @@ async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db)): :param db: Active database session. :return: Confirmation message on successful deletion. """ - menu = get_record_by_id(db, Menu, menu_id) - - if not menu: + menu_instance = get_record_by_id(db, Menu, menu_id) + if not menu_instance: raise HTTPException(status_code=404, detail="Menu not found.") - - delete_record(db, menu) + + # Check if the user has permission to delete a menu for this venue + check_user_permission(db, current_user, menu_instance.venue_id) + + delete_record(db, menu_instance) return {"detail": "Menu deleted successfully."} @@ -112,8 +125,8 @@ async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db)): @router.post("/category", response_model=MenuCategoryRead) async def create_menu_category( category_create: MenuCategoryCreate, - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Create a new menu category associated with a menu. @@ -126,6 +139,8 @@ async def create_menu_category( if not menu: raise HTTPException(status_code=404, detail="Menu not found.") + # Check if the user has permission to update a menu for this venue + check_user_permission(db, current_user, menu.venue_id) # Create a new MenuCategory instance from the provided data category_instance = MenuCategory.from_create_schema(category_create) @@ -139,8 +154,9 @@ async def create_menu_category( async def update_menu_category( category_id: uuid.UUID, category_update: MenuCategoryUpdate, - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): + """ Update an existing menu category's details using a partial update (PATCH). @@ -155,13 +171,16 @@ async def update_menu_category( if not category_instance: raise HTTPException(status_code=404, detail="Menu category not found.") + check_user_permission(db, current_user, category_instance.menu.venue_id) + # Update the category using the validated fields from MenuCategoryUpdate updated_category = update_record(db, category_instance, category_update) return MenuCategory.to_read_schema(updated_category) @router.delete("/category/{category_id}", response_model=dict) -async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db)): +async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Delete a menu category by its ID. @@ -174,6 +193,8 @@ async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db)) if not category: raise HTTPException(status_code=404, detail="Category not found.") + check_user_permission(db, current_user, category.menu.venue_id) + delete_record(db, category) return {"detail": "Category deleted successfully."} @@ -183,8 +204,8 @@ async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db)) @router.post("/subcategory/", response_model=MenuSubCategoryRead) async def create_menu_subcategory( subcategory_create: MenuSubCategoryCreate, - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Create a new menu subcategory associated with a category. @@ -198,6 +219,8 @@ async def create_menu_subcategory( if not category: raise HTTPException(status_code=404, detail="Category not found.") + check_user_permission(db, current_user, category.menu.venue_id) + # Create a new MenuSubCategory instance from the provided data subcategory_instance = MenuSubCategory.from_create_schema(subcategory_create) @@ -210,8 +233,8 @@ async def create_menu_subcategory( async def update_menu_subcategory( subcategory_id: uuid.UUID, subcategory_update: MenuSubCategoryUpdate, - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Update an existing menu subcategory. @@ -226,6 +249,8 @@ async def update_menu_subcategory( if not subcategory: raise HTTPException(status_code=404, detail="Subcategory not found.") + check_user_permission(db, current_user, subcategory.category.menu.venue_id) + # Update the subcategory with provided data update_data = subcategory_update.dict(exclude_unset=True) # Exclude unset fields for partial update updated_subcategory = update_record(db, subcategory, update_data) @@ -233,7 +258,8 @@ async def update_menu_subcategory( return MenuSubCategory.to_read_schema(updated_subcategory) @router.delete("/subcategory/{subcategory_id}", response_model=dict) -async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(get_db)): +async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Delete a menu subcategory by its ID. @@ -246,6 +272,8 @@ async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(ge if not subcategory: raise HTTPException(status_code=404, detail="Subcategory not found.") + check_user_permission(db, current_user, subcategory.category.menu.venue_id) + delete_record(db, subcategory) return {"detail": "Subcategory deleted successfully."} @@ -255,8 +283,8 @@ async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(ge @router.post("/item/", response_model=MenuItemRead) async def create_menu_item( item_create: MenuItemCreate, - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Create a new menu item associated with a subcategory. @@ -270,6 +298,8 @@ async def create_menu_item( if not subcategory: raise HTTPException(status_code=404, detail="Subcategory not found.") + check_user_permission(db, current_user, subcategory.category.menu.venue_id) + # Create a new MenuItem instance from the provided data item_instance = MenuItem.from_create_schema(item_create) @@ -282,8 +312,8 @@ async def create_menu_item( async def update_menu_item( item_id: uuid.UUID, item_update: MenuItemUpdate, - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Update an existing menu item. @@ -298,13 +328,16 @@ async def update_menu_item( if not item: raise HTTPException(status_code=404, detail="Menu item not found.") + check_user_permission(db, current_user, item.subcategory.category.menu.venue_id) + # Update the item with provided data updated_item = update_record(db, item, item_update) return MenuItem.to_read_schema(updated_item) @router.delete("/item/{item_id}", response_model=dict) -async def delete_menu_item(item_id: uuid.UUID, db: Session = Depends(get_db)): +async def delete_menu_item(item_id: uuid.UUID, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user)): """ Delete a menu item by its ID. @@ -316,7 +349,11 @@ async def delete_menu_item(item_id: uuid.UUID, db: Session = Depends(get_db)): if not item: raise HTTPException(status_code=404, detail="Menu item not found.") - + + check_user_permission(db, current_user, item.subcategory.category.menu.venue_id) + delete_record(db, item) - return {"detail": "Menu item deleted successfully."} \ No newline at end of file + return {"detail": "Menu item deleted successfully."} + +######################################################################################################### \ No newline at end of file diff --git a/backend/app/api/routes/qrcode.py b/backend/app/api/routes/qrcode.py index cd02362b4b..0c4f3d871a 100644 --- a/backend/app/api/routes/qrcode.py +++ b/backend/app/api/routes/qrcode.py @@ -10,7 +10,7 @@ from app.api.deps import SessionDep from app.models.qrcode import QRCode # Ensure you have this import for your model from app.schema.qrcode import QRCodeCreate, QRCodeRead # Ensure you have these imports for your schemas -from app.crud import ( +from app.util import ( get_all_records, get_record_by_id, create_record, diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index b41f6f6a77..8a715c666b 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Query, HTTPException, Depends from app.api.deps import SessionDep, get_business_user, get_current_user, get_public_user, get_super_user from app.models import UserBusiness, UserPublic -from app.crud import get_all_records, get_record_by_id, create_record, update_record, delete_record, patch_record +from app.util import get_all_records, get_record_by_id, create_record, update_record, delete_record, patch_record router = APIRouter() diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py index 1b404e8666..b63b07df24 100644 --- a/backend/app/api/routes/venues.py +++ b/backend/app/api/routes/venues.py @@ -1,4 +1,5 @@ from typing import List +from app.models.user import UserBusiness, UserVenueAssociation from fastapi import FastAPI, Depends, HTTPException, APIRouter from sqlmodel import Session from app.models.venue import QSR, Foodcourt, Restaurant, Nightclub, Venue @@ -11,9 +12,10 @@ QSRRead, RestaurantRead, NightclubRead, + VenueListResponse, ) -from app.api.deps import get_db # Assuming you have a dependency to get the database session -from app.crud import ( +from app.api.deps import get_business_user, get_db # Assuming you have a dependency to get the database session +from app.util import ( create_record, get_all_records, ) @@ -23,7 +25,8 @@ # POST endpoint for Foodcourt @router.post("/foodcourts/", response_model=FoodcourtRead) -def create_foodcourt(foodcourt: FoodcourtCreate, db: Session = Depends(get_db)): +def create_foodcourt(foodcourt: FoodcourtCreate, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user)): try: # Check if the venue exists venue_instance = Venue.from_create_schema(foodcourt.venue) @@ -32,7 +35,12 @@ def create_foodcourt(foodcourt: FoodcourtCreate, db: Session = Depends(get_db)): foodcourt_instance = Foodcourt.from_create_schema(venue_instance.id, foodcourt) # Create the new Foodcourt record in the database create_record(db, foodcourt_instance) - # Return the read schema for the created foodcourt + association = UserVenueAssociation( + user_id=current_user.id, + venue_id=venue_instance.id + ) + create_record(db, association) + return foodcourt_instance.to_read_schema() except Exception as e: @@ -47,7 +55,8 @@ def read_foodcourts(skip: int = 0, limit: int = 10, db: Session = Depends(get_db # POST endpoint for QSR @router.post("/qsrs/", response_model=QSRRead) -def create_qsr(qsr: QSRCreate, db: Session = Depends(get_db)): +def create_qsr(qsr: QSRCreate, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user)): try: # Check if the venue exists venue_instance = Venue.from_create_schema(qsr.venue) @@ -56,7 +65,11 @@ def create_qsr(qsr: QSRCreate, db: Session = Depends(get_db)): qsr_instance = QSR.from_create_schema(venue_instance.id, qsr) # Create the new Foodcourt record in the database create_record(db, qsr_instance) - # Return the read schema for the created foodcourt + association = UserVenueAssociation( + user_id=current_user.id, + venue_id=venue_instance.id + ) + create_record(db, association) return qsr_instance.to_read_schema() except Exception as e: @@ -71,7 +84,8 @@ def read_qsrs(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): # POST endpoint for Restaurant @router.post("/restaurants/", response_model=RestaurantRead) -def create_restaurant(restaurant: RestaurantCreate, db: Session = Depends(get_db)): +def create_restaurant(restaurant: RestaurantCreate, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user)): try: # Check if the venue exists venue_instance = Venue.from_create_schema(restaurant.venue) @@ -80,7 +94,11 @@ def create_restaurant(restaurant: RestaurantCreate, db: Session = Depends(get_db restaurant_instance = Restaurant.from_create_schema(venue_instance.id, restaurant) # Create the new Foodcourt record in the database create_record(db, restaurant_instance) - # Return the read schema for the created foodcourt + association = UserVenueAssociation( + user_id=current_user.id, + venue_id=venue_instance.id + ) + create_record(db, association) return restaurant_instance.to_read_schema() except Exception as e: @@ -95,7 +113,8 @@ def read_restaurants(skip: int = 0, limit: int = 10, db: Session = Depends(get_d # POST endpoint for Nightclub @router.post("/nightclubs/", response_model=NightclubRead) -def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db)): +def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user)): try: # Check if the venue exists venue_instance = Venue.from_create_schema(nightclub.venue) @@ -104,7 +123,11 @@ def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db)): nightclub_instance = Nightclub.from_create_schema(venue_instance.id, nightclub) # Create the new Foodcourt record in the database create_record(db, nightclub_instance) - # Return the read schema for the created foodcourt + association = UserVenueAssociation( + user_id=current_user.id, + venue_id=venue_instance.id + ) + create_record(db, association) return nightclub_instance.to_read_schema() except Exception as e: @@ -117,3 +140,52 @@ def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db)): def read_nightclubs(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return get_all_records(db, Nightclub, skip=skip, limit=limit) +@router.get("/my-venues/", response_model=VenueListResponse) +async def get_my_venues( + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user) +): + """ + Retrieve the venues managed by the current user, organized by venue type. + + This method leverages SQLAlchemy's efficient querying capabilities to minimize database load + while ensuring data integrity through the association table, UserVenueAssociation. + """ + + # Fetch all venues managed by the current user + managed_venues = ( + db.query(Venue) + .join(UserVenueAssociation) + .filter(UserVenueAssociation.user_id == current_user.id) + .all() + ) + + # Create a set for fast membership testing + managed_venue_ids = {venue.id for venue in managed_venues} + + # Initialize lists for categorized venues + nightclubs, qsrs, foodcourts, restaurants = [], [], [], [] + + # Efficiently query and convert Nightclubs + for nightclub in db.query(Nightclub).filter(Nightclub.venue_id.in_(managed_venue_ids)).all(): + nightclubs.append(nightclub.to_read_schema()) + + # Efficiently query and convert QSRs + for qsr in db.query(QSR).filter(QSR.venue_id.in_(managed_venue_ids)).all(): + qsrs.append(qsr.to_read_schema()) + + # Efficiently query and convert Foodcourts + for foodcourt in db.query(Foodcourt).filter(Foodcourt.venue_id.in_(managed_venue_ids)).all(): + foodcourts.append(foodcourt.to_read_schema()) + + # Efficiently query and convert Restaurants + for restaurant in db.query(Restaurant).filter(Restaurant.venue_id.in_(managed_venue_ids)).all(): + restaurants.append(restaurant.to_read_schema()) + + # Construct and return the response + return VenueListResponse( + nightclubs=nightclubs, + qsrs=qsrs, + foodcourts=foodcourts, + restaurants=restaurants + ) \ No newline at end of file diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py index 791b70fa60..830292ea7d 100644 --- a/backend/app/models/menu.py +++ b/backend/app/models/menu.py @@ -4,81 +4,52 @@ from sqlmodel import SQLModel, Field, Relationship from typing import List, Optional -class Menu(SQLModel, table=True): - __tablename__ = "menu" +class MenuItem(SQLModel, table=True): + __tablename__ = "menu_item" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + subcategory_id: uuid.UUID = Field(foreign_key="menu_subcategory.id",nullable=False) name: str = Field(nullable=False) + price: float = Field(nullable=False) description: Optional[str] = Field(default=None) - menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") - venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False) - - # Relationships - categories: List["MenuCategory"] = Relationship(back_populates="menu") - venue: "Venue" = Relationship(back_populates="menu") - - @classmethod - def from_create_schema(cls, schema: MenuCreate) -> "Menu": - return cls( - name=schema.name, - description=schema.description, - menu_type=schema.menu_type, - venue_id=schema.venue_id - ) - - @classmethod - def to_read_schema(cls, menu: "Menu") -> MenuRead: - for category in menu.categories: - print(f"lololo Category: {category}") - categories=[MenuCategory.to_read_schema(category) for category in menu.categories] - try: - ret = MenuRead( - menu_id=menu.id, - name=menu.name, - description=menu.description, - menu_type=menu.menu_type, - venue_id=menu.venue_id, - categories=categories - ) - print(ret) - except ValidationError as e: - print("Validation Error:", e.json()) - -class MenuCategory(SQLModel, table=True): - __tablename__ = "menu_category" - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - menu_id: uuid.UUID = Field(foreign_key="menu.id", nullable=False) - name: str = Field(nullable=False) + image_url: Optional[str] = Field(default=None) + is_veg: Optional[bool] = Field(default=None) + ingredients: Optional[str] = Field(default=None) + abv: Optional[float] = Field(default=None) + ibu: Optional[int] = Field(default=None) # Relationships - menu: "Menu" = Relationship(back_populates="categories") - sub_categories: List["MenuSubCategory"] = Relationship(back_populates="category") + subcategory: Optional["MenuSubCategory"] = Relationship(back_populates="menu_items") @classmethod - def from_create_schema(cls, schema: MenuCategoryCreate) -> "MenuCategory": + def from_create_schema(cls, schema: MenuItemCreate) -> "MenuItem": return cls( + subcategory_id=schema.subcategory_id, name=schema.name, - menu_id=schema.menu_id + price=schema.price, + description=schema.description, + image_url=schema.image_url, + is_veg=schema.is_veg, + ingredients=schema.ingredients, + abv=schema.abv, + ibu=schema.ibu ) @classmethod - def to_read_schema(cls, category: "MenuCategory") -> MenuCategoryRead: - print("Input Category: %s", category) - sub_categories=[ - MenuSubCategory.to_read_schema(sub) for sub in category.sub_categories or [] - ] - sub_categories = ( - [MenuSubCategory.to_read_schema(sub) for sub in category.sub_categories] - if category.sub_categories else None - ) - print(f"Category ID: {category.id}, Menu ID: {category.menu_id}, Name: {category.name}, Subcategories: {category.sub_categories}") - print("Subcategories: %s", sub_categories) # Log subcategories - return MenuCategoryRead( - category_id=category.id, - menu_id=category.menu_id, - name=category.name, - sub_categories=sub_categories + def to_read_schema(cls, item: "MenuItem") -> MenuItemRead: + return MenuItemRead( + item_id=item.id, + subcategory_id=item.subcategory_id, + name=item.name, + price=item.price, + description=item.description, + image_url=item.image_url, + is_veg=item.is_veg, + ingredients=item.ingredients, + abv=item.abv, + ibu=item.ibu ) +######################################################################################################### class MenuSubCategory(SQLModel, table=True): __tablename__ = "menu_subcategory" @@ -100,56 +71,75 @@ def from_create_schema(cls, schema: "MenuSubCategoryCreate") -> "MenuSubCategory ) @classmethod - def to_read_schema(cls, subcategory: "MenuSubCategory") -> "MenuSubCategoryRead": + def to_read_schema(cls, subcategory: "MenuSubCategory") -> MenuSubCategoryRead: return MenuSubCategoryRead( subcategory_id=subcategory.id, + category_id=subcategory.category_id, name=subcategory.name, - is_alcoholic=subcategory.is_alcoholic, # Include is_alcoholic in the read schema + is_alcoholic=subcategory.is_alcoholic, menu_items=[MenuItem.to_read_schema(item) for item in subcategory.menu_items] ) +######################################################################################################### -class MenuItem(SQLModel, table=True): - __tablename__ = "menu_item" +class MenuCategory(SQLModel, table=True): + __tablename__ = "menu_category" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - subcategory_id: uuid.UUID = Field(foreign_key="menu_subcategory.id",nullable=False) + menu_id: uuid.UUID = Field(foreign_key="menu.id", nullable=False) name: str = Field(nullable=False) - price: float = Field(nullable=False) - description: Optional[str] = Field(default=None) - image_url: Optional[str] = Field(default=None) - is_veg: Optional[bool] = Field(default=None) - ingredients: Optional[str] = Field(default=None) - abv: Optional[float] = Field(default=None) - ibu: Optional[int] = Field(default=None) # Relationships - subcategory: Optional["MenuSubCategory"] = Relationship(back_populates="menu_items") + menu: "Menu" = Relationship(back_populates="categories") + sub_categories: List["MenuSubCategory"] = Relationship(back_populates="category") @classmethod - def from_create_schema(cls, schema: MenuItemCreate) -> "MenuItem": + def from_create_schema(cls, schema: MenuCategoryCreate) -> "MenuCategory": + return cls( + name=schema.name, + menu_id=schema.menu_id + ) + + @classmethod + def to_read_schema(cls, category: "MenuCategory") -> MenuCategoryRead: + return MenuCategoryRead( + category_id=category.id, + menu_id=category.menu_id, + name=category.name, + sub_categories=[MenuSubCategory.to_read_schema(subcategory) for subcategory in category.sub_categories] + ) + +######################################################################################################### + +class Menu(SQLModel, table=True): + __tablename__ = "menu" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + name: str = Field(nullable=False) + description: Optional[str] = Field(default=None) + menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") + venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False) + + # Relationships + categories: List["MenuCategory"] = Relationship(back_populates="menu") + venue: "Venue" = Relationship(back_populates="menu") + + @classmethod + def from_create_schema(cls, schema: MenuCreate) -> "Menu": return cls( - subcategory_id=schema.subcategory_id, name=schema.name, - price=schema.price, description=schema.description, - image_url=schema.image_url, - is_veg=schema.is_veg, - ingredients=schema.ingredients, - abv=schema.abv, - ibu=schema.ibu + menu_type=schema.menu_type, + venue_id=schema.venue_id ) @classmethod - def to_read_schema(cls, item: "MenuItem") -> MenuItemRead: - return MenuItemRead( - item_id=item.id, - subcategory_id=item.subcategory_id, - name=item.name, - price=item.price, - description=item.description, - image_url=item.image_url, - is_veg=item.is_veg, - ingredients=item.ingredients, - abv=item.abv, - ibu=item.ibu - ) \ No newline at end of file + def to_read_schema(cls, menu: "Menu") -> MenuRead: + return MenuRead( + menu_id=menu.id, + name=menu.name, + description=menu.description, + menu_type=menu.menu_type, + venue_id=menu.venue_id, + categories=[MenuCategory.to_read_schema(category) for category in menu.categories] + ) + +######################################################################################################### \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 90ac576a77..a165387e56 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -8,8 +8,6 @@ # Shared properties class UserBase(BaseTimeModel): - email: EmailStr = Field(unique=True, nullable=True, index=True, max_length=255) - phone_number: Optional[str] = Field(unique=True, nullable=False,index=True,default=None) is_active: bool = True is_superuser: bool = False full_name: str | None = Field(default=None, max_length=255) @@ -18,6 +16,8 @@ class UserBase(BaseTimeModel): class UserPublic(UserBase, table=True): __tablename__ = "user_public" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + phone_number: Optional[str] = Field(unique=True, nullable=False,index=True,default=None) + email: Optional[EmailStr] = Field(default=None) date_of_birth: Optional[datetime] = Field(default=None) gender: Optional[str] = Field(default=None) registration_date: datetime = Field(nullable=False) @@ -40,20 +40,19 @@ class UserPublic(UserBase, table=True): class UserVenueAssociation(SQLModel, table=True): __tablename__ = "user_venue_association" - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) # Add a primary key - user_business_id: uuid.UUID = Field(foreign_key="user_business.id") - venue_id: uuid.UUID = Field(foreign_key="venue.id") - - # Additional fields can be added for tracking roles, timestamps, etc. + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) + venue_id: uuid.UUID = Field(foreign_key="venue.id", primary_key=True) role: Optional[str] = Field(default=None) # e.g., 'manager', 'owner' + + user: "UserBusiness" = Relationship(back_populates="venues_association") + venue: "Venue" = Relationship(back_populates="managing_users") - user_business: "UserBusiness" = Relationship(back_populates="venue_associations") - venue: "Venue" = Relationship(back_populates="user_associations") class UserBusiness(UserBase, table=True): __tablename__ = "user_business" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) registration_date: datetime = Field(nullable=False) - + email: EmailStr = Field(unique=True, nullable=False, index=True, max_length=255) + phone_number: Optional[str] = Field(default=None) # Relationships - venue_associations: List["UserVenueAssociation"] = Relationship(back_populates="user_business") - managed_venues: List["Venue"] = Relationship(back_populates="managing_users", link_model=UserVenueAssociation) \ No newline at end of file + venues_association: List[UserVenueAssociation] = Relationship(back_populates="user") \ No newline at end of file diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index a130ffa8c3..7e7164f2f0 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -27,8 +27,7 @@ class Venue(BaseTimeModel, table=True): zomato_link: Optional[str] = Field(default=None) swiggy_link: Optional[str] = Field(default=None) - user_associations: List["UserVenueAssociation"] = Relationship(back_populates="venue") - managing_users: List["UserBusiness"] = Relationship(back_populates="managed_venues", link_model=UserVenueAssociation) + managing_users: List["UserVenueAssociation"] = Relationship(back_populates="venue") qr_codes: List["QRCode"] = Relationship(back_populates="venue") menu: List["Menu"] = Relationship(back_populates="venue") pickup_locations: List["PickupLocation"] = Relationship(back_populates="venue") diff --git a/backend/app/schema/auth.py b/backend/app/schema/auth.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/schema/menu.py b/backend/app/schema/menu.py index 3ba083dfd6..1562a3bb0b 100644 --- a/backend/app/schema/menu.py +++ b/backend/app/schema/menu.py @@ -1,34 +1,7 @@ from typing import List, Optional import uuid -from app.schema.menu_category import MenuCategoryRead from pydantic import BaseModel, Field -# Base Schemas -class MenuRead(BaseModel): - menu_id: uuid.UUID # Unique identifier for the menu - name: str # Name of the menu (could be a restaurant menu or type of menu) - description: Optional[str] = None # Description of the menu - categories: Optional[List[MenuCategoryRead]] = None # Nested list of categories - venue_id: uuid.UUID # Foreign key to the venue - menu_type: Optional[str] = None - -class MenuCreate(BaseModel): - name: str # Name of the menu (could be a restaurant menu or type of menu) - description: Optional[str] = None # Description of the menu - venue_id: uuid.UUID # Foreign key to the venue - menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") - class Config: - from_attributes = True - -class MenuUpdate(BaseModel): - name: str # Name of the menu (could be a restaurant menu or type of menu) - description: Optional[str] = None # Description of the menu - menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") - class Config: - from_attributes = True - -#################################################################################################### - class MenuItemCreate(BaseModel): subcategory_id: uuid.UUID name: str @@ -69,25 +42,6 @@ class MenuItemUpdate(BaseModel): class Config: from_attributes = True -######################################################################################################### -class MenuCategoryRead(BaseModel): - category_id: uuid.UUID # Unique identifier for the category - name: str # Name of the category - menu_id : uuid.UUID - sub_categories: Optional[List["MenuSubCategoryRead"]] = None # List of subcategories - -class MenuCategoryCreate(BaseModel): - name: str # Name of the category - menu_id: uuid.UUID - class Config: - from_attributes = True - -class MenuCategoryUpdate(BaseModel): - name: Optional[str] = None # Category name can be updated - menu_id: Optional[uuid.UUID] = None # Menu ID can be updated - - class Config: - from_attributes = True ######################################################################################################### @@ -112,4 +66,51 @@ class MenuSubCategoryUpdate(BaseModel): menu_items: Optional[List["MenuItemUpdate"]] = [] # Optional list for updating menu items class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + +######################################################################################################### +class MenuCategoryRead(BaseModel): + category_id: uuid.UUID # Unique identifier for the category + name: str # Name of the category + menu_id : uuid.UUID + sub_categories: List[MenuSubCategoryRead] = [] # List of subcategories + +class MenuCategoryCreate(BaseModel): + name: str # Name of the category + menu_id: uuid.UUID + class Config: + from_attributes = True + +class MenuCategoryUpdate(BaseModel): + name: Optional[str] = None # Category name can be updated + menu_id: Optional[uuid.UUID] = None # Menu ID can be updated + + class Config: + from_attributes = True + +######################################################################################################### +class MenuRead(BaseModel): + menu_id: uuid.UUID # Unique identifier for the menu + name: str # Name of the menu (could be a restaurant menu or type of menu) + description: Optional[str] = None # Description of the menu + categories: List[MenuCategoryRead] = [] # Nested list of categories + venue_id: uuid.UUID # Foreign key to the venue + menu_type: Optional[str] = None + class Config: + from_attributes = True + +class MenuCreate(BaseModel): + name: str # Name of the menu (could be a restaurant menu or type of menu) + description: Optional[str] = None # Description of the menu + venue_id: uuid.UUID # Foreign key to the venue + menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") + class Config: + from_attributes = True + +class MenuUpdate(BaseModel): + name: str # Name of the menu (could be a restaurant menu or type of menu) + description: Optional[str] = None # Description of the menu + menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") + class Config: + from_attributes = True + \ No newline at end of file diff --git a/backend/app/schema/menu_category.py b/backend/app/schema/menu_category.py deleted file mode 100644 index 449accfa90..0000000000 --- a/backend/app/schema/menu_category.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import List, Optional -import uuid -from app.schema.menu_item import MenuItemCreate, MenuItemRead -from pydantic import BaseModel - - -class MenuCategoryRead(BaseModel): - name: str # Name of the category - menu_items: Optional[List[MenuItemRead]] = [] # List of items under this category - sub_categories: Optional[List["MenuSubCategoryRead"]] = [] # List of subcategories - -# Forward declaration for subcategory read schema to avoid circular imports -class MenuSubCategoryRead(BaseModel): - id: uuid.UUID # Unique identifier for the subcategory - name: str # Name of the subcategory - menu_items: Optional[List[MenuItemRead]] = [] # List of items under this subcategory - -class MenuCategoryCreate(BaseModel): - name: str # Name of the category - menu_items: Optional[List[MenuItemCreate]] = [] # List of items under this category - sub_categories: Optional[List["MenuSubCategoryCreate"]] = [] # List of subcategories - -# Forward declaration for subcategory to avoid circular imports -class MenuSubCategoryCreate(BaseModel): - name: str # Name of the subcategory - category_id: uuid.UUID # Foreign key to the parent category - menu_items: Optional[List[MenuItemCreate]] = [] # List of items under this subcategory \ No newline at end of file diff --git a/backend/app/schema/menu_item.py b/backend/app/schema/menu_item.py deleted file mode 100644 index c3ccc2d876..0000000000 --- a/backend/app/schema/menu_item.py +++ /dev/null @@ -1,31 +0,0 @@ -from pydantic import BaseModel -from typing import List, Optional -import uuid -from datetime import datetime - -# Request schema for creating a menu item -class MenuItemCreate(BaseModel): - category_id: uuid.UUID - subcategory_id: Optional[uuid.UUID] = None - name: str - price: float - description: Optional[str] = None - image_url: Optional[str] = None - is_veg: Optional[bool] = None - ingredients: Optional[str] = None - abv: Optional[float] = None - ibu: Optional[int] = None - -# Response schema for a menu item -class MenuItemRead(BaseModel): - id: uuid.UUID - category_id: uuid.UUID - subcategory_id: Optional[uuid.UUID] = None - name: str - price: float - description: Optional[str] = None - image_url: Optional[str] = None - is_veg: Optional[bool] = None - ingredients: Optional[str] = None - abv: Optional[float] = None - ibu: Optional[int] = None diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index b724b92e5b..03e5a06e34 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -99,4 +99,10 @@ class NightclubRead(BaseModel): age_limit: Optional[int] = None venue: VenueRead class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + +class VenueListResponse(BaseModel): + nightclubs: List[NightclubRead] + qsrs: List[QSRRead] + foodcourts: List[FoodcourtRead] + restaurants: List[RestaurantRead] \ No newline at end of file diff --git a/backend/app/tests/api/routes/test_users.py b/backend/app/tests/api/routes/test_users.py index ba9be65426..15d4a4d92e 100644 --- a/backend/app/tests/api/routes/test_users.py +++ b/backend/app/tests/api/routes/test_users.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from sqlmodel import Session, select -from app import crud +from app import util from app.core.config import settings from app.core.security import verify_password from app.models import User, UserCreate @@ -51,7 +51,7 @@ def test_create_user_new_email( ) assert 200 <= r.status_code < 300 created_user = r.json() - user = crud.get_user_by_email(session=db, email=username) + user = util.get_user_by_email(session=db, email=username) assert user assert user.email == created_user["email"] @@ -62,7 +62,7 @@ def test_get_existing_user( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) user_id = user.id r = client.get( f"{settings.API_V1_STR}/users/{user_id}", @@ -70,7 +70,7 @@ def test_get_existing_user( ) assert 200 <= r.status_code < 300 api_user = r.json() - existing_user = crud.get_user_by_email(session=db, email=username) + existing_user = util.get_user_by_email(session=db, email=username) assert existing_user assert existing_user.email == api_user["email"] @@ -79,7 +79,7 @@ def test_get_existing_user_current_user(client: TestClient, db: Session) -> None username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) user_id = user.id login_data = { @@ -97,7 +97,7 @@ def test_get_existing_user_current_user(client: TestClient, db: Session) -> None ) assert 200 <= r.status_code < 300 api_user = r.json() - existing_user = crud.get_user_by_email(session=db, email=username) + existing_user = util.get_user_by_email(session=db, email=username) assert existing_user assert existing_user.email == api_user["email"] @@ -120,7 +120,7 @@ def test_create_user_existing_username( # username = email password = random_lower_string() user_in = UserCreate(email=username, password=password) - crud.create_user(session=db, user_create=user_in) + util.create_user(session=db, user_create=user_in) data = {"email": username, "password": password} r = client.post( f"{settings.API_V1_STR}/users/", @@ -152,12 +152,12 @@ def test_retrieve_users( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - crud.create_user(session=db, user_create=user_in) + util.create_user(session=db, user_create=user_in) username2 = random_email() password2 = random_lower_string() user_in2 = UserCreate(email=username2, password=password2) - crud.create_user(session=db, user_create=user_in2) + util.create_user(session=db, user_create=user_in2) r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers) all_users = r.json() @@ -251,7 +251,7 @@ def test_update_user_me_email_exists( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) data = {"email": user.email} r = client.patch( @@ -326,7 +326,7 @@ def test_update_user( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) data = {"full_name": "Updated_full_name"} r = client.patch( @@ -365,12 +365,12 @@ def test_update_user_email_exists( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) username2 = random_email() password2 = random_lower_string() user_in2 = UserCreate(email=username2, password=password2) - user2 = crud.create_user(session=db, user_create=user_in2) + user2 = util.create_user(session=db, user_create=user_in2) data = {"email": user2.email} r = client.patch( @@ -386,7 +386,7 @@ def test_delete_user_me(client: TestClient, db: Session) -> None: username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) user_id = user.id login_data = { @@ -431,7 +431,7 @@ def test_delete_user_super_user( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) user_id = user.id r = client.delete( f"{settings.API_V1_STR}/users/{user_id}", @@ -458,7 +458,7 @@ def test_delete_user_not_found( def test_delete_user_current_super_user_error( client: TestClient, superuser_token_headers: dict[str, str], db: Session ) -> None: - super_user = crud.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER) + super_user = util.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER) assert super_user user_id = super_user.id @@ -476,7 +476,7 @@ def test_delete_user_without_privileges( username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) r = client.delete( f"{settings.API_V1_STR}/users/{user.id}", diff --git a/backend/app/tests/crud/test_user.py b/backend/app/tests/crud/test_user.py index e9eb4a0391..3e974909af 100644 --- a/backend/app/tests/crud/test_user.py +++ b/backend/app/tests/crud/test_user.py @@ -1,7 +1,7 @@ from fastapi.encoders import jsonable_encoder from sqlmodel import Session -from app import crud +from app import util from app.core.security import verify_password from app.models import User, UserCreate, UserUpdate from app.tests.utils.utils import random_email, random_lower_string @@ -11,7 +11,7 @@ def test_create_user(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) assert user.email == email assert hasattr(user, "hashed_password") @@ -20,8 +20,8 @@ def test_authenticate_user(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.create_user(session=db, user_create=user_in) - authenticated_user = crud.authenticate(session=db, email=email, password=password) + user = util.create_user(session=db, user_create=user_in) + authenticated_user = util.authenticate(session=db, email=email, password=password) assert authenticated_user assert user.email == authenticated_user.email @@ -29,7 +29,7 @@ def test_authenticate_user(db: Session) -> None: def test_not_authenticate_user(db: Session) -> None: email = random_email() password = random_lower_string() - user = crud.authenticate(session=db, email=email, password=password) + user = util.authenticate(session=db, email=email, password=password) assert user is None @@ -37,7 +37,7 @@ def test_check_if_user_is_active(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) assert user.is_active is True @@ -45,7 +45,7 @@ def test_check_if_user_is_active_inactive(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password, disabled=True) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) assert user.is_active @@ -53,7 +53,7 @@ def test_check_if_user_is_superuser(db: Session) -> None: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password, is_superuser=True) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) assert user.is_superuser is True @@ -61,7 +61,7 @@ def test_check_if_user_is_superuser_normal_user(db: Session) -> None: username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) assert user.is_superuser is False @@ -69,7 +69,7 @@ def test_get_user(db: Session) -> None: password = random_lower_string() username = random_email() user_in = UserCreate(email=username, password=password, is_superuser=True) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) user_2 = db.get(User, user.id) assert user_2 assert user.email == user_2.email @@ -80,11 +80,11 @@ def test_update_user(db: Session) -> None: password = random_lower_string() email = random_email() user_in = UserCreate(email=email, password=password, is_superuser=True) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) new_password = random_lower_string() user_in_update = UserUpdate(password=new_password, is_superuser=True) if user.id is not None: - crud.update_user(session=db, db_user=user, user_in=user_in_update) + util.update_user(session=db, db_user=user, user_in=user_in_update) user_2 = db.get(User, user.id) assert user_2 assert user.email == user_2.email diff --git a/backend/app/tests/utils/item.py b/backend/app/tests/utils/item.py index 6e32b3a84a..1016fd6072 100644 --- a/backend/app/tests/utils/item.py +++ b/backend/app/tests/utils/item.py @@ -1,6 +1,6 @@ from sqlmodel import Session -from app import crud +from app import util from app.models import Item, ItemCreate from app.tests.utils.user import create_random_user from app.tests.utils.utils import random_lower_string @@ -13,4 +13,4 @@ def create_random_item(db: Session) -> Item: title = random_lower_string() description = random_lower_string() item_in = ItemCreate(title=title, description=description) - return crud.create_item(session=db, item_in=item_in, owner_id=owner_id) + return util.create_item(session=db, item_in=item_in, owner_id=owner_id) diff --git a/backend/app/tests/utils/user.py b/backend/app/tests/utils/user.py index 9c1b073109..95fb061f8b 100644 --- a/backend/app/tests/utils/user.py +++ b/backend/app/tests/utils/user.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient from sqlmodel import Session -from app import crud +from app import util from app.core.config import settings from app.models import User, UserCreate, UserUpdate from app.tests.utils.utils import random_email, random_lower_string @@ -23,7 +23,7 @@ def create_random_user(db: Session) -> User: email = random_email() password = random_lower_string() user_in = UserCreate(email=email, password=password) - user = crud.create_user(session=db, user_create=user_in) + user = util.create_user(session=db, user_create=user_in) return user @@ -36,14 +36,14 @@ def authentication_token_from_email( If the user doesn't exist it is created first. """ password = random_lower_string() - user = crud.get_user_by_email(session=db, email=email) + user = util.get_user_by_email(session=db, email=email) if not user: user_in_create = UserCreate(email=email, password=password) - user = crud.create_user(session=db, user_create=user_in_create) + user = util.create_user(session=db, user_create=user_in_create) else: user_in_update = UserUpdate(password=password) if not user.id: raise Exception("User id not set") - user = crud.update_user(session=db, db_user=user, user_in=user_in_update) + user = util.update_user(session=db, db_user=user, user_in=user_in_update) return user_authentication_headers(client=client, email=email, password=password) diff --git a/backend/app/crud.py b/backend/app/util.py similarity index 85% rename from backend/app/crud.py rename to backend/app/util.py index 3f8eb2f08f..a1ca49f286 100644 --- a/backend/app/crud.py +++ b/backend/app/util.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone import logging import uuid +from app.models.user import UserBusiness, UserVenueAssociation from fastapi import HTTPException from pydantic import BaseModel from sqlmodel import SQLModel, Session, select @@ -163,4 +164,35 @@ def delete_record(db: Session, instance: SQLModel) -> None: :return: None """ db.delete(instance) - db.commit() \ No newline at end of file + db.commit() + + +def check_user_permission(db: Session, current_user: UserBusiness, venue_id: uuid.UUID): + """ + Check if the user has permission to manage the specified venue. + + Args: + db: Database session. + current_user: The current user object. + venue_id: The ID of the venue to check permissions for. + + Raises: + HTTPException: If the user does not have permission. + + Returns: + UserVenueAssociation: The association record if it exists. + """ + statement = ( + select(UserVenueAssociation) + .where( + UserVenueAssociation.user_id == current_user.id, + UserVenueAssociation.venue_id == venue_id + ) + ) + + user_venue_association = db.execute(statement).scalars().first() + + if user_venue_association is None: + raise HTTPException(status_code=403, detail="User does not have permission to manage this venue.") + + return user_venue_association \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock index 8d17ab3931..b60ec2ed79 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -2418,18 +2418,18 @@ files = [ [[package]] name = "pydantic" -version = "2.8.2" +version = "2.9.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, - {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, ] [package.dependencies] -annotated-types = ">=0.4.0" -pydantic-core = "2.20.1" +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" typing-extensions = [ {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, @@ -2437,103 +2437,104 @@ typing-extensions = [ [package.extras] email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.20.1" +version = "2.23.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, - {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, - {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, - {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, - {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, - {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, - {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, - {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, - {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, - {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, - {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, - {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, - {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, - {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, - {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, - {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, - {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, - {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, - {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, - {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, - {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, - {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, - {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, - {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, - {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, - {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, - {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, - {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, - {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, - {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, - {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, - {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, - {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, - {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, - {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, - {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, - {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, - {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, - {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, ] [package.dependencies] @@ -3531,4 +3532,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "98bf913f4679b721df205fa9a85e0e4d1a5555720d6de0298149adc1f5df2c6f" +content-hash = "bc72a371dfdc03c3879c619c44098140714cadd5df2825226834ce1b684f6eac" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e04879f4cb..599fcedd25 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,7 +12,7 @@ python-multipart = "0.0.9" email-validator = "^2.1.0.post1" passlib = {extras = ["bcrypt"], version = "^1.7.4"} tenacity = "^8.2.3" -pydantic = ">2.0" +pydantic = "^2.9.2" emails = "^0.6" psycopg2 = "^2.9.6" From 9f6027108390c6c3ff1ee5f5b7ba7f3e0ca55235 Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Sat, 2 Nov 2024 01:46:59 +0530 Subject: [PATCH 08/25] Added ruff linter --- .../20cc9e9039fd_create_qr_codes_table.py | 29 ++ ...a4_add_registration_date_to_user_public.py | 63 ++++ .../a1c01df04ba4_create_qrcode_table.py | 47 +++ .../bef89635a1b9_create_qr_codes_table.py | 37 +++ backend/app/api/deps.py | 57 ++-- backend/app/api/main.py | 5 +- backend/app/api/routes/login.py | 47 +-- backend/app/api/routes/menu.py | 279 +++++++++++------- backend/app/api/routes/qrcode.py | 163 +++++++--- backend/app/api/routes/users.py | 196 ++++++++---- backend/app/api/routes/venues.py | 117 +++++--- backend/app/constants.py | 8 + backend/app/core/config.py | 1 + backend/app/core/db.py | 7 +- backend/app/core/security.py | 13 +- backend/app/initial_data.py | 4 +- backend/app/main.py | 1 - backend/app/models/__init__.py | 44 ++- backend/app/models/auth.py | 26 +- backend/app/models/base_model.py | 39 ++- backend/app/models/club_visit.py | 24 +- backend/app/models/event.py | 23 +- backend/app/models/event_booking.py | 24 +- backend/app/models/event_offering.py | 19 +- backend/app/models/group.py | 39 ++- backend/app/models/group_wallet.py | 16 +- backend/app/models/group_wallet_topup.py | 14 +- backend/app/models/menu.py | 130 ++++---- backend/app/models/order.py | 75 +++-- backend/app/models/order_item.py | 30 +- backend/app/models/payment.py | 42 ++- backend/app/models/pickup_location.py | 15 +- backend/app/models/qrcode.py | 42 ++- backend/app/models/user.py | 131 ++++++-- backend/app/models/venue.py | 154 ++++++---- backend/app/schema/menu.py | 121 ++++---- backend/app/schema/qrcode.py | 37 ++- backend/app/schema/user.py | 66 +++++ backend/app/schema/venue.py | 112 ++++--- backend/app/util.py | 119 +++----- backend/app/utils.py | 22 +- backend/poetry.lock | 159 +++++++++- backend/pyproject.toml | 3 + 43 files changed, 1797 insertions(+), 803 deletions(-) create mode 100644 backend/app/alembic/versions/20cc9e9039fd_create_qr_codes_table.py create mode 100644 backend/app/alembic/versions/91f6f2bc36a4_add_registration_date_to_user_public.py create mode 100644 backend/app/alembic/versions/a1c01df04ba4_create_qrcode_table.py create mode 100644 backend/app/alembic/versions/bef89635a1b9_create_qr_codes_table.py create mode 100644 backend/app/constants.py create mode 100644 backend/app/schema/user.py diff --git a/backend/app/alembic/versions/20cc9e9039fd_create_qr_codes_table.py b/backend/app/alembic/versions/20cc9e9039fd_create_qr_codes_table.py new file mode 100644 index 0000000000..1ec5e79bcc --- /dev/null +++ b/backend/app/alembic/versions/20cc9e9039fd_create_qr_codes_table.py @@ -0,0 +1,29 @@ +"""Create qrcode table + +Revision ID: 20cc9e9039fd +Revises: 91f6f2bc36a4 +Create Date: 2024-11-01 10:17:32.083891 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '20cc9e9039fd' +down_revision = '91f6f2bc36a4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/91f6f2bc36a4_add_registration_date_to_user_public.py b/backend/app/alembic/versions/91f6f2bc36a4_add_registration_date_to_user_public.py new file mode 100644 index 0000000000..701da3f637 --- /dev/null +++ b/backend/app/alembic/versions/91f6f2bc36a4_add_registration_date_to_user_public.py @@ -0,0 +1,63 @@ +"""Add registration_date to user_public + +Revision ID: 91f6f2bc36a4 +Revises: 4951a24acf4c +Create Date: 2024-10-30 21:14:14.300480 + +""" +from alembic import op +from sqlalchemy.dialects.postgresql import ENUM +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '91f6f2bc36a4' +down_revision = '4951a24acf4c' +branch_labels = None +depends_on = None + +gender_enum = ENUM('male', 'female', 'others', name='gender') + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + gender_enum.create(op.get_bind()) + op.drop_table('carousel_poster') + op.drop_column('user_business', 'registration_date') + op.alter_column('user_public', 'gender', + type_=gender_enum, + existing_type=sa.String(), # Change to the existing type + existing_nullable=True, + postgresql_using='gender::gender') # If you need to convert existing data + + op.drop_column('user_public', 'registration_date') + op.drop_column('venue', 'h3_index') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venue', sa.Column('h3_index', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('user_public', sa.Column('registration_date', postgresql.TIMESTAMP(), autoincrement=False, nullable=False)) + op.drop_column('user_public', 'gender') + gender_enum.drop(op.get_bind()) + op.add_column('user_business', sa.Column('registration_date', postgresql.TIMESTAMP(), autoincrement=False, nullable=False)) + op.create_table('carousel_poster', + sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False), + sa.Column('image_url', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('deep_link', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('event_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('nightclub_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('foodcourt_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('qsr_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('restaurant_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('h3_index', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['event_id'], ['event.id'], name='carousel_poster_event_id_fkey'), + sa.ForeignKeyConstraint(['foodcourt_id'], ['foodcourt.id'], name='carousel_poster_foodcourt_id_fkey'), + sa.ForeignKeyConstraint(['nightclub_id'], ['nightclub.id'], name='carousel_poster_nightclub_id_fkey'), + sa.ForeignKeyConstraint(['qsr_id'], ['qsr.id'], name='carousel_poster_qsr_id_fkey'), + sa.ForeignKeyConstraint(['restaurant_id'], ['restaurant.id'], name='carousel_poster_restaurant_id_fkey'), + sa.PrimaryKeyConstraint('id', name='carousel_poster_pkey') + ) + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/a1c01df04ba4_create_qrcode_table.py b/backend/app/alembic/versions/a1c01df04ba4_create_qrcode_table.py new file mode 100644 index 0000000000..c404b005de --- /dev/null +++ b/backend/app/alembic/versions/a1c01df04ba4_create_qrcode_table.py @@ -0,0 +1,47 @@ +"""Create qrcode table + +Revision ID: a1c01df04ba4 +Revises: bef89635a1b9 +Create Date: 2024-11-01 13:29:27.200014 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a1c01df04ba4' +down_revision = 'bef89635a1b9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('qrcode', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=True), + sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('qr_codes') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('qr_codes', + sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('venue_id', sa.UUID(), autoincrement=False, nullable=True), + sa.Column('table_number', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], name='qr_codes_venue_id_fkey'), + sa.PrimaryKeyConstraint('id', name='qr_codes_pkey') + ) + op.drop_table('qrcode') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/bef89635a1b9_create_qr_codes_table.py b/backend/app/alembic/versions/bef89635a1b9_create_qr_codes_table.py new file mode 100644 index 0000000000..c412895ff4 --- /dev/null +++ b/backend/app/alembic/versions/bef89635a1b9_create_qr_codes_table.py @@ -0,0 +1,37 @@ +"""Create qrcode table + +Revision ID: bef89635a1b9 +Revises: 20cc9e9039fd +Create Date: 2024-11-01 10:26:05.045546 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'bef89635a1b9' +down_revision = '20cc9e9039fd' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('qrcode', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('venue_id', sa.Uuid(), nullable=True), + sa.Column('table_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('qrcode') + # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5f65d667e8..57bfe25812 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,12 +1,16 @@ -from app.models.auth import TokenBlacklist -from fastapi import Depends, HTTPException, Query, status +from collections.abc import Generator +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import ( + HTTPAuthorizationCredentials, + HTTPBearer, +) from sqlalchemy.orm import Session -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer -from app.models.user import UserPublic, UserBusiness -from app.core.security import get_jwt_payload -from typing import Annotated, Generator, Optional, Union -from app.core.config import settings + from app.core.db import engine +from app.core.security import get_jwt_payload +from app.models.user import UserBusiness, UserPublic # OAuth2PasswordBearer to extract the token from the request header bearer_scheme = HTTPBearer() @@ -18,38 +22,11 @@ def get_db() -> Generator[Session, None, None]: SessionDep = Annotated[Session, Depends(get_db)] -# # Check if the token is blacklisted (optional) -# def is_token_blacklisted(session: Session, user_id: str, provided_token: str) -> bool: -# """ -# Checks if the provided token is blacklisted by comparing it with the one stored in the user record. - -# Args: -# session (Session): SQLAlchemy session to query the database. -# user_id (str): The ID of the user. -# provided_token (str): The provided refresh token to check. - -# Returns: -# bool: True if the token is blacklisted (invalid), False otherwise. -# """ -# # Fetch the user from the database using the user_id -# user = session.query(UserPublic).filter(UserPublic.id == user_id).first() - -# if not user: -# raise HTTPException(status_code=404, detail="User not found") - -# # Check if the provided token matches the stored refresh token -# if user.refresh_token != provided_token: -# # The token does not match, consider it invalid or blacklisted -# return True - -# # If the token matches, it's not blacklisted -# return False - # Dependency to get the current user async def get_current_user( credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], session: SessionDep -) -> Union[UserPublic, UserBusiness]: +) -> UserPublic | UserBusiness: # print('credentials.credentials ', credentials.credentials) try: print('credentials.credentials ', credentials.credentials) @@ -87,10 +64,10 @@ async def get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" ) - + # Dependency to get the business user async def get_business_user( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], session: SessionDep ) -> UserBusiness: current_user = await get_current_user(credentials, session) @@ -103,7 +80,7 @@ async def get_business_user( # Dependency to get the superuser async def get_super_user( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], session: SessionDep ) -> UserBusiness: current_user = await get_business_user(credentials, session) @@ -116,7 +93,7 @@ async def get_super_user( # Dependency to get any public user async def get_public_user( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], session: SessionDep ) -> UserPublic: print('credentials.credentials ', credentials.credentials) @@ -127,4 +104,4 @@ async def get_public_user( status_code=status.HTTP_403_FORBIDDEN, detail="Not a public user", ) - return current_user \ No newline at end of file + return current_user diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 51afe9fa1f..e239e81226 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,11 +1,10 @@ from fastapi import APIRouter -from app.api.routes import venues, menu, users, login,qrcode - +from app.api.routes import login, menu, qrcode, users, venues api_router = APIRouter() api_router.include_router(venues.router, prefix="/venue", tags=["venue"]) api_router.include_router(menu.router, prefix="/menu", tags=["menu"]) api_router.include_router(users.router, prefix="/user", tags=["user"]) api_router.include_router(login.router, tags=["login"]) -api_router.include_router(qrcode.router, tags=["qrcode"]) +api_router.include_router(qrcode.router, prefix="/qrcode",tags=["qrcode"]) diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 7b722aff5e..643515827b 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -1,18 +1,25 @@ -from typing import Annotated, Union -from app.models.auth import RefreshTokenPayload, UserAuthResponse -from fastapi import APIRouter, Depends, FastAPI, HTTPException -import OTPLessAuthSDK -from app.models.user import UserBusiness, UserPublic # Import your UserPublic model -from app.api.deps import SessionDep, get_current_user +import hashlib from datetime import datetime, timedelta, timezone -from app.core.security import create_access_token, create_refresh_token, get_jwt_payload # Adjust the import based on your structure -from app.core.config import settings -from app.models.auth import OtplessToken -from fastapi.datastructures import QueryParams +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import SessionDep, get_current_user +from app.core.security import ( # Adjust the import based on your structure + create_access_token, + create_refresh_token, + get_jwt_payload, +) +from app.models.auth import OtplessToken, RefreshTokenPayload, UserAuthResponse +from app.models.user import UserBusiness, UserPublic # Import your UserPublic model router = APIRouter() +def generate_number_from_string(s): + return int(hashlib.sha256(s.encode()).hexdigest(), 16) % 10 ** 8 + + @router.post("/verify_token/business", response_model=UserAuthResponse) async def business_user_google_login(request: OtplessToken, session: SessionDep): try: @@ -37,8 +44,7 @@ async def business_user_google_login(request: OtplessToken, session: SessionDep) # Create new user if not found user = UserBusiness( email=email, - is_active=True, - registration_date=datetime.now(timezone.utc), + is_active=True ) session.add(user) session.commit() @@ -61,7 +67,7 @@ async def business_user_google_login(request: OtplessToken, session: SessionDep) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - + @router.post("/verify_token/public", response_model=UserAuthResponse) async def verify_token(request: OtplessToken, session: SessionDep): try: @@ -74,7 +80,8 @@ async def verify_token(request: OtplessToken, session: SessionDep): # ) # Simulated user details for demonstration - phone_number = "8130181469" # Replace this with the actual phone number from the SDK response + # phone_number = "8130181469" # Replace this with the actual phone number from the SDK response + phone_number = str(generate_number_from_string(request.otpless_token)) # Check for the user by phone number user = session.query(UserPublic).filter(UserPublic.phone_number == phone_number).first() @@ -83,8 +90,7 @@ async def verify_token(request: OtplessToken, session: SessionDep): # Create a new user if not found user = UserPublic( phone_number=phone_number, - is_active=True, - registration_date=datetime.now(timezone.utc), + is_active=True ) session.add(user) session.commit() @@ -107,7 +113,7 @@ async def verify_token(request: OtplessToken, session: SessionDep): except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - + @router.post("/refresh_token", response_model=UserAuthResponse) async def refresh_token(request: RefreshTokenPayload, session: SessionDep): @@ -117,7 +123,7 @@ async def refresh_token(request: RefreshTokenPayload, session: SessionDep): # Extract the user ID (sub) from the payload user_id = payload.sub - + # Fetch the user from the database using the user ID user = ( session.query(UserPublic) @@ -128,7 +134,7 @@ async def refresh_token(request: RefreshTokenPayload, session: SessionDep): .filter(UserBusiness.id == user_id) .first() ) - + if not user: raise HTTPException(status_code=404, detail="User not found") @@ -163,7 +169,7 @@ async def refresh_token(request: RefreshTokenPayload, session: SessionDep): @router.get("/logout") async def logout( session: SessionDep, - current_user: Annotated[Union[UserPublic, UserBusiness], Depends(get_current_user)]): + current_user: Annotated[UserPublic | UserBusiness, Depends(get_current_user)]): try: # Invalidate the refresh token by setting it to None or an empty string current_user.refresh_token = None @@ -174,4 +180,3 @@ async def logout( except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - \ No newline at end of file diff --git a/backend/app/api/routes/menu.py b/backend/app/api/routes/menu.py index 66499c4947..f845b7f7a1 100644 --- a/backend/app/api/routes/menu.py +++ b/backend/app/api/routes/menu.py @@ -1,52 +1,80 @@ import uuid -from app.schema.menu import MenuCategoryCreate, MenuCategoryRead, MenuCategoryUpdate, MenuCreate, MenuItemCreate, MenuItemRead, MenuItemUpdate, MenuRead, MenuSubCategoryCreate, MenuSubCategoryRead, MenuSubCategoryUpdate, MenuUpdate + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from app.api.deps import get_current_user, get_db from app.models.menu import Menu, MenuCategory, MenuItem, MenuSubCategory +from app.models.user import UserBusiness, UserPublic from app.models.venue import Venue -from app.models.user import UserBusiness, UserPublic, UserVenueAssociation -from sqlmodel import Session,select -from fastapi import APIRouter, Depends, HTTPException -from typing import List -from app.api.deps import SessionDep, get_current_user +from app.schema.menu import ( + MenuCategoryCreate, + MenuCategoryRead, + MenuCategoryUpdate, + MenuCreate, + MenuItemCreate, + MenuItemRead, + MenuItemUpdate, + MenuRead, + MenuSubCategoryCreate, + MenuSubCategoryRead, + MenuSubCategoryUpdate, + MenuUpdate, +) from app.util import ( - get_record_by_id, + check_user_permission, create_record, - update_record, delete_record, - check_user_permission + get_record_by_id, + update_record, ) -from app.api.deps import get_db + router = APIRouter() + # Get all menus of a specific venue -@router.get("/all/{venue_id}", response_model=List[MenuRead]) -async def read_menus(venue_id: uuid.UUID, db: Session = Depends(get_db), - current_user: UserPublic = Depends(get_current_user)): +@router.get("/all/{venue_id}", response_model=list[MenuRead]) +async def read_menus( + venue_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserPublic = Depends(get_current_user), # noqa: ARG001 +): """ Retrieve all menus for a specific venue. """ # Query the Menu table for menus associated with the specified venue statement = select(Menu).where(Menu.venue_id == venue_id) menus = db.execute(statement).scalars().all() # Execute the query - + if not menus: raise HTTPException(status_code=404, detail="No menus found for this venue.") - - return [Menu.to_read_schema(menu) for menu in menus] + + assert isinstance(menus[0], Menu), "Fetched Menu object is not of type Menu" + + return [menu.to_read_schema() for menu in menus] + @router.get("/menu/{menu_id}", response_model=MenuRead) -async def read_menu(menu_id: uuid.UUID, db: Session = Depends(get_db), - current_user: UserPublic = Depends(get_current_user)): +async def read_menu( + menu_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserPublic = Depends(get_current_user), # noqa: ARG001 +): """ Retrieve a specific menu by its ID. """ menu = get_record_by_id(db, Menu, menu_id) - ret = Menu.to_read_schema(menu) - return ret + + assert isinstance(menu, Menu), "The returned object is not of type Menu" + return menu.to_read_schema() @router.post("/", response_model=MenuRead) -async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): +async def create_menu( + menu_create: MenuCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Create a new menu for a specific venue. """ @@ -54,10 +82,10 @@ async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db), venue = get_record_by_id(db, Venue, menu_create.venue_id) if not venue: raise HTTPException(status_code=404, detail="Venue not found.") - + # Check if the user has permission to create a menu for this venue check_user_permission(db, current_user, menu_create.venue_id) - + try: # Create the Menu object menu_instance = Menu.from_create_schema(menu_create) @@ -65,22 +93,24 @@ async def create_menu(menu_create: MenuCreate, db: Session = Depends(get_db), # Use the create_record helper to save the menu to the database created_menu = create_record(db, menu_instance) - return Menu.to_read_schema(created_menu) - + assert isinstance(created_menu, Menu), "The returned object is not of type Menu" + return created_menu.to_read_schema() + except Exception as e: db.rollback() # Rollback in case of error raise HTTPException(status_code=400, detail=f"Error creating menu: {str(e)}") + @router.patch("/{menu_id}", response_model=MenuRead) async def update_menu( - menu_id: uuid.UUID, - menu_update: MenuUpdate, + menu_id: uuid.UUID, + menu_update: MenuUpdate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user) + current_user: UserBusiness = Depends(get_current_user), ): """ Update an existing menu's details using a partial update (PATCH). - + :param menu_id: The ID of the menu to update. :param menu_update: The fields to update, provided as a Pydantic model. :param db: Active database session. @@ -89,19 +119,24 @@ async def update_menu( # Retrieve the menu by its ID menu_instance = get_record_by_id(db, Menu, menu_id) # Check if the user has permission to update a menu for this venue - check_user_permission(db, current_user, menu_instance.venue_id) - + if not menu_instance: raise HTTPException(status_code=404, detail="Menu not found.") - + + check_user_permission(db, current_user, menu_instance.venue_id) + # Update the menu using the validated fields from MenuUpdate updated_menu = update_record(db, menu_instance, menu_update) - - return Menu.to_read_schema(updated_menu) + assert isinstance(updated_menu, Menu), "The returned object is not of type Menu" + return updated_menu.to_read_schema() + @router.delete("/{menu_id}", response_model=dict) -async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): +async def delete_menu( + menu_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Delete a menu by its ID. @@ -117,49 +152,56 @@ async def delete_menu(menu_id: uuid.UUID, db: Session = Depends(get_db), check_user_permission(db, current_user, menu_instance.venue_id) delete_record(db, menu_instance) - + return {"detail": "Menu deleted successfully."} + ############################################################################################################## + @router.post("/category", response_model=MenuCategoryRead) async def create_menu_category( - category_create: MenuCategoryCreate, + category_create: MenuCategoryCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): + current_user: UserBusiness = Depends(get_current_user), +): """ Create a new menu category associated with a menu. - + :param category_create: The details for the new menu category, provided as a Pydantic model. :param db: Active database session. :return: The created MenuCategory as a response. """ # Check if the menu exists menu = get_record_by_id(db, Menu, category_create.menu_id) - + if not menu: raise HTTPException(status_code=404, detail="Menu not found.") # Check if the user has permission to update a menu for this venue check_user_permission(db, current_user, menu.venue_id) - + # Create a new MenuCategory instance from the provided data category_instance = MenuCategory.from_create_schema(category_create) - + # Persist the new category in the database created_category = create_record(db, category_instance) - - return MenuCategory.to_read_schema(created_category) + + assert isinstance( + created_category, MenuCategory + ), "The returned object is not of type MenuCategory" + return created_category.to_read_schema() + @router.patch("/category/{category_id}", response_model=MenuCategoryRead) async def update_menu_category( - category_id: uuid.UUID, - category_update: MenuCategoryUpdate, + category_id: uuid.UUID, + category_update: MenuCategoryUpdate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): - + current_user: UserBusiness = Depends(get_current_user), +): """ Update an existing menu category's details using a partial update (PATCH). - + :param category_id: The ID of the menu category to update. :param category_update: The fields to update, provided as a Pydantic model. :param db: Active database session. @@ -167,20 +209,27 @@ async def update_menu_category( """ # Retrieve the category by its ID category_instance = get_record_by_id(db, MenuCategory, category_id) - + if not category_instance: raise HTTPException(status_code=404, detail="Menu category not found.") - + check_user_permission(db, current_user, category_instance.menu.venue_id) # Update the category using the validated fields from MenuCategoryUpdate updated_category = update_record(db, category_instance, category_update) - - return MenuCategory.to_read_schema(updated_category) + + assert isinstance( + updated_category, MenuCategory + ), "The returned object is not of type MenuCategory" + return updated_category.to_read_schema() + @router.delete("/category/{category_id}", response_model=dict) -async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): +async def delete_category( + category_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Delete a menu category by its ID. @@ -189,52 +238,60 @@ async def delete_category(category_id: uuid.UUID, db: Session = Depends(get_db), :return: Confirmation message on successful deletion. """ category = get_record_by_id(db, MenuCategory, category_id) - + if not category: raise HTTPException(status_code=404, detail="Category not found.") - + check_user_permission(db, current_user, category.menu.venue_id) delete_record(db, category) - + return {"detail": "Category deleted successfully."} + ############################################################################################################## + @router.post("/subcategory/", response_model=MenuSubCategoryRead) async def create_menu_subcategory( - subcategory_create: MenuSubCategoryCreate, + subcategory_create: MenuSubCategoryCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): + current_user: UserBusiness = Depends(get_current_user), +): """ Create a new menu subcategory associated with a category. - + :param subcategory_create: The details for the new menu subcategory, provided as a Pydantic model. :param db: Active database session. :return: The created MenuSubCategory as a response. """ # Check if the category exists category = get_record_by_id(db, MenuCategory, subcategory_create.category_id) - + if not category: raise HTTPException(status_code=404, detail="Category not found.") - + check_user_permission(db, current_user, category.menu.venue_id) # Create a new MenuSubCategory instance from the provided data subcategory_instance = MenuSubCategory.from_create_schema(subcategory_create) - + # Persist the new subcategory in the database created_subcategory = create_record(db, subcategory_instance) - - return MenuSubCategory.to_read_schema(created_subcategory) + + assert isinstance( + created_subcategory, MenuSubCategory + ), "The returned object is not of type MenuSubCategory" + return created_subcategory.to_read_schema() + @router.patch("/subcategory/{subcategory_id}", response_model=MenuSubCategoryRead) async def update_menu_subcategory( - subcategory_id: uuid.UUID, - subcategory_update: MenuSubCategoryUpdate, + subcategory_id: uuid.UUID, + subcategory_update: MenuSubCategoryUpdate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): + current_user: UserBusiness = Depends(get_current_user), +): """ Update an existing menu subcategory. @@ -245,21 +302,30 @@ async def update_menu_subcategory( """ # Check if the subcategory exists subcategory = get_record_by_id(db, MenuSubCategory, subcategory_id) - + if not subcategory: raise HTTPException(status_code=404, detail="Subcategory not found.") check_user_permission(db, current_user, subcategory.category.menu.venue_id) - + # Update the subcategory with provided data - update_data = subcategory_update.dict(exclude_unset=True) # Exclude unset fields for partial update + update_data = subcategory_update.dict( + exclude_unset=True + ) # Exclude unset fields for partial update updated_subcategory = update_record(db, subcategory, update_data) - - return MenuSubCategory.to_read_schema(updated_subcategory) + + assert isinstance( + updated_subcategory, MenuSubCategory + ), "The returned object is not of type MenuSubCategory" + return updated_subcategory.to_read_schema() + @router.delete("/subcategory/{subcategory_id}", response_model=dict) -async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): +async def delete_subcategory( + subcategory_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Delete a menu subcategory by its ID. @@ -268,23 +334,26 @@ async def delete_subcategory(subcategory_id: uuid.UUID, db: Session = Depends(ge :return: Confirmation message on successful deletion. """ subcategory = get_record_by_id(db, MenuSubCategory, subcategory_id) - + if not subcategory: raise HTTPException(status_code=404, detail="Subcategory not found.") - + check_user_permission(db, current_user, subcategory.category.menu.venue_id) delete_record(db, subcategory) - + return {"detail": "Subcategory deleted successfully."} + ############################################################################################################## + @router.post("/item/", response_model=MenuItemRead) async def create_menu_item( - item_create: MenuItemCreate, + item_create: MenuItemCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): + current_user: UserBusiness = Depends(get_current_user), +): """ Create a new menu item associated with a subcategory. @@ -294,26 +363,31 @@ async def create_menu_item( """ # Check if the subcategory exists subcategory = get_record_by_id(db, MenuSubCategory, item_create.subcategory_id) - + if not subcategory: raise HTTPException(status_code=404, detail="Subcategory not found.") - + check_user_permission(db, current_user, subcategory.category.menu.venue_id) # Create a new MenuItem instance from the provided data item_instance = MenuItem.from_create_schema(item_create) - + # Persist the new item in the database created_item = create_record(db, item_instance) - - return MenuItem.to_read_schema(created_item) + + assert isinstance( + created_item, MenuItem + ), "The returned object is not of type MenuItem" + return created_item.to_read_schema() + @router.patch("/item/{item_id}", response_model=MenuItemRead) async def update_menu_item( - item_id: uuid.UUID, - item_update: MenuItemUpdate, + item_id: uuid.UUID, + item_update: MenuItemUpdate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): + current_user: UserBusiness = Depends(get_current_user), +): """ Update an existing menu item. @@ -324,7 +398,7 @@ async def update_menu_item( """ # Check if the item exists item = get_record_by_id(db, MenuItem, item_id) - + if not item: raise HTTPException(status_code=404, detail="Menu item not found.") @@ -332,12 +406,18 @@ async def update_menu_item( # Update the item with provided data updated_item = update_record(db, item, item_update) - - return MenuItem.to_read_schema(updated_item) + assert isinstance( + updated_item, MenuItem + ), "The returned object is not of type MenuItem" + return updated_item.to_read_schema() + @router.delete("/item/{item_id}", response_model=dict) -async def delete_menu_item(item_id: uuid.UUID, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_current_user)): +async def delete_menu_item( + item_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Delete a menu item by its ID. @@ -346,14 +426,15 @@ async def delete_menu_item(item_id: uuid.UUID, db: Session = Depends(get_db), :return: Confirmation message on successful deletion. """ item = get_record_by_id(db, MenuItem, item_id) - + if not item: raise HTTPException(status_code=404, detail="Menu item not found.") check_user_permission(db, current_user, item.subcategory.category.menu.venue_id) delete_record(db, item) - + return {"detail": "Menu item deleted successfully."} -######################################################################################################### \ No newline at end of file + +######################################################################################################### diff --git a/backend/app/api/routes/qrcode.py b/backend/app/api/routes/qrcode.py index 0c4f3d871a..1eb66b76c2 100644 --- a/backend/app/api/routes/qrcode.py +++ b/backend/app/api/routes/qrcode.py @@ -1,71 +1,134 @@ -from pathlib import Path import uuid -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse from jinja2 import Template -from sqlalchemy import UUID -from sqlmodel import select -from typing import List -from app.api.deps import SessionDep +from sqlmodel import Session, select + +from app.api.deps import SessionDep, get_current_user, get_db from app.models.qrcode import QRCode # Ensure you have this import for your model -from app.schema.qrcode import QRCodeCreate, QRCodeRead # Ensure you have these imports for your schemas +from app.models.user import UserBusiness +from app.schema.qrcode import ( + QRCodeCreate, + QRCodeRead, + QRCodeUpdate, +) + +# Ensure you have these imports for your schemas from app.util import ( - get_all_records, + check_user_permission, + delete_record, get_record_by_id, - create_record, update_record, - patch_record, - delete_record ) router = APIRouter() -# Get all QR codes -@router.get("/qr_codes/", response_model=List[QRCodeRead]) -async def read_qr_codes(session: SessionDep): - # Retrieve all QR codes - return get_all_records(session, QRCode) + +# Return all QR codes for a specific venue +@router.get("/venue/{venue_id}", response_model=list[QRCodeRead]) +async def read_qrcode_by_venue( + venue_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): + """ + Retrieve all QR codes associated with a specific venue. + """ + qrcodes = ( + db.execute(select(QRCode).where(QRCode.venue_id == venue_id)).scalars().all() + ) + # if qrcode is empty, raise an error + if not qrcodes: + raise HTTPException(status_code=404, detail="No QR codes found for this venue.") + + check_user_permission(db, current_user, venue_id) + return [qr_code.to_read_schema() for qr_code in qrcodes] + # Get a specific QR code -@router.get("/qr_codes/{qr_code_id}", response_model=QRCodeRead) -async def read_qr_code(qr_code_id: uuid.UUID, session: SessionDep): +@router.get("/{qr_code_id}", response_model=QRCodeRead) +async def read_qr_code( + qr_code_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Retrieve a specific QR code by ID. """ - return get_record_by_id(session, QRCode, qr_code_id) + qr_code_instance = get_record_by_id(db, QRCode, qr_code_id) + check_user_permission(db, current_user, qr_code_instance.venue_id) + assert isinstance( + qr_code_instance, QRCode + ), "The returned object is not of type QRCode" + return qr_code_instance.to_read_schema() + # Create a new QR code -@router.post("/qr_codes/", response_model=QRCodeRead) -async def create_qr_code(qr_code: QRCodeCreate, session: SessionDep): +@router.post("/", response_model=QRCodeRead) +async def create_qr_code( + qr_code: QRCodeCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Create a new QR code. """ - return create_record(session, QRCode, qr_code) + check_user_permission(db, current_user, qr_code.venue_id) + try: + qr_code_instance = QRCode.from_create_schema(qr_code) + db.add(qr_code_instance) # Persist the new QR code + db.commit() # Commit the session + return ( + qr_code_instance.to_read_schema() + ) # Call the instance method to convert to QRCodeRead + except Exception as e: + db.rollback() # Rollback the session in case of any error + raise HTTPException(status_code=500, detail=str(e)) from e -# Update a QR code -@router.put("/qr_codes/{qr_code_id}", response_model=QRCodeRead) -async def update_qr_code(qr_code_id: uuid.UUID, updated_qr_code: QRCodeCreate, session: SessionDep): - """ - Update an existing QR code. - """ - return update_record(session, QRCode, qr_code_id, updated_qr_code) -# PATCH a QR code for partial updates -@router.patch("/qr_codes/{qr_code_id}", response_model=QRCodeRead) -async def patch_qr_code(qr_code_id: uuid.UUID, updated_qr_code: QRCodeCreate, session: SessionDep): - """ - Partially update an existing QR code. - """ - return patch_record(session, QRCode, qr_code_id, updated_qr_code) +# Patch a QR code for partial updates +@router.patch("/{qr_code_id}", response_model=QRCodeRead) +async def update_qr_code( + qr_code_id: uuid.UUID, + updated_qr_code: QRCodeUpdate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): + qr_code_instance = get_record_by_id(db, QRCode, qr_code_id) + + if not qr_code_instance: + raise HTTPException(status_code=404, detail="QR code not found.") + + # Check user permission before updating the QR code for this venue + check_user_permission(db, current_user, qr_code_instance.venue_id) + + updated_qr_code = update_record(db, qr_code_instance, updated_qr_code) + assert isinstance( + updated_qr_code, QRCode + ), "The returned object is not of type QRCode" + return updated_qr_code.to_read_schema() + # Delete a QR code -@router.delete("/qr_codes/{qr_code_id}", response_model=None) -async def delete_qr_code(qr_code_id: uuid.UUID, session: SessionDep): +@router.delete("/qrcode/{qr_code_id}", response_model=None) +async def delete_qr_code( + qr_code_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), +): """ Delete a QR code by ID. """ - return delete_record(session, QRCode, qr_code_id) + qr_code_instance = get_record_by_id(db, QRCode, qr_code_id) + + if not qr_code_instance: + raise HTTPException(status_code=404, detail="QR code not found.") + + check_user_permission(db, current_user, qr_code_instance.venue_id) + return delete_record(db, qr_code_instance) + @router.get("/scan/{qr_id}", response_class=HTMLResponse) async def scan_qr_code(qr_id: uuid.UUID, session: SessionDep): @@ -73,11 +136,13 @@ async def scan_qr_code(qr_id: uuid.UUID, session: SessionDep): Scan a QR code to determine its associated venue and redirect appropriately. """ # Retrieve the QR code from the database - qr_code_result = session.execute(select(QRCode).where(QRCode.id == qr_id)).one_or_none() - + qr_code_result = session.execute( + select(QRCode).where(QRCode.id == qr_id) + ).one_or_none() + if not qr_code_result: raise HTTPException(status_code=404, detail="QR code not found.") - + qr_code = qr_code_result[0] # Determine the venue type and ID from the QR code venue_type, venue_id = None, None @@ -100,13 +165,15 @@ async def scan_qr_code(qr_id: uuid.UUID, session: SessionDep): # Load the landing page HTML template template_path = Path(__file__).parent.parent.parent / "static" / "landing_page.html" - print('template_path:', template_path) + print("template_path:", template_path) try: template_str = template_path.read_text() - except FileNotFoundError: - raise HTTPException(status_code=500, detail="Landing page template not found.") + except FileNotFoundError as exc: + raise HTTPException( + status_code=500, detail="Landing page template not found." + ) from exc # Use string.Template to replace placeholders in the HTML template = Template(template_str) html_content = template.render(venueId=venue_id, venueType=venue_type) - return HTMLResponse(content=html_content) \ No newline at end of file + return HTMLResponse(content=html_content) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 8a715c666b..6a757720c0 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,148 +1,214 @@ -from typing import List, Union import uuid -from fastapi import APIRouter, Query, HTTPException, Depends -from app.api.deps import SessionDep, get_business_user, get_current_user, get_public_user, get_super_user + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session + +from app.api.deps import ( + SessionDep, + get_business_user, + get_current_user, + get_db, + get_public_user, + get_super_user, +) from app.models import UserBusiness, UserPublic -from app.util import get_all_records, get_record_by_id, create_record, update_record, delete_record, patch_record +from app.schema.user import ( + UserBusinessCreate, + UserBusinessRead, + UserBusinessUpdate, + UserPublicRead, + UserPublicUpdate, +) +from app.util import ( + create_record, + delete_record, + get_all_records, + get_record_by_id, + update_record, +) router = APIRouter() -@router.get("/user-businesses/", response_model=List[UserBusiness]) -async def read_user_businesses( - session: SessionDep, + +@router.get("/me", response_model=UserPublicRead | UserBusinessRead) +async def read_user_me( + current_user: UserPublic | UserBusiness = Depends(get_current_user), +): + print("current_user", current_user) + """ + Retrieve profile information of the currently authenticated user. + """ + x = current_user.to_read_schema() + print("hui", x) + return x + + +@router.get("/all-user-business/", response_model=list[UserBusinessRead]) +async def all_read_user_business( + db: Session = Depends(get_db), skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100), - current_user: UserBusiness = Depends(get_super_user) + current_user: UserBusiness = Depends(get_super_user), # noqa: ARG001 ): """ Retrieve a paginated list of user businesses. - **skip**: The page number (starting from 0) - **limit**: The number of items per page """ - return get_all_records(session, UserBusiness, skip=skip, limit=limit) + all_users = get_all_records(db, UserBusiness, skip=skip, limit=limit) + + if all_users is None: + raise HTTPException(status_code=404, detail="No user businesses found.") + + # Convert each record to its read schema + assert all( + hasattr(user, "to_read_schema") for user in all_users + ), "Each user must implement 'to_read_schema'" -@router.get("/user-businesses/{user_business_id}", response_model=UserBusiness) + return [user.to_read_schema() for user in all_users] + + +@router.get("/user-businesses/{user_business_id}", response_model=UserBusinessRead) async def read_user_business( user_business_id: uuid.UUID, - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_super_user), # noqa: ARG001 ): """ Retrieve a single user business by ID. - **user_business_id**: The ID of the user business to retrieve """ - return get_record_by_id(session, UserBusiness, user_business_id) + user_instance = get_record_by_id(db, UserBusiness, user_business_id) -@router.get("/me/user-businesses/{user_business_id}", response_model=UserBusiness) -async def read_user_business_me( - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) -): - """ - Retrieve a single user business by ID. - - **user_business_id**: The ID of the user business to retrieve - """ - return get_record_by_id(session, UserBusiness, current_user.id) + return user_instance.to_read_schema() -@router.post("/user-businesses/", response_model=UserBusiness) +@router.post("/user-businesses/", response_model=UserBusinessRead) async def create_user_business( - user_business: UserBusiness, - session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) + user_business: UserBusinessCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_super_user), ): """ Create a new user business. - **user_business**: The user business data to create """ - return create_record(session, UserBusiness, user_business) -@router.put("/user-businesses/{user_business_id}", response_model=UserBusiness) + try: + user_instance = UserBusiness.from_create_schema(user_business) + user_instance.id = current_user.id + return create_record(db, user_instance) + except Exception as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + + +@router.patch("/user-businesses/{user_business_id}", response_model=UserBusinessRead) async def update_user_business( - user_business: UserBusiness, - session: SessionDep, - current_user: UserBusiness = Depends(get_current_user) + user_business: UserBusinessUpdate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_current_user), ): """ Update an existing user business. - **user_business_id**: The ID of the user business to update - **user_business**: The updated user business data """ - return update_record(session, UserBusiness, current_user.id, user_business) + try: + user_instance = UserBusiness.from_create_schema(user_business) + return update_record(db, current_user, user_instance) + except Exception as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + -@router.delete("/user-businesses/{user_business_id}", response_model=UserBusiness) +@router.delete("/user-businesses/{user_business_id}", response_model=dict) async def delete_user_business( user_business_id: uuid.UUID, session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) + current_user: UserBusiness = Depends(get_super_user), # noqa: ARG001 ): """ Delete a user business by ID. - **user_business_id**: The ID of the user business to delete """ - delete_record(session, UserBusiness, user_business_id) + user_instance = get_record_by_id(session, UserBusiness, user_business_id) + + delete_record(session, user_instance) return {"message": f"UserBusiness with ID {user_business_id} has been deleted."} -@router.get("/user-public/", response_model=List[UserPublic]) -async def read_user_public( - session: SessionDep, + +@router.get("/all-user-public/", response_model=list[UserPublicRead]) +async def all_read_user_public( + db: Session = Depends(get_db), skip: int = Query(0, alias="page", ge=0), limit: int = Query(10, le=100), - current_user: UserBusiness = Depends(get_business_user) + current_user: UserBusiness = Depends(get_business_user), # noqa: ARG001 ): """ Retrieve a paginated list of user public. - **skip**: The page number (starting from 0) - **limit**: The number of items per page """ - return get_all_records(session, UserPublic, skip=skip, limit=limit) + all_users = get_all_records(db, UserPublic, skip=skip, limit=limit) + + if all_users is None: + raise HTTPException(status_code=404, detail="No user found.") + + assert all( + hasattr(user, "to_read_schema") for user in all_users + ), "Each user must implement 'to_read_schema'" + + # Convert each record to its read schema + return [user.to_read_schema() for user in all_users] + -@router.get("/user-public/{user_public_id}", response_model=UserPublic) +@router.get("/user-public/{user_public_id}", response_model=UserPublicRead) async def read_user_public( user_public_id: uuid.UUID, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user), # noqa: ARG001 ): """ Retrieve a single user public by ID. - **user_public_id**: The ID of the user public to retrieve """ - return get_record_by_id(session, UserPublic, user_public_id) + user_instance = get_record_by_id(db, UserBusiness, user_public_id) + + return user_instance.to_read_schema() -@router.get("/me/user-public/", response_model=UserPublic) -async def read_user_public_me( - session: SessionDep, - current_user: UserPublic = Depends(get_public_user) -): - print("current_user : ",current_user) - """ - Retrieve a single user public by ID. - - **user_public_id**: The ID of the user public to retrieve - """ - return get_record_by_id(session, UserPublic, current_user.id) -@router.put("/me/user-public/", response_model=UserPublic) +@router.patch("/user-public/", response_model=UserPublicRead) async def update_user_public_me( - user_public: UserPublic, - session: SessionDep, - current_user: UserPublic = Depends(get_public_user) + user_public: UserPublicUpdate, + db: Session = Depends(get_db), + current_user: UserPublic = Depends(get_public_user), ): """ Update an existing user public. - **user_public_id**: The ID of the user public to update - **user_public**: The updated user public data """ - return update_record(session, UserPublic, current_user.id , user_public) + print("user_public", user_public) + try: + user_instance = UserPublic.from_create_schema(user_public) + return update_record(db, current_user, user_instance) + except Exception as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/user-public/{user_public_id}", response_model=UserPublic) + +@router.delete("/user-public/{user_public_id}", response_model=dict) async def delete_user_public( user_public_id: uuid.UUID, session: SessionDep, - current_user: UserBusiness = Depends(get_super_user) + current_user: UserBusiness = Depends(get_super_user), # noqa: ARG001 ): """ Delete a user public by ID. - **user_public_id**: The ID of the user public to delete """ - delete_record(session, UserPublic, user_public_id) - return {"message": f"UserPublic with ID {user_public_id} has been deleted."} \ No newline at end of file + user_instance = get_record_by_id(session, UserBusiness, user_public_id) + + delete_record(session, user_instance) + return {"message": f"UserPublic with ID {user_public_id} has been deleted."} diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py index b63b07df24..ea2601a5bf 100644 --- a/backend/app/api/routes/venues.py +++ b/backend/app/api/routes/venues.py @@ -1,20 +1,25 @@ -from typing import List -from app.models.user import UserBusiness, UserVenueAssociation -from fastapi import FastAPI, Depends, HTTPException, APIRouter +from fastapi import APIRouter, Depends, FastAPI, HTTPException from sqlmodel import Session -from app.models.venue import QSR, Foodcourt, Restaurant, Nightclub, Venue + +from app.api.deps import ( + get_business_user, + get_db, +) +from app.models.user import UserBusiness, UserVenueAssociation +from app.models.venue import QSR, Foodcourt, Nightclub, Restaurant, Venue from app.schema.venue import ( FoodcourtCreate, - QSRCreate, - RestaurantCreate, - NightclubCreate, FoodcourtRead, + NightclubCreate, + NightclubRead, + QSRCreate, QSRRead, + RestaurantCreate, RestaurantRead, - NightclubRead, VenueListResponse, ) -from app.api.deps import get_business_user, get_db # Assuming you have a dependency to get the database session + +# Assuming you have a dependency to get the database session from app.util import ( create_record, get_all_records, @@ -23,10 +28,14 @@ app = FastAPI() router = APIRouter() + # POST endpoint for Foodcourt @router.post("/foodcourts/", response_model=FoodcourtRead) -def create_foodcourt(foodcourt: FoodcourtCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_business_user)): +def create_foodcourt( + foodcourt: FoodcourtCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user), +): try: # Check if the venue exists venue_instance = Venue.from_create_schema(foodcourt.venue) @@ -36,27 +45,31 @@ def create_foodcourt(foodcourt: FoodcourtCreate, db: Session = Depends(get_db), # Create the new Foodcourt record in the database create_record(db, foodcourt_instance) association = UserVenueAssociation( - user_id=current_user.id, - venue_id=venue_instance.id + user_id=current_user.id, venue_id=venue_instance.id ) create_record(db, association) - + return foodcourt_instance.to_read_schema() - + except Exception as e: # Rollback the session in case of any error db.rollback() raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error + # GET endpoint for Foodcourt -@router.get("/foodcourts/", response_model=List[FoodcourtRead]) +@router.get("/foodcourts/", response_model=list[FoodcourtRead]) def read_foodcourts(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return get_all_records(db, Foodcourt, skip=skip, limit=limit) + # POST endpoint for QSR @router.post("/qsrs/", response_model=QSRRead) -def create_qsr(qsr: QSRCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_business_user)): +def create_qsr( + qsr: QSRCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user), +): try: # Check if the venue exists venue_instance = Venue.from_create_schema(qsr.venue) @@ -66,55 +79,65 @@ def create_qsr(qsr: QSRCreate, db: Session = Depends(get_db), # Create the new Foodcourt record in the database create_record(db, qsr_instance) association = UserVenueAssociation( - user_id=current_user.id, - venue_id=venue_instance.id + user_id=current_user.id, venue_id=venue_instance.id ) create_record(db, association) return qsr_instance.to_read_schema() - + except Exception as e: # Rollback the session in case of any error db.rollback() raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error + # GET endpoint for QSR -@router.get("/qsrs/", response_model=List[QSRRead]) +@router.get("/qsrs/", response_model=list[QSRRead]) def read_qsrs(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return get_all_records(db, QSR, skip=skip, limit=limit) + # POST endpoint for Restaurant @router.post("/restaurants/", response_model=RestaurantRead) -def create_restaurant(restaurant: RestaurantCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_business_user)): +def create_restaurant( + restaurant: RestaurantCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user), +): try: # Check if the venue exists venue_instance = Venue.from_create_schema(restaurant.venue) create_record(db, venue_instance) # Persist the new venue # Use the newly created venue instance - restaurant_instance = Restaurant.from_create_schema(venue_instance.id, restaurant) + restaurant_instance = Restaurant.from_create_schema( + venue_instance.id, restaurant + ) # Create the new Foodcourt record in the database create_record(db, restaurant_instance) association = UserVenueAssociation( - user_id=current_user.id, - venue_id=venue_instance.id + user_id=current_user.id, venue_id=venue_instance.id ) create_record(db, association) return restaurant_instance.to_read_schema() - + except Exception as e: # Rollback the session in case of any error db.rollback() raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error + # GET endpoint for Restaurant -@router.get("/restaurants/", response_model=List[RestaurantRead]) +@router.get("/restaurants/", response_model=list[RestaurantRead]) def read_restaurants(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return get_all_records(db, Restaurant, skip=skip, limit=limit) + # POST endpoint for Nightclub @router.post("/nightclubs/", response_model=NightclubRead) -def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_business_user)): +def create_nightclub( + nightclub: NightclubCreate, + db: Session = Depends(get_db), + current_user: UserBusiness = Depends(get_business_user), +): try: # Check if the venue exists venue_instance = Venue.from_create_schema(nightclub.venue) @@ -124,30 +147,31 @@ def create_nightclub(nightclub: NightclubCreate, db: Session = Depends(get_db), # Create the new Foodcourt record in the database create_record(db, nightclub_instance) association = UserVenueAssociation( - user_id=current_user.id, - venue_id=venue_instance.id + user_id=current_user.id, venue_id=venue_instance.id ) create_record(db, association) return nightclub_instance.to_read_schema() - + except Exception as e: # Rollback the session in case of any error db.rollback() raise HTTPException(status_code=500, detail=str(e)) # Respond with a 500 error + # GET endpoint for Nightclub -@router.get("/nightclubs/", response_model=List[NightclubRead]) +@router.get("/nightclubs/", response_model=list[NightclubRead]) def read_nightclubs(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return get_all_records(db, Nightclub, skip=skip, limit=limit) + @router.get("/my-venues/", response_model=VenueListResponse) async def get_my_venues( db: Session = Depends(get_db), - current_user: UserBusiness = Depends(get_business_user) + current_user: UserBusiness = Depends(get_business_user), ): """ Retrieve the venues managed by the current user, organized by venue type. - + This method leverages SQLAlchemy's efficient querying capabilities to minimize database load while ensuring data integrity through the association table, UserVenueAssociation. """ @@ -167,7 +191,9 @@ async def get_my_venues( nightclubs, qsrs, foodcourts, restaurants = [], [], [], [] # Efficiently query and convert Nightclubs - for nightclub in db.query(Nightclub).filter(Nightclub.venue_id.in_(managed_venue_ids)).all(): + for nightclub in ( + db.query(Nightclub).filter(Nightclub.venue_id.in_(managed_venue_ids)).all() + ): nightclubs.append(nightclub.to_read_schema()) # Efficiently query and convert QSRs @@ -175,17 +201,18 @@ async def get_my_venues( qsrs.append(qsr.to_read_schema()) # Efficiently query and convert Foodcourts - for foodcourt in db.query(Foodcourt).filter(Foodcourt.venue_id.in_(managed_venue_ids)).all(): + for foodcourt in ( + db.query(Foodcourt).filter(Foodcourt.venue_id.in_(managed_venue_ids)).all() + ): foodcourts.append(foodcourt.to_read_schema()) # Efficiently query and convert Restaurants - for restaurant in db.query(Restaurant).filter(Restaurant.venue_id.in_(managed_venue_ids)).all(): + for restaurant in ( + db.query(Restaurant).filter(Restaurant.venue_id.in_(managed_venue_ids)).all() + ): restaurants.append(restaurant.to_read_schema()) # Construct and return the response return VenueListResponse( - nightclubs=nightclubs, - qsrs=qsrs, - foodcourts=foodcourts, - restaurants=restaurants - ) \ No newline at end of file + nightclubs=nightclubs, qsrs=qsrs, foodcourts=foodcourts, restaurants=restaurants + ) diff --git a/backend/app/constants.py b/backend/app/constants.py new file mode 100644 index 0000000000..5ce00e9968 --- /dev/null +++ b/backend/app/constants.py @@ -0,0 +1,8 @@ +# constants.py +from enum import Enum + + +class Gender(Enum): + MALE = "male" + FEMALE = "female" + OTHERS = ("others",) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4afe4e6d36..c5392033df 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,6 +1,7 @@ import secrets import warnings from typing import Annotated, Any, ClassVar, Literal + from pydantic import ( AnyUrl, BeforeValidator, diff --git a/backend/app/core/db.py b/backend/app/core/db.py index e8eb7671d2..6b8554581e 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,6 +1,5 @@ -from datetime import datetime from sqlmodel import Session, create_engine -from app.models.user import UserBusiness + from app.core.config import settings print("SQLALCHEMY_DATABASE_URI : ", str(settings.SQLALCHEMY_DATABASE_URI)) @@ -42,9 +41,9 @@ def init_db() -> None: # session.commit() # print("here4") # session.refresh(user_in) - + # Other initial setup tasks can go here # Example: Create default Nightclub, Foodcourt, etc. # ... - print("Database initialization complete.") \ No newline at end of file + print("Database initialization complete.") diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 0e0046cd04..cdae82e4cd 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,10 +1,11 @@ +import uuid # For generating unique token ID from datetime import datetime, timedelta, timezone + +import jwt from fastapi import HTTPException, status -from jwt.exceptions import InvalidTokenError # Import the correct exception + from app.core.config import settings from app.models.auth import AccessToken, RefreshToken, TokenModel -import jwt -import uuid # For generating unique token ID ALGORITHM = "HS256" @@ -18,8 +19,6 @@ def get_jwt_payload(token: str) -> TokenModel: except jwt.ExpiredSignatureError: # Handle expired token - raise HTTPException(status_code=401, detail="Token has expired") - except jwt.ExpiredSignatureError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired" @@ -58,7 +57,7 @@ def create_refresh_token(subject: str) -> RefreshToken: expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) jti = str(uuid.uuid4()) to_encode = {"sub": str(subject), "exp": expire, "jti": jti} - encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) # Create an instance of RefreshToken with the encoded JWT and expiration time refresh_token = RefreshToken( @@ -66,4 +65,4 @@ def create_refresh_token(subject: str) -> RefreshToken: expires_at=expire ) - return refresh_token \ No newline at end of file + return refresh_token diff --git a/backend/app/initial_data.py b/backend/app/initial_data.py index e72da8788f..43552c5299 100644 --- a/backend/app/initial_data.py +++ b/backend/app/initial_data.py @@ -1,8 +1,6 @@ import logging -from sqlmodel import Session - -from app.core.db import engine, init_db +from app.core.db import init_db logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) diff --git a/backend/app/main.py b/backend/app/main.py index 35b4454ea3..29a97ad94d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,3 @@ -from fastapi.staticfiles import StaticFiles import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5cbfeb9407..9127d20095 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,21 +9,47 @@ from .group_wallet import GroupWallet from .group_wallet_topup import GroupWalletTopup from .menu import Menu -from .order import NightclubOrder, RestaurantOrder, QSROrder +from .order import NightclubOrder, QSROrder, RestaurantOrder from .order_item import OrderItem +from .payment import ( + PaymentEvent, + PaymentOrderNightclub, + PaymentOrderQSR, + PaymentOrderRestaurant, +) from .pickup_location import PickupLocation +from .qrcode import QRCode from .user import UserBusiness, UserPublic -from .venue import Nightclub, QSR, Restaurant, Foodcourt -from .payment import PaymentOrderNightclub, PaymentOrderRestaurant, PaymentOrderQSR, PaymentEvent +from .venue import QSR, Foodcourt, Nightclub, Restaurant # Make all models accessible when importing app.models __all__ = [ - "SQLModel", "ClubVisit", "Event", "EventBooking", "EventOffering", "Group", "GroupWallet", - "GroupWalletTopup", "Menu", "NightclubOrder", "RestaurantOrder", "QSROrder", "OrderItem", - "PickupLocation", "UserBusiness", "UserPublic", "Nightclub", "QSR", "Restaurant", - "Foodcourt", "PaymentOrderNightclub", "PaymentOrderRestaurant", "PaymentOrderQSR", - "PaymentEvent" + "SQLModel", + "ClubVisit", + "Event", + "EventBooking", + "EventOffering", + "Group", + "GroupWallet", + "GroupWalletTopup", + "Menu", + "NightclubOrder", + "RestaurantOrder", + "QSROrder", + "OrderItem", + "PickupLocation", + "UserBusiness", + "UserPublic", + "Nightclub", + "QSR", + "Restaurant", + "Foodcourt", + "PaymentOrderNightclub", + "PaymentOrderRestaurant", + "PaymentOrderQSR", + "PaymentEvent", + "QRCode", ] -print("target_metadata in init:", SQLModel.metadata.tables) \ No newline at end of file +print("target_metadata in init:", SQLModel.metadata.tables) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index e86339e5a7..39b3286a07 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -1,14 +1,18 @@ -from sqlmodel import SQLModel, Field -from datetime import datetime, timezone import uuid -from typing import Optional +from datetime import datetime, timezone + from pydantic import EmailStr +from sqlmodel import Field, SQLModel + class TokenBlacklist(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) jti: str = Field(index=True, unique=True) # Store JWT ID (jti) user_id: uuid.UUID = Field(nullable=False) - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False) + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), nullable=False + ) + class OtplessPhoneAuthDetails(SQLModel): mode: str @@ -16,15 +20,18 @@ class OtplessPhoneAuthDetails(SQLModel): country_code: str auth_state: str + class OtplessEmailAuthDetails(SQLModel): email: EmailStr mode: str auth_state: str + class AuthenticationDetails(SQLModel): phone: OtplessPhoneAuthDetails email: OtplessEmailAuthDetails + class OtplessVerifyTokenResponse(SQLModel): name: str email: EmailStr @@ -38,27 +45,32 @@ class OtplessVerifyTokenResponse(SQLModel): auth_time: str authentication_details: AuthenticationDetails + class OtplessToken(SQLModel): otpless_token: str + class TokenModel(SQLModel): - sub: str - exp: Optional[int] = None + sub: str + exp: int | None = None + class RefreshTokenPayload(SQLModel): refresh_token: str + class AccessToken(SQLModel): token: str expires_at: datetime token_type: str = "Bearer" + class RefreshToken(SQLModel): token: str expires_at: datetime + class UserAuthResponse(SQLModel): access_token: AccessToken refresh_token: RefreshToken issued_at: datetime - \ No newline at end of file diff --git a/backend/app/models/base_model.py b/backend/app/models/base_model.py index a848065f9c..427e48519f 100644 --- a/backend/app/models/base_model.py +++ b/backend/app/models/base_model.py @@ -1,7 +1,34 @@ -from sqlmodel import SQLModel, Field -from datetime import timezone, datetime -from typing import Optional +""" +Base model class for all models in the application. +""" -class BaseTimeModel(SQLModel): - created_at: Optional[datetime] = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False) - updated_at: Optional[datetime] = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False) \ No newline at end of file +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from sqlmodel import Field, SQLModel + + +class BaseTimeModel(SQLModel, ABC): + """ + Base class for models that require timestamp fields. + + Attributes: + created_at (Optional[datetime]): The timestamp when the model instance was created. + updated_at (Optional[datetime]): The timestamp when the model instance was last updated. + """ + + created_at: datetime | None = Field( + default_factory=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at: datetime | None = Field( + default_factory=lambda: datetime.now(timezone.utc), nullable=False + ) + + @abstractmethod + def to_read_schema(self): + """Convert the model instance to its read schema representation.""" + + @classmethod + @abstractmethod + def from_create_schema(cls, schema): + """Create a model instance from the provided create schema.""" diff --git a/backend/app/models/club_visit.py b/backend/app/models/club_visit.py index 4b8bd4949d..7b9a6b5a86 100644 --- a/backend/app/models/club_visit.py +++ b/backend/app/models/club_visit.py @@ -1,20 +1,26 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.group import Group + from app.models.user import UserPublic + from app.models.venue import Nightclub + class ClubVisit(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - user_id: Optional[uuid.UUID] = Field(foreign_key="user_public.id", nullable=False) - group_id: Optional[uuid.UUID] = Field(foreign_key="group.id", nullable=True) - nightclub_id: Optional[uuid.UUID] = Field(foreign_key="nightclub.id", nullable=False) + user_id: uuid.UUID | None = Field(foreign_key="user_public.id", nullable=False) + group_id: uuid.UUID | None = Field(foreign_key="group.id", nullable=True) + nightclub_id: uuid.UUID | None = Field(foreign_key="nightclub.id", nullable=False) entry_time: datetime = Field(nullable=False) - exit_time: Optional[datetime] = Field(nullable=True) - cover_charge: Optional[float] = Field(nullable=True) - total_bill: Optional[float] = Field(nullable=True) + exit_time: datetime | None = Field(nullable=True) + cover_charge: float | None = Field(nullable=True) + total_bill: float | None = Field(nullable=True) # Relationships user: Optional["UserPublic"] = Relationship(back_populates="club_visits") group: Optional["Group"] = Relationship(back_populates="club_visits") nightclub: Optional["Nightclub"] = Relationship(back_populates="club_visits") - diff --git a/backend/app/models/event.py b/backend/app/models/event.py index 1a2923061e..7b3f52efcb 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -1,19 +1,26 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.event_booking import EventBooking + from app.models.event_offering import EventOffering + from app.models.venue import Nightclub + class Event(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) + nightclub_id: uuid.UUID = Field(foreign_key="nightclub.id", nullable=False) title: str = Field(nullable=False) start_time: datetime = Field(nullable=False) end_time: datetime = Field(nullable=False) - image_url: Optional[str] = Field(nullable=True) - age_restriction: Optional[int] = Field(nullable=True) - dress_code: Optional[str] = Field(nullable=True) + image_url: str | None = Field(nullable=True) + age_restriction: int | None = Field(nullable=True) + dress_code: str | None = Field(nullable=True) # Relationships nightclub: Optional["Nightclub"] = Relationship(back_populates="events") - offerings: List["EventOffering"] = Relationship(back_populates="event") - event_bookings: List["EventBooking"] = Relationship(back_populates="event") \ No newline at end of file + offerings: list["EventOffering"] = Relationship(back_populates="event") + event_bookings: list["EventBooking"] = Relationship(back_populates="event") diff --git a/backend/app/models/event_booking.py b/backend/app/models/event_booking.py index e28e1eb381..24e456d232 100644 --- a/backend/app/models/event_booking.py +++ b/backend/app/models/event_booking.py @@ -1,14 +1,22 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.event import Event + from app.models.event_offering import EventOffering + from app.models.payment import PaymentEvent + from app.models.user import UserPublic + class EventBooking(SQLModel, table=True): __tablename__ = "event_booking" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - user_id: Optional[uuid.UUID] = Field(foreign_key="user_public.id", nullable=False) - event_id: Optional[uuid.UUID] = Field(foreign_key="event.id", nullable=False) + user_id: uuid.UUID | None = Field(foreign_key="user_public.id", nullable=False) + event_id: uuid.UUID | None = Field(foreign_key="event.id", nullable=False) booking_time: datetime = Field(nullable=False) total_amount: float = Field(nullable=False) status: str = Field(nullable=False) @@ -16,5 +24,9 @@ class EventBooking(SQLModel, table=True): # Relationships user: Optional["UserPublic"] = Relationship(back_populates="event_bookings") event: Optional["Event"] = Relationship(back_populates="event_bookings") - payment: Optional["PaymentEvent"] = Relationship(back_populates="event_booking", sa_relationship_kwargs={"uselist": False}) - event_offerings: List["EventOffering"] = Relationship(back_populates="event_booking") \ No newline at end of file + payment: Optional["PaymentEvent"] = Relationship( + back_populates="event_booking", sa_relationship_kwargs={"uselist": False} + ) + event_offerings: list["EventOffering"] = Relationship( + back_populates="event_booking" + ) diff --git a/backend/app/models/event_offering.py b/backend/app/models/event_offering.py index 27db18d4be..f7466ba95f 100644 --- a/backend/app/models/event_offering.py +++ b/backend/app/models/event_offering.py @@ -1,7 +1,12 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional -from typing import List +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.event import Event + from app.models.event_booking import EventBooking + # Stag, couple etc class EventOffering(SQLModel, table=True): @@ -13,10 +18,12 @@ class EventOffering(SQLModel, table=True): description: str = Field(nullable=False) price: float = Field(nullable=False) total_guests_per_pass: int = Field(nullable=False) - cover_charge: Optional[float] = Field(nullable=True) - additional_charges: Optional[float] = Field(nullable=True) + cover_charge: float | None = Field(nullable=True) + additional_charges: float | None = Field(nullable=True) availability: int = Field(nullable=False) # Relationships event: Optional["Event"] = Relationship(back_populates="offerings") - event_booking: Optional["EventBooking"] = Relationship(back_populates="event_offerings") \ No newline at end of file + event_booking: Optional["EventBooking"] = Relationship( + back_populates="event_offerings" + ) diff --git a/backend/app/models/group.py b/backend/app/models/group.py index 3f90a1886b..8c19fbe24c 100644 --- a/backend/app/models/group.py +++ b/backend/app/models/group.py @@ -1,31 +1,48 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.club_visit import ClubVisit + from app.models.group_wallet import GroupWallet + from app.models.order import NightclubOrder + from app.models.user import UserPublic + from app.models.venue import Nightclub + class GroupMembers(SQLModel, table=True): group_id: uuid.UUID = Field(foreign_key="group.id", primary_key=True) user_id: uuid.UUID = Field(foreign_key="user_public.id", primary_key=True) + class GroupNightclubOrderLink(SQLModel, table=True): __tablename__ = "group_nightclub_order_link" - + group_id: uuid.UUID = Field(foreign_key="group.id", primary_key=True) - nightclub_order_id: uuid.UUID = Field( foreign_key="nightclub_order.id", primary_key=True) + nightclub_order_id: uuid.UUID = Field( + foreign_key="nightclub_order.id", primary_key=True + ) + class Group(SQLModel, table=True): __tablename__ = "group" - + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - nightclub_id: Optional[uuid.UUID] = Field(foreign_key="nightclub.id") + nightclub_id: uuid.UUID | None = Field(foreign_key="nightclub.id") created_at: datetime = Field(default=datetime.now(timezone.utc)) admin_user_id: uuid.UUID = Field(foreign_key="user_public.id", nullable=False) - table_number: Optional[str] = Field(default=None) + table_number: str | None = Field(default=None) # Relationships admin_user: Optional["UserPublic"] = Relationship(back_populates="managed_groups") wallet: Optional["GroupWallet"] = Relationship(back_populates="group") - members: List["UserPublic"] = Relationship(back_populates="groups", link_model=GroupMembers) - club_visits: List["ClubVisit"] = Relationship(back_populates="group") - nightclub_orders: List["NightclubOrder"] = Relationship(back_populates="groups", link_model=GroupNightclubOrderLink) - nightclubs: List["Nightclub"] = Relationship(back_populates="group") \ No newline at end of file + members: list["UserPublic"] = Relationship( + back_populates="groups", link_model=GroupMembers + ) + club_visits: list["ClubVisit"] = Relationship(back_populates="group") + nightclub_orders: list["NightclubOrder"] = Relationship( + back_populates="groups", link_model=GroupNightclubOrderLink + ) + nightclubs: list["Nightclub"] = Relationship(back_populates="group") diff --git a/backend/app/models/group_wallet.py b/backend/app/models/group_wallet.py index 007418e3ba..43565545fb 100644 --- a/backend/app/models/group_wallet.py +++ b/backend/app/models/group_wallet.py @@ -1,14 +1,20 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List +from typing import TYPE_CHECKING, Optional -# (TODO) Added another model : Group wallet transactions +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.group import Group + from app.models.group_wallet_topup import GroupWalletTopup + + +# (TODO) Added another model : Group wallet transactions class GroupWallet(SQLModel, table=True): __tablename__ = "group_wallet" - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) group_id: uuid.UUID = Field(foreign_key="group.id", nullable=False, unique=True) balance: float = Field(default=0.0, nullable=False) # Relationships group: Optional["Group"] = Relationship(back_populates="wallet") - topups: List["GroupWalletTopup"] = Relationship(back_populates="group_wallet") + topups: list["GroupWalletTopup"] = Relationship(back_populates="group_wallet") diff --git a/backend/app/models/group_wallet_topup.py b/backend/app/models/group_wallet_topup.py index 61f10c80d8..b73e697ba0 100644 --- a/backend/app/models/group_wallet_topup.py +++ b/backend/app/models/group_wallet_topup.py @@ -1,14 +1,18 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.group_wallet import GroupWallet class GroupWalletTopup(SQLModel, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - group_wallet_id: uuid.UUID = Field(foreign_key="group_wallet.id", nullable=False) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) + group_wallet_id: uuid.UUID = Field(foreign_key="group_wallet.id", nullable=False) amount: float = Field(nullable=False) topup_time: datetime = Field(nullable=False) # Relationships - group_wallet: Optional["GroupWallet"] = Relationship(back_populates="topups") \ No newline at end of file + group_wallet: Optional["GroupWallet"] = Relationship(back_populates="topups") diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py index 830292ea7d..2990418fff 100644 --- a/backend/app/models/menu.py +++ b/backend/app/models/menu.py @@ -1,21 +1,35 @@ import uuid -from app.schema.menu import MenuCategoryCreate, MenuCategoryRead, MenuCreate, MenuItemCreate, MenuItemRead, MenuRead, MenuSubCategoryCreate, MenuSubCategoryRead -from pydantic import ValidationError -from sqlmodel import SQLModel, Field, Relationship -from typing import List, Optional +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.venue import Venue + +from app.schema.menu import ( + MenuCategoryCreate, + MenuCategoryRead, + MenuCreate, + MenuItemCreate, + MenuItemRead, + MenuRead, + MenuSubCategoryCreate, + MenuSubCategoryRead, +) + class MenuItem(SQLModel, table=True): __tablename__ = "menu_item" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - subcategory_id: uuid.UUID = Field(foreign_key="menu_subcategory.id",nullable=False) + subcategory_id: uuid.UUID = Field(foreign_key="menu_subcategory.id", nullable=False) name: str = Field(nullable=False) price: float = Field(nullable=False) - description: Optional[str] = Field(default=None) - image_url: Optional[str] = Field(default=None) - is_veg: Optional[bool] = Field(default=None) - ingredients: Optional[str] = Field(default=None) - abv: Optional[float] = Field(default=None) - ibu: Optional[int] = Field(default=None) + description: str | None = Field(default=None) + image_url: str | None = Field(default=None) + is_veg: bool | None = Field(default=None) + ingredients: str | None = Field(default=None) + abv: float | None = Field(default=None) + ibu: int | None = Field(default=None) # Relationships subcategory: Optional["MenuSubCategory"] = Relationship(back_populates="menu_items") @@ -31,26 +45,28 @@ def from_create_schema(cls, schema: MenuItemCreate) -> "MenuItem": is_veg=schema.is_veg, ingredients=schema.ingredients, abv=schema.abv, - ibu=schema.ibu + ibu=schema.ibu, ) @classmethod - def to_read_schema(cls, item: "MenuItem") -> MenuItemRead: + def to_read_schema(self) -> MenuItemRead: return MenuItemRead( - item_id=item.id, - subcategory_id=item.subcategory_id, - name=item.name, - price=item.price, - description=item.description, - image_url=item.image_url, - is_veg=item.is_veg, - ingredients=item.ingredients, - abv=item.abv, - ibu=item.ibu + item_id=self.id, + subcategory_id=self.subcategory_id, + name=self.name, + price=self.price, + description=self.description, + image_url=self.image_url, + is_veg=self.is_veg, + ingredients=self.ingredients, + abv=self.abv, + ibu=self.ibu, ) + ######################################################################################################### + class MenuSubCategory(SQLModel, table=True): __tablename__ = "menu_subcategory" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) @@ -60,28 +76,30 @@ class MenuSubCategory(SQLModel, table=True): # Relationships category: "MenuCategory" = Relationship(back_populates="sub_categories") - menu_items: List["MenuItem"] = Relationship(back_populates="subcategory") + menu_items: list["MenuItem"] = Relationship(back_populates="subcategory") @classmethod def from_create_schema(cls, schema: "MenuSubCategoryCreate") -> "MenuSubCategory": return cls( name=schema.name, category_id=schema.category_id, - is_alcoholic=schema.is_alcoholic # Include is_alcoholic in the model + is_alcoholic=schema.is_alcoholic, # Include is_alcoholic in the model ) @classmethod - def to_read_schema(cls, subcategory: "MenuSubCategory") -> MenuSubCategoryRead: + def to_read_schema(self) -> MenuSubCategoryRead: return MenuSubCategoryRead( - subcategory_id=subcategory.id, - category_id=subcategory.category_id, - name=subcategory.name, - is_alcoholic=subcategory.is_alcoholic, - menu_items=[MenuItem.to_read_schema(item) for item in subcategory.menu_items] + subcategory_id=self.id, + category_id=self.category_id, + name=self.name, + is_alcoholic=self.is_alcoholic, + menu_items=[item.to_read_schema() for item in self.menu_items], ) + ######################################################################################################### + class MenuCategory(SQLModel, table=True): __tablename__ = "menu_category" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) @@ -90,56 +108,60 @@ class MenuCategory(SQLModel, table=True): # Relationships menu: "Menu" = Relationship(back_populates="categories") - sub_categories: List["MenuSubCategory"] = Relationship(back_populates="category") + sub_categories: list["MenuSubCategory"] = Relationship(back_populates="category") @classmethod def from_create_schema(cls, schema: MenuCategoryCreate) -> "MenuCategory": - return cls( - name=schema.name, - menu_id=schema.menu_id - ) + return cls(name=schema.name, menu_id=schema.menu_id) @classmethod - def to_read_schema(cls, category: "MenuCategory") -> MenuCategoryRead: + def to_read_schema(cls, self) -> MenuCategoryRead: return MenuCategoryRead( - category_id=category.id, - menu_id=category.menu_id, - name=category.name, - sub_categories=[MenuSubCategory.to_read_schema(subcategory) for subcategory in category.sub_categories] + category_id=self.id, + menu_id=self.menu_id, + name=self.name, + sub_categories=[ + subcategory.to_read_schema() for subcategory in self.sub_categories + ], ) + ######################################################################################################### + class Menu(SQLModel, table=True): __tablename__ = "menu" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) name: str = Field(nullable=False) - description: Optional[str] = Field(default=None) - menu_type: Optional[str] = Field(default=None) # Type of menu (e.g., "Food", "Drink") + description: str | None = Field(default=None) + menu_type: str | None = Field(default=None) # Type of menu (e.g., "Food", "Drink") venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False) # Relationships - categories: List["MenuCategory"] = Relationship(back_populates="menu") + categories: list["MenuCategory"] = Relationship(back_populates="menu") venue: "Venue" = Relationship(back_populates="menu") - + @classmethod def from_create_schema(cls, schema: MenuCreate) -> "Menu": return cls( name=schema.name, description=schema.description, menu_type=schema.menu_type, - venue_id=schema.venue_id + venue_id=schema.venue_id, ) @classmethod - def to_read_schema(cls, menu: "Menu") -> MenuRead: + def to_read_schema(self) -> MenuRead: return MenuRead( - menu_id=menu.id, - name=menu.name, - description=menu.description, - menu_type=menu.menu_type, - venue_id=menu.venue_id, - categories=[MenuCategory.to_read_schema(category) for category in menu.categories] + menu_id=self.id, + name=self.name, + description=self.description, + menu_type=self.menu_type, + venue_id=self.venue_id, + categories=[ + MenuCategory.to_read_schema(category) for category in self.categories + ], ) -######################################################################################################### \ No newline at end of file + +######################################################################################################### diff --git a/backend/app/models/order.py b/backend/app/models/order.py index 571a2e4908..9a45eb4789 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -1,48 +1,79 @@ import uuid -from app.models.group import GroupNightclubOrderLink -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +from app.models.group import GroupNightclubOrderLink + +if TYPE_CHECKING: + from app.models.group import Group + from app.models.order_item import OrderItem + from app.models.payment import ( + PaymentOrderNightclub, + PaymentOrderQSR, + PaymentOrderRestaurant, + ) + from app.models.pickup_location import PickupLocation + from app.models.user import UserPublic + from app.models.venue import QSR, Nightclub, Restaurant class OrderBase(SQLModel): user_id: uuid.UUID = Field(foreign_key="user_public.id") - pickup_location_id: Optional[uuid.UUID] = Field(default=None, foreign_key="pickup_location.id") - note: Optional[str] = Field(nullable=True) + pickup_location_id: uuid.UUID | None = Field( + default=None, foreign_key="pickup_location.id" + ) + note: str | None = Field(nullable=True) order_time: datetime = Field(nullable=False) total_amount: float = Field(nullable=False) - taxes_and_charges: Optional[float] = Field(default=None) - cover_charge_used: Optional[float] = Field(default=None) + taxes_and_charges: float | None = Field(default=None) + cover_charge_used: float | None = Field(default=None) status: str = Field(nullable=False) - service_type: Optional[str] = Field(default=None) + service_type: str | None = Field(default=None) + class NightclubOrder(OrderBase, table=True): __tablename__ = "nightclub_order" - + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - venue_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub.id") - payment_id: Optional[uuid.UUID] = Field(default=None, foreign_key="payment_source_nightclub.id") - pickup_location_id: Optional[uuid.UUID] = Field(default=None, foreign_key="pickup_location.id") + venue_id: uuid.UUID | None = Field(default=None, foreign_key="nightclub.id") + payment_id: uuid.UUID | None = Field( + default=None, foreign_key="payment_source_nightclub.id" + ) + pickup_location_id: uuid.UUID | None = Field( + default=None, foreign_key="pickup_location.id" + ) # Relationships user: Optional["UserPublic"] = Relationship(back_populates="nightclub_orders") nightclub: Optional["Nightclub"] = Relationship(back_populates="orders") pickup_location: Optional["PickupLocation"] = Relationship(back_populates="orders") - payment: Optional["PaymentOrderNightclub"] = Relationship(back_populates="order", sa_relationship_kwargs={"uselist": False}) - groups: List["Group"] = Relationship(back_populates="nightclub_orders", link_model=GroupNightclubOrderLink) # Many-to-many - order_items: List["OrderItem"] = Relationship(back_populates="nightclub_order") + payment: Optional["PaymentOrderNightclub"] = Relationship( + back_populates="order", sa_relationship_kwargs={"uselist": False} + ) + groups: list["Group"] = Relationship( + back_populates="nightclub_orders", link_model=GroupNightclubOrderLink + ) # Many-to-many + order_items: list["OrderItem"] = Relationship(back_populates="nightclub_order") + class RestaurantOrder(OrderBase, table=True): __tablename__ = "restaurant_order" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - venue_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant.id") - payment_id: Optional[uuid.UUID] = Field(default=None, foreign_key="payment_source_restaurant.id") + venue_id: uuid.UUID | None = Field(default=None, foreign_key="restaurant.id") + payment_id: uuid.UUID | None = Field( + default=None, foreign_key="payment_source_restaurant.id" + ) # Relationships user: Optional["UserPublic"] = Relationship(back_populates="restaurant_orders") restaurant: Optional["Restaurant"] = Relationship(back_populates="orders") - payment: Optional["PaymentOrderRestaurant"] = Relationship(back_populates="order", sa_relationship_kwargs={"uselist": False}) - order_items: List["OrderItem"] = Relationship(back_populates="restaurant_order") + payment: Optional["PaymentOrderRestaurant"] = Relationship( + back_populates="order", sa_relationship_kwargs={"uselist": False} + ) + order_items: list["OrderItem"] = Relationship(back_populates="restaurant_order") + class QSROrder(OrderBase, table=True): __tablename__ = "qsr_order" @@ -54,5 +85,7 @@ class QSROrder(OrderBase, table=True): # Relationships user: Optional["UserPublic"] = Relationship(back_populates="qsr_orders") qsr: Optional["QSR"] = Relationship(back_populates="orders") - payment: Optional["PaymentOrderQSR"] = Relationship(back_populates="order", sa_relationship_kwargs={"uselist": False}) - order_items: List["OrderItem"] = Relationship(back_populates="qsr_order") \ No newline at end of file + payment: Optional["PaymentOrderQSR"] = Relationship( + back_populates="order", sa_relationship_kwargs={"uselist": False} + ) + order_items: list["OrderItem"] = Relationship(back_populates="qsr_order") diff --git a/backend/app/models/order_item.py b/backend/app/models/order_item.py index 0e0447ab26..56d9998487 100644 --- a/backend/app/models/order_item.py +++ b/backend/app/models/order_item.py @@ -1,17 +1,29 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.models.order import NightclubOrder, QSROrder, RestaurantOrder class OrderItem(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - nightclub_order_id: Optional[uuid.UUID] = Field(default=None, foreign_key="nightclub_order.id") - restaurant_order_id: Optional[uuid.UUID] = Field(default=None, foreign_key="restaurant_order.id") - qsr_order_id: Optional[uuid.UUID] = Field(default=None, foreign_key="qsr_order.id") + nightclub_order_id: uuid.UUID | None = Field( + default=None, foreign_key="nightclub_order.id" + ) + restaurant_order_id: uuid.UUID | None = Field( + default=None, foreign_key="restaurant_order.id" + ) + qsr_order_id: uuid.UUID | None = Field(default=None, foreign_key="qsr_order.id") item_id: uuid.UUID = Field(foreign_key="menu_item.id", nullable=False) quantity: int = Field(nullable=False) - + # Relationships - nightclub_order: Optional["NightclubOrder"] = Relationship(back_populates="order_items") - restaurant_order: Optional["RestaurantOrder"] = Relationship(back_populates="order_items") - qsr_order: Optional["QSROrder"] = Relationship(back_populates="order_items") \ No newline at end of file + nightclub_order: Optional["NightclubOrder"] = Relationship( + back_populates="order_items" + ) + restaurant_order: Optional["RestaurantOrder"] = Relationship( + back_populates="order_items" + ) + qsr_order: Optional["QSROrder"] = Relationship(back_populates="order_items") diff --git a/backend/app/models/payment.py b/backend/app/models/payment.py index d47db3c729..341e1b5bea 100644 --- a/backend/app/models/payment.py +++ b/backend/app/models/payment.py @@ -1,44 +1,64 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional from datetime import datetime +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from app.models.event_booking import EventBooking + from app.models.order import NightclubOrder, QSROrder, RestaurantOrder + from app.models.user import UserPublic +from sqlmodel import Field, Relationship, SQLModel + class PaymentBase(SQLModel): user_id: uuid.UUID = Field(foreign_key="user_public.id", nullable=False) source_type: str = Field(nullable=False) # Changed to str - gateway_transaction_id: Optional[uuid.UUID] = Field(default=None) + gateway_transaction_id: uuid.UUID | None = Field(default=None) payment_time: datetime = Field(nullable=False) amount: float = Field(nullable=False) status: str = Field(nullable=False) # e.g., Paid, Pending, Failed source_type: str = Field(nullable=False) + class PaymentOrderNightclub(PaymentBase, table=True): __tablename__ = "payment_source_nightclub" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) retry_count: int = Field(default=0) - last_attempt_time: Optional[datetime] = Field(default=None) - order: "NightclubOrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + last_attempt_time: datetime | None = Field(default=None) + order: "NightclubOrder" = Relationship( + back_populates="payment", sa_relationship_kwargs={"uselist": False} + ) user: Optional["UserPublic"] = Relationship(back_populates="nightclub_payments") + class PaymentOrderQSR(PaymentBase, table=True): __tablename__ = "payment_source_qsr" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) retry_count: int = Field(default=0) - last_attempt_time: Optional[datetime] = Field(default=None) - order: "QSROrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + last_attempt_time: datetime | None = Field(default=None) + order: "QSROrder" = Relationship( + back_populates="payment", sa_relationship_kwargs={"uselist": False} + ) user: Optional["UserPublic"] = Relationship(back_populates="qsr_payments") + class PaymentOrderRestaurant(PaymentBase, table=True): __tablename__ = "payment_source_restaurant" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) retry_count: int = Field(default=0) - last_attempt_time: Optional[datetime] = Field(default=None) - order: "RestaurantOrder" = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + last_attempt_time: datetime | None = Field(default=None) + order: "RestaurantOrder" = Relationship( + back_populates="payment", sa_relationship_kwargs={"uselist": False} + ) user: Optional["UserPublic"] = Relationship(back_populates="restaurant_payments") + class PaymentEvent(PaymentBase, table=True): __tablename__ = "payment_event" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - event_booking_id: Optional[uuid.UUID] = Field(default=None, foreign_key="event_booking.id") - event_booking: Optional["EventBooking"] = Relationship(back_populates="payment", sa_relationship_kwargs={"uselist": False}) + event_booking_id: uuid.UUID | None = Field( + default=None, foreign_key="event_booking.id" + ) + event_booking: Optional["EventBooking"] = Relationship( + back_populates="payment", sa_relationship_kwargs={"uselist": False} + ) user: Optional["UserPublic"] = Relationship(back_populates="event_payments") diff --git a/backend/app/models/pickup_location.py b/backend/app/models/pickup_location.py index d2173c632c..31b4ac345a 100644 --- a/backend/app/models/pickup_location.py +++ b/backend/app/models/pickup_location.py @@ -1,6 +1,11 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from app.models.order import NightclubOrder + from app.models.venue import Venue +from sqlmodel import Field, Relationship, SQLModel + class PickupLocation(SQLModel, table=True): __tablename__ = "pickup_location" @@ -8,10 +13,10 @@ class PickupLocation(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False) name: str = Field(nullable=False) - description: Optional[str] = Field(default=None) + description: str | None = Field(default=None) # Relationships - orders: List["NightclubOrder"] = Relationship(back_populates="pickup_location") + orders: list["NightclubOrder"] = Relationship(back_populates="pickup_location") # Optionally, if you have a specific type of venue for PickupLocation - venue: Optional["Venue"] = Relationship(back_populates="pickup_locations") \ No newline at end of file + venue: Optional["Venue"] = Relationship(back_populates="pickup_locations") diff --git a/backend/app/models/qrcode.py b/backend/app/models/qrcode.py index d006749a0c..4fca5dd7af 100644 --- a/backend/app/models/qrcode.py +++ b/backend/app/models/qrcode.py @@ -1,15 +1,31 @@ import uuid -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List -from pydantic import model_validator, ValidationError - -class QRBase(SQLModel): - table_number: Optional[str] = Field(default=None, nullable=True) - -class QRCode(QRBase, table=True): - __tablename__ = "qr_codes" - id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) - venue_id: Optional[uuid.UUID] = Field(default=None, foreign_key="venue.id") - +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from app.models.venue import Venue +from sqlmodel import Field, Relationship + +from app.models.base_model import BaseTimeModel +from app.schema.qrcode import QRCodeCreate, QRCodeRead + + +class QRCode(BaseTimeModel, table=True): + __tablename__ = "qrcode" + + id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True) + venue_id: uuid.UUID | None = Field(default=None, foreign_key="venue.id") + table_number: str | None = Field(default=None, nullable=True) + # Relationships - venue: Optional["Venue"] = Relationship(back_populates="qr_codes") \ No newline at end of file + venue: Optional["Venue"] = Relationship(back_populates="qrcode") + + @classmethod + def from_create_schema(cls, schema: "QRCodeCreate") -> "QRCode": + return cls(venue_id=schema.venue_id, table_number=schema.table_number) + + def to_read_schema(self) -> "QRCodeRead": + return QRCodeRead( + id=self.id, # Access the actual value of the id + venue_id=self.venue_id, # Access the actual value of venue_id + table_number=self.table_number, # Provide a default empty string if None + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a165387e56..0877cbd102 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,10 +1,34 @@ -from app.models.group import GroupMembers -from app.models.base_model import BaseTimeModel -from sqlmodel import SQLModel, Field, Relationship -from typing import TYPE_CHECKING, Optional, List -from datetime import datetime import uuid +from datetime import datetime +from typing import TYPE_CHECKING + from pydantic import EmailStr +from sqlmodel import Field, Relationship, SQLModel + +from app.constants import Gender +from app.models.base_model import BaseTimeModel +from app.models.club_visit import ClubVisit +from app.models.event_booking import EventBooking +from app.models.group import GroupMembers +from app.models.order import NightclubOrder, QSROrder, RestaurantOrder +from app.models.payment import ( + PaymentEvent, + PaymentOrderNightclub, + PaymentOrderQSR, + PaymentOrderRestaurant, +) +from app.models.venue import Venue + +if TYPE_CHECKING: + from app.models.group import Group + +from app.schema.user import ( + UserBusinessCreate, + UserBusinessRead, + UserPublicCreate, + UserPublicRead, +) + # Shared properties class UserBase(BaseTimeModel): @@ -13,46 +37,95 @@ class UserBase(BaseTimeModel): full_name: str | None = Field(default=None, max_length=255) refresh_token: str = Field(nullable=True) + class UserPublic(UserBase, table=True): __tablename__ = "user_public" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - phone_number: Optional[str] = Field(unique=True, nullable=False,index=True,default=None) - email: Optional[EmailStr] = Field(default=None) - date_of_birth: Optional[datetime] = Field(default=None) - gender: Optional[str] = Field(default=None) - registration_date: datetime = Field(nullable=False) - profile_picture: Optional[str] = Field(default=None) - preferences: Optional[str] = Field(default=None) + phone_number: str | None = Field( + unique=True, nullable=False, index=True, default=None + ) + email: EmailStr | None = Field(default=None) + date_of_birth: datetime | None = Field(default=None) + gender: Gender | None = Field(default=None) + profile_picture: str | None = Field(default=None) + preferences: str | None = Field(default=None) # Relationships - nightclub_orders: List["NightclubOrder"] = Relationship(back_populates="user") - restaurant_orders: List["RestaurantOrder"] = Relationship(back_populates="user") - qsr_orders: List["QSROrder"] = Relationship(back_populates="user") - - club_visits: List["ClubVisit"] = Relationship(back_populates="user") - event_bookings: List["EventBooking"] = Relationship(back_populates="user") - groups: List["Group"] = Relationship(back_populates="members", link_model=GroupMembers) - managed_groups: List["Group"] = Relationship(back_populates="admin_user") - nightclub_payments: List["PaymentOrderNightclub"] = Relationship(back_populates="user") - qsr_payments: List["PaymentOrderQSR"] = Relationship(back_populates="user") - restaurant_payments: List["PaymentOrderRestaurant"] = Relationship(back_populates="user") - event_payments: List["PaymentEvent"] = Relationship(back_populates="user") + nightclub_orders: list["NightclubOrder"] = Relationship(back_populates="user") + restaurant_orders: list["RestaurantOrder"] = Relationship(back_populates="user") + qsr_orders: list["QSROrder"] = Relationship(back_populates="user") + + club_visits: list["ClubVisit"] = Relationship(back_populates="user") + event_bookings: list["EventBooking"] = Relationship(back_populates="user") + groups: list["Group"] = Relationship( + back_populates="members", link_model=GroupMembers + ) + managed_groups: list["Group"] = Relationship(back_populates="admin_user") + nightclub_payments: list["PaymentOrderNightclub"] = Relationship( + back_populates="user" + ) + qsr_payments: list["PaymentOrderQSR"] = Relationship(back_populates="user") + restaurant_payments: list["PaymentOrderRestaurant"] = Relationship( + back_populates="user" + ) + event_payments: list["PaymentEvent"] = Relationship(back_populates="user") + + @classmethod + def from_create_schema(cls, schema: UserPublicCreate) -> "UserPublic": + return cls( + full_name=schema.full_name, + phone_number=schema.phone_number, + email=schema.email, + date_of_birth=schema.date_of_birth, + gender=schema.gender, + profile_picture=schema.profile_picture, + preferences=schema.preferences, + ) + + def to_read_schema(self) -> UserPublicRead: + return UserPublicRead( + id=self.id, + full_name=self.full_name, + phone_number=self.phone_number, + email=self.email, + date_of_birth=self.date_of_birth, + gender=self.gender, + profile_picture=self.profile_picture, + preferences=self.preferences, + ) + class UserVenueAssociation(SQLModel, table=True): __tablename__ = "user_venue_association" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) user_id: uuid.UUID = Field(foreign_key="user_business.id", primary_key=True) venue_id: uuid.UUID = Field(foreign_key="venue.id", primary_key=True) - role: Optional[str] = Field(default=None) # e.g., 'manager', 'owner' - + role: str | None = Field(default=None) # e.g., 'manager', 'owner' + user: "UserBusiness" = Relationship(back_populates="venues_association") venue: "Venue" = Relationship(back_populates="managing_users") + class UserBusiness(UserBase, table=True): __tablename__ = "user_business" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - registration_date: datetime = Field(nullable=False) email: EmailStr = Field(unique=True, nullable=False, index=True, max_length=255) - phone_number: Optional[str] = Field(default=None) + phone_number: str | None = Field(default=None) # Relationships - venues_association: List[UserVenueAssociation] = Relationship(back_populates="user") \ No newline at end of file + venues_association: list[UserVenueAssociation] = Relationship(back_populates="user") + + @classmethod + def from_create_schema(cls, schema: UserBusinessCreate) -> "UserBusiness": + return cls( + full_name=schema.full_name, + email=schema.email, + phone_number=schema.phone_number, + ) + + def to_read_schema(self) -> UserBusinessRead: + return UserBusinessRead( + id=self.id, + full_name=self.full_name, + email=self.email, + phone_number=self.phone_number, + ) diff --git a/backend/app/models/venue.py b/backend/app/models/venue.py index 7e7164f2f0..d9c661759c 100644 --- a/backend/app/models/venue.py +++ b/backend/app/models/venue.py @@ -1,43 +1,68 @@ import uuid -from app.models.base_model import BaseTimeModel -from app.models.user import UserVenueAssociation -from app.schema.venue import FoodcourtCreate, FoodcourtRead, NightclubCreate, NightclubRead, QSRCreate, QSRRead, RestaurantCreate, RestaurantRead, VenueCreate, VenueRead -from sqlmodel import SQLModel, Field, Relationship -from typing import Optional, List from datetime import time +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship + +from app.models.base_model import BaseTimeModel + +if TYPE_CHECKING: + from app.models.club_visit import ClubVisit + from app.models.event import Event + from app.models.group import Group + from app.models.menu import Menu + from app.models.order import NightclubOrder, QSROrder, RestaurantOrder + from app.models.pickup_location import PickupLocation + from app.models.qrcode import QRCode + from app.models.user import UserVenueAssociation +from app.schema.venue import ( + FoodcourtCreate, + FoodcourtRead, + NightclubCreate, + NightclubRead, + QSRCreate, + QSRRead, + RestaurantCreate, + RestaurantRead, + VenueCreate, + VenueRead, +) + class Venue(BaseTimeModel, table=True): __tablename__ = "venue" - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) # Missing id field + id: uuid.UUID = Field( + default_factory=uuid.uuid4, primary_key=True, index=True + ) # Missing id field name: str = Field(nullable=False, index=True) - address: Optional[str] = Field(default=None) - latitude: Optional[float] = Field(default=None) - longitude: Optional[float] = Field(default=None) - capacity: Optional[int] = Field(default=None) - description: Optional[str] = Field(default=None) - google_rating: Optional[float] = Field(default=None) - instagram_handle: Optional[str] = Field(default=None) - instagram_token: Optional[str] = Field(default=None) - google_map_link: Optional[str] = Field(default=None) - mobile_number: Optional[str] = Field(default=None) - email: Optional[str] = Field(default=None) - opening_time: Optional[time] = Field(default=None) - closing_time: Optional[time] = Field(default=None) - avg_expense_for_two: Optional[float] = Field(default=None) - zomato_link: Optional[str] = Field(default=None) - swiggy_link: Optional[str] = Field(default=None) - - managing_users: List["UserVenueAssociation"] = Relationship(back_populates="venue") - qr_codes: List["QRCode"] = Relationship(back_populates="venue") - menu: List["Menu"] = Relationship(back_populates="venue") - pickup_locations: List["PickupLocation"] = Relationship(back_populates="venue") + address: str | None = Field(default=None) + latitude: float | None = Field(default=None) + longitude: float | None = Field(default=None) + capacity: int | None = Field(default=None) + description: str | None = Field(default=None) + google_rating: float | None = Field(default=None) + instagram_handle: str | None = Field(default=None) + instagram_token: str | None = Field(default=None) + google_map_link: str | None = Field(default=None) + mobile_number: str | None = Field(default=None) + email: str | None = Field(default=None) + opening_time: time | None = Field(default=None) + closing_time: time | None = Field(default=None) + avg_expense_for_two: float | None = Field(default=None) + zomato_link: str | None = Field(default=None) + swiggy_link: str | None = Field(default=None) + + managing_users: list["UserVenueAssociation"] = Relationship(back_populates="venue") + qrcode: list["QRCode"] = Relationship(back_populates="venue") + menu: list["Menu"] = Relationship(back_populates="venue") + pickup_locations: list["PickupLocation"] = Relationship(back_populates="venue") # Back-references for specific venue types foodcourt: Optional["Foodcourt"] = Relationship(back_populates="venue") qsr: Optional["QSR"] = Relationship(back_populates="venue") restaurant: Optional["Restaurant"] = Relationship(back_populates="venue") nightclub: Optional["Nightclub"] = Relationship(back_populates="venue") - + @classmethod def from_create_schema(cls, venue_create: VenueCreate) -> "Venue": return cls( @@ -53,7 +78,7 @@ def from_create_schema(cls, venue_create: VenueCreate) -> "Venue": closing_time=venue_create.closing_time, avg_expense_for_two=venue_create.avg_expense_for_two, zomato_link=venue_create.zomato_link, - swiggy_link=venue_create.swiggy_link + swiggy_link=venue_create.swiggy_link, ) def to_read_schema(self) -> VenueRead: @@ -77,25 +102,28 @@ def to_read_schema(self) -> VenueRead: swiggy_link=self.swiggy_link, ) + # Specific Venue Types class Foodcourt(BaseTimeModel, table=True): __tablename__ = "foodcourt" - + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - total_qsrs: Optional[int] = Field(default=None) # Example specific field for foodcourt - seating_capacity: Optional[int] = Field(default=None) # Specific to foodcourts + total_qsrs: int | None = Field(default=None) # Example specific field for foodcourt + seating_capacity: int | None = Field(default=None) # Specific to foodcourts venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) - + # Relationships venue: Venue = Relationship(back_populates="foodcourt") - qsrs: List["QSR"] = Relationship(back_populates="foodcourt") + qsrs: list["QSR"] = Relationship(back_populates="foodcourt") @classmethod - def from_create_schema(cls, venue_id: uuid ,foodcourt_create: FoodcourtCreate) -> "Foodcourt": - return cls ( + def from_create_schema( + cls, venue_id: uuid, foodcourt_create: FoodcourtCreate + ) -> "Foodcourt": + return cls( total_qsrs=foodcourt_create.total_qsrs, seating_capacity=foodcourt_create.seating_capacity, - venue_id = venue_id + venue_id=venue_id, ) def to_read_schema(self) -> FoodcourtRead: @@ -105,22 +133,25 @@ def to_read_schema(self) -> FoodcourtRead: total_qsrs=self.total_qsrs, seating_capacity=self.seating_capacity, venue=venue_read, - qsrs=[qsr.to_read_schema() for qsr in self.qsrs] + qsrs=[qsr.to_read_schema() for qsr in self.qsrs], ) + class QSR(BaseTimeModel, table=True): __tablename__ = "qsr" - + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) - foodcourt_id: Optional[uuid.UUID] = Field(foreign_key="foodcourt.id", nullable=True, index=True) + foodcourt_id: uuid.UUID | None = Field( + foreign_key="foodcourt.id", nullable=True, index=True + ) - drive_thru: Optional[bool] = Field(default=False) # Specific field for QSR + drive_thru: bool | None = Field(default=False) # Specific field for QSR venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) venue: Venue = Relationship(back_populates="qsr") - foodcourt: Optional[Foodcourt] = Relationship(back_populates="qsrs") - orders: List["QSROrder"] = Relationship(back_populates="qsr") - + foodcourt: Foodcourt | None = Relationship(back_populates="qsrs") + orders: list["QSROrder"] = Relationship(back_populates="qsr") + @classmethod def from_create_schema(cls, venue_id: uuid, qsr_create: QSRCreate) -> "QSR": return cls( @@ -135,20 +166,25 @@ def to_read_schema(self) -> QSRRead: id=self.id, foodcourt_id=self.foodcourt_id, drive_thru=self.drive_thru, - venue = venue_read + venue=venue_read, ) - + + class Restaurant(BaseTimeModel, table=True): __tablename__ = "restaurant" - + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) venue: Venue = Relationship(back_populates="restaurant") - cuisine_type: Optional[str] = Field(default=None) # Example specific field for restaurant - orders: List["RestaurantOrder"] = Relationship(back_populates="restaurant") + cuisine_type: str | None = Field( + default=None + ) # Example specific field for restaurant + orders: list["RestaurantOrder"] = Relationship(back_populates="restaurant") @classmethod - def from_create_schema(cls,venue_id, restaurant_create: RestaurantCreate) -> "Restaurant": + def from_create_schema( + cls, venue_id, restaurant_create: RestaurantCreate + ) -> "Restaurant": return cls( venue_id=venue_id, cuisine_type=restaurant_create.cuisine_type, @@ -162,22 +198,24 @@ def to_read_schema(self) -> RestaurantRead: cuisine_type=self.cuisine_type, ) + class Nightclub(BaseTimeModel, table=True): __tablename__ = "nightclub" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True) venue_id: uuid.UUID = Field(foreign_key="venue.id", nullable=False, index=True) - age_limit: Optional[int] = Field(default=None) + age_limit: int | None = Field(default=None) # Relationships - events: List["Event"] = Relationship(back_populates="nightclub") - club_visits: List["ClubVisit"] = Relationship(back_populates="nightclub") - orders: List["NightclubOrder"] = Relationship(back_populates="nightclub") - group: List["Group"] = Relationship(back_populates="nightclubs") + events: list["Event"] = Relationship(back_populates="nightclub") + club_visits: list["ClubVisit"] = Relationship(back_populates="nightclub") + orders: list["NightclubOrder"] = Relationship(back_populates="nightclub") + group: list["Group"] = Relationship(back_populates="nightclubs") venue: Venue = Relationship(back_populates="nightclub") - @classmethod - def from_create_schema(cls, venue_id, nightclub_create: NightclubCreate) -> "Nightclub": + def from_create_schema( + cls, venue_id, nightclub_create: NightclubCreate + ) -> "Nightclub": return cls( venue_id=venue_id, age_limit=nightclub_create.age_limit, @@ -189,4 +227,4 @@ def to_read_schema(self) -> NightclubRead: age_limit=self.age_limit, id=self.id, venue=venue_read, - ) \ No newline at end of file + ) diff --git a/backend/app/schema/menu.py b/backend/app/schema/menu.py index 1562a3bb0b..1d3d45d609 100644 --- a/backend/app/schema/menu.py +++ b/backend/app/schema/menu.py @@ -1,43 +1,49 @@ -from typing import List, Optional import uuid + from pydantic import BaseModel, Field + class MenuItemCreate(BaseModel): subcategory_id: uuid.UUID - name: str - price: float - description: Optional[str] = None - image_url: Optional[str] = None - is_veg: Optional[bool] = None - ingredients: Optional[str] = None - abv: Optional[float] = None - ibu: Optional[int] = None + name: str + price: float + description: str | None = None + image_url: str | None = None + is_veg: bool | None = None + ingredients: str | None = None + abv: float | None = None + ibu: int | None = None + class Config: from_attributes = True + + # Response schema for a menu item class MenuItemRead(BaseModel): item_id: uuid.UUID subcategory_id: uuid.UUID - name: str - price: float - description: Optional[str] = None - image_url: Optional[str] = None - is_veg: Optional[bool] = None - ingredients: Optional[str] = None - abv: Optional[float] = None - ibu: Optional[int] = None + name: str + price: float + description: str | None = None + image_url: str | None = None + is_veg: bool | None = None + ingredients: str | None = None + abv: float | None = None + ibu: int | None = None + class Config: from_attributes = True - + + class MenuItemUpdate(BaseModel): - name: Optional[str] = None # Name can be updated - price: Optional[float] = None # Price can be updated - description: Optional[str] = None # Description is optional and updatable - image_url: Optional[str] = None # Image URL is optional and updatable - is_veg: Optional[bool] = None # Optionally update veg/non-veg status - ingredients: Optional[str] = None # Ingredients list is optional and updatable - abv: Optional[float] = None # Alcohol by volume can be updated - ibu: Optional[int] = None # International Bitterness Units can be updated + name: str | None = None # Name can be updated + price: float | None = None # Price can be updated + description: str | None = None # Description is optional and updatable + image_url: str | None = None # Image URL is optional and updatable + is_veg: bool | None = None # Optionally update veg/non-veg status + ingredients: str | None = None # Ingredients list is optional and updatable + abv: float | None = None # Alcohol by volume can be updated + ibu: int | None = None # International Bitterness Units can be updated class Config: from_attributes = True @@ -45,72 +51,87 @@ class Config: ######################################################################################################### + class MenuSubCategoryCreate(BaseModel): name: str # Name of the subcategory is_alcoholic: bool = Field(default=False) category_id: uuid.UUID # Foreign key to the parent category + class Config: from_attributes = True + class MenuSubCategoryRead(BaseModel): subcategory_id: uuid.UUID # Unique identifier for the subcategory category_id: uuid.UUID - is_alcoholic: bool + is_alcoholic: bool name: str # Name of the subcategory - menu_items: Optional[List[MenuItemRead]] = [] # List of items under this subcategory - + menu_items: list[MenuItemRead] | None = [] # list of items under this subcategory + + class MenuSubCategoryUpdate(BaseModel): - name: Optional[str] = None # Optional name for update - is_alcoholic: Optional[bool] = None # Optional field for update - category_id: Optional[uuid.UUID] = None # Optional foreign key to update category - menu_items: Optional[List["MenuItemUpdate"]] = [] # Optional list for updating menu items + name: str | None = None # Optional name for update + is_alcoholic: bool | None = None # Optional field for update + category_id: uuid.UUID | None = None # Optional foreign key to update category + menu_items: list["MenuItemUpdate"] | None = ( + [] + ) # Optional list for updating menu items class Config: from_attributes = True + ######################################################################################################### class MenuCategoryRead(BaseModel): category_id: uuid.UUID # Unique identifier for the category name: str # Name of the category - menu_id : uuid.UUID - sub_categories: List[MenuSubCategoryRead] = [] # List of subcategories + menu_id: uuid.UUID + sub_categories: list[MenuSubCategoryRead] = [] # list of subcategories + class MenuCategoryCreate(BaseModel): name: str # Name of the category menu_id: uuid.UUID + class Config: from_attributes = True + class MenuCategoryUpdate(BaseModel): - name: Optional[str] = None # Category name can be updated - menu_id: Optional[uuid.UUID] = None # Menu ID can be updated + name: str | None = None # Category name can be updated + menu_id: uuid.UUID | None = None # Menu ID can be updated class Config: from_attributes = True - + + ######################################################################################################### class MenuRead(BaseModel): menu_id: uuid.UUID # Unique identifier for the menu name: str # Name of the menu (could be a restaurant menu or type of menu) - description: Optional[str] = None # Description of the menu - categories: List[MenuCategoryRead] = [] # Nested list of categories + description: str | None = None # Description of the menu + categories: list[MenuCategoryRead] = [] # Nested list of categories venue_id: uuid.UUID # Foreign key to the venue - menu_type: Optional[str] = None + menu_type: str | None = None + class Config: - from_attributes = True + from_attributes = True + class MenuCreate(BaseModel): name: str # Name of the menu (could be a restaurant menu or type of menu) - description: Optional[str] = None # Description of the menu + description: str | None = None # Description of the menu venue_id: uuid.UUID # Foreign key to the venue - menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") + menu_type: str | None = None # Type of menu (e.g., "Food", "Drink") + class Config: - from_attributes = True - + from_attributes = True + + class MenuUpdate(BaseModel): name: str # Name of the menu (could be a restaurant menu or type of menu) - description: Optional[str] = None # Description of the menu - menu_type: Optional[str] = None # Type of menu (e.g., "Food", "Drink") + description: str | None = None # Description of the menu + menu_type: str | None = None # Type of menu (e.g., "Food", "Drink") + class Config: - from_attributes = True - \ No newline at end of file + from_attributes = True diff --git a/backend/app/schema/qrcode.py b/backend/app/schema/qrcode.py index eaa725f165..d0decde0b7 100644 --- a/backend/app/schema/qrcode.py +++ b/backend/app/schema/qrcode.py @@ -1,25 +1,32 @@ -from typing import Optional from uuid import UUID -from pydantic import BaseModel, Field -class QRCodeBase(BaseModel): - table_number: Optional[str] = Field(default=None, nullable=True) - foodcourt_id: Optional[UUID] = Field(default=None) - qsr_id: Optional[UUID] = Field(default=None) - nightclub_id: Optional[UUID] = Field(default=None) - restaurant_id: Optional[UUID] = Field(default=None) +from pydantic import BaseModel -class QRCodeCreate(QRCodeBase): + +class QRCodeCreate(BaseModel): + """ + Schema for creating a new QR code. + """ + + table_number: str | None = None + venue_id: UUID # Foreign key to the venue + + +class QRCodeUpdate(BaseModel): """ Schema for creating a new QR code. - Only one of foodcourt_id, qsr_id, nightclub_id, or restaurant_id should be present. """ - - class Config: - from_attributes = True -class QRCodeRead(QRCodeBase): + table_number: str | None = None + +class QRCodeRead(BaseModel): + """ + Schema for reading a QR code. + """ + id: UUID # Automatically added by the model + venue_id: UUID + table_number: str | None class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/backend/app/schema/user.py b/backend/app/schema/user.py new file mode 100644 index 0000000000..6331cb9a6b --- /dev/null +++ b/backend/app/schema/user.py @@ -0,0 +1,66 @@ +# Create and Read Schemas + +import uuid +from datetime import datetime + +from pydantic import BaseModel, EmailStr + +from app.constants import Gender + + +class UserPublicCreate(BaseModel): + full_name: str + phone_number: str | None = None + email: EmailStr | None = None + date_of_birth: datetime | None = None + gender: Gender | None = None + profile_picture: str | None = None + preferences: str | None = None + + +class UserPublicUpdate(BaseModel): + full_name: str | None = None + phone_number: str | None = None + email: EmailStr | None = None + date_of_birth: datetime | None + gender: Gender | None = None + profile_picture: str | None = None + preferences: str | None = None + + +class UserPublicRead(BaseModel): + id: uuid.UUID + phone_number: str + full_name: str | None = None + email: EmailStr | None = None + date_of_birth: datetime | None = None + gender: Gender | None = None + profile_picture: str | None = None + preferences: str | None = None + + class Config: + from_attributes = True + extra = "forbid" + + +class UserBusinessCreate(BaseModel): + full_name: str | None = None + email: EmailStr + phone_number: str | None = None + + +class UserBusinessUpdate(BaseModel): + full_name: str | None = None + email: EmailStr | None = None + phone_number: str | None = None + + +class UserBusinessRead(BaseModel): + id: uuid.UUID + full_name: str | None = None + email: EmailStr + phone_number: str | None = None + + class Config: + from_attributes = True + extra = "forbid" diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index 03e5a06e34..497e2ba543 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -1,42 +1,50 @@ -from pydantic import BaseModel -from typing import Optional, List import uuid from datetime import time +from pydantic import BaseModel + + # Venue base details (composition) class VenueCreate(BaseModel): name: str - capacity: Optional[int] = None - description: Optional[str] = None - instagram_handle: Optional[str] = None - instagram_token: Optional[str] = None - mobile_number: Optional[str] = None - email: Optional[str] = None - opening_time: Optional[time] = None - closing_time: Optional[time] = None - avg_expense_for_two: Optional[float] = None - zomato_link: Optional[str] = None - swiggy_link: Optional[str] = None - google_map_link : Optional[str] = None - + capacity: int | None = None + description: str | None = None + instagram_handle: str | None = None + instagram_token: str | None = None + mobile_number: str | None = None + email: str | None = None + opening_time: time | None = None + closing_time: time | None = None + avg_expense_for_two: float | None = None + zomato_link: str | None = None + swiggy_link: str | None = None + google_map_link: str | None = None + + class FoodcourtCreate(BaseModel): - total_qsrs: Optional[int] = None - seating_capacity: Optional[int] = None + total_qsrs: int | None = None + seating_capacity: int | None = None venue: VenueCreate + class Config: from_attributes = True + + class QSRCreate(BaseModel): - drive_thru: Optional[bool] = False - foodcourt_id: Optional[uuid.UUID] = None + drive_thru: bool | None = False + foodcourt_id: uuid.UUID | None = None venue: VenueCreate + class Config: from_attributes = True + # Restaurant Schemas class RestaurantCreate(BaseModel): - cuisine_type: Optional[str] = None + cuisine_type: str | None = None venue_id: uuid.UUID venue: VenueCreate + class Config: from_attributes = True @@ -44,7 +52,7 @@ class Config: # Nightclub Schemas class NightclubCreate(BaseModel): venue: VenueCreate - age_limit: Optional[int] = None + age_limit: int | None = None class Config: from_attributes = True @@ -53,56 +61,64 @@ class Config: class VenueRead(BaseModel): id: uuid.UUID name: str - address: Optional[str] - latitude: Optional[float] - longitude: Optional[float] - capacity: Optional[int] - description: Optional[str] - google_rating: Optional[float] - instagram_handle: Optional[str] - google_map_link: Optional[str] - mobile_number: Optional[str] - email: Optional[str] - opening_time: Optional[time] - closing_time: Optional[time] - avg_expense_for_two: Optional[float] - zomato_link: Optional[str] - swiggy_link: Optional[str] + address: str | None + latitude: float | None + longitude: float | None + capacity: int | None + description: str | None + google_rating: float | None + instagram_handle: str | None + google_map_link: str | None + mobile_number: str | None + email: str | None + opening_time: time | None + closing_time: time | None + avg_expense_for_two: float | None + zomato_link: str | None + swiggy_link: str | None + class FoodcourtRead(BaseModel): id: uuid.UUID - total_qsrs: Optional[int] = None # Specific field for foodcourt - seating_capacity: Optional[int] = None # Specific to foodcourts + total_qsrs: int | None = None # Specific field for foodcourt + seating_capacity: int | None = None # Specific to foodcourts venue: VenueRead - qsrs: List["QSRRead"] = [] # List of QSRs in the foodcourt + qsrs: list["QSRRead"] = [] # list of QSRs in the foodcourt class Config: from_attributes = True + class QSRRead(BaseModel): id: uuid.UUID # Add any specific fields for QSR if needed - foodcourt_id: Optional[uuid.UUID] = None # Reference to the associated foodcourt + foodcourt_id: uuid.UUID | None = None # Reference to the associated foodcourt venue: VenueRead + class Config: from_attributes = True -class RestaurantRead(BaseModel): + +class RestaurantRead(BaseModel): id: uuid.UUID - cuisine_type: Optional[str] = None + cuisine_type: str | None = None venue: VenueRead + class Config: from_attributes = True + class NightclubRead(BaseModel): id: uuid.UUID - age_limit: Optional[int] = None + age_limit: int | None = None venue: VenueRead + class Config: from_attributes = True - + + class VenueListResponse(BaseModel): - nightclubs: List[NightclubRead] - qsrs: List[QSRRead] - foodcourts: List[FoodcourtRead] - restaurants: List[RestaurantRead] \ No newline at end of file + nightclubs: list[NightclubRead] + qsrs: list[QSRRead] + foodcourts: list[FoodcourtRead] + restaurants: list[RestaurantRead] diff --git a/backend/app/util.py b/backend/app/util.py index a1ca49f286..717c23fb02 100644 --- a/backend/app/util.py +++ b/backend/app/util.py @@ -1,34 +1,42 @@ -from datetime import datetime, timezone import logging import uuid -from app.models.user import UserBusiness, UserVenueAssociation +from typing import TypeVar + from fastapi import HTTPException from pydantic import BaseModel -from sqlmodel import SQLModel, Session, select -from typing import List, Optional, Type, TypeVar +from sqlmodel import Session, SQLModel, select + +from app.models.user import UserBusiness, UserVenueAssociation # Generic CRUD function to get all records with pagination +T = TypeVar("T", bound=SQLModel) + + def get_all_records( - session: Session, model: Type[SQLModel], skip: int = 0, limit: int = 10 -) -> List[SQLModel]: + session: Session, model: type[T], skip: int = 0, limit: int = 10 +) -> list[SQLModel]: """ - Retrieve a paginated list of records. + Retrieve a paginated list of records as schemas. - **session**: Database session - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR, Foodcourt) - **skip**: Number of records to skip - **limit**: Number of records to return """ + try: + # Fetch raw model instances statement = select(model).offset(skip).limit(limit) result = session.execute(statement).scalars().all() + + # Convert each record to its read schema return result - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error retrieving {model.__name__} records: {str(e)}") + except ValueError as ve: + # Handle errors (optional, add specific error handling as needed) + print(f"Error fetching records: {ve}") + return [] -# Function to get a single record by ID -T = TypeVar('T', bound=SQLModel) -def get_record_by_id(db: Session, model: Type[T], record_id: uuid.UUID) -> Optional[T]: +def get_record_by_id(db: Session, model: type[T], record_id: uuid.UUID) -> T | None: """ Generic function to retrieve a record by its ID. @@ -45,14 +53,15 @@ def get_record_by_id(db: Session, model: Type[T], record_id: uuid.UUID) -> Optio """ record = db.get(model, record_id) if not record: - raise HTTPException(status_code=404, detail=f"{model.__name__} with ID {record_id} not found.") + raise HTTPException( + status_code=404, detail=f"{model.__name__} with ID {record_id} not found." + ) return record + + # Function to create a new record # Create a new record -def create_record( - db: Session, - instance: SQLModel -) -> SQLModel: +def create_record(db: Session, instance: SQLModel) -> SQLModel: """ Create a new record in the database with automatic timestamp management. @@ -75,11 +84,8 @@ def create_record( return instance # Return the created instance -def update_record( - db: Session, - instance: SQLModel, - update_data: BaseModel -) -> SQLModel: + +def update_record(db: Session, instance: SQLModel, update_data: BaseModel) -> SQLModel: """ Update an existing record in the database, applying only the changes provided by a Pydantic model. This approach ensures validation of input data and prevents partial updates with invalid fields. @@ -108,52 +114,19 @@ def update_record( return instance except ValueError as ve: - logging.error(f"Validation error: {ve}") + logging.error("Validation error: %s", ve) db.rollback() # Undo any changes in case of failure raise HTTPException(status_code=400, detail=str(ve)) except Exception as e: - logging.error(f"Unexpected error during record update: {e}") + logging.error("Unexpected error during record update: %s", e) db.rollback() # Rollback any transaction in case of failure - raise HTTPException(status_code=500, detail="An internal error occurred while updating the record.") - -# Partially update an existing record -def patch_record( - session: Session, model: Type[SQLModel], record_id: uuid.UUID, obj_in: SQLModel -) -> SQLModel: - """ - Partially update an existing record with automatic `updated_at` timestamp. - - **session**: Database session - - **model**: SQLModel class (e.g., Nightclub, Restaurant, QSR) - - **record_id**: ID of the record to update - - **obj_in**: Partial data to update the record - """ - try: - # Get the existing record from the database - obj = get_record_by_id(session, model, record_id) - - # Convert the incoming data - obj_data = obj_in.model_dump() - - # Update the fields on the object - for field, value in obj_data.items(): - setattr(obj, field, value) - - # Set `updated_at` to the current time - obj.updated_at = datetime.utcnow() - - # Commit the changes - session.add(obj) - session.commit() - session.refresh(obj) - - return obj - except HTTPException as e: - raise e - except Exception as e: - session.rollback() - raise HTTPException(status_code=500, detail=f"Error updating {model.__name__}: {str(e)}") - + raise HTTPException( + status_code=500, + detail="An internal error occurred while updating the record.", + ) from e + + # Function to delete a record def delete_record(db: Session, instance: SQLModel) -> None: """ @@ -165,7 +138,7 @@ def delete_record(db: Session, instance: SQLModel) -> None: """ db.delete(instance) db.commit() - + def check_user_permission(db: Session, current_user: UserBusiness, venue_id: uuid.UUID): """ @@ -178,21 +151,21 @@ def check_user_permission(db: Session, current_user: UserBusiness, venue_id: uui Raises: HTTPException: If the user does not have permission. - + Returns: UserVenueAssociation: The association record if it exists. """ - statement = ( - select(UserVenueAssociation) - .where( - UserVenueAssociation.user_id == current_user.id, - UserVenueAssociation.venue_id == venue_id - ) + statement = select(UserVenueAssociation).where( + UserVenueAssociation.user_id == current_user.id, + UserVenueAssociation.venue_id == venue_id, ) user_venue_association = db.execute(statement).scalars().first() if user_venue_association is None: - raise HTTPException(status_code=403, detail="User does not have permission to manage this venue.") + raise HTTPException( + status_code=403, + detail="User does not have permission to manage this venue.", + ) - return user_venue_association \ No newline at end of file + return user_venue_association diff --git a/backend/app/utils.py b/backend/app/utils.py index e2044c5766..34f7324bbc 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,12 +1,15 @@ import re -from typing import Optional, List, Tuple + import unshortenit + class CoordinatesNotFoundError(Exception): """Custom exception raised when coordinates cannot be found in the Google Maps link.""" + pass -def extract_coordinates_from_full_link(link: str) -> Optional[Tuple[float, float]]: + +def extract_coordinates_from_full_link(link: str) -> tuple[float, float] | None: """ Extracts latitude and longitude from a full Google Maps link. @@ -18,15 +21,18 @@ def extract_coordinates_from_full_link(link: str) -> Optional[Tuple[float, float """ lat_long_regex = r"@([-+]?\d*\.\d+),([-+]?\d*\.\d+)" match = re.search(lat_long_regex, link) - + if match: latitude = float(match.group(1)) longitude = float(match.group(2)) return latitude, longitude - + return None -def get_coordinates_from_google_maps(link: str) -> Optional[List[Tuple[str, float, float]]]: + +def get_coordinates_from_google_maps( + link: str, +) -> list[tuple[str, float, float]] | None: """ Retrieves latitude and longitude from a Google Maps link by unshortening it and extracting coordinates. @@ -37,7 +43,7 @@ def get_coordinates_from_google_maps(link: str) -> Optional[List[Tuple[str, floa CoordinatesNotFoundError: If coordinates are not found in the link. Returns: - Optional[List[Tuple[str, float, float]]]: A list of tuples containing the link, latitude, and longitude, + Optional[list[Tuple[str, float, float]]]: A list of tuples containing the link, latitude, and longitude, or raises an exception if coordinates are not found. """ # Unshorten the URL if it's a shortened link @@ -48,5 +54,5 @@ def get_coordinates_from_google_maps(link: str) -> Optional[List[Tuple[str, floa if coordinates: latitude, longitude = coordinates return [(unshortened_url, latitude, longitude)] - - raise CoordinatesNotFoundError(f"No coordinates found in the provided link: {link}") \ No newline at end of file + + raise CoordinatesNotFoundError(f"No coordinates found in the provided link: {link}") diff --git a/backend/poetry.lock b/backend/poetry.lock index b60ec2ed79..9a650bbce7 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -373,6 +373,22 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +[[package]] +name = "autoscraper" +version = "1.1.14" +description = "A Smart, Automatic, Fast and Lightweight Web Scraper for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "autoscraper-1.1.14-py3-none-any.whl", hash = "sha256:fc0265723f6bcc80ee908d7be8d5318129f3388d2830d049fc0bda6c25695cf9"}, + {file = "autoscraper-1.1.14.tar.gz", hash = "sha256:281901477fb69aa09aa235abbd15bb38c46df1682c2cad504d0ac1ee0b6b81d0"}, +] + +[package.dependencies] +bs4 = "*" +lxml = "*" +requests = "*" + [[package]] name = "babel" version = "2.16.0" @@ -1959,6 +1975,20 @@ PyJWT = "*" requests = "*" rsa = "*" +[[package]] +name = "outcome" +version = "1.3.0.post0" +description = "Capture the outcome of Python function calls." +optional = false +python-versions = ">=3.7" +files = [ + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, +] + +[package.dependencies] +attrs = ">=19.2.0" + [[package]] name = "packaging" version = "24.1" @@ -2605,6 +2635,18 @@ files = [ {file = "pypika_tortoise-0.1.6-py3-none-any.whl", hash = "sha256:2d68bbb7e377673743cff42aa1059f3a80228d411fbcae591e4465e173109fd8"}, ] +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + [[package]] name = "pytest" version = "7.4.4" @@ -2819,6 +2861,25 @@ files = [ {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, ] +[[package]] +name = "selenium" +version = "4.25.0" +description = "Official Python bindings for Selenium WebDriver" +optional = false +python-versions = ">=3.8" +files = [ + {file = "selenium-4.25.0-py3-none-any.whl", hash = "sha256:3798d2d12b4a570bc5790163ba57fef10b2afee958bf1d80f2a3cf07c4141f33"}, + {file = "selenium-4.25.0.tar.gz", hash = "sha256:95d08d3b82fb353f3c474895154516604c7f0e6a9a565ae6498ef36c9bac6921"}, +] + +[package.dependencies] +certifi = ">=2021.10.8" +trio = ">=0.17,<1.0" +trio-websocket = ">=0.9,<1.0" +typing_extensions = ">=4.9,<5.0" +urllib3 = {version = ">=1.26,<3", extras = ["socks"]} +websocket-client = ">=1.8,<2.0" + [[package]] name = "sentry-sdk" version = "1.45.1" @@ -2889,6 +2950,17 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted list, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "soupsieve" version = "2.6" @@ -3093,6 +3165,42 @@ asyncodbc = ["asyncodbc (>=0.1.1,<0.2.0)"] asyncpg = ["asyncpg"] psycopg = ["psycopg[binary,pool] (>=3.0.12,<4.0.0)"] +[[package]] +name = "trio" +version = "0.27.0" +description = "A friendly Python library for async concurrency and I/O" +optional = false +python-versions = ">=3.8" +files = [ + {file = "trio-0.27.0-py3-none-any.whl", hash = "sha256:68eabbcf8f457d925df62da780eff15ff5dc68fd6b367e2dde59f7aaf2a0b884"}, + {file = "trio-0.27.0.tar.gz", hash = "sha256:1dcc95ab1726b2da054afea8fd761af74bad79bd52381b84eae408e983c76831"}, +] + +[package.dependencies] +attrs = ">=23.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = "*" +outcome = "*" +sniffio = ">=1.3.0" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.11.1" +description = "WebSocket library for Trio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f"}, + {file = "trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638"}, +] + +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +trio = ">=0.11" +wsproto = ">=0.14" + [[package]] name = "types-passlib" version = "1.7.7.20240327" @@ -3152,6 +3260,9 @@ files = [ {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] @@ -3335,6 +3446,38 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "webdriver-manager" +version = "4.0.2" +description = "Library provides the way to automatically manage drivers for different browsers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "webdriver_manager-4.0.2-py2.py3-none-any.whl", hash = "sha256:75908d92ecc45ff2b9953614459c633db8f9aa1ff30181cefe8696e312908129"}, + {file = "webdriver_manager-4.0.2.tar.gz", hash = "sha256:efedf428f92fd6d5c924a0d054e6d1322dd77aab790e834ee767af392b35590f"}, +] + +[package.dependencies] +packaging = "*" +python-dotenv = "*" +requests = "*" + +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "websockets" version = "12.0" @@ -3416,6 +3559,20 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + [[package]] name = "wtforms" version = "3.1.2" @@ -3532,4 +3689,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "bc72a371dfdc03c3879c619c44098140714cadd5df2825226834ce1b684f6eac" +content-hash = "b4928f406ec773720ee75b07d0f5b824a7e93d20dba0e6366c779efc02c87848" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 599fcedd25..f730e9491b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -39,6 +39,9 @@ sqladmin = {extras = ["full"], version = "^0.19.0"} unshortenit = "^0.4.0" aiohttp = "^3.10.10" bs4 = "^0.0.2" +selenium = "^4.25.0" +webdriver-manager = "^4.0.2" +autoscraper = "^1.1.14" [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" From 22fc2e7fa2b525247acc970552c502ce8e1737a0 Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Thu, 30 Jan 2025 18:06:53 +0530 Subject: [PATCH 09/25] add menu scrapper --- .env | 2 +- backend/menu_scrapper/test.py | 200 ++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 backend/menu_scrapper/test.py diff --git a/.env b/.env index 5110b5b506..4e5f20c55b 100644 --- a/.env +++ b/.env @@ -10,7 +10,7 @@ STACK_NAME=full-stack-fastapi-project # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=PDma3zqc5pq9LZVfVj7qL5bB6mC4OCIN0sKdjSiKOlI +SECRET_KEY=0_vuFnI9SIxZ4dxA5TcIroZRKp_ljxMuknEAcXDsGgo FIRST_SUPERUSER=arpit.singh@example.com FIRST_SUPERUSER_PASSWORD=SOciaSOcia diff --git a/backend/menu_scrapper/test.py b/backend/menu_scrapper/test.py new file mode 100644 index 0000000000..c874d374c6 --- /dev/null +++ b/backend/menu_scrapper/test.py @@ -0,0 +1,200 @@ +import json +import requests +from bs4 import BeautifulSoup +import logging +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, HttpUrl +from typing import Dict, Any + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = FastAPI() + +class ZomatoUrl(BaseModel): + url: HttpUrl + +def extract_menu_data(json_data: dict) -> dict: + try: + logger.debug("Starting menu data extraction...") + restaurant_data = json_data.get('pages', {}).get('current', {}) + restaurant_details = json_data.get('pages', {}).get('restaurant', {}) + + # Debug log the structure + logger.debug(f"Available keys in json_data: {list(json_data.keys())}") + logger.debug(f"Restaurant details keys: {list(restaurant_details.keys())}") + + restaurant_id = next(iter(restaurant_details)) if restaurant_details else None + + if not restaurant_id: + logger.error("No restaurant ID found") + return {} + + res_info = restaurant_details[restaurant_id].get('sections', {}) + logger.debug(f"Available sections: {list(res_info.keys())}") + + # Restaurant Info + basic_info = res_info.get('SECTION_BASIC_INFO', {}) + restaurant_info = { + 'restaurant_id': basic_info.get('res_id'), + 'name': basic_info.get('name'), + 'cuisines': basic_info.get('cuisine_string'), + 'rating': { + 'aggregate_rating': basic_info.get('rating', {}).get('aggregate_rating'), + 'votes': basic_info.get('rating', {}).get('votes'), + 'rating_text': basic_info.get('rating', {}).get('rating_text') + }, + 'location': { + 'locality': restaurant_data.get('pageDescription', ''), + 'url': basic_info.get('resUrl') + }, + 'timing': { + 'description': basic_info.get('timing', {}).get('timing_desc'), + 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) + } + } + + # Menu Items + menu_data = res_info.get('SECTION_MENU_WIDGET', {}) + logger.debug(f"Menu widget data keys: {list(menu_data.keys())}") + + menu_categories = [] + for category in menu_data.get('categories', []): + category_items = { + 'category': category.get('name', ''), + 'items': [] + } + + for item in category.get('items', []): + menu_item = { + 'id': str(item.get('id')), + 'name': item.get('name'), + 'description': item.get('description', ''), + 'price': float(item.get('price', 0)), + 'image_url': item.get('imageUrl', ''), + 'is_veg': item.get('isVeg', True), + 'spice_level': item.get('spiceLevel', 'None') + } + category_items['items'].append(menu_item) + + if category_items['items']: + menu_categories.append(category_items) + + return { + 'restaurant_info': restaurant_info, + 'menu': menu_categories + } + + except Exception as e: + logger.error(f"Error extracting menu data: {str(e)}") + return {} + +def fetch_zomato_data(url: str) -> str: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + } + + try: + logger.info(f"Fetching data from URL: {url}") + response = requests.get(url, headers=headers) + response.raise_for_status() + logger.debug(f"Response status code: {response.status_code}") + return response.text + except Exception as e: + logger.error(f"Error fetching data: {str(e)}") + raise + +def parse_zomato_page(html_content: str) -> dict: + try: + logger.debug("Starting page parsing...") + soup = BeautifulSoup(html_content, 'html.parser') + + # Find script with PRELOADED_STATE + scripts = soup.find_all('script') + target_script = None + + for script in scripts: + if script.string and 'window.__PRELOADED_STATE__' in script.string: + target_script = script + break + + if not target_script: + raise ValueError("Could not find PRELOADED_STATE in page") + + # Extract and clean JSON string + json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] + json_str = json_str.split(');')[0].strip() + + # Clean the JSON string + json_str = json_str.strip('"') + json_str = json_str.replace('\\"', '"') + json_str = json_str.replace('\\\\', '\\') + json_str = json_str.replace('\\n', '') + + # Debug log + logger.debug(f"Extracted JSON string (first 200 chars): {json_str[:200]}...") + + # Parse JSON + parsed_data = json.loads(json_str) + + # Save raw data for debugging + with open('raw_data.json', 'w', encoding='utf-8') as f: + json.dump(parsed_data, f, indent=2, ensure_ascii=False) + logger.info("Raw data saved to raw_data.json") + + return parsed_data + + except Exception as e: + logger.error(f"Error parsing page: {str(e)}") + logger.error(f"Script content: {target_script.string[:200] if target_script else 'No script found'}") + raise + +@app.get("/") +async def root(): + return {"message": "Welcome to Zomato Menu Scraper"} + +@app.post("/scrape-menu") +async def scrape_menu(request: ZomatoUrl) -> Dict[Any, Any]: + try: + if "zomato.com" not in str(request.url): + raise HTTPException( + status_code=400, + detail="Invalid URL. Please provide a valid Zomato restaurant URL" + ) + + logger.info("Starting scraping process...") + html_content = fetch_zomato_data(str(request.url)) + json_data = parse_zomato_page(html_content) + formatted_data = extract_menu_data(json_data) + + if not formatted_data: + raise HTTPException( + status_code=500, + detail="Failed to extract menu data" + ) + + # Save formatted data for debugging + with open('formatted_data.json', 'w', encoding='utf-8') as f: + json.dump(formatted_data, f, indent=2, ensure_ascii=False) + logger.info("Formatted data saved to formatted_data.json") + + return formatted_data + + except Exception as e: + logger.error(f"Scraping failed: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to scrape menu: {str(e)}" + ) + +if __name__ == "__main__": + import uvicorn + logger.info("Starting Zomato Menu Scraper...") + uvicorn.run(app, host="0.0.0.0", port=8000) From c35a139a9d3fadf0a644743a99473beca2427e8b Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Sun, 2 Feb 2025 14:51:00 +0530 Subject: [PATCH 10/25] fixed restaurant apischema --- backend/app/schema/venue.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index dd85e8276c..82753f3671 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -42,7 +42,6 @@ class Config: # Restaurant Schemas class RestaurantCreate(BaseModel): cuisine_type: str | None = None - venue_id: uuid.UUID venue: VenueCreate class Config: From ef85d62229bce5d4319c8c8a755c65d16bd0867f Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Tue, 4 Feb 2025 12:23:22 +0530 Subject: [PATCH 11/25] update readme --- README.md | 235 ---------------------------------------------- backend/README.md | 90 +----------------- 2 files changed, 1 insertion(+), 324 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index 80188b0e26..0000000000 --- a/README.md +++ /dev/null @@ -1,235 +0,0 @@ -# Full Stack FastAPI Template - -Test -Coverage - -## Technology Stack and Features - -- ⚡ [**FastAPI**](https://fastapi.tiangolo.com) for the Python backend API. - - 🧰 [SQLModel](https://sqlmodel.tiangolo.com) for the Python SQL database interactions (ORM). - - 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management. - - 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database. -- 🚀 [React](https://react.dev) for the frontend. - - 💃 Using TypeScript, hooks, Vite, and other parts of a modern frontend stack. - - 🎨 [Chakra UI](https://chakra-ui.com) for the frontend components. - - 🤖 An automatically generated frontend client. - - 🧪 [Playwright](https://playwright.dev) for End-to-End testing. - - 🦇 Dark mode support. -- 🐋 [Docker Compose](https://www.docker.com) for development and production. -- 🔒 Secure password hashing by default. -- 🔑 JWT (JSON Web Token) authentication. -- 📫 Email based password recovery. -- ✅ Tests with [Pytest](https://pytest.org). -- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer. -- 🚢 Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates. -- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions. - -### Dashboard Login - -[![API docs](img/login.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Admin - -[![API docs](img/dashboard.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Create User - -[![API docs](img/dashboard-create.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Items - -[![API docs](img/dashboard-items.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - User Settings - -[![API docs](img/dashboard-user-settings.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Dark Mode - -[![API docs](img/dashboard-dark.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Interactive API Documentation - -[![API docs](img/docs.png)](https://github.com/fastapi/full-stack-fastapi-template) - -## How To Use It - -You can **just fork or clone** this repository and use it as is. - -✨ It just works. ✨ - -### How to Use a Private Repository - -If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks. - -But you can do the following: - -- Create a new GitHub repo, for example `my-full-stack`. -- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`: - -```bash -git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack -``` - -- Enter into the new directory: - -```bash -cd my-full-stack -``` - -- Set the new origin to your new repository, copy it from the GitHub interface, for example: - -```bash -git remote set-url origin git@github.com:octocat/my-full-stack.git -``` - -- Add this repo as another "remote" to allow you to get updates later: - -```bash -git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git -``` - -- Push the code to your new repository: - -```bash -git push -u origin master -``` - -### Update From the Original Template - -After cloning the repository, and after doing changes, you might want to get the latest changes from this original template. - -- Make sure you added the original repository as a remote, you can check it with: - -```bash -git remote -v - -origin git@github.com:octocat/my-full-stack.git (fetch) -origin git@github.com:octocat/my-full-stack.git (push) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (push) -``` - -- Pull the latest changes without merging: - -```bash -git pull --no-commit upstream master -``` - -This will download the latest changes from this template without committing them, that way you can check everything is right before committing. - -- If there are conflicts, solve them in your editor. - -- Once you are done, commit the changes: - -```bash -git merge --continue -``` - -### Configure - -You can then update configs in the `.env` files to customize your configurations. - -Before deploying it, make sure you change at least the values for: - -- `SECRET_KEY` -- `FIRST_SUPERUSER_PASSWORD` -- `POSTGRES_PASSWORD` - -You can (and should) pass these as environment variables from secrets. - -Read the [deployment.md](./deployment.md) docs for more details. - -### Generate Secret Keys - -Some environment variables in the `.env` file have a default value of `changethis`. - -You have to change them with a secret key, to generate secret keys you can run the following command: - -```bash -python -c "import secrets; print(secrets.token_urlsafe(32))" -``` - -Copy the content and use that as password / secret key. And run that again to generate another secure key. - -## How To Use It - Alternative With Copier - -This repository also supports generating a new project using [Copier](https://copier.readthedocs.io). - -It will copy all the files, ask you configuration questions, and update the `.env` files with your answers. - -### Install Copier - -You can install Copier with: - -```bash -pip install copier -``` - -Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with: - -```bash -pipx install copier -``` - -**Note**: If you have `pipx`, installing copier is optional, you could run it directly. - -### Generate a Project With Copier - -Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`. - -Go to the directory that will be the parent of your project, and run the command with your project's name: - -```bash -copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -If you have `pipx` and you didn't install `copier`, you can run it directly: - -```bash -pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files. - -### Input Variables - -Copier will ask you for some data, you might want to have at hand before generating the project. - -But don't worry, you can just update any of that in the `.env` files afterwards. - -The input variables, with their default values (some auto generated) are: - -- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env). -- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env). -- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above. -- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env). -- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env). -- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env. -- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env. -- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env. -- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env. -- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above. -- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env. - -## Backend Development - -Backend docs: [backend/README.md](./backend/README.md). - -## Deployment - -Deployment docs: [deployment.md](./deployment.md). - -## Development - -General development docs: [development.md](./development.md). - -This includes using Docker Compose, custom local domains, `.env` configurations, etc. - -## Release Notes - -Check the file [release-notes.md](./release-notes.md). - -## License - -The Full Stack FastAPI Template is licensed under the terms of the MIT license. diff --git a/backend/README.md b/backend/README.md index 7e7829677f..972a0b1cff 100644 --- a/backend/README.md +++ b/backend/README.md @@ -17,8 +17,6 @@ docker compose up -d Frontend, built with Docker, with routes handled based on the path: http://localhost -Backend, JSON based web API based on OpenAPI: http://localhost/api/ - Automatic interactive documentation with Swagger UI (from the OpenAPI backend): http://localhost/docs Adminer, database web administration: http://localhost:8080 @@ -105,55 +103,7 @@ root@7f2607af31c3:/app# that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. -There you can use the script `/start-reload.sh` to run the debug live reloading server. You can run that script from inside the container with: - -```console -$ bash /start-reload.sh -``` - -...it will look like: - -```console -root@7f2607af31c3:/app# bash /start-reload.sh -``` - -and then hit enter. That runs the live reloading server that auto reloads when it detects code changes. - -Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter"). - -...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server. - -### Backend tests - -To test the backend run: - -```console -$ bash ./scripts/test.sh -``` - -The tests run with Pytest, modify and add tests to `./backend/app/tests/`. - -If you use GitHub Actions the tests will run automatically. - -#### Test running stack - -If your stack is already up and you just want to run the tests, you can use: - -```bash -docker compose exec backend bash /app/tests-start.sh -``` - -That `/app/tests-start.sh` script just calls `pytest` after making sure that the rest of the stack is running. If you need to pass extra arguments to `pytest`, you can pass them to that command and they will be forwarded. - -For example, to stop on first error: - -```bash -docker compose exec backend bash /app/tests-start.sh -x -``` -#### Test Coverage - -When the tests are run, a file `htmlcov/index.html` is generated, you can open it in your browser to see the coverage of the tests. ### Migrations @@ -165,42 +115,4 @@ Make sure you create a "revision" of your models and that you "upgrade" your dat ```console $ docker compose exec backend bash -``` - -* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`. - -* After changing a model (for example, adding a column), inside the container, create a revision, e.g.: - -```console -$ alembic revision --autogenerate -m "Add column last_name to User model" -``` - -* Commit to the git repository the files generated in the alembic directory. - -* After creating the revision, run the migration in the database (this is what will actually change the database): - -```console -$ alembic upgrade head -``` - -If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in: - -```python -SQLModel.metadata.create_all(engine) -``` - -and comment the line in the file `prestart.sh` that contains: - -```console -$ alembic upgrade head -``` - -If you don't want to start with the default models and want to remove them / modify them, from the beginning, without having any previous revision, you can remove the revision files (`.py` Python files) under `./backend/app/alembic/versions/`. And then create a first migration as described above. - -## Email Templates - -The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application. - -Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code. - -Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory. +``` \ No newline at end of file From 476767994c0b22bf6f95bd5b7828e03a70501300 Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Tue, 4 Feb 2025 13:09:12 +0530 Subject: [PATCH 12/25] update base model --- backend/app/models/base_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/models/base_model.py b/backend/app/models/base_model.py index 427e48519f..e61e2e0719 100644 --- a/backend/app/models/base_model.py +++ b/backend/app/models/base_model.py @@ -18,10 +18,10 @@ class BaseTimeModel(SQLModel, ABC): """ created_at: datetime | None = Field( - default_factory=lambda: datetime.now(timezone.utc), nullable=False + default_factory=lambda: datetime.now(timezone.utc), nullable=True ) updated_at: datetime | None = Field( - default_factory=lambda: datetime.now(timezone.utc), nullable=False + default_factory=lambda: datetime.now(timezone.utc), nullable=True ) @abstractmethod From 6e2f60d4cd23c1252167490ceeb2271dd832810b Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Tue, 4 Feb 2025 13:35:54 +0530 Subject: [PATCH 13/25] Adding readme --- backend/README.md | 102 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/backend/README.md b/backend/README.md index 972a0b1cff..cb82724abf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -95,24 +95,104 @@ and then `exec` inside the running container: $ docker compose exec backend bash ``` -You should see an output like: +that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. -```console -root@7f2607af31c3:/app# -``` +### Migrations -that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. +Make sure you create a "revision" of your models and upgrade your database with that revision every time you change them. This updates your database schema to reflect model changes and prevents application errors. +#### Steps to Perform Migration +1. **Start an Interactive Session:** + Open a shell inside the backend container: + ```bash + docker compose exec backend bash + ``` -### Migrations +2. **Generate a New Migration Revision:** + Automatically create a new migration based on your model changes: + ```bash + alembic revision --autogenerate -m "Describe your schema changes" + ``` + +3. **Review the Migration Script:** + Check the generated migration file in the migrations directory to confirm the changes. Modify the migration file if necessary. -As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository. +4. **Apply the Migration:** + Run the migration to update your database schema: + ```bash + alembic upgrade head + ``` -Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors. +5. **Verify the Update:** + Ensure that your database reflects the changes by reviewing logs or using a database client. * Start an interactive session in the backend container: -```console -$ docker compose exec backend bash -``` \ No newline at end of file +## Additional Backend Documentation + +### Project Structure + +- **app/** + - **api/**: Contains the RESTful API endpoint definitions using FastAPI. Each module here relates to a specific resource by: + - Defining route paths for HTTP methods (GET, POST, PUT, DELETE). + - Validating input data using Pydantic models. + - Handling exceptions and returning standardized JSON responses compliant with OpenAPI. + - **models.py**: Contains the SQLModel-based database models that define: + - The table schema including columns, data types, and constraints. + - Relationships between tables along with validation rules and default values. + **Note:** Always generate a new Alembic migration and upgrade your database after modifying this file. + - **crud.py**: Implements a set of CRUD functions that abstract database interactions, including: + - Creating and inserting new records. + - Fetching single or multiple records. + - Updating records with new data. + - Deleting records with robust error handling. +- **Docker Setup** + - `docker-compose.yml` & `docker-compose.override.yml`: Used to start the entire stack for development. The volume mappings allow live reloading of code changes. + - `.dockerignore`: Lists files and directories that are not needed during the Docker build process. +- **Version Control** + - `.gitignore`: Ensures that generated files (like caches, virtual environments, etc.) are not committed to version control. + +### Development Workflow + +- **Dependency management:** Uses [Poetry](https://python-poetry.org/) to handle dependencies and virtual environments. +- **Running the application:** Start the services with: + ```bash + docker compose up -d + ``` + Then, for local development and debugging, you can attach to the backend container using: + ```bash + docker compose exec backend bash + ``` +- **Migrations:** Use Alembic for database schema migrations. Always update your migration scripts when modifying models. +- **Debugging & Testing:** Leverage the VS Code configurations to set breakpoints and run tests directly from the editor. + +### Extending the Application + +- **Adding New APIs:** When creating new endpoints, add a new module under the `app/api/` directory. Each endpoint should: + - Define RESTful routes implementing the required CRUD functionalities. + - Use dependency injection to handle authentication and request validation. + - Return structured responses according to OpenAPI standards. +- **Modifying Data Models:** When altering `app/models.py`: + - Update table schemas, add or modify columns, and adjust relationships. + - Create and apply a corresponding Alembic migration to keep the database schema synchronized. + +This section is intended to serve as a high-level guide for both new and experienced developers on understanding and extending the backend of this FastAPI project. + +## Modules Overview + +### Schema + +The `app/schema/` directory contains Pydantic models that are responsible for: + - **Data Structure Definition:** Outlining the data formats used for API requests and responses. + - **Validation & Serialization:** Enforcing business logic and ensuring that incoming and outgoing data adhere to expected formats. For example, custom validators (such as in `carousel_poster.py`) ensure that specific business rules are followed. + - **ORM Integration:** Converting ORM objects into serializable formats using the `from_attributes = True` configuration. + +### Core + +The `app/core/` directory includes essential modules that form the backbone of the application: + - **config.py:** Manages application configuration and environment variables using Pydantic's BaseSettings. It also constructs derived settings (like the SQLAlchemy database URI and server host). + - **db.py:** Initializes and provides access to the database engine and sessions, ensuring smooth database connectivity and supporting operations like database initialization. + - **security.py:** Implements security measures such as JWT-based token creation and validation, ensuring secure API operations. + +These modules together ensure a modular, secure, and maintainable backend system. \ No newline at end of file From 8bb8b6053107562ab9ec6aa9b99d24821ba9836a Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Tue, 4 Feb 2025 13:37:54 +0530 Subject: [PATCH 14/25] remove tests --- backend/app/tests/__init__.py | 0 backend/app/tests/api/__init__.py | 0 backend/app/tests/api/routes/__init__.py | 0 backend/app/tests/api/routes/test_items.py | 164 ------ backend/app/tests/api/routes/test_login.py | 104 ---- backend/app/tests/api/routes/test_users.py | 486 ------------------ backend/app/tests/conftest.py | 42 -- backend/app/tests/crud/__init__.py | 0 backend/app/tests/crud/test_user.py | 91 ---- backend/app/tests/scripts/__init__.py | 0 .../tests/scripts/test_backend_pre_start.py | 33 -- .../app/tests/scripts/test_test_pre_start.py | 33 -- backend/app/tests/utils/__init__.py | 0 backend/app/tests/utils/item.py | 16 - backend/app/tests/utils/user.py | 49 -- backend/app/tests/utils/utils.py | 26 - 16 files changed, 1044 deletions(-) delete mode 100644 backend/app/tests/__init__.py delete mode 100644 backend/app/tests/api/__init__.py delete mode 100644 backend/app/tests/api/routes/__init__.py delete mode 100644 backend/app/tests/api/routes/test_items.py delete mode 100644 backend/app/tests/api/routes/test_login.py delete mode 100644 backend/app/tests/api/routes/test_users.py delete mode 100644 backend/app/tests/conftest.py delete mode 100644 backend/app/tests/crud/__init__.py delete mode 100644 backend/app/tests/crud/test_user.py delete mode 100644 backend/app/tests/scripts/__init__.py delete mode 100644 backend/app/tests/scripts/test_backend_pre_start.py delete mode 100644 backend/app/tests/scripts/test_test_pre_start.py delete mode 100644 backend/app/tests/utils/__init__.py delete mode 100644 backend/app/tests/utils/item.py delete mode 100644 backend/app/tests/utils/user.py delete mode 100644 backend/app/tests/utils/utils.py diff --git a/backend/app/tests/__init__.py b/backend/app/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/tests/api/__init__.py b/backend/app/tests/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/tests/api/routes/__init__.py b/backend/app/tests/api/routes/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/tests/api/routes/test_items.py b/backend/app/tests/api/routes/test_items.py deleted file mode 100644 index c215238a69..0000000000 --- a/backend/app/tests/api/routes/test_items.py +++ /dev/null @@ -1,164 +0,0 @@ -import uuid - -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app.core.config import settings -from app.tests.utils.item import create_random_item - - -def test_create_item( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"title": "Foo", "description": "Fighters"} - response = client.post( - f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert "id" in content - assert "owner_id" in content - - -def test_read_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == item.title - assert content["description"] == item.description - assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) - - -def test_read_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - response = client.get( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_read_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Not enough permissions" - - -def test_read_items( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - create_random_item(db) - create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert len(content["data"]) >= 2 - - -def test_update_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) - - -def test_update_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_update_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - json=data, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Not enough permissions" - - -def test_delete_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.delete( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["message"] == "Item deleted successfully" - - -def test_delete_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - response = client.delete( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_delete_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.delete( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Not enough permissions" diff --git a/backend/app/tests/api/routes/test_login.py b/backend/app/tests/api/routes/test_login.py deleted file mode 100644 index 34fe8ee560..0000000000 --- a/backend/app/tests/api/routes/test_login.py +++ /dev/null @@ -1,104 +0,0 @@ -from unittest.mock import patch - -from fastapi.testclient import TestClient -from sqlmodel import Session, select - -from app.core.config import settings -from app.core.security import verify_password -from app.models import User -from app.utils import generate_password_reset_token - - -def test_get_access_token(client: TestClient) -> None: - login_data = { - "username": settings.FIRST_SUPERUSER, - "password": settings.FIRST_SUPERUSER_PASSWORD, - } - r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - assert r.status_code == 200 - assert "access_token" in tokens - assert tokens["access_token"] - - -def test_get_access_token_incorrect_password(client: TestClient) -> None: - login_data = { - "username": settings.FIRST_SUPERUSER, - "password": "incorrect", - } - r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - assert r.status_code == 400 - - -def test_use_access_token( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - r = client.post( - f"{settings.API_V1_STR}/login/test-token", - headers=superuser_token_headers, - ) - result = r.json() - assert r.status_code == 200 - assert "email" in result - - -def test_recovery_password( - client: TestClient, normal_user_token_headers: dict[str, str] -) -> None: - with ( - patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), - patch("app.core.config.settings.SMTP_USER", "admin@example.com"), - ): - email = "test@example.com" - r = client.post( - f"{settings.API_V1_STR}/password-recovery/{email}", - headers=normal_user_token_headers, - ) - assert r.status_code == 200 - assert r.json() == {"message": "Password recovery email sent"} - - -def test_recovery_password_user_not_exits( - client: TestClient, normal_user_token_headers: dict[str, str] -) -> None: - email = "jVgQr@example.com" - r = client.post( - f"{settings.API_V1_STR}/password-recovery/{email}", - headers=normal_user_token_headers, - ) - assert r.status_code == 404 - - -def test_reset_password( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - token = generate_password_reset_token(email=settings.FIRST_SUPERUSER) - data = {"new_password": "changethis", "token": token} - r = client.post( - f"{settings.API_V1_STR}/reset-password/", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 200 - assert r.json() == {"message": "Password updated successfully"} - - user_query = select(User).where(User.email == settings.FIRST_SUPERUSER) - user = db.exec(user_query).first() - assert user - assert verify_password(data["new_password"], user.hashed_password) - - -def test_reset_password_invalid_token( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"new_password": "changethis", "token": "invalid"} - r = client.post( - f"{settings.API_V1_STR}/reset-password/", - headers=superuser_token_headers, - json=data, - ) - response = r.json() - - assert "detail" in response - assert r.status_code == 400 - assert response["detail"] == "Invalid token" diff --git a/backend/app/tests/api/routes/test_users.py b/backend/app/tests/api/routes/test_users.py deleted file mode 100644 index 15d4a4d92e..0000000000 --- a/backend/app/tests/api/routes/test_users.py +++ /dev/null @@ -1,486 +0,0 @@ -import uuid -from unittest.mock import patch - -from fastapi.testclient import TestClient -from sqlmodel import Session, select - -from app import util -from app.core.config import settings -from app.core.security import verify_password -from app.models import User, UserCreate -from app.tests.utils.utils import random_email, random_lower_string - - -def test_get_users_superuser_me( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - r = client.get(f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers) - current_user = r.json() - assert current_user - assert current_user["is_active"] is True - assert current_user["is_superuser"] - assert current_user["email"] == settings.FIRST_SUPERUSER - - -def test_get_users_normal_user_me( - client: TestClient, normal_user_token_headers: dict[str, str] -) -> None: - r = client.get(f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers) - current_user = r.json() - assert current_user - assert current_user["is_active"] is True - assert current_user["is_superuser"] is False - assert current_user["email"] == settings.EMAIL_TEST_USER - - -def test_create_user_new_email( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - with ( - patch("app.utils.send_email", return_value=None), - patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), - patch("app.core.config.settings.SMTP_USER", "admin@example.com"), - ): - username = random_email() - password = random_lower_string() - data = {"email": username, "password": password} - r = client.post( - f"{settings.API_V1_STR}/users/", - headers=superuser_token_headers, - json=data, - ) - assert 200 <= r.status_code < 300 - created_user = r.json() - user = util.get_user_by_email(session=db, email=username) - assert user - assert user.email == created_user["email"] - - -def test_get_existing_user( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - user_id = user.id - r = client.get( - f"{settings.API_V1_STR}/users/{user_id}", - headers=superuser_token_headers, - ) - assert 200 <= r.status_code < 300 - api_user = r.json() - existing_user = util.get_user_by_email(session=db, email=username) - assert existing_user - assert existing_user.email == api_user["email"] - - -def test_get_existing_user_current_user(client: TestClient, db: Session) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - user_id = user.id - - login_data = { - "username": username, - "password": password, - } - r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - a_token = tokens["access_token"] - headers = {"Authorization": f"Bearer {a_token}"} - - r = client.get( - f"{settings.API_V1_STR}/users/{user_id}", - headers=headers, - ) - assert 200 <= r.status_code < 300 - api_user = r.json() - existing_user = util.get_user_by_email(session=db, email=username) - assert existing_user - assert existing_user.email == api_user["email"] - - -def test_get_existing_user_permissions_error( - client: TestClient, normal_user_token_headers: dict[str, str] -) -> None: - r = client.get( - f"{settings.API_V1_STR}/users/{uuid.uuid4()}", - headers=normal_user_token_headers, - ) - assert r.status_code == 403 - assert r.json() == {"detail": "The user doesn't have enough privileges"} - - -def test_create_user_existing_username( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - # username = email - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - util.create_user(session=db, user_create=user_in) - data = {"email": username, "password": password} - r = client.post( - f"{settings.API_V1_STR}/users/", - headers=superuser_token_headers, - json=data, - ) - created_user = r.json() - assert r.status_code == 400 - assert "_id" not in created_user - - -def test_create_user_by_normal_user( - client: TestClient, normal_user_token_headers: dict[str, str] -) -> None: - username = random_email() - password = random_lower_string() - data = {"email": username, "password": password} - r = client.post( - f"{settings.API_V1_STR}/users/", - headers=normal_user_token_headers, - json=data, - ) - assert r.status_code == 403 - - -def test_retrieve_users( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - util.create_user(session=db, user_create=user_in) - - username2 = random_email() - password2 = random_lower_string() - user_in2 = UserCreate(email=username2, password=password2) - util.create_user(session=db, user_create=user_in2) - - r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers) - all_users = r.json() - - assert len(all_users["data"]) > 1 - assert "count" in all_users - for item in all_users["data"]: - assert "email" in item - - -def test_update_user_me( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - full_name = "Updated Name" - email = random_email() - data = {"full_name": full_name, "email": email} - r = client.patch( - f"{settings.API_V1_STR}/users/me", - headers=normal_user_token_headers, - json=data, - ) - assert r.status_code == 200 - updated_user = r.json() - assert updated_user["email"] == email - assert updated_user["full_name"] == full_name - - user_query = select(User).where(User.email == email) - user_db = db.exec(user_query).first() - assert user_db - assert user_db.email == email - assert user_db.full_name == full_name - - -def test_update_password_me( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - new_password = random_lower_string() - data = { - "current_password": settings.FIRST_SUPERUSER_PASSWORD, - "new_password": new_password, - } - r = client.patch( - f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 200 - updated_user = r.json() - assert updated_user["message"] == "Password updated successfully" - - user_query = select(User).where(User.email == settings.FIRST_SUPERUSER) - user_db = db.exec(user_query).first() - assert user_db - assert user_db.email == settings.FIRST_SUPERUSER - assert verify_password(new_password, user_db.hashed_password) - - # Revert to the old password to keep consistency in test - old_data = { - "current_password": new_password, - "new_password": settings.FIRST_SUPERUSER_PASSWORD, - } - r = client.patch( - f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, - json=old_data, - ) - db.refresh(user_db) - - assert r.status_code == 200 - assert verify_password(settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password) - - -def test_update_password_me_incorrect_password( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - new_password = random_lower_string() - data = {"current_password": new_password, "new_password": new_password} - r = client.patch( - f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 400 - updated_user = r.json() - assert updated_user["detail"] == "Incorrect password" - - -def test_update_user_me_email_exists( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - - data = {"email": user.email} - r = client.patch( - f"{settings.API_V1_STR}/users/me", - headers=normal_user_token_headers, - json=data, - ) - assert r.status_code == 409 - assert r.json()["detail"] == "User with this email already exists" - - -def test_update_password_me_same_password_error( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = { - "current_password": settings.FIRST_SUPERUSER_PASSWORD, - "new_password": settings.FIRST_SUPERUSER_PASSWORD, - } - r = client.patch( - f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 400 - updated_user = r.json() - assert ( - updated_user["detail"] == "New password cannot be the same as the current one" - ) - - -def test_register_user(client: TestClient, db: Session) -> None: - username = random_email() - password = random_lower_string() - full_name = random_lower_string() - data = {"email": username, "password": password, "full_name": full_name} - r = client.post( - f"{settings.API_V1_STR}/users/signup", - json=data, - ) - assert r.status_code == 200 - created_user = r.json() - assert created_user["email"] == username - assert created_user["full_name"] == full_name - - user_query = select(User).where(User.email == username) - user_db = db.exec(user_query).first() - assert user_db - assert user_db.email == username - assert user_db.full_name == full_name - assert verify_password(password, user_db.hashed_password) - - -def test_register_user_already_exists_error(client: TestClient) -> None: - password = random_lower_string() - full_name = random_lower_string() - data = { - "email": settings.FIRST_SUPERUSER, - "password": password, - "full_name": full_name, - } - r = client.post( - f"{settings.API_V1_STR}/users/signup", - json=data, - ) - assert r.status_code == 400 - assert r.json()["detail"] == "The user with this email already exists in the system" - - -def test_update_user( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - - data = {"full_name": "Updated_full_name"} - r = client.patch( - f"{settings.API_V1_STR}/users/{user.id}", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 200 - updated_user = r.json() - - assert updated_user["full_name"] == "Updated_full_name" - - user_query = select(User).where(User.email == username) - user_db = db.exec(user_query).first() - db.refresh(user_db) - assert user_db - assert user_db.full_name == "Updated_full_name" - - -def test_update_user_not_exists( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"full_name": "Updated_full_name"} - r = client.patch( - f"{settings.API_V1_STR}/users/{uuid.uuid4()}", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 404 - assert r.json()["detail"] == "The user with this id does not exist in the system" - - -def test_update_user_email_exists( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - - username2 = random_email() - password2 = random_lower_string() - user_in2 = UserCreate(email=username2, password=password2) - user2 = util.create_user(session=db, user_create=user_in2) - - data = {"email": user2.email} - r = client.patch( - f"{settings.API_V1_STR}/users/{user.id}", - headers=superuser_token_headers, - json=data, - ) - assert r.status_code == 409 - assert r.json()["detail"] == "User with this email already exists" - - -def test_delete_user_me(client: TestClient, db: Session) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - user_id = user.id - - login_data = { - "username": username, - "password": password, - } - r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - a_token = tokens["access_token"] - headers = {"Authorization": f"Bearer {a_token}"} - - r = client.delete( - f"{settings.API_V1_STR}/users/me", - headers=headers, - ) - assert r.status_code == 200 - deleted_user = r.json() - assert deleted_user["message"] == "User deleted successfully" - result = db.exec(select(User).where(User.id == user_id)).first() - assert result is None - - user_query = select(User).where(User.id == user_id) - user_db = db.execute(user_query).first() - assert user_db is None - - -def test_delete_user_me_as_superuser( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - r = client.delete( - f"{settings.API_V1_STR}/users/me", - headers=superuser_token_headers, - ) - assert r.status_code == 403 - response = r.json() - assert response["detail"] == "Super users are not allowed to delete themselves" - - -def test_delete_user_super_user( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - user_id = user.id - r = client.delete( - f"{settings.API_V1_STR}/users/{user_id}", - headers=superuser_token_headers, - ) - assert r.status_code == 200 - deleted_user = r.json() - assert deleted_user["message"] == "User deleted successfully" - result = db.exec(select(User).where(User.id == user_id)).first() - assert result is None - - -def test_delete_user_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - r = client.delete( - f"{settings.API_V1_STR}/users/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert r.status_code == 404 - assert r.json()["detail"] == "User not found" - - -def test_delete_user_current_super_user_error( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - super_user = util.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER) - assert super_user - user_id = super_user.id - - r = client.delete( - f"{settings.API_V1_STR}/users/{user_id}", - headers=superuser_token_headers, - ) - assert r.status_code == 403 - assert r.json()["detail"] == "Super users are not allowed to delete themselves" - - -def test_delete_user_without_privileges( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - - r = client.delete( - f"{settings.API_V1_STR}/users/{user.id}", - headers=normal_user_token_headers, - ) - assert r.status_code == 403 - assert r.json()["detail"] == "The user doesn't have enough privileges" diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py deleted file mode 100644 index 90ab39a357..0000000000 --- a/backend/app/tests/conftest.py +++ /dev/null @@ -1,42 +0,0 @@ -from collections.abc import Generator - -import pytest -from fastapi.testclient import TestClient -from sqlmodel import Session, delete - -from app.core.config import settings -from app.core.db import engine, init_db -from app.main import app -from app.models import Item, User -from app.tests.utils.user import authentication_token_from_email -from app.tests.utils.utils import get_superuser_token_headers - - -@pytest.fixture(scope="session", autouse=True) -def db() -> Generator[Session, None, None]: - with Session(engine) as session: - init_db(session) - yield session - statement = delete(Item) - session.execute(statement) - statement = delete(User) - session.execute(statement) - session.commit() - - -@pytest.fixture(scope="module") -def client() -> Generator[TestClient, None, None]: - with TestClient(app) as c: - yield c - - -@pytest.fixture(scope="module") -def superuser_token_headers(client: TestClient) -> dict[str, str]: - return get_superuser_token_headers(client) - - -@pytest.fixture(scope="module") -def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]: - return authentication_token_from_email( - client=client, email=settings.EMAIL_TEST_USER, db=db - ) diff --git a/backend/app/tests/crud/__init__.py b/backend/app/tests/crud/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/tests/crud/test_user.py b/backend/app/tests/crud/test_user.py deleted file mode 100644 index 3e974909af..0000000000 --- a/backend/app/tests/crud/test_user.py +++ /dev/null @@ -1,91 +0,0 @@ -from fastapi.encoders import jsonable_encoder -from sqlmodel import Session - -from app import util -from app.core.security import verify_password -from app.models import User, UserCreate, UserUpdate -from app.tests.utils.utils import random_email, random_lower_string - - -def test_create_user(db: Session) -> None: - email = random_email() - password = random_lower_string() - user_in = UserCreate(email=email, password=password) - user = util.create_user(session=db, user_create=user_in) - assert user.email == email - assert hasattr(user, "hashed_password") - - -def test_authenticate_user(db: Session) -> None: - email = random_email() - password = random_lower_string() - user_in = UserCreate(email=email, password=password) - user = util.create_user(session=db, user_create=user_in) - authenticated_user = util.authenticate(session=db, email=email, password=password) - assert authenticated_user - assert user.email == authenticated_user.email - - -def test_not_authenticate_user(db: Session) -> None: - email = random_email() - password = random_lower_string() - user = util.authenticate(session=db, email=email, password=password) - assert user is None - - -def test_check_if_user_is_active(db: Session) -> None: - email = random_email() - password = random_lower_string() - user_in = UserCreate(email=email, password=password) - user = util.create_user(session=db, user_create=user_in) - assert user.is_active is True - - -def test_check_if_user_is_active_inactive(db: Session) -> None: - email = random_email() - password = random_lower_string() - user_in = UserCreate(email=email, password=password, disabled=True) - user = util.create_user(session=db, user_create=user_in) - assert user.is_active - - -def test_check_if_user_is_superuser(db: Session) -> None: - email = random_email() - password = random_lower_string() - user_in = UserCreate(email=email, password=password, is_superuser=True) - user = util.create_user(session=db, user_create=user_in) - assert user.is_superuser is True - - -def test_check_if_user_is_superuser_normal_user(db: Session) -> None: - username = random_email() - password = random_lower_string() - user_in = UserCreate(email=username, password=password) - user = util.create_user(session=db, user_create=user_in) - assert user.is_superuser is False - - -def test_get_user(db: Session) -> None: - password = random_lower_string() - username = random_email() - user_in = UserCreate(email=username, password=password, is_superuser=True) - user = util.create_user(session=db, user_create=user_in) - user_2 = db.get(User, user.id) - assert user_2 - assert user.email == user_2.email - assert jsonable_encoder(user) == jsonable_encoder(user_2) - - -def test_update_user(db: Session) -> None: - password = random_lower_string() - email = random_email() - user_in = UserCreate(email=email, password=password, is_superuser=True) - user = util.create_user(session=db, user_create=user_in) - new_password = random_lower_string() - user_in_update = UserUpdate(password=new_password, is_superuser=True) - if user.id is not None: - util.update_user(session=db, db_user=user, user_in=user_in_update) - user_2 = db.get(User, user.id) - assert user_2 - assert user.email == user_2.email - assert verify_password(new_password, user_2.hashed_password) diff --git a/backend/app/tests/scripts/__init__.py b/backend/app/tests/scripts/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/tests/scripts/test_backend_pre_start.py b/backend/app/tests/scripts/test_backend_pre_start.py deleted file mode 100644 index 631690fcf6..0000000000 --- a/backend/app/tests/scripts/test_backend_pre_start.py +++ /dev/null @@ -1,33 +0,0 @@ -from unittest.mock import MagicMock, patch - -from sqlmodel import select - -from app.backend_pre_start import init, logger - - -def test_init_successful_connection() -> None: - engine_mock = MagicMock() - - session_mock = MagicMock() - exec_mock = MagicMock(return_value=True) - session_mock.configure_mock(**{"exec.return_value": exec_mock}) - - with ( - patch("sqlmodel.Session", return_value=session_mock), - patch.object(logger, "info"), - patch.object(logger, "error"), - patch.object(logger, "warn"), - ): - try: - init(engine_mock) - connection_successful = True - except Exception: - connection_successful = False - - assert ( - connection_successful - ), "The database connection should be successful and not raise an exception." - - assert session_mock.exec.called_once_with( - select(1) - ), "The session should execute a select statement once." diff --git a/backend/app/tests/scripts/test_test_pre_start.py b/backend/app/tests/scripts/test_test_pre_start.py deleted file mode 100644 index a176f380de..0000000000 --- a/backend/app/tests/scripts/test_test_pre_start.py +++ /dev/null @@ -1,33 +0,0 @@ -from unittest.mock import MagicMock, patch - -from sqlmodel import select - -from app.tests_pre_start import init, logger - - -def test_init_successful_connection() -> None: - engine_mock = MagicMock() - - session_mock = MagicMock() - exec_mock = MagicMock(return_value=True) - session_mock.configure_mock(**{"exec.return_value": exec_mock}) - - with ( - patch("sqlmodel.Session", return_value=session_mock), - patch.object(logger, "info"), - patch.object(logger, "error"), - patch.object(logger, "warn"), - ): - try: - init(engine_mock) - connection_successful = True - except Exception: - connection_successful = False - - assert ( - connection_successful - ), "The database connection should be successful and not raise an exception." - - assert session_mock.exec.called_once_with( - select(1) - ), "The session should execute a select statement once." diff --git a/backend/app/tests/utils/__init__.py b/backend/app/tests/utils/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/app/tests/utils/item.py b/backend/app/tests/utils/item.py deleted file mode 100644 index 1016fd6072..0000000000 --- a/backend/app/tests/utils/item.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlmodel import Session - -from app import util -from app.models import Item, ItemCreate -from app.tests.utils.user import create_random_user -from app.tests.utils.utils import random_lower_string - - -def create_random_item(db: Session) -> Item: - user = create_random_user(db) - owner_id = user.id - assert owner_id is not None - title = random_lower_string() - description = random_lower_string() - item_in = ItemCreate(title=title, description=description) - return util.create_item(session=db, item_in=item_in, owner_id=owner_id) diff --git a/backend/app/tests/utils/user.py b/backend/app/tests/utils/user.py deleted file mode 100644 index 95fb061f8b..0000000000 --- a/backend/app/tests/utils/user.py +++ /dev/null @@ -1,49 +0,0 @@ -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app import util -from app.core.config import settings -from app.models import User, UserCreate, UserUpdate -from app.tests.utils.utils import random_email, random_lower_string - - -def user_authentication_headers( - *, client: TestClient, email: str, password: str -) -> dict[str, str]: - data = {"username": email, "password": password} - - r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) - response = r.json() - auth_token = response["access_token"] - headers = {"Authorization": f"Bearer {auth_token}"} - return headers - - -def create_random_user(db: Session) -> User: - email = random_email() - password = random_lower_string() - user_in = UserCreate(email=email, password=password) - user = util.create_user(session=db, user_create=user_in) - return user - - -def authentication_token_from_email( - *, client: TestClient, email: str, db: Session -) -> dict[str, str]: - """ - Return a valid token for the user with given email. - - If the user doesn't exist it is created first. - """ - password = random_lower_string() - user = util.get_user_by_email(session=db, email=email) - if not user: - user_in_create = UserCreate(email=email, password=password) - user = util.create_user(session=db, user_create=user_in_create) - else: - user_in_update = UserUpdate(password=password) - if not user.id: - raise Exception("User id not set") - user = util.update_user(session=db, db_user=user, user_in=user_in_update) - - return user_authentication_headers(client=client, email=email, password=password) diff --git a/backend/app/tests/utils/utils.py b/backend/app/tests/utils/utils.py deleted file mode 100644 index 184bac44d9..0000000000 --- a/backend/app/tests/utils/utils.py +++ /dev/null @@ -1,26 +0,0 @@ -import random -import string - -from fastapi.testclient import TestClient - -from app.core.config import settings - - -def random_lower_string() -> str: - return "".join(random.choices(string.ascii_lowercase, k=32)) - - -def random_email() -> str: - return f"{random_lower_string()}@{random_lower_string()}.com" - - -def get_superuser_token_headers(client: TestClient) -> dict[str, str]: - login_data = { - "username": settings.FIRST_SUPERUSER, - "password": settings.FIRST_SUPERUSER_PASSWORD, - } - r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - a_token = tokens["access_token"] - headers = {"Authorization": f"Bearer {a_token}"} - return headers From 0df8b4c8edf15c178aa925c33255b71037cadedd Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Thu, 6 Feb 2025 15:49:27 +0530 Subject: [PATCH 15/25] add scrapping script --- backend/menu_scrapper/demo.py | 0 backend/menu_scrapper/test.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 backend/menu_scrapper/demo.py diff --git a/backend/menu_scrapper/demo.py b/backend/menu_scrapper/demo.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/menu_scrapper/test.py b/backend/menu_scrapper/test.py index c874d374c6..5ed1ebb595 100644 --- a/backend/menu_scrapper/test.py +++ b/backend/menu_scrapper/test.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel, HttpUrl from typing import Dict, Any - + # Configure logging logging.basicConfig( level=logging.DEBUG, From bb1263ea62bc86d5d0a4347176657742f39c7173 Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Thu, 6 Feb 2025 18:26:57 +0530 Subject: [PATCH 16/25] migration issue --- .../versions/15b7e49466ec_your_new_changes.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/app/alembic/versions/15b7e49466ec_your_new_changes.py diff --git a/backend/app/alembic/versions/15b7e49466ec_your_new_changes.py b/backend/app/alembic/versions/15b7e49466ec_your_new_changes.py new file mode 100644 index 0000000000..532f52e1b3 --- /dev/null +++ b/backend/app/alembic/versions/15b7e49466ec_your_new_changes.py @@ -0,0 +1,29 @@ +"""your_new_changes + +Revision ID: 15b7e49466ec +Revises: 6a0826f924e8 +Create Date: 2025-02-06 12:56:25.950098 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '15b7e49466ec' +down_revision = '6a0826f924e8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### From 0563c47e3b91084dce87f5f903718cc6f7bf8645 Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Thu, 6 Feb 2025 23:24:22 +0530 Subject: [PATCH 17/25] add scrapping api --- acme.json | 2 + backend/app/api/main.py | 3 +- backend/app/api/routes/scrapper.py | 172 +++++++++++++++++++++++++++++ letsencrypt/acme.json | 0 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 acme.json create mode 100644 backend/app/api/routes/scrapper.py create mode 100644 letsencrypt/acme.json diff --git a/acme.json b/acme.json new file mode 100644 index 0000000000..0fa97337f5 --- /dev/null +++ b/acme.json @@ -0,0 +1,2 @@ +touch letsencrypt/acme.json +chmod 600 letsencrypt/acme.json diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e920235072..71bd7cb7d2 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import carousel, login, menu, qrcode, users, venues +from app.api.routes import carousel, login, menu, qrcode, users, venues,scrapper api_router = APIRouter() api_router.include_router(venues.router, prefix="/venue", tags=["venue"]) @@ -9,3 +9,4 @@ api_router.include_router(login.router, tags=["login"]) api_router.include_router(qrcode.router, tags=["qrcode"]) api_router.include_router(carousel.router, prefix="/carousel", tags=["carousel"]) +api_router.include_router(scrapper.router, prefix="/scrapper", tags=["scrapper"]) \ No newline at end of file diff --git a/backend/app/api/routes/scrapper.py b/backend/app/api/routes/scrapper.py new file mode 100644 index 0000000000..b98d25f578 --- /dev/null +++ b/backend/app/api/routes/scrapper.py @@ -0,0 +1,172 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, HttpUrl +from typing import Dict, Any +import json +import requests +from bs4 import BeautifulSoup +import logging + + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +router = APIRouter() + +class ZomatoUrl(BaseModel): + url: HttpUrl + +def extract_menu_data(json_data: dict) -> dict: + try: + logger.debug("Starting menu data extraction...") + restaurant_data = json_data.get('pages', {}).get('current', {}) + restaurant_details = json_data.get('pages', {}).get('restaurant', {}) + + restaurant_id = next(iter(restaurant_details)) if restaurant_details else None + + if not restaurant_id: + logger.error("No restaurant ID found") + return {} + + res_info = restaurant_details[restaurant_id].get('sections', {}) + + # Restaurant Info + basic_info = res_info.get('SECTION_BASIC_INFO', {}) + restaurant_info = { + 'restaurant_id': basic_info.get('res_id'), + 'name': basic_info.get('name'), + 'cuisines': basic_info.get('cuisine_string'), + 'rating': { + 'aggregate_rating': basic_info.get('rating', {}).get('aggregate_rating'), + 'votes': basic_info.get('rating', {}).get('votes'), + 'rating_text': basic_info.get('rating', {}).get('rating_text') + }, + 'location': { + 'locality': restaurant_data.get('pageDescription', ''), + 'url': basic_info.get('resUrl') + }, + 'timing': { + 'description': basic_info.get('timing', {}).get('timing_desc'), + 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) + } + } + + # Menu Items + menu_data = res_info.get('SECTION_MENU_WIDGET', {}) + + menu_categories = [] + for category in menu_data.get('categories', []): + category_items = { + 'category': category.get('name', ''), + 'items': [] + } + + for item in category.get('items', []): + menu_item = { + 'id': str(item.get('id')), + 'name': item.get('name'), + 'description': item.get('description', ''), + 'price': float(item.get('price', 0)), + 'image_url': item.get('imageUrl', ''), + 'is_veg': item.get('isVeg', True), + 'spice_level': item.get('spiceLevel', 'None') + } + category_items['items'].append(menu_item) + + if category_items['items']: + menu_categories.append(category_items) + + return { + 'restaurant_info': restaurant_info, + 'menu': menu_categories + } + + except Exception as e: + logger.error(f"Error extracting menu data: {str(e)}") + return {} + +def fetch_zomato_data(url: str) -> str: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + } + + try: + logger.info(f"Fetching data from URL: {url}") + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.text + except Exception as e: + logger.error(f"Error fetching data: {str(e)}") + raise + +def parse_zomato_page(html_content: str) -> dict: + try: + logger.debug("Starting page parsing...") + soup = BeautifulSoup(html_content, 'html.parser') + + + scripts = soup.find_all('script') + target_script = None + + for script in scripts: + if script.string and 'window.__PRELOADED_STATE__' in script.string: + target_script = script + break + + if not target_script: + raise ValueError("Could not find PRELOADED_STATE in page") + + # Extract and clean JSON string + json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] + json_str = json_str.split(');')[0].strip() + + # Clean the JSON string + json_str = json_str.strip('"') + json_str = json_str.replace('\\"', '"') + json_str = json_str.replace('\\\\', '\\') + json_str = json_str.replace('\\n', '') + + # Parse JSON + return json.loads(json_str) + + except Exception as e: + logger.error(f"Error parsing page: {str(e)}") + raise + +@router.get("/") +async def get_scrapper_info(): + return {"message": "Welcome to Zomato Menu Scraper"} + +@router.post("/menu") +async def scrape_menu(request: ZomatoUrl) -> Dict[Any, Any]: + try: + if "zomato.com" not in str(request.url): + raise HTTPException( + status_code=400, + detail="Invalid URL. Please provide a valid Zomato restaurant URL" + ) + + logger.info("Starting scraping process...") + html_content = fetch_zomato_data(str(request.url)) + json_data = parse_zomato_page(html_content) + formatted_data = extract_menu_data(json_data) + + if not formatted_data: + raise HTTPException( + status_code=500, + detail="Failed to extract menu data" + ) + + return formatted_data + + except Exception as e: + logger.error(f"Scraping failed: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to scrape menu: {str(e)}" + ) diff --git a/letsencrypt/acme.json b/letsencrypt/acme.json new file mode 100644 index 0000000000..e69de29bb2 From c4b9a2cc01ede54da6cd3e85fd46911d1e34d44e Mon Sep 17 00:00:00 2001 From: "arpit.singh" Date: Fri, 7 Feb 2025 22:53:05 +0530 Subject: [PATCH 18/25] bug fix --- backend/app/models/menu.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py index f1017dce68..f8d5ce70fd 100644 --- a/backend/app/models/menu.py +++ b/backend/app/models/menu.py @@ -85,10 +85,9 @@ def from_create_schema(cls, schema: "MenuSubCategoryCreate") -> "MenuSubCategory return cls( name=schema.name, category_id=schema.category_id, - is_alcoholic=schema.is_alcoholic, # Include is_alcoholic in the model + is_alcoholic=schema.is_alcoholic, ) - @classmethod def to_read_schema(self) -> MenuSubCategoryRead: return MenuSubCategoryRead( subcategory_id=self.id, @@ -116,8 +115,7 @@ class MenuCategory(BaseTimeModel, table=True): def from_create_schema(cls, schema: MenuCategoryCreate) -> "MenuCategory": return cls(name=schema.name, menu_id=schema.menu_id) - @classmethod - def to_read_schema(cls, self) -> MenuCategoryRead: + def to_read_schema(self) -> MenuCategoryRead: return MenuCategoryRead( category_id=self.id, menu_id=self.menu_id, From 9abe085783f0916a54d0899aae0c4c19e0318c70 Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Thu, 30 Jan 2025 18:06:53 +0530 Subject: [PATCH 19/25] add menu scrapper --- .env | 2 +- backend/menu_scrapper/test.py | 200 ++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 backend/menu_scrapper/test.py diff --git a/.env b/.env index 5110b5b506..4e5f20c55b 100644 --- a/.env +++ b/.env @@ -10,7 +10,7 @@ STACK_NAME=full-stack-fastapi-project # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=PDma3zqc5pq9LZVfVj7qL5bB6mC4OCIN0sKdjSiKOlI +SECRET_KEY=0_vuFnI9SIxZ4dxA5TcIroZRKp_ljxMuknEAcXDsGgo FIRST_SUPERUSER=arpit.singh@example.com FIRST_SUPERUSER_PASSWORD=SOciaSOcia diff --git a/backend/menu_scrapper/test.py b/backend/menu_scrapper/test.py new file mode 100644 index 0000000000..c874d374c6 --- /dev/null +++ b/backend/menu_scrapper/test.py @@ -0,0 +1,200 @@ +import json +import requests +from bs4 import BeautifulSoup +import logging +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, HttpUrl +from typing import Dict, Any + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = FastAPI() + +class ZomatoUrl(BaseModel): + url: HttpUrl + +def extract_menu_data(json_data: dict) -> dict: + try: + logger.debug("Starting menu data extraction...") + restaurant_data = json_data.get('pages', {}).get('current', {}) + restaurant_details = json_data.get('pages', {}).get('restaurant', {}) + + # Debug log the structure + logger.debug(f"Available keys in json_data: {list(json_data.keys())}") + logger.debug(f"Restaurant details keys: {list(restaurant_details.keys())}") + + restaurant_id = next(iter(restaurant_details)) if restaurant_details else None + + if not restaurant_id: + logger.error("No restaurant ID found") + return {} + + res_info = restaurant_details[restaurant_id].get('sections', {}) + logger.debug(f"Available sections: {list(res_info.keys())}") + + # Restaurant Info + basic_info = res_info.get('SECTION_BASIC_INFO', {}) + restaurant_info = { + 'restaurant_id': basic_info.get('res_id'), + 'name': basic_info.get('name'), + 'cuisines': basic_info.get('cuisine_string'), + 'rating': { + 'aggregate_rating': basic_info.get('rating', {}).get('aggregate_rating'), + 'votes': basic_info.get('rating', {}).get('votes'), + 'rating_text': basic_info.get('rating', {}).get('rating_text') + }, + 'location': { + 'locality': restaurant_data.get('pageDescription', ''), + 'url': basic_info.get('resUrl') + }, + 'timing': { + 'description': basic_info.get('timing', {}).get('timing_desc'), + 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) + } + } + + # Menu Items + menu_data = res_info.get('SECTION_MENU_WIDGET', {}) + logger.debug(f"Menu widget data keys: {list(menu_data.keys())}") + + menu_categories = [] + for category in menu_data.get('categories', []): + category_items = { + 'category': category.get('name', ''), + 'items': [] + } + + for item in category.get('items', []): + menu_item = { + 'id': str(item.get('id')), + 'name': item.get('name'), + 'description': item.get('description', ''), + 'price': float(item.get('price', 0)), + 'image_url': item.get('imageUrl', ''), + 'is_veg': item.get('isVeg', True), + 'spice_level': item.get('spiceLevel', 'None') + } + category_items['items'].append(menu_item) + + if category_items['items']: + menu_categories.append(category_items) + + return { + 'restaurant_info': restaurant_info, + 'menu': menu_categories + } + + except Exception as e: + logger.error(f"Error extracting menu data: {str(e)}") + return {} + +def fetch_zomato_data(url: str) -> str: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + } + + try: + logger.info(f"Fetching data from URL: {url}") + response = requests.get(url, headers=headers) + response.raise_for_status() + logger.debug(f"Response status code: {response.status_code}") + return response.text + except Exception as e: + logger.error(f"Error fetching data: {str(e)}") + raise + +def parse_zomato_page(html_content: str) -> dict: + try: + logger.debug("Starting page parsing...") + soup = BeautifulSoup(html_content, 'html.parser') + + # Find script with PRELOADED_STATE + scripts = soup.find_all('script') + target_script = None + + for script in scripts: + if script.string and 'window.__PRELOADED_STATE__' in script.string: + target_script = script + break + + if not target_script: + raise ValueError("Could not find PRELOADED_STATE in page") + + # Extract and clean JSON string + json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] + json_str = json_str.split(');')[0].strip() + + # Clean the JSON string + json_str = json_str.strip('"') + json_str = json_str.replace('\\"', '"') + json_str = json_str.replace('\\\\', '\\') + json_str = json_str.replace('\\n', '') + + # Debug log + logger.debug(f"Extracted JSON string (first 200 chars): {json_str[:200]}...") + + # Parse JSON + parsed_data = json.loads(json_str) + + # Save raw data for debugging + with open('raw_data.json', 'w', encoding='utf-8') as f: + json.dump(parsed_data, f, indent=2, ensure_ascii=False) + logger.info("Raw data saved to raw_data.json") + + return parsed_data + + except Exception as e: + logger.error(f"Error parsing page: {str(e)}") + logger.error(f"Script content: {target_script.string[:200] if target_script else 'No script found'}") + raise + +@app.get("/") +async def root(): + return {"message": "Welcome to Zomato Menu Scraper"} + +@app.post("/scrape-menu") +async def scrape_menu(request: ZomatoUrl) -> Dict[Any, Any]: + try: + if "zomato.com" not in str(request.url): + raise HTTPException( + status_code=400, + detail="Invalid URL. Please provide a valid Zomato restaurant URL" + ) + + logger.info("Starting scraping process...") + html_content = fetch_zomato_data(str(request.url)) + json_data = parse_zomato_page(html_content) + formatted_data = extract_menu_data(json_data) + + if not formatted_data: + raise HTTPException( + status_code=500, + detail="Failed to extract menu data" + ) + + # Save formatted data for debugging + with open('formatted_data.json', 'w', encoding='utf-8') as f: + json.dump(formatted_data, f, indent=2, ensure_ascii=False) + logger.info("Formatted data saved to formatted_data.json") + + return formatted_data + + except Exception as e: + logger.error(f"Scraping failed: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to scrape menu: {str(e)}" + ) + +if __name__ == "__main__": + import uvicorn + logger.info("Starting Zomato Menu Scraper...") + uvicorn.run(app, host="0.0.0.0", port=8000) From fc385127b4c6c4076f1847908bb463808f98b042 Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Thu, 6 Feb 2025 15:49:27 +0530 Subject: [PATCH 20/25] add scrapping script --- backend/menu_scrapper/demo.py | 0 backend/menu_scrapper/test.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 backend/menu_scrapper/demo.py diff --git a/backend/menu_scrapper/demo.py b/backend/menu_scrapper/demo.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/menu_scrapper/test.py b/backend/menu_scrapper/test.py index c874d374c6..5ed1ebb595 100644 --- a/backend/menu_scrapper/test.py +++ b/backend/menu_scrapper/test.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel, HttpUrl from typing import Dict, Any - + # Configure logging logging.basicConfig( level=logging.DEBUG, From 3863eebf575be6871f708a0cfae46b1ffe91e594 Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Thu, 6 Feb 2025 23:24:22 +0530 Subject: [PATCH 21/25] add scrapping api --- acme.json | 2 + backend/app/api/main.py | 3 +- backend/app/api/routes/scrapper.py | 172 +++++++++++++++++++++++++++++ letsencrypt/acme.json | 0 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 acme.json create mode 100644 backend/app/api/routes/scrapper.py create mode 100644 letsencrypt/acme.json diff --git a/acme.json b/acme.json new file mode 100644 index 0000000000..0fa97337f5 --- /dev/null +++ b/acme.json @@ -0,0 +1,2 @@ +touch letsencrypt/acme.json +chmod 600 letsencrypt/acme.json diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e920235072..71bd7cb7d2 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import carousel, login, menu, qrcode, users, venues +from app.api.routes import carousel, login, menu, qrcode, users, venues,scrapper api_router = APIRouter() api_router.include_router(venues.router, prefix="/venue", tags=["venue"]) @@ -9,3 +9,4 @@ api_router.include_router(login.router, tags=["login"]) api_router.include_router(qrcode.router, tags=["qrcode"]) api_router.include_router(carousel.router, prefix="/carousel", tags=["carousel"]) +api_router.include_router(scrapper.router, prefix="/scrapper", tags=["scrapper"]) \ No newline at end of file diff --git a/backend/app/api/routes/scrapper.py b/backend/app/api/routes/scrapper.py new file mode 100644 index 0000000000..b98d25f578 --- /dev/null +++ b/backend/app/api/routes/scrapper.py @@ -0,0 +1,172 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, HttpUrl +from typing import Dict, Any +import json +import requests +from bs4 import BeautifulSoup +import logging + + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +router = APIRouter() + +class ZomatoUrl(BaseModel): + url: HttpUrl + +def extract_menu_data(json_data: dict) -> dict: + try: + logger.debug("Starting menu data extraction...") + restaurant_data = json_data.get('pages', {}).get('current', {}) + restaurant_details = json_data.get('pages', {}).get('restaurant', {}) + + restaurant_id = next(iter(restaurant_details)) if restaurant_details else None + + if not restaurant_id: + logger.error("No restaurant ID found") + return {} + + res_info = restaurant_details[restaurant_id].get('sections', {}) + + # Restaurant Info + basic_info = res_info.get('SECTION_BASIC_INFO', {}) + restaurant_info = { + 'restaurant_id': basic_info.get('res_id'), + 'name': basic_info.get('name'), + 'cuisines': basic_info.get('cuisine_string'), + 'rating': { + 'aggregate_rating': basic_info.get('rating', {}).get('aggregate_rating'), + 'votes': basic_info.get('rating', {}).get('votes'), + 'rating_text': basic_info.get('rating', {}).get('rating_text') + }, + 'location': { + 'locality': restaurant_data.get('pageDescription', ''), + 'url': basic_info.get('resUrl') + }, + 'timing': { + 'description': basic_info.get('timing', {}).get('timing_desc'), + 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) + } + } + + # Menu Items + menu_data = res_info.get('SECTION_MENU_WIDGET', {}) + + menu_categories = [] + for category in menu_data.get('categories', []): + category_items = { + 'category': category.get('name', ''), + 'items': [] + } + + for item in category.get('items', []): + menu_item = { + 'id': str(item.get('id')), + 'name': item.get('name'), + 'description': item.get('description', ''), + 'price': float(item.get('price', 0)), + 'image_url': item.get('imageUrl', ''), + 'is_veg': item.get('isVeg', True), + 'spice_level': item.get('spiceLevel', 'None') + } + category_items['items'].append(menu_item) + + if category_items['items']: + menu_categories.append(category_items) + + return { + 'restaurant_info': restaurant_info, + 'menu': menu_categories + } + + except Exception as e: + logger.error(f"Error extracting menu data: {str(e)}") + return {} + +def fetch_zomato_data(url: str) -> str: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + } + + try: + logger.info(f"Fetching data from URL: {url}") + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.text + except Exception as e: + logger.error(f"Error fetching data: {str(e)}") + raise + +def parse_zomato_page(html_content: str) -> dict: + try: + logger.debug("Starting page parsing...") + soup = BeautifulSoup(html_content, 'html.parser') + + + scripts = soup.find_all('script') + target_script = None + + for script in scripts: + if script.string and 'window.__PRELOADED_STATE__' in script.string: + target_script = script + break + + if not target_script: + raise ValueError("Could not find PRELOADED_STATE in page") + + # Extract and clean JSON string + json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] + json_str = json_str.split(');')[0].strip() + + # Clean the JSON string + json_str = json_str.strip('"') + json_str = json_str.replace('\\"', '"') + json_str = json_str.replace('\\\\', '\\') + json_str = json_str.replace('\\n', '') + + # Parse JSON + return json.loads(json_str) + + except Exception as e: + logger.error(f"Error parsing page: {str(e)}") + raise + +@router.get("/") +async def get_scrapper_info(): + return {"message": "Welcome to Zomato Menu Scraper"} + +@router.post("/menu") +async def scrape_menu(request: ZomatoUrl) -> Dict[Any, Any]: + try: + if "zomato.com" not in str(request.url): + raise HTTPException( + status_code=400, + detail="Invalid URL. Please provide a valid Zomato restaurant URL" + ) + + logger.info("Starting scraping process...") + html_content = fetch_zomato_data(str(request.url)) + json_data = parse_zomato_page(html_content) + formatted_data = extract_menu_data(json_data) + + if not formatted_data: + raise HTTPException( + status_code=500, + detail="Failed to extract menu data" + ) + + return formatted_data + + except Exception as e: + logger.error(f"Scraping failed: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to scrape menu: {str(e)}" + ) diff --git a/letsencrypt/acme.json b/letsencrypt/acme.json new file mode 100644 index 0000000000..e69de29bb2 From c179b84a87d691537cd4be44a5b05b14ef6ca4aa Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Sun, 9 Feb 2025 06:16:00 +0530 Subject: [PATCH 22/25] update scraping api --- backend/app/api/routes/scrapper.py | 125 ++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 18 deletions(-) diff --git a/backend/app/api/routes/scrapper.py b/backend/app/api/routes/scrapper.py index b98d25f578..ac5e451f81 100644 --- a/backend/app/api/routes/scrapper.py +++ b/backend/app/api/routes/scrapper.py @@ -1,11 +1,15 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel, HttpUrl from typing import Dict, Any import json import requests from bs4 import BeautifulSoup import logging - +from app.api.deps import SessionDep, get_business_user +from app.models.user import UserBusiness, UserVenueAssociation +from app.models.venue import Venue, Restaurant +from app.models.menu import Menu, MenuCategory, MenuItem +from app.util import create_record logging.basicConfig( level=logging.DEBUG, @@ -18,6 +22,22 @@ class ZomatoUrl(BaseModel): url: HttpUrl +def parse_price(price_str: str) -> float: + """Convert price string to float, handling variations""" + try: + cleaned_price = price_str.replace('₹', '').replace(',', '').strip() + return float(cleaned_price) + except (ValueError, AttributeError): + return 0.0 + +def handle_price_variations(price_data: dict) -> tuple[float, str]: + """Handle half/full price variations""" + if isinstance(price_data, dict) and ('half' in price_data or 'full' in price_data): + description = f"Half: ₹{price_data.get('half', 0)}, Full: ₹{price_data.get('full', 0)}" + price = price_data.get('full', price_data.get('half', 0)) + return parse_price(price), description + return parse_price(price_data), "" + def extract_menu_data(json_data: dict) -> dict: try: logger.debug("Starting menu data extraction...") @@ -55,8 +75,8 @@ def extract_menu_data(json_data: dict) -> dict: # Menu Items menu_data = res_info.get('SECTION_MENU_WIDGET', {}) - menu_categories = [] + for category in menu_data.get('categories', []): category_items = { 'category': category.get('name', ''), @@ -64,11 +84,16 @@ def extract_menu_data(json_data: dict) -> dict: } for item in category.get('items', []): + price, price_description = handle_price_variations(item.get('price', 0)) + description = item.get('description', '') + if price_description: + description = f"{description}\n{price_description}" if description else price_description + menu_item = { 'id': str(item.get('id')), 'name': item.get('name'), - 'description': item.get('description', ''), - 'price': float(item.get('price', 0)), + 'description': description, + 'price': price, 'image_url': item.get('imageUrl', ''), 'is_veg': item.get('isVeg', True), 'spice_level': item.get('spiceLevel', 'None') @@ -109,7 +134,6 @@ def parse_zomato_page(html_content: str) -> dict: logger.debug("Starting page parsing...") soup = BeautifulSoup(html_content, 'html.parser') - scripts = soup.find_all('script') target_script = None @@ -121,17 +145,14 @@ def parse_zomato_page(html_content: str) -> dict: if not target_script: raise ValueError("Could not find PRELOADED_STATE in page") - # Extract and clean JSON string json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] json_str = json_str.split(');')[0].strip() - # Clean the JSON string json_str = json_str.strip('"') json_str = json_str.replace('\\"', '"') json_str = json_str.replace('\\\\', '\\') json_str = json_str.replace('\\n', '') - # Parse JSON return json.loads(json_str) except Exception as e: @@ -143,7 +164,11 @@ async def get_scrapper_info(): return {"message": "Welcome to Zomato Menu Scraper"} @router.post("/menu") -async def scrape_menu(request: ZomatoUrl) -> Dict[Any, Any]: +async def scrape_and_create_menu( + request: ZomatoUrl, + session: SessionDep, + current_user: UserBusiness = Depends(get_business_user) +): try: if "zomato.com" not in str(request.url): raise HTTPException( @@ -151,22 +176,86 @@ async def scrape_menu(request: ZomatoUrl) -> Dict[Any, Any]: detail="Invalid URL. Please provide a valid Zomato restaurant URL" ) - logger.info("Starting scraping process...") + # 1. Scrape data html_content = fetch_zomato_data(str(request.url)) json_data = parse_zomato_page(html_content) - formatted_data = extract_menu_data(json_data) + scraped_data = extract_menu_data(json_data) - if not formatted_data: + if not scraped_data: raise HTTPException( status_code=500, - detail="Failed to extract menu data" + detail="Failed to extract menu data from Zomato" ) - - return formatted_data + + # 2. Create venue + venue_data = { + "name": scraped_data["restaurant_info"]["name"], + "address": scraped_data["restaurant_info"]["location"]["locality"], + "contact": "", # Add if available + "cuisines": scraped_data["restaurant_info"]["cuisines"], + "rating": scraped_data["restaurant_info"]["rating"]["aggregate_rating"], + "timing": scraped_data["restaurant_info"]["timing"]["description"] + } + + # Create Venue instance + venue_instance = Venue.from_create_schema(venue_data) + create_record(session, venue_instance) + + # Create Restaurant instance + restaurant_instance = Restaurant.from_create_schema(venue_instance.id, venue_data) + create_record(session, restaurant_instance) + + # Create user-venue association + association = UserVenueAssociation( + user_id=current_user.id, + venue_id=venue_instance.id + ) + create_record(session, association) + + # 3. Create main menu + menu_data = { + "name": f"{scraped_data['restaurant_info']['name']} Menu", + "description": f"Menu imported from Zomato", + "venue_id": venue_instance.id, + "is_active": True + } + menu_instance = Menu(**menu_data) + create_record(session, menu_instance) + + # 4. Create categories and items + for category in scraped_data["menu"]: + category_data = { + "name": category["category"], + "description": f"Category for {category['category']}", + "menu_id": menu_instance.id + } + category_instance = MenuCategory(**category_data) + create_record(session, category_instance) + + for item in category["items"]: + item_data = { + "name": item["name"], + "description": item["description"], + "price": float(item["price"]), + "category_id": category_instance.id, + "is_available": True, + "is_vegetarian": item["is_veg"], + "image_url": item["image_url"] if item["image_url"] else None + } + item_instance = MenuItem(**item_data) + create_record(session, item_instance) + + return { + "message": "Menu successfully created", + "venue_id": venue_instance.id, + "menu_id": menu_instance.id, + "restaurant_name": scraped_data["restaurant_info"]["name"] + } except Exception as e: - logger.error(f"Scraping failed: {str(e)}") + session.rollback() + logger.error(f"Menu creation failed: {str(e)}") raise HTTPException( status_code=500, - detail=f"Failed to scrape menu: {str(e)}" + detail=f"Failed to create menu: {str(e)}" ) From 9c3ee889e42a193e58358d7c3563134f8e0cc73d Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Sun, 9 Feb 2025 08:12:20 +0530 Subject: [PATCH 23/25] refactor the scrapper.py to handle multiple ids --- backend/app/api/routes/scrapper.py | 220 ++++++++++++----------------- 1 file changed, 93 insertions(+), 127 deletions(-) diff --git a/backend/app/api/routes/scrapper.py b/backend/app/api/routes/scrapper.py index ac5e451f81..2aeec492cd 100644 --- a/backend/app/api/routes/scrapper.py +++ b/backend/app/api/routes/scrapper.py @@ -5,11 +5,19 @@ import requests from bs4 import BeautifulSoup import logging +from datetime import datetime, time from app.api.deps import SessionDep, get_business_user from app.models.user import UserBusiness, UserVenueAssociation from app.models.venue import Venue, Restaurant -from app.models.menu import Menu, MenuCategory, MenuItem +from app.models.menu import Menu, MenuCategory, MenuSubCategory, MenuItem from app.util import create_record +from app.schema.venue import RestaurantCreate, VenueCreate +from app.schema.menu import ( + MenuCreate, + MenuCategoryCreate, + MenuSubCategoryCreate, + MenuItemCreate +) logging.basicConfig( level=logging.DEBUG, @@ -22,21 +30,14 @@ class ZomatoUrl(BaseModel): url: HttpUrl -def parse_price(price_str: str) -> float: - """Convert price string to float, handling variations""" +def parse_time(time_str: str) -> time: + """Convert time string to time object""" try: - cleaned_price = price_str.replace('₹', '').replace(',', '').strip() - return float(cleaned_price) - except (ValueError, AttributeError): - return 0.0 - -def handle_price_variations(price_data: dict) -> tuple[float, str]: - """Handle half/full price variations""" - if isinstance(price_data, dict) and ('half' in price_data or 'full' in price_data): - description = f"Half: ₹{price_data.get('half', 0)}, Full: ₹{price_data.get('full', 0)}" - price = price_data.get('full', price_data.get('half', 0)) - return parse_price(price), description - return parse_price(price_data), "" + # Assuming format like "9am – 11:15pm" + opening_time = time_str.split('–')[0].strip() + return datetime.strptime(opening_time, '%I%p').time() + except: + return None def extract_menu_data(json_data: dict) -> dict: try: @@ -51,9 +52,13 @@ def extract_menu_data(json_data: dict) -> dict: return {} res_info = restaurant_details[restaurant_id].get('sections', {}) - - # Restaurant Info basic_info = res_info.get('SECTION_BASIC_INFO', {}) + + # Extract timing + timing_desc = basic_info.get('timing', {}).get('timing_desc', '') + opening_time = parse_time(timing_desc) if timing_desc else None + + # Restaurant Info restaurant_info = { 'restaurant_id': basic_info.get('res_id'), 'name': basic_info.get('name'), @@ -68,9 +73,11 @@ def extract_menu_data(json_data: dict) -> dict: 'url': basic_info.get('resUrl') }, 'timing': { - 'description': basic_info.get('timing', {}).get('timing_desc'), + 'description': timing_desc, + 'opening_time': opening_time, 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) - } + }, + 'avg_cost_for_two': basic_info.get('costText', {}).get('text', '0').replace('₹', '').replace(',', '').strip() } # Menu Items @@ -84,19 +91,19 @@ def extract_menu_data(json_data: dict) -> dict: } for item in category.get('items', []): - price, price_description = handle_price_variations(item.get('price', 0)) - description = item.get('description', '') - if price_description: - description = f"{description}\n{price_description}" if description else price_description - + price_str = str(item.get('price', '0')).replace('₹', '').replace(',', '').strip() + try: + price = float(price_str) + except ValueError: + price = 0.0 + menu_item = { 'id': str(item.get('id')), 'name': item.get('name'), - 'description': description, + 'description': item.get('description', ''), 'price': price, 'image_url': item.get('imageUrl', ''), 'is_veg': item.get('isVeg', True), - 'spice_level': item.get('spiceLevel', 'None') } category_items['items'].append(menu_item) @@ -112,56 +119,7 @@ def extract_menu_data(json_data: dict) -> dict: logger.error(f"Error extracting menu data: {str(e)}") return {} -def fetch_zomato_data(url: str) -> str: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Connection': 'keep-alive', - } - - try: - logger.info(f"Fetching data from URL: {url}") - response = requests.get(url, headers=headers) - response.raise_for_status() - return response.text - except Exception as e: - logger.error(f"Error fetching data: {str(e)}") - raise - -def parse_zomato_page(html_content: str) -> dict: - try: - logger.debug("Starting page parsing...") - soup = BeautifulSoup(html_content, 'html.parser') - - scripts = soup.find_all('script') - target_script = None - - for script in scripts: - if script.string and 'window.__PRELOADED_STATE__' in script.string: - target_script = script - break - - if not target_script: - raise ValueError("Could not find PRELOADED_STATE in page") - - json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] - json_str = json_str.split(');')[0].strip() - - json_str = json_str.strip('"') - json_str = json_str.replace('\\"', '"') - json_str = json_str.replace('\\\\', '\\') - json_str = json_str.replace('\\n', '') - - return json.loads(json_str) - - except Exception as e: - logger.error(f"Error parsing page: {str(e)}") - raise - -@router.get("/") -async def get_scrapper_info(): - return {"message": "Welcome to Zomato Menu Scraper"} +# ... keep your existing fetch_zomato_data and parse_zomato_page functions ... @router.post("/menu") async def scrape_and_create_menu( @@ -170,12 +128,6 @@ async def scrape_and_create_menu( current_user: UserBusiness = Depends(get_business_user) ): try: - if "zomato.com" not in str(request.url): - raise HTTPException( - status_code=400, - detail="Invalid URL. Please provide a valid Zomato restaurant URL" - ) - # 1. Scrape data html_content = fetch_zomato_data(str(request.url)) json_data = parse_zomato_page(html_content) @@ -187,69 +139,83 @@ async def scrape_and_create_menu( detail="Failed to extract menu data from Zomato" ) - # 2. Create venue - venue_data = { - "name": scraped_data["restaurant_info"]["name"], - "address": scraped_data["restaurant_info"]["location"]["locality"], - "contact": "", # Add if available - "cuisines": scraped_data["restaurant_info"]["cuisines"], - "rating": scraped_data["restaurant_info"]["rating"]["aggregate_rating"], - "timing": scraped_data["restaurant_info"]["timing"]["description"] - } - - # Create Venue instance - venue_instance = Venue.from_create_schema(venue_data) + # 2. Create Restaurant with Venue + venue_data = VenueCreate( + name=scraped_data['restaurant_info']['name'], + description=f"Restaurant imported from Zomato", + mobile_number=None, # Add if available in scraped data + email=None, # Add if available + opening_time=scraped_data['restaurant_info']['timing']['opening_time'], + avg_expense_for_two=float(scraped_data['restaurant_info']['avg_cost_for_two']), + zomato_link=str(request.url) + ) + + restaurant_data = RestaurantCreate( + venue=venue_data, + cuisine_type=scraped_data['restaurant_info']['cuisines'] + ) + + # Create venue and restaurant + venue_instance = Venue.from_create_schema(restaurant_data.venue) create_record(session, venue_instance) - # Create Restaurant instance - restaurant_instance = Restaurant.from_create_schema(venue_instance.id, venue_data) + restaurant_instance = Restaurant.from_create_schema(venue_instance.id, restaurant_data) create_record(session, restaurant_instance) - # Create user-venue association + # Create association association = UserVenueAssociation( user_id=current_user.id, venue_id=venue_instance.id ) create_record(session, association) - # 3. Create main menu - menu_data = { - "name": f"{scraped_data['restaurant_info']['name']} Menu", - "description": f"Menu imported from Zomato", - "venue_id": venue_instance.id, - "is_active": True - } - menu_instance = Menu(**menu_data) + # 3. Create Menu + menu_data = MenuCreate( + name=f"{scraped_data['restaurant_info']['name']} Menu", + description="Imported from Zomato", + venue_id=venue_instance.id, + menu_type="Food" + ) + menu_instance = Menu(**menu_data.dict()) create_record(session, menu_instance) - # 4. Create categories and items - for category in scraped_data["menu"]: - category_data = { - "name": category["category"], - "description": f"Category for {category['category']}", - "menu_id": menu_instance.id - } - category_instance = MenuCategory(**category_data) + # 4. Create Categories, Subcategories, and Items + for category in scraped_data['menu']: + # Create Category + category_data = MenuCategoryCreate( + name=category['category'], + menu_id=menu_instance.id + ) + category_instance = MenuCategory(**category_data.dict()) create_record(session, category_instance) - for item in category["items"]: - item_data = { - "name": item["name"], - "description": item["description"], - "price": float(item["price"]), - "category_id": category_instance.id, - "is_available": True, - "is_vegetarian": item["is_veg"], - "image_url": item["image_url"] if item["image_url"] else None - } - item_instance = MenuItem(**item_data) + # Create default subcategory for each category + subcategory_data = MenuSubCategoryCreate( + name=f"{category['category']} Items", + category_id=category_instance.id, + is_alcoholic=False # Default value + ) + subcategory_instance = MenuSubCategory(**subcategory_data.dict()) + create_record(session, subcategory_instance) + + # Create items under subcategory + for item in category['items']: + item_data = MenuItemCreate( + name=item['name'], + description=item['description'], + price=float(item['price']), + subcategory_id=subcategory_instance.id, + is_veg=item['is_veg'], + image_url=item['image_url'] if item['image_url'] else None + ) + item_instance = MenuItem(**item_data.dict()) create_record(session, item_instance) return { "message": "Menu successfully created", - "venue_id": venue_instance.id, - "menu_id": menu_instance.id, - "restaurant_name": scraped_data["restaurant_info"]["name"] + "venue_id": str(venue_instance.id), + "menu_id": str(menu_instance.id), + "restaurant_name": scraped_data['restaurant_info']['name'] } except Exception as e: From 412e78e3b4c1d503e1e1c9595af9b735f459aa8e Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Wed, 12 Feb 2025 02:46:28 +0530 Subject: [PATCH 24/25] fix menu creation --- backend/app/api/routes/scrapper.py | 200 ++++++++--------------------- 1 file changed, 53 insertions(+), 147 deletions(-) diff --git a/backend/app/api/routes/scrapper.py b/backend/app/api/routes/scrapper.py index 2aeec492cd..6f145bc889 100644 --- a/backend/app/api/routes/scrapper.py +++ b/backend/app/api/routes/scrapper.py @@ -1,16 +1,13 @@ from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel, HttpUrl -from typing import Dict, Any import json import requests from bs4 import BeautifulSoup import logging from datetime import datetime, time +import httpx from app.api.deps import SessionDep, get_business_user -from app.models.user import UserBusiness, UserVenueAssociation -from app.models.venue import Venue, Restaurant -from app.models.menu import Menu, MenuCategory, MenuSubCategory, MenuItem -from app.util import create_record +from app.models.user import UserBusiness from app.schema.venue import RestaurantCreate, VenueCreate from app.schema.menu import ( MenuCreate, @@ -19,10 +16,7 @@ MenuItemCreate ) -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(levelname)s - %(message)s' -) +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) router = APIRouter() @@ -30,14 +24,48 @@ class ZomatoUrl(BaseModel): url: HttpUrl -def parse_time(time_str: str) -> time: - """Convert time string to time object""" +def fetch_zomato_data(url: str) -> str: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + } + try: - # Assuming format like "9am – 11:15pm" - opening_time = time_str.split('–')[0].strip() - return datetime.strptime(opening_time, '%I%p').time() - except: - return None + logger.info(f"Fetching data from URL: {url}") + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.text + except Exception as e: + logger.error(f"Error fetching data: {str(e)}") + raise + +def parse_zomato_page(html_content: str) -> dict: + try: + logger.debug("Starting page parsing...") + soup = BeautifulSoup(html_content, 'html.parser') + + scripts = soup.find_all('script') + target_script = None + + for script in scripts: + if script.string and 'window.__PRELOADED_STATE__' in script.string: + target_script = script + break + + if not target_script: + raise ValueError("Could not find PRELOADED_STATE in page") + + json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] + json_str = json_str.split(');')[0].strip() + json_str = json_str.strip('"').replace('\\"', '"').replace('\\\\', '\\').replace('\\n', '') + + return json.loads(json_str) + + except Exception as e: + logger.error(f"Error parsing page: {str(e)}") + raise def extract_menu_data(json_data: dict) -> dict: try: @@ -46,21 +74,13 @@ def extract_menu_data(json_data: dict) -> dict: restaurant_details = json_data.get('pages', {}).get('restaurant', {}) restaurant_id = next(iter(restaurant_details)) if restaurant_details else None - if not restaurant_id: - logger.error("No restaurant ID found") - return {} + raise ValueError("No restaurant ID found") res_info = restaurant_details[restaurant_id].get('sections', {}) basic_info = res_info.get('SECTION_BASIC_INFO', {}) - - # Extract timing - timing_desc = basic_info.get('timing', {}).get('timing_desc', '') - opening_time = parse_time(timing_desc) if timing_desc else None - - # Restaurant Info + restaurant_info = { - 'restaurant_id': basic_info.get('res_id'), 'name': basic_info.get('name'), 'cuisines': basic_info.get('cuisine_string'), 'rating': { @@ -73,16 +93,13 @@ def extract_menu_data(json_data: dict) -> dict: 'url': basic_info.get('resUrl') }, 'timing': { - 'description': timing_desc, - 'opening_time': opening_time, + 'description': basic_info.get('timing', {}).get('timing_desc'), 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) - }, - 'avg_cost_for_two': basic_info.get('costText', {}).get('text', '0').replace('₹', '').replace(',', '').strip() + } } - # Menu Items - menu_data = res_info.get('SECTION_MENU_WIDGET', {}) menu_categories = [] + menu_data = res_info.get('SECTION_MENU_WIDGET', {}) for category in menu_data.get('categories', []): category_items = { @@ -91,19 +108,13 @@ def extract_menu_data(json_data: dict) -> dict: } for item in category.get('items', []): - price_str = str(item.get('price', '0')).replace('₹', '').replace(',', '').strip() - try: - price = float(price_str) - except ValueError: - price = 0.0 - menu_item = { - 'id': str(item.get('id')), 'name': item.get('name'), 'description': item.get('description', ''), - 'price': price, + 'price': float(item.get('price', 0)), 'image_url': item.get('imageUrl', ''), 'is_veg': item.get('isVeg', True), + 'spice_level': item.get('spiceLevel', 'None') } category_items['items'].append(menu_item) @@ -117,111 +128,6 @@ def extract_menu_data(json_data: dict) -> dict: except Exception as e: logger.error(f"Error extracting menu data: {str(e)}") - return {} - -# ... keep your existing fetch_zomato_data and parse_zomato_page functions ... - -@router.post("/menu") -async def scrape_and_create_menu( - request: ZomatoUrl, - session: SessionDep, - current_user: UserBusiness = Depends(get_business_user) -): - try: - # 1. Scrape data - html_content = fetch_zomato_data(str(request.url)) - json_data = parse_zomato_page(html_content) - scraped_data = extract_menu_data(json_data) - - if not scraped_data: - raise HTTPException( - status_code=500, - detail="Failed to extract menu data from Zomato" - ) - - # 2. Create Restaurant with Venue - venue_data = VenueCreate( - name=scraped_data['restaurant_info']['name'], - description=f"Restaurant imported from Zomato", - mobile_number=None, # Add if available in scraped data - email=None, # Add if available - opening_time=scraped_data['restaurant_info']['timing']['opening_time'], - avg_expense_for_two=float(scraped_data['restaurant_info']['avg_cost_for_two']), - zomato_link=str(request.url) - ) - - restaurant_data = RestaurantCreate( - venue=venue_data, - cuisine_type=scraped_data['restaurant_info']['cuisines'] - ) - - # Create venue and restaurant - venue_instance = Venue.from_create_schema(restaurant_data.venue) - create_record(session, venue_instance) - - restaurant_instance = Restaurant.from_create_schema(venue_instance.id, restaurant_data) - create_record(session, restaurant_instance) - - # Create association - association = UserVenueAssociation( - user_id=current_user.id, - venue_id=venue_instance.id - ) - create_record(session, association) - - # 3. Create Menu - menu_data = MenuCreate( - name=f"{scraped_data['restaurant_info']['name']} Menu", - description="Imported from Zomato", - venue_id=venue_instance.id, - menu_type="Food" - ) - menu_instance = Menu(**menu_data.dict()) - create_record(session, menu_instance) - - # 4. Create Categories, Subcategories, and Items - for category in scraped_data['menu']: - # Create Category - category_data = MenuCategoryCreate( - name=category['category'], - menu_id=menu_instance.id - ) - category_instance = MenuCategory(**category_data.dict()) - create_record(session, category_instance) - - # Create default subcategory for each category - subcategory_data = MenuSubCategoryCreate( - name=f"{category['category']} Items", - category_id=category_instance.id, - is_alcoholic=False # Default value - ) - subcategory_instance = MenuSubCategory(**subcategory_data.dict()) - create_record(session, subcategory_instance) - - # Create items under subcategory - for item in category['items']: - item_data = MenuItemCreate( - name=item['name'], - description=item['description'], - price=float(item['price']), - subcategory_id=subcategory_instance.id, - is_veg=item['is_veg'], - image_url=item['image_url'] if item['image_url'] else None - ) - item_instance = MenuItem(**item_data.dict()) - create_record(session, item_instance) + raise - return { - "message": "Menu successfully created", - "venue_id": str(venue_instance.id), - "menu_id": str(menu_instance.id), - "restaurant_name": scraped_data['restaurant_info']['name'] - } - - except Exception as e: - session.rollback() - logger.error(f"Menu creation failed: {str(e)}") - raise HTTPException( - status_code=500, - detail=f"Failed to create menu: {str(e)}" - ) +async def create_restaurant(client: httpx.AsyncClient, restaurant_data: From af921ae09ccc24f5b6b359a226220e9d42fcff26 Mon Sep 17 00:00:00 2001 From: shreyash776 Date: Sun, 2 Mar 2025 17:08:22 +0530 Subject: [PATCH 25/25] fix: data menu data formatting --- backend/app/api/routes/scrapper.py | 187 +- backend/app/api/routes/utils.py | 184 + backend/app/schema/venue.py | 13 +- backend/data.json | 6190 ++++++++++++++++++++++++++++ types.d.ts | 67 + 5 files changed, 6581 insertions(+), 60 deletions(-) create mode 100644 backend/app/api/routes/utils.py create mode 100644 backend/data.json create mode 100644 types.d.ts diff --git a/backend/app/api/routes/scrapper.py b/backend/app/api/routes/scrapper.py index c9618b5f1d..96c5c62554 100644 --- a/backend/app/api/routes/scrapper.py +++ b/backend/app/api/routes/scrapper.py @@ -15,6 +15,7 @@ MenuSubCategoryCreate, MenuItemCreate ) +from app.api.routes.utils import transform_restaurant_data logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -26,10 +27,9 @@ class ZomatoUrl(BaseModel): def fetch_zomato_data(url: str) -> str: headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', - 'Connection': 'keep-alive', } try: @@ -43,33 +43,25 @@ def fetch_zomato_data(url: str) -> str: def parse_zomato_page(html_content: str) -> dict: try: - logger.debug("Starting page parsing...") soup = BeautifulSoup(html_content, 'html.parser') - scripts = soup.find_all('script') - target_script = None for script in scripts: if script.string and 'window.__PRELOADED_STATE__' in script.string: - target_script = script - break - - if not target_script: - raise ValueError("Could not find PRELOADED_STATE in page") - - json_str = target_script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] - json_str = json_str.split(');')[0].strip() - json_str = json_str.strip('"').replace('\\"', '"').replace('\\\\', '\\').replace('\\n', '') - - return json.loads(json_str) - + json_str = script.string.split('window.__PRELOADED_STATE__ = JSON.parse(')[1] + json_str = json_str.split(');')[0].strip() + json_str = json_str.strip('"').replace('\\"', '"').replace('\\\\', '\\') + return json.loads(json_str) + + raise ValueError("Could not find PRELOADED_STATE in page") except Exception as e: logger.error(f"Error parsing page: {str(e)}") raise def extract_menu_data(json_data: dict) -> dict: try: - logger.debug("Starting menu data extraction...") + with open('data.json', 'w') as f: + json.dump(json_data, f, indent=4) restaurant_data = json_data.get('pages', {}).get('current', {}) restaurant_details = json_data.get('pages', {}).get('restaurant', {}) @@ -79,56 +71,99 @@ def extract_menu_data(json_data: dict) -> dict: res_info = restaurant_details[restaurant_id].get('sections', {}) basic_info = res_info.get('SECTION_BASIC_INFO', {}) - + menu_widget = res_info.get('SECTION_MENU_WIDGET', {}) + + + # Validate required name field + if not basic_info.get('name'): + raise ValueError("Restaurant name is required") + restaurant_info = { - 'name': basic_info.get('name'), - 'cuisines': basic_info.get('cuisine_string'), - 'rating': { - 'aggregate_rating': basic_info.get('rating', {}).get('aggregate_rating'), - 'votes': basic_info.get('rating', {}).get('votes'), - 'rating_text': basic_info.get('rating', {}).get('rating_text') - }, - 'location': { - 'locality': restaurant_data.get('pageDescription', ''), - 'url': basic_info.get('resUrl') - }, - 'timing': { - 'description': basic_info.get('timing', {}).get('timing_desc'), - 'hours': basic_info.get('timing', {}).get('customised_timings', {}).get('opening_hours', []) + 'cuisine_type': basic_info.get('cuisine_string', ''), + 'venue': { + 'name': basic_info.get('name', ''), + 'address': basic_info.get('address', ''), + 'locality': basic_info.get('locality_verbose', ''), + 'city': basic_info.get('city', ''), + 'latitude': basic_info.get('latitude', '0'), + 'longitude': basic_info.get('longitude', '0'), + 'zipcode': basic_info.get('zipcode', ''), + 'rating': basic_info.get('rating', {}).get('aggregate_rating', '0'), + 'timing': basic_info.get('timing', {}).get('timing', ''), + 'avg_cost_for_two': basic_info.get('average_cost_for_two', 0) } } - + + menu_categories = [] - menu_data = res_info.get('SECTION_MENU_WIDGET', {}) - - for category in menu_data.get('categories', []): - category_items = { - 'category': category.get('name', ''), - 'items': [] + print("Catorgies", menu_widget.get('menu', {}).get('categories', [])) + for category in menu_widget.get('menu', {}).get('categories', []): + print("category", category) + category_data = { + 'name': category.get('name', ''), + 'description': category.get('description', ''), + 'subcategories': [] } + # Group items by subcategory + subcategories = {} for item in category.get('items', []): + subcategory_name = item.get('category', 'Other') + + if subcategory_name not in subcategories: + subcategories[subcategory_name] = { + 'name': subcategory_name, + 'description': '', + 'items': [] + } + menu_item = { - 'name': item.get('name'), - 'description': item.get('description', ''), - 'price': float(item.get('price', 0)), - 'image_url': item.get('imageUrl', ''), + 'name': item.get('name', ''), + 'description': item.get('desc', ''), 'is_veg': item.get('isVeg', True), - 'spice_level': item.get('spiceLevel', 'None') + 'image_url': item.get('itemImage', ''), + 'variants': [] } - category_items['items'].append(menu_item) + + # Handle variants + if item.get('variantsV2'): + for variant in item['variantsV2']: + menu_item['variants'].append({ + 'name': variant.get('variantName', ''), + 'price': float(variant.get('price', 0)) / 100, + 'is_default': variant.get('isDefault', False) + }) + else: + menu_item['variants'].append({ + 'name': 'Regular', + 'price': float(item.get('defaultPrice', 0)) / 100, + 'is_default': True + }) + + subcategories[subcategory_name]['items'].append(menu_item) - if category_items['items']: - menu_categories.append(category_items) - + # Add non-empty subcategories to category + category_data['subcategories'] = [ + subcat for subcat in subcategories.values() + if subcat['items'] + ] + + if category_data['subcategories']: + menu_categories.append(category_data) + return { + 'status': 'success', 'restaurant_info': restaurant_info, 'menu': menu_categories } - + except Exception as e: logger.error(f"Error extracting menu data: {str(e)}") - raise + raise HTTPException( + status_code=500, + detail=f"Failed to extract menu data: {str(e)}" + ) + async def create_restaurant(client: httpx.AsyncClient, restaurant_data: RestaurantCreate): response = await client.post("/venue/restaurants/", json=restaurant_data.dict()) @@ -161,18 +196,21 @@ async def scrape_and_create_menu( current_user: UserBusiness = Depends(get_business_user) ): try: - # 1. Scrape data + # 1. Scrape and log data html_content = fetch_zomato_data(str(request.url)) json_data = parse_zomato_page(html_content) scraped_data = extract_menu_data(json_data) + + logger.info("Scraped Restaurant Info:") + logger.info(f"Name: {scraped_data['restaurant_info']['name']}") + logger.info(f"Cuisines: {scraped_data['restaurant_info']['cuisines']}") async with httpx.AsyncClient() as client: # 2. Create Restaurant venue_data = VenueCreate( name=scraped_data['restaurant_info']['name'], description="Restaurant imported from Zomato", - opening_time=scraped_data['restaurant_info']['timing']['opening_time'], - avg_expense_for_two=float(scraped_data['restaurant_info']['avg_cost_for_two']), + avg_expense_for_two=scraped_data['restaurant_info']['avg_cost_for_two'], zomato_link=str(request.url) ) @@ -196,7 +234,6 @@ async def scrape_and_create_menu( # 4. Create Categories, Subcategories, and Items sequentially for category in scraped_data['menu']: - # Create Category category_data = MenuCategoryCreate( name=category['category'], menu_id=menu_id @@ -204,7 +241,6 @@ async def scrape_and_create_menu( category_id = await create_category(client, category_data) logger.info(f"Created category: {category['category']}") - # Create default subcategory subcategory_data = MenuSubCategoryCreate( name=f"{category['category']} Items", category_id=category_id, @@ -213,7 +249,6 @@ async def scrape_and_create_menu( subcategory_id = await create_subcategory(client, subcategory_data) logger.info(f"Created subcategory for {category['category']}") - # Create items one by one for item in category['items']: item_data = MenuItemCreate( name=item['name'], @@ -230,9 +265,45 @@ async def scrape_and_create_menu( "message": "Menu successfully created", "venue_id": str(venue_id), "menu_id": str(menu_id), - "restaurant_name": scraped_data['restaurant_info']['name'] + "restaurant_name": scraped_data['restaurant_info']['name'], + "scraped_data": scraped_data } except Exception as e: logger.error(f"Menu creation failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to create menu: {str(e)}") + + +@router.get("/menu/scrape") +async def get_scraped_menu(url: str): + try: + # Validate URL + zomato_url = ZomatoUrl(url=url) + + # Scrape data using existing functions + html_content = fetch_zomato_data(str(zomato_url.url)) + json_data = parse_zomato_page(html_content) + # print(json_data) + # scraped_data = extract_menu_data(json_data) + print("==== cleaning the data ====") + scraped_data = transform_restaurant_data(json_data) + print(scraped_data) + + return { + "status": "success", + "restaurant_info": scraped_data, + "menu": scraped_data['menu'] + } + + except ValueError as ve: + logger.error(f"Invalid URL format: {str(ve)}") + raise HTTPException( + status_code=400, + detail=f"Invalid Zomato URL: {str(ve)}" + ) + except Exception as e: + logger.error(f"Scraping failed: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to scrape menu data: {str(e)}" + ) diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py new file mode 100644 index 0000000000..6b169536db --- /dev/null +++ b/backend/app/api/routes/utils.py @@ -0,0 +1,184 @@ +def transform_restaurant_data(zomato_data): + """ + Transform Zomato restaurant data into the specified MenuData format. + + Args: + zomato_data (dict): The Zomato restaurant data in its original format + + Returns: + dict: The transformed data in MenuData format + """ + try: + # Extract the menu data from the Zomato data structure + restaurant = zomato_data.get('pages', {}).get('restaurant', {}) + + resId = list(restaurant)[0] + + menu_list = zomato_data.get('pages', {}).get('restaurant', {}).get(resId, {}).get('order', {}).get('menuList', {}) + + if not menu_list: + menu_list = zomato_data.get('order', {}).get('menuList', {}) + + menus = menu_list.get('menus', []) + + # Initialize the result structure + result = { + "menu": [] + } + + # Process each menu category + + categories = [] + for menu_entry in menus: + + + + + menu = menu_entry.get('menu', {}) + subcategories = menu.get('categories', []) + + for sub_cat_entry in subcategories: + subcategory = sub_cat_entry.get('category', {}) + subcategory_name = subcategory.get('name', '') + items = subcategory.get('items', []) + + # Check if we need to create a subcategory + has_subcategories = False + subcategories_map = {} + + # First, scan items to see if they have any group information that could be used as subcategories + for item_entry in items: + item = item_entry.get('item', {}) + # Look for potential subcategory indicators in the item data + # This could be customized based on the actual data structure + groups = item.get('groups', []) + if groups: + has_subcategories = True + for group_entry in groups: + group = group_entry.get('group', {}) + subcategory_name = group.get('name', 'Other') + if subcategory_name not in subcategories_map: + subcategories_map[subcategory_name] = [] + + # If no subcategories found, create a default one + if not has_subcategories: + subcategories_map['General'] = [] + + # Process each menu item and assign to appropriate subcategory + for item_entry in items: + item = item_entry.get('item', {}) + + # Transform the item to match the MenuItem interface + transformed_item = { + "id": item.get('id', ''), + "name": item.get('name', ''), + "description": item.get('desc', ''), + "price": item.get('price', 0), + "is_veg": any(tag == "pure_veg" for tag in item.get('tag_slugs', [])), + "spice_level": determine_spice_level(item), + "image_url": extract_image_url(item) + } + + # Determine which subcategory this item belongs to + assigned = False + groups = item.get('groups', []) + if groups: + for group_entry in groups: + group = group_entry.get('group', {}) + subcategory_name = group.get('name', 'Other') + if subcategory_name in subcategories_map: + subcategories_map[subcategory_name].append(transformed_item) + assigned = True + break + + # If no subcategory was assigned, put in the first available subcategory + if not assigned: + default_subcategory = next(iter(subcategories_map.keys())) + subcategories_map[default_subcategory].append(transformed_item) + + # Create the category entry with its subcategories + category_entry = { + "category": menu.get('name', ''), + "subcategories": [ + { + "subcategory": subcategory_name, + "items": items_list + } + for subcategory_name, items_list in subcategories_map.items() + ] + } + + result["menu"].append(category_entry) + + return result + + except Exception as e: + print(f"Error transforming data: {e}") + # Return a minimal valid structure in case of error + return {"menu": []} + +def determine_spice_level(item): + """ + Determine the spice level of an item based on available tags or description. + + Args: + item (dict): The menu item data + + Returns: + str: One of 'None', 'Mild', 'Medium', 'Spicy', or 'Hot' + """ + # Check tags for spice indicators + tags = item.get('tag_slugs', []) + desc = item.get('desc', '').lower() + + # This is a simple heuristic and could be enhanced based on actual data patterns + if any(spicy_tag in tags for spicy_tag in ['extra_spicy', 'very_hot']): + return 'Hot' + elif any(spicy_tag in tags for spicy_tag in ['spicy', 'hot']): + return 'Spicy' + elif any(medium_tag in tags for medium_tag in ['medium_spicy', 'medium_hot']): + return 'Medium' + elif any(mild_tag in tags for mild_tag in ['mild_spicy', 'slightly_spicy']): + return 'Mild' + + # Check description for spice indicators + if any(hot_term in desc for hot_term in ['very spicy', 'extra hot', 'extremely spicy']): + return 'Hot' + elif any(spicy_term in desc for spicy_term in ['spicy', 'hot']): + return 'Spicy' + elif any(medium_term in desc for medium_term in ['medium spicy', 'moderately spiced']): + return 'Medium' + elif any(mild_term in desc for mild_term in ['mild spice', 'slightly spicy']): + return 'Mild' + + # Default value + return 'None' + +def extract_image_url(item): + """ + Extract the image URL from an item. + + Args: + item (dict): The menu item data + + Returns: + str: The image URL, or None if not available + """ + # First try to get the specific item image URL + image_url = item.get('item_image_url') + if image_url: + return image_url + + # Then check the media array for images + media = item.get('media', []) + for media_item in media: + if media_item.get('mediaType') == 'image': + image_data = media_item.get('image', {}) + if image_data and image_data.get('url'): + return image_data.get('url') + + # If no image found, return None + return None + +# Example usage: +# transformed_data = transform_restaurant_data(zomato_data) diff --git a/backend/app/schema/venue.py b/backend/app/schema/venue.py index 82753f3671..3f78fa4bef 100644 --- a/backend/app/schema/venue.py +++ b/backend/app/schema/venue.py @@ -1,7 +1,7 @@ import uuid from datetime import time -from pydantic import BaseModel +from pydantic import BaseModel, Field, validator # Venue base details (composition) @@ -41,9 +41,18 @@ class Config: # Restaurant Schemas class RestaurantCreate(BaseModel): - cuisine_type: str | None = None + cuisine_type: str | None = Field( + default=None, + description="Comma-separated list of cuisine types" + ) venue: VenueCreate + @validator('cuisine_type', pre=True) + def format_cuisine_type(cls, v): + if isinstance(v, list): + return ', '.join(v) # Convert list to comma-separated string + return v + class Config: from_attributes = True diff --git a/backend/data.json b/backend/data.json new file mode 100644 index 0000000000..04b6f88589 --- /dev/null +++ b/backend/data.json @@ -0,0 +1,6190 @@ +{ + "pages": { + "current": { + "name": "restaurant", + "pageTitle": "Schezwan Spicy Food, Modinagar Locality order online - Zomato", + "pageDescription": "Order food online from Schezwan Spicy Food, Modinagar Locality, Modinagar. Get great offers and super fast food delivery when you order food online from Schezwan Spicy Food on Zomato.", + "resId": 19713383, + "pageUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order", + "canonicalUrl": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/order", + "title": "Order Online", + "subType": "order", + "key": "order", + "ogTitle": "Schezwan Spicy Food, Modinagar Locality order online - Zomato", + "ogDescription": "Order food online from Schezwan Spicy Food, Modinagar Locality, Modinagar. Get great offers and super fast food delivery when you order food online from Schezwan Spicy Food on Zomato.", + "ogUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order", + "ampHtmlUrl": "", + "isFloodReliefRes": false, + "isNoIndex": false, + "checkoutUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order/verify", + "show_rating_v15": true, + "showBookingV2": true, + "isRestaurantPageV2": true, + "isMobile": 0, + "isOAuthV2Enabled": false, + "useAuthSdkForLogin": true, + "useAuthSdkForLogout": false, + "gaPageType": "Restaurant" + }, + "contact": { + "contactPageBannerData": [], + "snippetData": [], + "formData": {}, + "buisinessEnquiriesData": {} + }, + "gift": { + "crystalData": {} + }, + "goodbye": {}, + "restaurant": { + "19713383": { + "sections": { + "SECTION_IMAGE_CAROUSEL": { + "entities": [ + { + "entity_type": "IMAGES", + "entity_ids": [ + "r_YwMjUyMTA4OTIw" + ] + } + ], + "has_more_photo": false, + "obpImage": { + "entity_type": "IMAGES", + "entity_ids": [] + }, + "is_partner": false + }, + "SECTION_BASIC_INFO": { + "res_id": 19713383, + "name": "Schezwan Spicy Food", + "cuisine_string": "Fast Food, Chinese", + "rating": { + "has_fake_reviews": 0, + "aggregate_rating": "4.3", + "rating_text": "4.3", + "rating_subtitle": "Very Good", + "rating_color": "5BA829", + "votes": 156, + "subtext": "REVIEWS", + "is_new": false + }, + "rating_new": { + "newlyOpenedObj": null, + "suspiciousReviewObj": null, + "ratings": { + "DINING": { + "rating_type": "DINING", + "rating": "", + "reviewCount": "0", + "reviewTextSmall": "0 Reviews", + "subtext": "Does not offer Dining", + "color": "", + "ratingV2": "-", + "subtitle": "DINING", + "sideSubTitle": "Dining Ratings", + "bgColorV2": { + "type": "green", + "tint": "100" + }, + "textColorV2": { + "type": "green", + "tint": "500" + }, + "newOnDining": false + }, + "DELIVERY": { + "rating_type": "DELIVERY", + "rating": "4.3", + "reviewCount": "156", + "reviewTextSmall": "156 Reviews", + "subtext": "156 Delivery Reviews", + "color": "#E23744", + "ratingV2": "4.3", + "subtitle": "DELIVERY", + "sideSubTitle": "Delivery Ratings", + "bgColorV2": { + "type": "green", + "tint": "700" + }, + "newOnDelivery": false + } + } + }, + "res_status_text": "Closed", + "timing": { + "timing_desc": "Opens tomorrow at 11:22am", + "customised_timings": { + "opening_hours": [ + { + "timing": "11:22am \u2013 9:26pm", + "days": "Mon" + }, + { + "timing": "Closed", + "days": "Tue-Sun" + } + ] + }, + "show_open_now": false, + "show_timing_info": false + }, + "is_delivery_only": true, + "is_perm_closed": false, + "is_temp_closed": false, + "is_opening_soon": 0, + "should_ban_ugc": false, + "is_shelled": false, + "media_alert": 0, + "learn_more_text": "Learn More", + "res_thumb": "https://b.zmtcdn.com/images/res_avatar_476_320_1x_new.png?output-format=webp", + "disclaimer_text": "", + "resUrl": "/modinagar/schezwan-spicy-food-modinagar-locality", + "enableClientSideAds": false, + "is_partner": false, + "disable_open_app": 0, + "backToHomeUrl": "https://www.zomato.com/modinagar/restaurants?order-online=1" + }, + "SECTION_FEATURE_RAIL": [], + "SECTION_RES_HEADER_DETAILS": { + "LOCALITY": { + "text": "Modinagar Locality, Modinagar", + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants" + }, + "CUISINES": [ + { + "deeplink": "zomato://search?deeplink_filters=WyJ7XCJjb250ZXh0XCI6XCJhbGxcIn0iLCJ7XCJjdWlzaW5lX2lkXCI6W1wiNDBcIl19Il0%3D", + "url": "https://www.zomato.com/modinagar/restaurants/fast-food/", + "name": "Fast Food" + }, + { + "deeplink": "zomato://search?deeplink_filters=WyJ7XCJjb250ZXh0XCI6XCJhbGxcIn0iLCJ7XCJjdWlzaW5lX2lkXCI6W1wiMjVcIl19Il0%3D", + "url": "https://www.zomato.com/modinagar/restaurants/chinese/", + "name": "Chinese" + } + ], + "ESTABLISHMENTS": [] + }, + "SECTION_RES_CONTACT": { + "city_id": 11595, + "city_name": "Modinagar", + "country_id": 1, + "country_name": "India", + "zipcode": "201204", + "is_dark_kitchen": false, + "locality_verbose": "Modinagar Locality, Modinagar", + "latitude": "28.8302229471", + "longitude": "77.5706658886", + "static_map_url": "https://maps.zomato.com/php/staticmap?center=28.8302229471,77.5706658886&maptype=zomato&markers=28.8302229471,77.5706658886,pin_res32&sensor=false&scale=2&zoom=16&language=en&size=240x150&size=400x240", + "address": "Upper Bazar, near Jain shikanji Modinagar Locality", + "is_phone_available": 1, + "phoneDetails": { + "title": "Phone Numbers", + "phoneStr": "+918979041283" + }, + "res_chain_text": "", + "res_group_text": "", + "res_chain_url": "", + "res_group_url": "", + "show_res_map": true + }, + "SECTION_MAGIC_LINKS": [ + { + "title": "Related to Schezwan Spicy Food, Modinagar Locality", + "magicLinks": [ + { + "url": "https://www.zomato.com/modinagar", + "title": "Modinagar Restaurants", + "displayName": "Restaurants in Modinagar, Modinagar Restaurants" + }, + { + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants", + "title": "Modinagar Locality restaurants", + "displayName": "Modinagar Locality restaurants" + }, + { + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants?sort=best", + "title": "Best Modinagar Locality restaurants", + "displayName": "Best Modinagar Locality restaurants" + }, + { + "url": "https://www.zomato.com/modinagar/modinagar-city-restaurants", + "title": "Modinagar City restaurants", + "displayName": "Modinagar City restaurants" + }, + { + "url": "https://www.zomato.com/modinagar", + "title": " in Modinagar", + "displayName": " in Modinagar" + }, + { + "url": "https://www.zomato.com/restaurants-near-me", + "title": " near me", + "displayName": " near me" + }, + { + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants", + "title": " in Modinagar Locality", + "displayName": " in Modinagar Locality" + }, + { + "url": "https://www.zomato.com/modinagar", + "title": " in Modinagar", + "displayName": " in Modinagar" + }, + { + "url": "https://www.zomato.com/restaurants-near-me", + "title": " near me", + "displayName": " near me" + }, + { + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants", + "title": " in Modinagar Locality", + "displayName": " in Modinagar Locality" + }, + { + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants?order-online=1", + "title": "Order food online in Modinagar Locality", + "displayName": "Order food online in Modinagar Locality" + }, + { + "url": "https://www.zomato.com/modinagar/restaurants?order-online=1", + "title": "Order food online in Modinagar", + "displayName": "Order food online in Modinagar" + } + ] + }, + { + "title": "Restaurants around Modinagar Locality", + "magicLinks": [ + { + "url": "https://www.zomato.com/modinagar/muradnagar-locality-restaurants", + "title": "List of Muradnagar Locality restaurants", + "displayName": "Muradnagar Locality restaurants" + } + ] + }, + { + "title": "Frequent searches leading to this page", + "magicLinks": [ + { + "url": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality", + "title": "", + "displayName": "schezwan spicy food menu" + }, + { + "url": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality", + "title": "", + "displayName": "schezwan spicy food modinagar locality menu" + }, + { + "url": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality", + "title": "", + "displayName": "schezwan spicy food modinagar" + }, + { + "url": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality", + "title": "", + "displayName": "schezwan spicy food modinagar menu" + }, + { + "url": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality", + "title": "", + "displayName": "schezwan spicy food restaurant" + } + ] + }, + { + "title": "Top Stores", + "magicLinks": [] + } + ], + "SECTION_RATING_DATA": { + "header": "Rate your experience for", + "options": [ + { + "value": "dining", + "label": "Dining" + }, + { + "value": "delivery", + "label": "Delivery" + } + ], + "selected": "dining" + }, + "SECTION_OBP_TAGS": [ + { + "entityType": "isDeliveryOnly", + "isImage": false, + "title": "Delivery Only", + "imageUrl": "" + } + ], + "SECTION_BREADCRUMBS": [ + { + "title": "Home", + "url": "https://www.zomato.com" + }, + { + "title": "India", + "url": "https://www.zomato.com/india" + }, + { + "title": "Modinagar", + "url": "https://www.zomato.com/modinagar/restaurants" + }, + { + "title": "Modinagar Locality", + "url": "https://www.zomato.com/modinagar/modinagar-locality-restaurants" + }, + { + "title": "Schezwan Spicy Food", + "url": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/order" + }, + { + "title": "Order Online", + "url": "" + } + ], + "SECTION_USER_ACTIONS": { + "share": { + "url": "http://zoma.to/r/19713383" + }, + "review": { + "reviewed": false + }, + "photo": [], + "bookmark": { + "count": 0, + "bookmarked": false + } + } + }, + "navbarSection": [ + { + "name": "restaurant", + "pageTitle": "Schezwan Spicy Food, Modinagar Locality order online - Zomato", + "pageDescription": "Order food online from Schezwan Spicy Food, Modinagar Locality, Modinagar. Get great offers and super fast food delivery when you order food online from Schezwan Spicy Food on Zomato.", + "resId": 19713383, + "pageUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order", + "canonicalUrl": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/order", + "title": "Order Online", + "subType": "order", + "key": "order", + "ogTitle": "Schezwan Spicy Food, Modinagar Locality order online - Zomato", + "ogDescription": "Order food online from Schezwan Spicy Food, Modinagar Locality, Modinagar. Get great offers and super fast food delivery when you order food online from Schezwan Spicy Food on Zomato.", + "ogUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order", + "ampHtmlUrl": "", + "isFloodReliefRes": false, + "isNoIndex": false, + "checkoutUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order/verify", + "children": [ + { + "key": "ctg_22246933", + "title": "Starters (6)" + }, + { + "key": "ctg_22246934", + "title": "Main Course (1)" + }, + { + "key": "ctg_22246916", + "title": "Fried Rice and Noodles (7)" + }, + { + "key": "ctg_22246915", + "title": "Burgers (2)" + }, + { + "key": "ctg_22246917", + "title": "Rolls (2)" + }, + { + "key": "ctg_22246914", + "title": "Momos (4)" + }, + { + "key": "ctg_22246911", + "title": "Snacks (2)" + } + ] + }, + { + "name": "restaurant", + "pageTitle": "Reviews of Schezwan Spicy Food, Modinagar Locality, Modinagar | Zomato ", + "pageDescription": "Reviews of Schezwan Spicy Food Modinagar Locality; see all unbiased reviews of Schezwan Spicy Food Modinagar for delivery and dining on Zomato.", + "resId": 19713383, + "pageUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/reviews", + "canonicalUrl": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/reviews", + "title": "Reviews", + "subType": "reviews", + "key": "reviews", + "ogTitle": "Reviews of Schezwan Spicy Food, Modinagar Locality, Modinagar | Zomato ", + "ogDescription": "Reviews of Schezwan Spicy Food Modinagar Locality; see all unbiased reviews of Schezwan Spicy Food Modinagar for delivery and dining on Zomato.", + "ogUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/reviews", + "ampHtmlUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/reviews?amp=1", + "isFloodReliefRes": false, + "isNoIndex": false + } + ], + "trackingData": { + "googleAdsPayload": { + "addToCart": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/1QC4COu6ne0BENLpkMkD" + } + } + } + }, + "orderDetails": { + "hasOnlineOrdering": true, + "isServiceable": false, + "promoOffer": "", + "promoSubText": "", + "deeplink": "zomato://order/19713383", + "deliveryTime": "", + "onlineStatusCode": 710, + "statusReasonCode": 7210, + "trackingText": "Live tracking not available", + "minOrderAmountDetails": { + "minOrderAmount": 0, + "minOrderDisplayAmount": "\u20b9 0", + "minOrderAmountDisplayText": "\u20b9 0 minimum item total required to place an order" + }, + "isTrackingAvailableOnApp": null, + "isO2Active": false + }, + "takeAwayDetails": null, + "experimentParams": { + "promo_blocker_on_page_load": false, + "show_native_promo_blocker": false + }, + "metaData": { + "currencyDetails": { + "currency": "\u20b9", + "currencyISOCode": "INR", + "currency_affix": "prefix", + "currency_on_right": 0 + }, + "offers": [] + }, + "order": { + "menuList": { + "menus": [ + { + "menu": { + "id": "ctg_22246933", + "name": "Starters", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446769", + "name": "", + "items": [ + { + "item": { + "id": "ctl_339496832", + "name": "Manchurian Dry", + "price": 0, + "desc": "", + "min_price": 80, + "max_price": 140, + "default_price": 80, + "display_price": 80, + "variant_id": "", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "manchuriandry", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "manchuriandry-starters-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"manchurian-dry\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442593", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061504", + "name": "Half", + "price": 80, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524524", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "", + "item_metadata": "{\"v\":424524524,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"manchurian-dry\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061505", + "name": "Full", + "price": 140, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524525", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "", + "item_metadata": "{\"v\":424524525,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"manchurian-dry\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":80,\"lowest_v_price\":80,\"highest_v_price\":140,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_352829567", + "name": "Chilli Potato", + "price": 0, + "desc": "", + "min_price": 70, + "max_price": 110, + "default_price": 70, + "display_price": 70, + "variant_id": "", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "chillipotato", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "chillipotato-starters-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"chilli-potato\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_37533689", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_103197755", + "name": "Half", + "price": 70, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_440195575", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "", + "item_metadata": "{\"v\":440195575,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"chilli-potato\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_103197756", + "name": "Full", + "price": 110, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_440195576", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "", + "item_metadata": "{\"v\":440195576,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"chilli-potato\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":70,\"lowest_v_price\":70,\"highest_v_price\":110,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_432129807", + "name": "Honey Chilli Potato", + "price": 0, + "desc": "", + "min_price": 80, + "max_price": 135, + "default_price": 80, + "display_price": 80, + "variant_id": "", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 3, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "honeychillipotato", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-not-vegan", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-not-vegan" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "honeychillipotato-starters-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"honey-chilli-potato\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_43856069", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_120454631", + "name": "Half", + "price": 80, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_532968853", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "", + "item_metadata": "{\"v\":532968853,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"honey-chilli-potato\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_120454632", + "name": "Full", + "price": 135, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_532968854", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "", + "item_metadata": "{\"v\":532968854,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"honey-chilli-potato\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":80,\"lowest_v_price\":80,\"highest_v_price\":135,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_432129808", + "name": "Paneer 65", + "price": 160, + "desc": "", + "min_price": 160, + "max_price": 160, + "default_price": 160, + "display_price": 160, + "variant_id": "v_532968855", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 4, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "paneer65", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-not-vegan", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-not-vegan" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "paneer65-starters-19713383", + "item_metadata": "{\"v\":532968855,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"paneer-65\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + }, + { + "item": { + "id": "ctl_432129809", + "name": "Paneer Chilli", + "price": 180, + "desc": "", + "min_price": 180, + "max_price": 180, + "default_price": 180, + "display_price": 180, + "variant_id": "v_532968856", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 5, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "paneerchilli", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-not-vegan", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-not-vegan" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "paneerchilli-starters-19713383", + "item_metadata": "{\"v\":532968856,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"paneer-chilli\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + }, + { + "item": { + "id": "ctl_437305137", + "name": "Chole Bhature", + "price": 100, + "desc": "O", + "min_price": 100, + "max_price": 100, + "default_price": 100, + "display_price": 100, + "variant_id": "v_539061466", + "parent_menu_id": "ctg_22246933", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 6, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "cholebhature", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Starters", + "fb_slug": "cholebhature-starters-19713383", + "item_metadata": "{\"v\":539061466,\"s_ctg\":33446769,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"chole-bhature\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 1, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + }, + { + "menu": { + "id": "ctg_22246934", + "name": "Main Course", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446770", + "name": "", + "items": [ + { + "item": { + "id": "ctl_339496835", + "name": "Manchurian Gravy", + "price": 0, + "desc": "", + "min_price": 95, + "max_price": 160, + "default_price": 95, + "display_price": 95, + "variant_id": "", + "parent_menu_id": "ctg_22246934", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "manchuriangravy", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Main Course", + "fb_slug": "manchuriangravy-maincourse-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446770,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"manchurian-gravy\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442594", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061506", + "name": "Half", + "price": 95, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524528", + "parent_menu_id": "ctg_22246934", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Main Course", + "fb_slug": "", + "item_metadata": "{\"v\":424524528,\"s_ctg\":33446770,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"manchurian-gravy\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061507", + "name": "Full", + "price": 160, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524529", + "parent_menu_id": "ctg_22246934", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Main Course", + "fb_slug": "", + "item_metadata": "{\"v\":424524529,\"s_ctg\":33446770,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"manchurian-gravy\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":95,\"lowest_v_price\":95,\"highest_v_price\":160,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 0, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + }, + { + "menu": { + "id": "ctg_22246916", + "name": "Fried Rice and Noodles", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446768", + "name": "Fried Rice", + "items": [ + { + "item": { + "id": "ctl_339496829", + "name": "Fried Rice", + "price": 0, + "desc": "", + "min_price": 90, + "max_price": 150, + "default_price": 90, + "display_price": 90, + "variant_id": "", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "friedrice", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "friedrice-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442591", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061500", + "name": "Half", + "price": 90, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524519", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524519,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061501", + "name": "Full", + "price": 150, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524520", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524520,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":90,\"lowest_v_price\":90,\"highest_v_price\":150,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_339496830", + "name": "Triple Fried Rice", + "price": 180, + "desc": "", + "min_price": 180, + "max_price": 180, + "default_price": 180, + "display_price": 180, + "variant_id": "v_424524521", + "parent_menu_id": "ctg_22246916", + "item_image_url": "https://b.zmtcdn.com/data/dish_photos/f4b/7ba5a5491a1d6bdfe6df1e3b1342df4b.jpeg", + "item_image_thumb_url": "https://b.zmtcdn.com/data/dish_photos/f4b/7ba5a5491a1d6bdfe6df1e3b1342df4b.jpeg?fit=around%7C200%3A200&crop=200%3A200%3B%2A%2C%2A", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": true, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "triplefriedrice", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [ + { + "mediaType": "image", + "image": { + "url": "https://b.zmtcdn.com/data/dish_photos/f4b/7ba5a5491a1d6bdfe6df1e3b1342df4b.jpeg?fit=around%7C200%3A200&crop=200%3A200%3B%2A%2C%2A", + "data": null, + "bgColor": null, + "borderColor": null, + "bgColorHex": null, + "animation": null, + "animate": null, + "aspectRatio": null, + "type": null, + "height": null, + "width": null, + "clickAction": null, + "border": null, + "shouldIgnoreResizing": null, + "tracking": null, + "placeholderColor": null, + "heightRatio": null, + "scaleMode": null, + "filter": null, + "overlayTextData": null, + "overlayIcon": null, + "containerAnimations": null, + "overlayAnimation": null, + "offsetRatio": null, + "shouldExpand": null, + "cornerRadius": null, + "trackingData": null, + "gravity": null, + "repeatCount": null, + "themeConfig": null + }, + "video": null, + "audio": null, + "lottie": null + } + ], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "triplefriedrice-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":424524521,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"triple-fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + }, + { + "item": { + "id": "ctl_339496831", + "name": "Schezwan Fried Rice", + "price": 0, + "desc": "", + "min_price": 120, + "max_price": 190, + "default_price": 120, + "display_price": 120, + "variant_id": "", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 3, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "schezwanfriedrice", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-spicy", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-spicy" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "schezwanfriedrice-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"schezwan-fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442592", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061502", + "name": "Half", + "price": 120, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524522", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524522,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"schezwan-fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061503", + "name": "Full", + "price": 190, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524523", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524523,\"s_ctg\":33446768,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"schezwan-fried-rice\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":120,\"lowest_v_price\":120,\"highest_v_price\":190,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + }, + { + "category": { + "id": "s_ctg_33446742", + "name": "Noodles", + "items": [ + { + "item": { + "id": "ctl_339496656", + "name": "Veg Noodles", + "price": 0, + "desc": "", + "min_price": 55, + "max_price": 95, + "default_price": 55, + "display_price": 55, + "variant_id": "", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "vegnoodles", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "vegnoodles-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"veg-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442545", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061399", + "name": "Half", + "price": 55, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524291", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524291,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"veg-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061400", + "name": "Full", + "price": 95, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524292", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524292,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"veg-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":55,\"lowest_v_price\":55,\"highest_v_price\":95,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_339496657", + "name": "Singapuri Noodles", + "price": 0, + "desc": "", + "min_price": 80, + "max_price": 135, + "default_price": 80, + "display_price": 80, + "variant_id": "", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "singapurinoodles", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "singapurinoodles-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"singapuri-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442546", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061401", + "name": "Half", + "price": 80, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524293", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524293,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"singapuri-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061402", + "name": "Full", + "price": 135, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524294", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524294,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"singapuri-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":80,\"lowest_v_price\":80,\"highest_v_price\":135,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_339496658", + "name": "Triple Noodles", + "price": 170, + "desc": "", + "min_price": 170, + "max_price": 170, + "default_price": 170, + "display_price": 170, + "variant_id": "v_424524295", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 3, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "triplenoodles", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "triplenoodles-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":424524295,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"triple-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + }, + { + "item": { + "id": "ctl_339496659", + "name": "Schezwan Noodles", + "price": 0, + "desc": "", + "min_price": 100, + "max_price": 180, + "default_price": 100, + "display_price": 100, + "variant_id": "", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 4, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "schezwannoodles", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-spicy", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-spicy" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "schezwannoodles-friedriceandnoodles-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"schezwan-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442547", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061403", + "name": "Half", + "price": 100, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524296", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524296,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"schezwan-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061404", + "name": "Full", + "price": 180, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524297", + "parent_menu_id": "ctg_22246916", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Fried Rice and Noodles", + "fb_slug": "", + "item_metadata": "{\"v\":424524297,\"s_ctg\":33446742,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"schezwan-noodles\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":100,\"lowest_v_price\":100,\"highest_v_price\":180,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 0, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + }, + { + "menu": { + "id": "ctg_22246915", + "name": "Burgers", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446741", + "name": "", + "items": [ + { + "item": { + "id": "ctl_339496654", + "name": "Veg Burger", + "price": 40, + "desc": "", + "min_price": 40, + "max_price": 40, + "default_price": 40, + "display_price": 40, + "variant_id": "v_424524289", + "parent_menu_id": "ctg_22246915", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "vegburger", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Burgers", + "fb_slug": "vegburger-burgers-19713383", + "item_metadata": "{\"v\":424524289,\"s_ctg\":33446741,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"veg-burger\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + }, + { + "item": { + "id": "ctl_339496655", + "name": "Cheese Burger", + "price": 65, + "desc": "", + "min_price": 65, + "max_price": 65, + "default_price": 65, + "display_price": 65, + "variant_id": "v_424524290", + "parent_menu_id": "ctg_22246915", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "cheeseburger", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-not-vegan", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-not-vegan" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Burgers", + "fb_slug": "cheeseburger-burgers-19713383", + "item_metadata": "{\"v\":424524290,\"s_ctg\":33446741,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"cheese-burger\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 0, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + }, + { + "menu": { + "id": "ctg_22246917", + "name": "Rolls", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446743", + "name": "", + "items": [ + { + "item": { + "id": "ctl_339496660", + "name": "Veg Roll", + "price": 70, + "desc": "", + "min_price": 70, + "max_price": 70, + "default_price": 70, + "display_price": 70, + "variant_id": "v_424524298", + "parent_menu_id": "ctg_22246917", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "vegroll", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Rolls", + "fb_slug": "vegroll-rolls-19713383", + "item_metadata": "{\"v\":424524298,\"s_ctg\":33446743,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"veg-roll\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + }, + { + "item": { + "id": "ctl_339496661", + "name": "Paneer Roll", + "price": 95, + "desc": "", + "min_price": 95, + "max_price": 95, + "default_price": 95, + "display_price": 95, + "variant_id": "v_424524299", + "parent_menu_id": "ctg_22246917", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "paneerroll", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-not-vegan", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-not-vegan" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Rolls", + "fb_slug": "paneerroll-rolls-19713383", + "item_metadata": "{\"v\":424524299,\"s_ctg\":33446743,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"paneer-roll\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1 + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 0, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + }, + { + "menu": { + "id": "ctg_22246914", + "name": "Momos", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446740", + "name": "", + "items": [ + { + "item": { + "id": "ctl_339496649", + "name": "Steamed Momos", + "price": 0, + "desc": "", + "min_price": 55, + "max_price": 95, + "default_price": 55, + "display_price": 55, + "variant_id": "", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "steamedmomos", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "steamedmomos-momos-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"steamed-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442540", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061389", + "name": "Half", + "price": 55, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524279", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524279,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"steamed-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061390", + "name": "Full", + "price": 95, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524280", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524280,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"steamed-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":55,\"lowest_v_price\":55,\"highest_v_price\":95,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_339496650", + "name": "Fried Momos", + "price": 0, + "desc": "", + "min_price": 70, + "max_price": 110, + "default_price": 70, + "display_price": 70, + "variant_id": "", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "friedmomos", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "friedmomos-momos-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"fried-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442541", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061391", + "name": "Half", + "price": 70, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524281", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524281,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"fried-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061392", + "name": "Full", + "price": 110, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524282", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524282,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"fried-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":70,\"lowest_v_price\":70,\"highest_v_price\":110,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_339496651", + "name": "Paneer Momos", + "price": 0, + "desc": "", + "min_price": 95, + "max_price": 160, + "default_price": 95, + "display_price": 95, + "variant_id": "", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 3, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "paneermomos", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "sf-not-vegan", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services", + "sf-not-vegan" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "paneermomos-momos-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"paneer-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442542", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061393", + "name": "Half", + "price": 95, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524283", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524283,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"paneer-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061394", + "name": "Full", + "price": 160, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524284", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524284,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"paneer-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":95,\"lowest_v_price\":95,\"highest_v_price\":160,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_339496652", + "name": "Kurkure Momos", + "price": 0, + "desc": "", + "min_price": 110, + "max_price": 180, + "default_price": 110, + "display_price": 110, + "variant_id": "", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 4, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "kurkuremomos", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "kurkuremomos-momos-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"kurkure-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442543", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061395", + "name": "Half", + "price": 110, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524285", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524285,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"kurkure-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061396", + "name": "Full", + "price": 180, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524286", + "parent_menu_id": "ctg_22246914", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Momos", + "fb_slug": "", + "item_metadata": "{\"v\":424524286,\"s_ctg\":33446740,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"kurkure-momos\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":110,\"lowest_v_price\":110,\"highest_v_price\":180,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 0, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + }, + { + "menu": { + "id": "ctg_22246911", + "name": "Snacks", + "cart_category_id": 0, + "categories": [ + { + "category": { + "id": "s_ctg_33446737", + "name": "", + "items": [ + { + "item": { + "id": "ctl_339496646", + "name": "Spring Roll", + "price": 0, + "desc": "", + "min_price": 40, + "max_price": 70, + "default_price": 40, + "display_price": 40, + "variant_id": "", + "parent_menu_id": "ctg_22246911", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 3, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "springroll", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Snacks", + "fb_slug": "springroll-snacks-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446737,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"spring-roll\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_36442539", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_100061387", + "name": "Half", + "price": 40, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524275", + "parent_menu_id": "ctg_22246911", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Snacks", + "fb_slug": "", + "item_metadata": "{\"v\":424524275,\"s_ctg\":33446737,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"spring-roll\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_100061388", + "name": "Full", + "price": 70, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_424524276", + "parent_menu_id": "ctg_22246911", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Snacks", + "fb_slug": "", + "item_metadata": "{\"v\":424524276,\"s_ctg\":33446737,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"spring-roll\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":40,\"lowest_v_price\":40,\"highest_v_price\":70,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + }, + { + "item": { + "id": "ctl_432129811", + "name": "French Fries", + "price": 0, + "desc": "", + "min_price": 55, + "max_price": 95, + "default_price": 55, + "display_price": 55, + "variant_id": "", + "parent_menu_id": "ctg_22246911", + "item_image_url": "https://b.zmtcdn.com/data/dish_photos/78d/ff2e39ee70bc2b00197ff5810f73078d.jpeg", + "item_image_thumb_url": "https://b.zmtcdn.com/data/dish_photos/78d/ff2e39ee70bc2b00197ff5810f73078d.jpeg?fit=around%7C200%3A200&crop=200%3A200%3B%2A%2C%2A", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 4, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": true, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "frenchfries", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [ + { + "mediaType": "image", + "image": { + "url": "https://b.zmtcdn.com/data/dish_photos/78d/ff2e39ee70bc2b00197ff5810f73078d.jpeg?fit=around%7C200%3A200&crop=200%3A200%3B%2A%2C%2A", + "data": null, + "bgColor": null, + "borderColor": null, + "bgColorHex": null, + "animation": null, + "animate": null, + "aspectRatio": null, + "type": null, + "height": null, + "width": null, + "clickAction": null, + "border": null, + "shouldIgnoreResizing": null, + "tracking": null, + "placeholderColor": null, + "heightRatio": null, + "scaleMode": null, + "filter": null, + "overlayTextData": null, + "overlayIcon": null, + "containerAnimations": null, + "overlayAnimation": null, + "offsetRatio": null, + "shouldExpand": null, + "cornerRadius": null, + "trackingData": null, + "gravity": null, + "repeatCount": null, + "themeConfig": null + }, + "video": null, + "audio": null, + "lottie": null + } + ], + "info_tags": [], + "tag_slugs": [ + "veg", + "services", + "delivery-enabled" + ], + "service_slugs": [ + "delivery-enabled" + ], + "dietary_slugs": [ + "veg" + ], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [ + "services" + ], + "disclaimer_tag_slugs": [], + "entity_type": "catalogue", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Snacks", + "fb_slug": "frenchfries-snacks-19713383", + "item_metadata": "{\"v\":0,\"s_ctg\":33446737,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"french-fries\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "tag_ids": "1", + "item_tag_image": "https://b.zmtcdn.com/data/o2_assets/958e4cb4a8b255659daae2c430f976ea1608028693.png?output-format=webp", + "tag_images": [], + "tag_texts": [], + "tag_objects": [], + "hide_in_meals_menu": 1, + "groups": [ + { + "group": { + "id": "p_43856070", + "name": "Quantity", + "label": "Quantity", + "min": 1, + "max": 1, + "parent_menu_id": 0, + "hasFocus": 0, + "entity_type": "property", + "name_slug": "quantity", + "tracking_metadata": "{\"has_discounted_addon\":false,\"is_dynamic_modifier_group\":false,\"is_dynamic_modifier_group_v2\":false}", + "max_selections_per_item": 1, + "parent_visiblity": 1, + "show_customisation": true, + "items": [ + { + "item": { + "id": "pv_120454633", + "name": "Half", + "price": 55, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_532968858", + "parent_menu_id": "ctg_22246911", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 1, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "half", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "is_default": 1, + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Snacks", + "fb_slug": "", + "item_metadata": "{\"v\":532968858,\"s_ctg\":33446737,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"french-fries\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + }, + { + "item": { + "id": "pv_120454634", + "name": "Full", + "price": 95, + "desc": "", + "min_price": 0, + "max_price": 0, + "default_price": 0, + "display_price": 0, + "variant_id": "v_532968859", + "parent_menu_id": "ctg_22246911", + "always_show_on_checkout": 0, + "is_bogo_active": false, + "order": 2, + "auto_add": 0, + "auto_add_quantity": 0, + "show_item_image": false, + "visible": false, + "item_state": "available", + "rating": null, + "name_slug": "full", + "free_dish_quantity": 0, + "item_type": "dish", + "added_bottom_text_field": null, + "modifier_group_ids": [], + "not_added_bottom_text_field": null, + "tracking_dish_type": "dish", + "disabled_text": null, + "media": [], + "info_tags": [], + "tag_slugs": [], + "service_slugs": [], + "dietary_slugs": [], + "inapplicable_filter_tag_slugs": [], + "secondary_tag_slugs": [], + "disclaimer_tag_slugs": [], + "entity_type": "property_value", + "primary_tag_slug": "", + "food_legends": null, + "search_alias": "Snacks", + "fb_slug": "", + "item_metadata": "{\"v\":532968859,\"s_ctg\":33446737,\"cmb_sel\":0,\"cmb\":0,\"ff\":false,\"ctl_n\":\"french-fries\"}", + "added_bottom_text": "", + "not_added_bottom_text": "", + "not_added_bottom_text_color": "", + "remove_popup_text": "", + "item_auto_add_toast_message": "", + "item_auto_already_added_toast_message": "", + "remove_popup_title": "", + "show_customisation": false + } + } + ], + "reference_items": [] + } + } + ], + "enable_customisation_on_cart": false, + "customisation_tracking_metadata": "{\"exp_no\":1,\"default_v_price\":55,\"lowest_v_price\":55,\"highest_v_price\":95,\"total_mgs\":-1,\"total_v_count\":2,\"properties\":1}" + } + } + ], + "reference_items": [], + "tag_ids": "1", + "is_expanded": 0 + } + } + ], + "is_expanded": 0, + "should_disable_items": false, + "subtitle": null, + "stepper_disabled_text": "", + "is_hidden": false + } + } + ], + "modifierGroups": {}, + "promosOnMenu": { + "promos": [] + }, + "offerSnackbar": [], + "postbackParams": "logistics_partner_id%3D0%26vendor_serviceability_flow%3D0%26delivery_mode%3Ddelivery", + "onlinePaymentsFlag": 1, + "showItemsFilter": 1, + "address": [], + "fssaiInfo": { + "text": "Lic. No. 22721879000010", + "image": "https://b.zmtcdn.com/data/o2_banners/54de14cdc3793dfce39a46c989f3e5c1.jpg?output-format=webp" + }, + "tags": [ + { + "slug": "bestseller", + "title": { + "text": "BESTSELLER", + "font": null, + "color": { + "tint": "500", + "type": "white", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + }, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "bg_color": { + "tint": "300", + "type": "orange", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + } + }, + { + "slug": "veg", + "title": { + "text": "Veg", + "font": null, + "color": null, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "image": { + "url": "https://b.zmtcdn.com/data/o2_assets/54539a4514498a9b2d80c537c35126ca1670328399.png?output-format=webp", + "data": null, + "bgColor": null, + "borderColor": null, + "bgColorHex": null, + "animation": null, + "animate": null, + "aspectRatio": null, + "type": null, + "height": null, + "width": null, + "clickAction": null, + "border": null, + "shouldIgnoreResizing": null, + "tracking": null, + "placeholderColor": null, + "heightRatio": null, + "scaleMode": null, + "filter": null, + "overlayTextData": null, + "overlayIcon": null, + "containerAnimations": null, + "overlayAnimation": null, + "offsetRatio": null, + "shouldExpand": null, + "cornerRadius": null, + "trackingData": null, + "gravity": null, + "repeatCount": null, + "themeConfig": null + } + }, + { + "slug": "non-veg", + "title": { + "text": "Non-Veg", + "font": null, + "color": null, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "image": { + "url": "https://b.zmtcdn.com/data/o2_assets/5d4de18a3d402878965275e9f24656951635253564.png?output-format=webp", + "data": null, + "bgColor": null, + "borderColor": null, + "bgColorHex": null, + "animation": null, + "animate": null, + "aspectRatio": null, + "type": null, + "height": null, + "width": null, + "clickAction": null, + "border": null, + "shouldIgnoreResizing": null, + "tracking": null, + "placeholderColor": null, + "heightRatio": null, + "scaleMode": null, + "filter": null, + "overlayTextData": null, + "overlayIcon": null, + "containerAnimations": null, + "overlayAnimation": null, + "offsetRatio": null, + "shouldExpand": null, + "cornerRadius": null, + "trackingData": null, + "gravity": null, + "repeatCount": null, + "themeConfig": null + } + }, + { + "slug": "egg", + "title": { + "text": "Egg", + "font": null, + "color": null, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "image": { + "url": "https://b.zmtcdn.com/data/o2_assets/5d4de18a3d402878965275e9f24656951635253564.png?output-format=webp", + "data": null, + "bgColor": null, + "borderColor": null, + "bgColorHex": null, + "animation": null, + "animate": null, + "aspectRatio": null, + "type": null, + "height": null, + "width": null, + "clickAction": null, + "border": null, + "shouldIgnoreResizing": null, + "tracking": null, + "placeholderColor": null, + "heightRatio": null, + "scaleMode": null, + "filter": null, + "overlayTextData": null, + "overlayIcon": null, + "containerAnimations": null, + "overlayAnimation": null, + "offsetRatio": null, + "shouldExpand": null, + "cornerRadius": null, + "trackingData": null, + "gravity": null, + "repeatCount": null, + "themeConfig": null + } + }, + { + "slug": "services", + "title": { + "text": "Services", + "font": null, + "color": null, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + } + }, + { + "slug": "sf-not-vegan", + "title": { + "text": "Not Vegan", + "font": null, + "color": null, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + } + }, + { + "slug": "sf-spicy", + "title": { + "text": "SF Spicy", + "font": null, + "color": null, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + } + }, + { + "slug": "non-promo", + "title": { + "text": "Not eligible for coupons", + "font": null, + "color": { + "tint": "500", + "type": "grey", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + }, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "bg_color": { + "tint": "100", + "type": "grey", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + } + }, + { + "slug": "new", + "title": { + "text": "NEW", + "font": null, + "color": { + "tint": "500", + "type": "white", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + }, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "bg_color": { + "tint": "400", + "type": "red", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + } + }, + { + "slug": "must-try", + "title": { + "text": "MUST TRY", + "font": null, + "color": { + "tint": "500", + "type": "white", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + }, + "bgColor": null, + "prefixIcon": null, + "suffixIcon": null, + "prefixImage": null, + "prefix_image": null, + "suffixImage": null, + "deeplink": null, + "strike": null, + "prefixText": null, + "suffixText": null, + "numberOfLines": null, + "alignment": null, + "disableTitleLetterSpacing": null, + "kerning": null, + "gradient": null, + "isMarkdown": null, + "is_markdown": null, + "shouldRoundForHeight": null, + "suffixButton": null, + "clickAction": null, + "markdownVersion": null, + "maxCharLimit": null, + "type": null, + "size": null, + "prefixTextHexCode": null, + "replacementText": null, + "hexCode": null, + "isClickable": null, + "expandedText": null, + "collapsedText": null, + "trackingData": null, + "underline": null, + "shouldNotAdjustFontSize": null, + "maxLines": null, + "animation": null, + "leftIcon": null, + "icon": null, + "rightIcon": null, + "maxTruncationLineLimit": null, + "shouldShowReadMore": null, + "themeBucket": null, + "spacing_config": null, + "time": null, + "id": null, + "rightTagText": null, + "containerAnimations": null, + "margin": null + }, + "bg_color": { + "tint": "400", + "type": "blue", + "alpha": null, + "transparency": null, + "darkTint": null, + "darkType": null, + "themeBucket": null + } + } + ] + }, + "proBranchDeeplink": "", + "liveTrackingDeeplink": "" + }, + "trackingDataLogin": { + "googleAdsPayload": { + "mobileProfileIconClick": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/gEmzCLCZwfABENLpkMkD" + } + }, + "loginClick": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/MOW8CIO6-uwBENLpkMkD" + } + }, + "signupClick": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/M4gKCJi9-uwBENLpkMkD" + } + }, + "signupSuccess": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/NWiMCPrDne0BENLpkMkD" + } + } + } + }, + "cartData": {} + } + }, + "awards": { + "cities": [], + "cityWinners": [], + "currentCity": {}, + "loader": false + }, + "user": {}, + "userSettings": {}, + "sauceBlog": {}, + "Kitchen": { + "kitchenApiKey": "", + "defaultLat": 0, + "defaultLng": 0 + }, + "celebrations": { + "setFormRequirement": false + }, + "cdng": {}, + "postOrder": { + "orderData": {}, + "orderSupportData": {}, + "deliveryAddressDetails": {}, + "riderDetails": { + "riderName": "", + "riderPhone": "" + }, + "restaurantDetails": {}, + "creatorDetails": {}, + "deliveryStatus": false, + "deliveryMessage": "", + "deliveryLabel": "", + "status": "", + "deliveryTimeStr": "", + "pollingStatus": 0, + "orderCreationTime": 0, + "orderDeliveryTime": 0, + "currentStatus": "", + "deliveryMode": "", + "crystalData": { + "items": {} + } + }, + "zomaland": { + "currentCityId": 0, + "pageType": "HOME", + "userData": { + "tickets": [] + }, + "uiStates": { + "ticketsModalVisible": false, + "showLoader": false, + "ticketView": "FULL_TICKET", + "galleryIndex": 0, + "showTicket": false + }, + "cityLevelData": {}, + "zomalandPages": {}, + "zomalandDeepLink": { + "link": [] + } + }, + "orderOnline": { + "paymentDetails": { + "payment_method_type": "", + "isZcreditsSelected": false + }, + "paymentState": { + "checkoutStatus": "IDLE", + "errorState": { + "code": 0, + "errorMessage": "" + } + }, + "kitValidationStatus": false, + "makePaymentParams": null, + "isPlaceOrderSuccess": false, + "sectionVerifyPhone": { + "allCountryCode": [], + "selectedCountryCode": {} + }, + "sectionAutoApplyPromoCodes": { + "autoApplyPromoCodes": [] + }, + "trackingData": { + "googleAdsPayload": { + "addToCart": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/1QC4COu6ne0BENLpkMkD" + } + } + } + }, + "trackingDataLogin": { + "googleAdsPayload": { + "mobileProfileIconClick": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/gEmzCLCZwfABENLpkMkD" + } + }, + "loginClick": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/MOW8CIO6-uwBENLpkMkD" + } + }, + "signupClick": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/M4gKCJi9-uwBENLpkMkD" + } + }, + "signupSuccess": { + "eventName": "conversion", + "payload": { + "send_to": "AW-958674130/NWiMCPrDne0BENLpkMkD" + } + } + } + } + }, + "deliverycities": { + "allO2Cities": [] + }, + "zomatoForWork": {}, + "pageNotFound": {}, + "collections": {}, + "collectionDetails": {}, + "appDownload": {}, + "contests": {}, + "search": {}, + "singleJob": {}, + "goldSubscriptionAgreement": { + "pageTitle": "" + }, + "zoomBackgrounds": {}, + "country": {}, + "tablePostBooking": {}, + "city": {}, + "gold": { + "plans": [], + "customSectionsForCity": [], + "orderingRestaurants": [], + "dineoutRestaurants": [], + "faqs": [], + "constants": { + "planSectionHeading": "", + "faqHeading": "", + "aboutGoldText": "", + "goldLogoSrc": "" + } + }, + "feedingPhilippines": {}, + "feedingIndonesia": {}, + "talentHub": {}, + "dining": { + "cartUi": { + "isCheckboxClicked": false, + "isUserClicked": false + } + }, + "scanner": {}, + "cupcake": {}, + "partnershipInit": {}, + "paymentStatus": {}, + "planPage": { + "benefitsData": [] + }, + "dotePdp": {}, + "doteHome": {}, + "familyPlanPage": { + "familyPlanData": [] + }, + "orderCartProgress": { + "currentState": "CART_IDLE" + }, + "financialInformation": {}, + "investorRelations": {}, + "investorRelationsV2": {}, + "goldMarketingPage": { + "mainText": "", + "bottomText": "", + "headerText": "", + "oneLink": "" + }, + "agentSearch": { + "selectedRes": null, + "selectedDishes": null, + "disableResSelection": false, + "disableDishSelection": false, + "isMultiSelectOn": true + }, + "agentRestaurant": { + "resItems": null, + "disabledMenuItemSelection": false, + "lastAddedItemData": null, + "orderData": null + }, + "diningPay": {}, + "bloggers": { + "bannerData": [], + "snippetData": [], + "formData": [] + }, + "neighbourhoods": {}, + "resAdminToolkit": {}, + "individualPhotoPage": {}, + "openGiftCard": {}, + "proPage": {}, + "zopayStoryUploader": {}, + "orderShare": { + "riderDetails": {}, + "resDetails": {}, + "userDetails": {}, + "deliveryState": "", + "orderDeliveredSnippet": {}, + "orderRejectedSnippet": {}, + "mapSection": {}, + "resName": "", + "hashed_tab_id": "", + "resId": 0, + "headerData": {}, + "mapSectionStaticData": {}, + "orderId": 0, + "linkExpiredSnippet": {} + }, + "giftCard": {}, + "zLiveHomePageReducer": {}, + "zLiveCartReducer": { + "packagesData": [], + "paymentSdkData": {}, + "paymentKitStates": {} + }, + "zLiveModalReducer": { + "type": null, + "data": null + }, + "zLiveV2PageReducer": {}, + "zLiveV2CartReducer": { + "packagesData": [], + "seatsIoData": null + }, + "zLiveV2ModalReducer": { + "type": null, + "data": null + }, + "zLiveV2ZpaykitReducer": { + "paymentSdkData": {}, + "paymentKitStates": {}, + "buildCartApiResponse": {} + }, + "zLiveV2ErrorReducer": { + "isError": false + }, + "zLiveV2CustomerDetailsReducer": { + "customerDetailsForm": { + "fullName": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "mobileNum": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "email": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "addressLine1": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "addressLine2": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "cityPincode": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "cityName": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "stateId": { + "value": "", + "isTriggerError": false + }, + "stateName": { + "value": "", + "isTriggerError": false + }, + "withPet": { + "value": true, + "isTriggerError": false + }, + "petName": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "petBreed": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "petBirthDate": { + "value": "", + "isError": false, + "isTriggerError": false + }, + "formType": { + "value": "" + } + } + }, + "irctcPartnership": {}, + "campaigns": {} + }, + "blogData": { + "blogs": [], + "error": null, + "isfetching": null + }, + "pageUrlMappings": { + "/modinagar/schezwan-spicy-food-modinagar-locality/order": { + "name": "restaurant", + "pageTitle": "Schezwan Spicy Food, Modinagar Locality order online - Zomato", + "pageDescription": "Order food online from Schezwan Spicy Food, Modinagar Locality, Modinagar. Get great offers and super fast food delivery when you order food online from Schezwan Spicy Food on Zomato.", + "resId": 19713383, + "pageUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order", + "canonicalUrl": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/order", + "title": "Order Online", + "subType": "order", + "key": "order", + "ogTitle": "Schezwan Spicy Food, Modinagar Locality order online - Zomato", + "ogDescription": "Order food online from Schezwan Spicy Food, Modinagar Locality, Modinagar. Get great offers and super fast food delivery when you order food online from Schezwan Spicy Food on Zomato.", + "ogUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order", + "ampHtmlUrl": "", + "isFloodReliefRes": false, + "isNoIndex": false, + "checkoutUrl": "/modinagar/schezwan-spicy-food-modinagar-locality/order/verify", + "show_rating_v15": true, + "showBookingV2": true, + "isRestaurantPageV2": true, + "isMobile": 0, + "isOAuthV2Enabled": false, + "useAuthSdkForLogin": true, + "useAuthSdkForLogout": false, + "gaPageType": "Restaurant" + } + }, + "careers": { + "departments": [] + }, + "allJobs": { + "openings": [], + "filters": [] + }, + "department": {}, + "aboutus": { + "leadershipData": [] + }, + "sneakpeek": {}, + "apiState": {}, + "entities": { + "REVIEWS": {}, + "IMAGES": { + "r_YwMjUyMTA4OTIw": { + "photoId": "r_YwMjUyMTA4OTIw", + "url": "https://b.zmtcdn.com/data/pictures/3/19713383/71da879caaa34072035375454ccdc2cf.png?output-format=webp", + "thumbUrl": "https://b.zmtcdn.com/data/pictures/3/19713383/71da879caaa34072035375454ccdc2cf.png?fit=around%7C200%3A200&crop=200%3A200%3B%2A%2C%2A", + "uploaderName": "Zomato", + "uploaderProfilePic": "https://b.zmtcdn.com/images/square_zomato_logo_new.svg", + "uploaderProfileUrl": "", + "timestamp": "Apr 28, 2021", + "likeCount": 0, + "commentCount": 0, + "comments": [], + "isLiked": 0, + "hash": "" + } + }, + "VIDEOS": {}, + "REVIEW_COMMENTS": {}, + "REVIEW_REPLIES": {}, + "PHOTO_COMMENTS": {}, + "POSITIVE_TAGS": {}, + "NEGATIVE_TAGS": {}, + "RATING": {}, + "EVENTS": {}, + "AD_BANNERS": {}, + "RESTAURANTS": {}, + "COLLECTIONS": {}, + "ORDER": {}, + "ADDRESSES": {}, + "USER": {}, + "PENDING_REVIEW": {}, + "CDNG_ORDER": {}, + "DOTE_ORDER": {}, + "ZLIVE_EVENTS": {} + }, + "user": { + "currentAddress": {}, + "is_admin_user": false, + "admin_access": [], + "admin_links": [] + }, + "uiLogic": { + "isPreciseLocationBannerOpen": true, + "searchPageMounted": false, + "isUniversalLocationWithBannerModalOpen": false, + "isUniversalLocationModalWithDishCardOpen": false, + "mountPartnershipPreciseLocationModal": true, + "universalLMDishCard": {}, + "promoBlockerOnPageLoadAllowed": false + }, + "location": { + "currentLocation": { + "addressId": 0, + "entityId": 11595, + "entityType": "city", + "locationType": "", + "isOrderLocation": 1, + "cityId": 11595, + "latitude": "28.8310000000000000", + "longitude": "77.5780000000000000", + "userDefinedLatitude": 28.831, + "userDefinedLongitude": 77.578, + "entityName": "Modinagar", + "orderLocationName": "Modinagar", + "cityName": "Modinagar", + "countryId": 1, + "countryName": "India", + "displayTitle": "Modinagar", + "o2Serviceable": true, + "placeId": "52540", + "cellId": "4110813000082915328", + "deliverySubzoneId": 52540, + "placeType": "DSZ", + "placeName": "Modinagar", + "isO2City": true, + "fetchFromGoogle": false, + "fetchedFromCookie": false, + "locationPrompt": [], + "isO2OnlyCity": true, + "addressBlocker": 0, + "address_template": [], + "otherRestaurantsUrl": "" + } + }, + "gAds": [], + "footer": { + "languages": [ + { + "name": "English", + "value": "en" + }, + { + "name": "T\u00fcrk\u00e7e", + "value": "tr" + }, + { + "name": "\u0939\u093f\u0902\u0926\u0940", + "value": "hi" + }, + { + "name": "Portugu\u00eas (BR)", + "value": "pt_br" + }, + { + "name": "Indonesian", + "value": "id" + }, + { + "name": "Portugu\u00eas (PT)", + "value": "pt" + }, + { + "name": "Espa\u00f1ol", + "value": "es" + }, + { + "name": "\u010ce\u0161tina", + "value": "cs" + }, + { + "name": "Sloven\u010dina", + "value": "sk" + }, + { + "name": "Polish", + "value": "pl" + }, + { + "name": "Italian", + "value": "it" + }, + { + "name": "Vietnamese", + "value": "vi" + } + ], + "selectedLanguage": { + "name": "English", + "value": "en" + }, + "linksData": { + "aboutusContent": [ + { + "label": "Who We Are", + "link": "https://www.zomato.com/who-we-are" + }, + { + "label": "Blog", + "link": "https://blog.zomato.com/" + }, + { + "label": "Work With Us", + "link": "https://www.zomato.com/careers" + }, + { + "label": "Investor Relations", + "link": "https://www.zomato.com/investor-relations" + }, + { + "label": "Report Fraud", + "link": "https://www.zomato.com/report-fraud" + }, + { + "label": "Press Kit", + "link": "https://blog.zomato.com/press-kit" + }, + { + "label": "Contact Us", + "link": "https://www.zomato.com/contact" + } + ], + "learnMoreContent": [ + { + "label": "Privacy", + "link": "https://www.zomato.com/privacy" + }, + { + "label": "Security", + "link": "https://www.zomato.com/security" + }, + { + "label": "Terms", + "link": "https://www.zomato.com/conditions" + } + ], + "restaurantsContent": [ + { + "label": "Partner With Us", + "link": "https://www.zomato.com/partner_with_us" + }, + { + "label": "Apps For You", + "link": "https://play.google.com/store/apps/details?id=com.application.services.partner&hl=en_IN&gl=US" + } + ], + "countries": [ + { + "id": 1, + "name": "India", + "value": "india" + }, + { + "id": 14, + "name": "Australia", + "value": "australia" + }, + { + "id": 30, + "name": "Brazil", + "value": "brazil" + }, + { + "id": 37, + "name": "Canada", + "value": "canada" + }, + { + "id": 42, + "name": "Chile", + "value": "chile" + }, + { + "id": 54, + "name": "Czech Republic", + "value": "czechrepublic" + }, + { + "id": 94, + "name": "Indonesia", + "value": "indonesia" + }, + { + "id": 97, + "name": "Ireland", + "value": "ireland" + }, + { + "id": 99, + "name": "Italy", + "value": "italy" + }, + { + "id": 112, + "name": "Lebanon", + "value": "lebanon" + }, + { + "id": 123, + "name": "Malaysia", + "value": "malaysia" + }, + { + "id": 148, + "name": "New Zealand", + "value": "newzealand" + }, + { + "id": 162, + "name": "Philippines", + "value": "philippines" + }, + { + "id": 163, + "name": "Poland", + "value": "poland" + }, + { + "id": 164, + "name": "Portugal", + "value": "portugal" + }, + { + "id": 166, + "name": "Qatar", + "value": "qatar" + }, + { + "id": 184, + "name": "Singapore", + "value": "singapore" + }, + { + "id": 185, + "name": "Slovakia", + "value": "slovakia" + }, + { + "id": 189, + "name": "South Africa", + "value": "southafrica" + }, + { + "id": 191, + "name": "Sri Lanka", + "value": "srilanka" + }, + { + "id": 208, + "name": "Turkey", + "value": "turkey" + }, + { + "id": 214, + "name": "UAE", + "value": "uae" + }, + { + "id": 215, + "name": "United Kingdom", + "value": "uk" + }, + { + "id": 216, + "name": "USA", + "value": "usa" + } + ], + "enterpriseContent": [ + { + "label": "Zomato For Enterprise", + "link": "https://www.zomato.com/enterprise-solutions" + } + ], + "zomaverseContent": [ + { + "label": "Zomato", + "link": "https://www.zomato.com/" + }, + { + "label": "Blinkit", + "link": "https://www.blinkit.com/" + }, + { + "label": "District", + "link": "https://www.district.in/" + }, + { + "label": "Feeding India", + "link": "https://www.feedingindia.org/" + }, + { + "label": "Hyperpure", + "link": "https://www.hyperpure.com/" + }, + { + "label": "Zomato Live", + "link": "https://www.zomato.com/live" + }, + { + "label": "Zomaland", + "link": "https://www.zomato.com/zomaland" + }, + { + "label": "Weather Union", + "link": "https://www.weatherunion.com/" + } + ] + }, + "footerDataArray": { + "ABOUT_US": "About Zomato", + "ZOMAVERSE": "Zomaverse", + "FOR_RESTAURANTS": "For Restaurants", + "LEARN_MORE": "Learn More", + "SOCIAL_LINKS": "Social links" + }, + "disclaimerText": "By continuing past this page, you agree to our Terms of Service, Cookie Policy, Privacy Policy and Content Policies. All trademarks are properties of their respective owners. 2008-2025 \u00a9 Zomato\u2122 Ltd. All rights reserved." + }, + "langKeys": { + "RES_INFO_TITLE": "About this place", + "ADDRESS_TITLE": "Address", + "FEATURED_IN_TITLE": "Featured in Collections", + "RATE_EXPERIENCE_TITLE": "Tap to rate your experience", + "WRITE_REVIEW_TITLE": "Write a Review", + "CONTACT_TITLE": "Tap a number to call", + "REVIEW_INPUT_LABEL": "Write your Review", + "REVIEW_ERROR_TOAST_MSG": "Minimum {0} tag(s) required", + "IMAGE_UPLOADER_DRAG_DROP_LABEL": "Drag & drop to upload or", + "IMAGE_UPLOADER_BROWSE_BUTTON_LABEL": "Browse", + "CLEAR_TEXT": "clear", + "VIEW_GALLERY": "View Gallery", + "OUR_SPONSORS_TITLE": "Explore other restaurants", + "SIMILAR_RES_TITLE": "Similar restaurants", + "HISTOGRAM_TITLE": "Trustworthy Reviews", + "HISTOGRAM_DESC": "100% genuine reviews. We weed out all the fake reviews", + "HISTOGRAM_TRENDS_TITLE": "Recent ratings trend", + "MOST_RECENT": "most recent", + "PEOPLE_SAY": "Review Highlights", + "ADD_REVIEW_TITLE": "Add Review", + "ORDER_TITLE": "Order Online", + "REVIEWS_TITLE": "Reviews", + "EVENT_TITLE": "Events", + "SEE_ALL_EVENTS_TITLE": "See all events", + "OPENING_HOURS": "Opening Hours", + "HAPPY_HOURS": "Happy Hours", + "CHEF_DETAILS_TITLE": "CHEF DETAILS", + "NO_MENU_TEXT": "We don't have a menu for this restaurant yet", + "EMPTY_MENU_LIST_STRING": "It's empty here!", + "NO_REVIEW_TEXT": "Your ratings and reviews go a long way towards helping people decide where to eat", + "VIEW_ALL_REVIEWS_TITLE": "View all reviews", + "CALCULATE_COST_TEXT": "How do we calculate cost for two?", + "FILTER_ALL_REVIEWS": "All Reviews", + "FILTER_FOLLOWING": "Following", + "FILTER_POPULAR": "Popular", + "FILTER_MY_REVIEWS": "My Reviews", + "FILTER_BLOGGERS_REVIEWS": "Bloggers", + "FILTER_ORDER_REVIEWS": "Order Reviews", + "SORT_NEWEST_FIRST": "Newest First", + "SORT_OLDEST_FIRST": "Oldest First", + "SORT_HIGHEST_RATED": "Highest Rated", + "SORT_LOWEST_RATED": "Lowest Rated", + "POSITIVE_TAGS": "POSITIVE", + "NEGATIVE_TAGS": "NEGATIVE", + "DAILY_MENU_TITLE": "Daily menu", + "DELETE_REVIEW_TEXT": "Are you sure you want to delete this review? This action cannot be undone.", + "DIRECTION_TITLE": "Direction", + "BOOKMARK_TITLE": "Bookmark", + "SHARE_TITLE": "Share", + "CALL_TEXT": "Call", + "COPY_TEXT": "Copy", + "PROMO_COPIED_TEXT": "Copied to clipboard", + "FEATURED_IN_TEXT": "Featured in", + "REVIEWS_TEXT": "reviews", + "FOLLOWERS_TEXT": "Followers", + "FOLLOW_TEXT": "Follow", + "LIKES_TEXT": "Votes for helpful", + "COMMENTS_TEXT": "Comments", + "LIKE_TEXT": "Helpful vote", + "COMMENT_TEXT": "Comment", + "LIKED_TEXT": "Helpful", + "SEE_ALL_MENUS_TEXT": "See all menus", + "SEE_FULL_MENU_TEXT": "See full menu", + "UGC_DISABLED_ERROR": "Sorry! You can only post content for operational restaurants", + "NUMBER_UNAVAILABLE": "Number not available", + "DONATE_NOW": "Donate Now", + "TAKEAWAY_TITLE": "Online Takeaway", + "TAKEAWAY_PICKUP_ADDRESS_PREFIX": "You will need to pick up this order from", + "ORDER_ADDRESS_PREFIX": "Delivering to: ", + "ORDER_ADDRESS_PREFIX_NOT_DELIVERING": "Does not deliver to: ", + "REPEAT_CUST_ADD_NEW_CAPTION": "Add new", + "REPEAT_CUST_REPEAT_LAST_CAPTION": "Repeat Last", + "REPEAT_CUST_MODAL_TITLE": "Repeat last used customisation", + "REMOVE_CUST_MODAL_TITLE": "Remove your items", + "MIN_MAX_QTY_ERROR_MSG": "Please select a minimum of {0} and maximum of {1} {2}", + "CUST_MENU_RADIO_SELECT_MESSAGE": "You can choose any 1 option", + "CUST_MENU_MULTI_SELECT_MIN_MAX_MESSAGE": "You can choose a minimum of {0} and maximum of {1} options", + "CUST_MENU_MULTI_SELECT_MAX_MESSAGE": "You can choose upto {0} options", + "CUST_MENU_ADD_TO_ORDER_BUTTON_CAPTION": "Add to order", + "VEG_ONLY_FILTER_LABEL": "veg only", + "NON_VEG_ONLY_FILTER_LABEL": "non veg only", + "CONTAINS_EGG_FILTER_LABEL": "contains egg", + "MENU_SEARCH_PLACEHOLDER_TEXT": "Search within menu", + "OFFER_DETAILS_MODAL_TITLE": "Offer Details", + "CLOSED_FOR_ONLINE_ORDERGING_TEXT": "Currently closed for online ordering", + "AVAILABLE_ON_THE_APP": "Available on the App", + "LIVE_TRACKING_OPEN_APP_MODAL_TITLE": "Live order tracking on the Zomato app", + "LIVE_TRACKING_OPEN_APP_MODAL_DESCRIPTION": "Switch to the app for the latest offers, better experience and more.", + "OPEN_APP_MODAL_CLICK_ACTION_TEXT": "Use the App", + "OPEN_APP_MODAL_CLOSE_ACTION_TEXT": "Not Now", + "MORE_INFO_TEXT": "More Info", + "FIND_OTHER_RESTAURANTS_TEXT": "Find restaurants delivering to your location", + "LOCATION_MODAL_WITH_DISH_CARD_DESC": "Enter delivery location to add this to your cart", + "RES_DOES_NOT_DELIVER": "This restaurant doesn't deliver to your location", + "DOES_NOT_DELIVER_DESCRIPTION": "To continue please view other restaurants or change location", + "VIEW_OTHER_RES_TEXT": "View Other Restaurants", + "DELIVERING_TO_TEXT": "Delivering to", + "CHANGE_LOCATION_TEXT": "Change Location", + "RES_NOT_SERVICEABLE_TEXT": "Currently not accepting orders", + "FIND_OTHER_RESTAURANTS": "Find other restaurants", + "ORDER_ONLINE_NOT_AVAILABLE_TITLE": "Online ordering is only supported on the Zomato mobile app", + "ORDER_ONLINE_NOT_AVAILABLE_BUTTON": "Open this page in the App", + "DOWNLOAD_THE_APP": "Download the App", + "ORDER_ONLINE_NOT_AVAILABLE_DESKTOP": "Online ordering is only supported on the mobile app", + "ORDER_SUSHI_EDIT_ADDRESS_TITLE": "Edit address", + "ORDER_SUSHI_DELIVERY_AREA_LABEL": "DELIVERY AREA", + "ORDER_SUSHI_ADDRESS_INPUT_PLACEHOLDER": "House no. / Flat no. / Floor / Building", + "ORDER_SUSHI_INSTRUCTIONS_INPUT_PLACEHOLDER": "Delivery instruction if any:", + "ORDER_SUSHI_BACK_TO_ADDRESS_BUTTON_CAPTION": "Back to select an address", + "ORDER_SUSHI_CANCEL_BUTTON_CAPTION": "CANCEL", + "ORDER_SUSHI_CHANGE_BUTTON_CAPTION": "CHANGE", + "ORDER_SUSHI_SAVE_AND_PROCEED_BUTTON_CAPTION": "Save and proceed", + "ORDER_SUSHI_ADD_OTHER_TAG_PLACEHOLDER": "Add tag", + "ORDER_SUSHI_SEARCH_INPUT_PLACEHOLDER": "Start typing to search", + "ORDER_SUSHI_RECENT_LOCATION_HEADER_TEXT": "recent locations", + "ORDER_SUSHI_BACK_TO_ADD_ADDRESS_BUTTON_CAPTION": "Back to add address", + "ORDER_SUSHI_ADDRESS_TYPE_LABEL_WORK": "Work", + "ORDER_SUSHI_ADDRESS_TYPE_LABEL_HOME": "Home", + "ORDER_SUSHI_ADDRESS_TYPE_LABEL_HOTEL": "Hotel", + "ORDER_SUSHI_ADDRESS_TYPE_LABEL_OTHER": "Other", + "ORDER_SUSHI_SEARCH_LOCATION_MODAL_TITLE": "Search Location", + "ORDER_SUSHI_SELECT_ADDRESS_MODAL_TITLE": "Select an address", + "ORDER_SUSHI_SAVED_ADDRESS_SEARCH_PLACEHOLDER": "Search in saved addresses", + "ORDER_SUSHI_ADD_ADDRESS_SEARCH_PLACEHOLDER": "Add new address", + "ORDER_SUSHI_SAVED_ADDRESSES_TITLE": "SAVED ADDRESSES", + "ORDER_SUSHI_DELIVERS_HERE_TEXT": "Delivers here", + "ORDER_SUSHI_NOT_DELIVER_HERE_TEXT": "Doesn't deliver here", + "ORDER_SUSHI_EDIT_BUTTON_CAPTION": "Edit", + "ORDER_SUSHI_SET_DELIVERY_LOCATION_TITLE": "Set your delivery location", + "ORDER_SUSHI_CONFIRM_AND_PROCEED_BUTTON_CAPTION": "Confirm and Proceed", + "ORDER_SUSHI_MOVE_THE_PIN": "Move the pin", + "ORDER_SUSHI_ADDRESS_BLOCKER_TEXT": "Please choose a more specific location below or move the pin on the map to the intended location.", + "ORDER_SUSHI_GOOGLE_MAP_PROMPT_LINE1": "Your order will be delivered here", + "ORDER_SUSHI_GOOGLE_MAP_PROMPT_LINE2": "Move pin to your exact location", + "ORDER_SUSHI_ADD_LABEL": "ADD", + "ORDER_SUSHI_CUSTOMIZE_LABEL": "customizable", + "ORDER_SUSHI_OUT_OF_STOCK_LABEL": "Out of stock", + "ORDER_SUSHI_MENU_BUTTON_CAPTION": "Menu", + "ORDER_SUSHI_ITEMS_SUFFIX_TEXT": "ITEMS", + "ORDER_SUSHI_SINGLE_ITEM_SUFFIX_TEXT": "ITEM", + "ORDER_SUSHI_CONTINUE_BUTTTON_CAPTION": "Continue", + "ORDER_SUSHI_CLEAR_BUTTON_CAPTION": "Clear Cart", + "ORDER_SUSHI_AMOUNT_SUFFIX_PLUS_TAXES_TEXT": "plus taxes", + "ORDER_SUSHI_CART_HEADER_TEXT": "Your Orders", + "ORDER_SUSHI_DEKTOP_TOGGLE_BUTTON_SUFFIX_TEXT": "Your Order", + "ORDER_SUSHI_SUBTOTAL_TEXT": "Subtotal", + "NO_SEARCH_RESULT_FOUND": "We could not understand what you mean, try rephrasing the query.", + "TRENDING_SEARCHES": "Trending Searches", + "NO_TRENDING_SEARCH": "No results found for Trending Searches", + "TOP_RESTAURANTS": "Top Restaurants", + "SEARCH_PLACEHOLDER": "Search for restaurant, cuisine or a dish", + "DETECT_LOCATION": "Detect current location", + "DETECT_LOCATION_SUBTITLE": "Using GPS", + "ADD_ADDRESS": "Add address", + "SAVED_ADDRESSES": "Saved Addresses", + "POPULAR_LOCATIONS": "Popular Locations", + "LOCATION_NO_RESULT_SUB": "Check for spelling errors or search for a nearby location", + "LOCATION_NO_RESULT": "No results found", + "SEARCH_MODAL_MOBILE_VIEW_TITLE": "Select location", + "GEO_LOCATION_NO_BROWSER_SUPPORT": "Seems like, Your browser does not support Geolocation.", + "GEO_LOCATION_PERMISSION_DENIED": "Please enable location permission from settings and try again!", + "GEO_LOCATION_POSITION_UNAVAILABLE": "We can't locate your position, please try again!", + "GEO_LOCATION_TIMEOUT": "Request for location has timed out!", + "GEO_LOCATION_UNKNOWN_ERROR": "An unknown error occurred, Please try again!", + "GEO_LOCATION_DEFAULT_ERROR": "An unknown error occurred, Please try again!", + "PROFILE_LINK_NAME": "Profile", + "REVIEWS_LINK_NAME": "Reviews", + "SETTINGS_LINK_NAME": "Settings", + "LOGOUT_LINK_NAME": "Log out", + "LOGIN_FAILED_TITLE": "Login Failed", + "SIGNUP_FAILED_TITLE": "Signup Failed", + "OTP_VERIFICATION_TITLE": "Enter OTP", + "LOGIN_TITLE": "Log in", + "SIGNUP_TITLE": "Sign up", + "SIGNUP_NAME_ERROR_MESSAGE": "Please enter a valid name", + "SIGNUP_EMPTY_EMAIL_ERROR_MESSAGE": "Please enter an email", + "SIGNUP_INVALID_EMAIL_ERROR_MESSAGE": "Invalid Email id", + "SIGNUP_PHONE_ERROR_MESSAGE": "Please enter phone number", + "SIGNUP_FULL_NAME_LABEL": "Full Name", + "SIGNUP_EMAIL_LABEL": "Email", + "SIGNUP_PHONE_LABEL": "Phone number", + "TERMS_OF_SERVICE_TEXT": "Terms of Service", + "PRIVACY_POLICY_TEXT": "Privacy Policy", + "CONTENT_POLICIES": "Content Policies", + "AGREE_TO_ZOMATO_POLICY_TEXT": "I agree to Zomato's {0}, {1} and {2}", + "CREATE_ACCOUNT_BUTTON_TEXT": "Create account", + "ALREADY_HAVE_AN_ACCOUNT_TEXT": "Already have an account? {0}", + "LOGIN_WITH_PHONE_ERROR": "Login with Phone number is not currently available", + "NEW_TO_ZOMATO_TEXT": "New to Zomato?", + "SEND_OTP_TEXT": "Send OTP", + "ERROR_MESSAAGE_BOX_TRY_OTHER_METHODS_TEXT": "Try using other methods", + "ERROR_MESSAAGE_BOX_SKIP_FOR_NOW_TEXT": "Skip for now", + "NEW_OTP_HAS_BEEN_SENT_TEXT": "A new OTP has been sent", + "NOT_RECEIVED_OTP_TEXT": "Didn't receive OTP?", + "RESEND_NOW_TEXT": "Resend Now", + "TERMINATE_VERIFICATION_TEXT": "Are you sure you want to terminate the verification?", + "YES_BUTTON_TEXT": "Yes", + "NO_BUTTON_TEXT": "No", + "CONTINUE_WITH_GOOGLE_BUTTON_TEXT": "Continue with Google", + "ERROR_OCCURED_TEXT": "Error occurred", + "OR_TEXT": "or", + "OTP_TEXT_BOX_LABEL": "OTP", + "OTP_TEXT_BOX_PROCEED_BUTTON": "Proceed", + "OTP_NOT_RECEIVED_TEXT": "Not received OTP? ", + "COOKIE_BANNER_TEXT": "By using this site you agree to Zomato's use of cookies to give you a personalised experience. Please read the cookie policy for more information or to delete/block them." + }, + "deviceSpecificInfo": { + "browser": { + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "name": "Unknown", + "version": "?", + "platform": "windows" + } + }, + "pageBlockerInfo": {}, + "fullPageAds": { + "pageViews": [], + "adVisible": false + }, + "networkState": { + "isOnline": -1 + }, + "fetchConfigs": { + "headers": {} + }, + "hrefLangInfo": [ + { + "link": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "en-in", + "isSelected": true + }, + { + "link": "https://www.zomato.com/hi/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "hi-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/bn/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "bn-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/te/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "te-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/ta/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "ta-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/kn/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "kn-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/mr/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "mr-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/gu/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "gu-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/pa/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "pa-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/ml/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "ml-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/or/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "or-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/hi-en/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "hi-en-in", + "isSelected": false + }, + { + "link": "https://www.zomato.com/modinagar/schezwan-spicy-food-modinagar-locality/order", + "hreflang": "x-default", + "isSelected": false + } + ], + "pageConfig": { + "showLocationBannerAutoPopup": false, + "openAppPill": [], + "isLocationPopupFlowAllowed": false, + "cacheMeta": { + "cacheable": false, + "max-age": 172800, + "trackingData": { + "identifier": "19713383" + } + }, + "orderPageBlocker": { + "showO2": false, + "desktopDeeplinkUrl": "" + }, + "hideCookieBanner": true, + "showRatingV2": true + }, + "partnershipLoginModal": { + "isVisible": false + }, + "partnershipLoginOptionModal": { + "isVisible": false + }, + "doesNotDeliverModal": { + "isVisible": false + }, + "backButton": { + "showLoadingState": false + }, + "renderingStrategy": { + "isSSG": false + } +} \ No newline at end of file diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000000..250321aa52 --- /dev/null +++ b/types.d.ts @@ -0,0 +1,67 @@ +interface RestaurantMenuResponse { + status: 'success' | 'error'; + restaurant_info: { + cuisine_type: string; + venue: { + name: string; + address: string; + locality: string; + city: string; + latitude: string; + longitude: string; + zipcode: string; + rating: string; + timing: string; + avg_cost_for_two: number; + }; + }; + menu: Array<{ + name: string; + description: string; + subcategories: Array<{ + name: string; + description: string; + items: Array<{ + name: string; + description: string; + is_veg: boolean; + image_url: string; + variants: Array<{ + name: string; + price: number; + is_default: boolean; + }>; + }>; + }>; + }>; + } + + + + + interface MenuItem { + id: string; + name: string; + description: string; + price: number; + is_veg: boolean; + spice_level: 'None' | 'Mild' | 'Medium' | 'Spicy' | 'Hot'; + image_url: any; // Using 'any' for image imports, could be refined based on your setup + } + + interface MenuSubcategory { + subcategory: string; + items: MenuItem[]; + } + + interface MenuCategory { + category: string; + subcategories: MenuSubcategory[]; + } + + interface MenuData { + menu: MenuCategory[]; + } + + // Assuming isWeb is defined elsewhere in your codebase + declare const isWeb: boolean; \ No newline at end of file