From be37aed91f3beb012993f1364dbb4853ade1505c Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sat, 15 Nov 2025 19:59:15 -0500 Subject: [PATCH 01/19] update models and run migrations --- backend/python/app/models/__init__.py | 2 + backend/python/app/models/admin.py | 35 +++++--- backend/python/app/models/driver.py | 25 ++++-- backend/python/app/models/system_settings.py | 41 +++++++++ backend/python/app/models/user.py | 45 ++++++++++ backend/python/migrations/env.py | 2 + ...39eae_update_driver_admin_and_add_user_.py | 89 +++++++++++++++++++ 7 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 backend/python/app/models/system_settings.py create mode 100644 backend/python/app/models/user.py create mode 100644 backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py diff --git a/backend/python/app/models/__init__.py b/backend/python/app/models/__init__.py index 7fcc9e7b..c4b5019b 100644 --- a/backend/python/app/models/__init__.py +++ b/backend/python/app/models/__init__.py @@ -93,6 +93,8 @@ def init_app(_app: Any | None = None) -> None: from .route_group_membership import RouteGroupMembership # noqa: F401 from .route_stop import RouteStop # noqa: F401 from .simple_entity import SimpleEntity # noqa: F401 + from .system_settings import SystemSettings #noqa: F401 + from .user import User # noqa: F401 init_database() diff --git a/backend/python/app/models/admin.py b/backend/python/app/models/admin.py index 35ca56ee..65e31704 100644 --- a/backend/python/app/models/admin.py +++ b/backend/python/app/models/admin.py @@ -2,22 +2,19 @@ from uuid import UUID, uuid4 from pydantic import EmailStr, field_validator -from sqlmodel import Field, SQLModel +from sqlmodel import Field, SQLModel, Relationship from app.utilities.utils import validate_phone +from app.models.user import User + from .base import BaseModel class AdminBase(SQLModel): """Shared fields between table and API models""" - admin_name: str = Field(min_length=1, max_length=100, nullable=False) - default_cap: int | None = Field(default=None) admin_phone: str = Field(min_length=1, max_length=100, nullable=False) - admin_email: EmailStr = Field(nullable=False) - route_start_time: datetime.time | None = Field(default=None) - warehouse_location: str | None = Field(default=None, min_length=1) @field_validator("admin_phone") @classmethod @@ -32,26 +29,36 @@ class Admin(AdminBase, BaseModel, table=True): __tablename__ = "admin_info" admin_id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="users.user_id", unique=True, nullable=False) + + user: User = Relationship() class AdminCreate(AdminBase): """Create request model""" - + user_id: UUID pass class AdminRead(AdminBase): """Read response model""" - admin_id: UUID + user_id: UUID + + # pulled from User + name: str + email: EmailStr + address: str + auth_id: str + role: str class AdminUpdate(SQLModel): """Update request model - all optional""" - - admin_name: str | None = Field(default=None, min_length=1, max_length=100) - default_cap: int | None = Field(default=None) + # admin-specific admin_phone: str | None = Field(default=None, min_length=1, max_length=100) - admin_email: EmailStr | None = Field(default=None) - route_start_time: datetime.time | None = Field(default=None) - warehouse_location: str | None = Field(default=None, min_length=1) + + # user fields + name: str | None = Field(default=None, min_length=1, max_length=255) + email: EmailStr | None = Field(default=None) + address: str | None = Field(default=None, min_length=1, max_length=255) diff --git a/backend/python/app/models/driver.py b/backend/python/app/models/driver.py index a7b2bba6..a8428964 100644 --- a/backend/python/app/models/driver.py +++ b/backend/python/app/models/driver.py @@ -1,18 +1,17 @@ from uuid import UUID, uuid4 from pydantic import EmailStr, field_validator -from sqlmodel import Field, SQLModel +from sqlmodel import Field, SQLModel, Relationship from app.utilities.utils import validate_phone from .base import BaseModel +from app.models.user import User + class DriverBase(SQLModel): - name: str = Field(min_length=1, max_length=255) - email: EmailStr = Field(unique=True, index=True, max_length=254) phone: str = Field(min_length=1, max_length=20) - address: str = Field(min_length=1, max_length=255) license_plate: str = Field(min_length=1, max_length=20) car_make_model: str = Field(min_length=1, max_length=255) active: bool = Field(default=True) @@ -29,16 +28,22 @@ class Driver(DriverBase, BaseModel, table=True): __tablename__ = "drivers" driver_id: UUID = Field(default_factory=uuid4, primary_key=True, index=True) - auth_id: str = Field(nullable=False, unique=True, index=True) + user_id: UUID = Field(foreign_key="users.user_id", unique=True, nullable=False) + user: User = Relationship() class DriverCreate(DriverBase): - password: str = Field(min_length=8, max_length=100) + user_id: UUID # link to created User class DriverRead(DriverBase): driver_id: UUID auth_id: str + user_id: UUID + name: str + email: EmailStr + address: str + role: str # comes from User class DriverUpdate(SQLModel): @@ -54,14 +59,16 @@ class DriverUpdate(SQLModel): class DriverRegister(SQLModel): """Driver registration request""" - + # User fields name: str = Field(min_length=1, max_length=255) email: EmailStr = Field(max_length=254) - phone: str = Field(min_length=1, max_length=20) address: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=8, max_length=100) + + # Driver fields + phone: str = Field(min_length=1, max_length=20) license_plate: str = Field(min_length=1, max_length=20) car_make_model: str = Field(min_length=1, max_length=255) - password: str = Field(min_length=8, max_length=100) @field_validator("phone") @classmethod diff --git a/backend/python/app/models/system_settings.py b/backend/python/app/models/system_settings.py new file mode 100644 index 00000000..6c581c34 --- /dev/null +++ b/backend/python/app/models/system_settings.py @@ -0,0 +1,41 @@ +import datetime +from uuid import UUID, uuid4 + +from pydantic import EmailStr, field_validator +from sqlmodel import Field, SQLModel + +from app.utilities.utils import validate_phone + +from .base import BaseModel + + +class SystemSettingsBase(SQLModel): + """Shared fields between table and API models""" + + default_cap: int | None = Field(default=None) + route_start_time: datetime.time | None = Field(default=None) + warehouse_location: str | None = Field(default=None, min_length=1) + + +class SystemSettings(SystemSettingsBase, BaseModel, table=True): + """Database table model""" + + __tablename__ = "system_settings" + + system_settings_id: UUID = Field(default_factory=uuid4, primary_key=True) + + +class SystemSettingsCreate(SystemSettingsBase): + """Create request model""" + pass + + +class SystemSettingsRead(SystemSettingsBase): + """Read response model""" + + system_settings_id: UUID + + +class SystemSettingsUpdate(SystemSettingsBase): + """Update request model - all optional""" + pass diff --git a/backend/python/app/models/user.py b/backend/python/app/models/user.py new file mode 100644 index 00000000..9e836428 --- /dev/null +++ b/backend/python/app/models/user.py @@ -0,0 +1,45 @@ +from uuid import UUID, uuid4 + +from pydantic import EmailStr, field_validator +from sqlmodel import Field, SQLModel + +from .base import BaseModel + + +class UserBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + email: EmailStr = Field(unique=True, index=True, max_length=254) + address: str = Field(min_length=1, max_length=255) + + +class User(UserBase, BaseModel, table=True): + __tablename__ = "users" + + user_id: UUID = Field(default_factory=uuid4, primary_key=True, index=True) + auth_id: str = Field(nullable=False, unique=True, index=True) + role: str = Field(min_length=1, max_length=255, default="driver") + + +class UserCreate(UserBase): + password: str = Field(min_length=8, max_length=100) + + +class UserRead(UserBase): + user_id: UUID + auth_id: str + role: str + + +class UserUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + email: EmailStr | None = Field(default=None, max_length=254) + address: str | None = Field(default=None, min_length=1, max_length=255) + + +class UserRegister(SQLModel): + """User registration request""" + + name: str = Field(min_length=1, max_length=255) + email: EmailStr = Field(max_length=254) + address: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=8, max_length=100) diff --git a/backend/python/migrations/env.py b/backend/python/migrations/env.py index a88c1b38..37381d06 100644 --- a/backend/python/migrations/env.py +++ b/backend/python/migrations/env.py @@ -29,6 +29,8 @@ from app.models.route_group_membership import RouteGroupMembership from app.models.route_stop import RouteStop from app.models.simple_entity import SimpleEntity +from app.models.system_settings import SystemSettings +from app.models.user import User # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py b/backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py new file mode 100644 index 00000000..f17bac92 --- /dev/null +++ b/backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py @@ -0,0 +1,89 @@ +"""Update Driver, Admin and Add User, SystemSettings + +Revision ID: 988258439eae +Revises: 7af7d4689b08 +Create Date: 2025-11-16 00:48:20.003510 + +""" +import sqlmodel +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '988258439eae' +down_revision = '7af7d4689b08' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('system_settings', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('default_cap', sa.Integer(), nullable=True), + sa.Column('route_start_time', sa.Time(), nullable=True), + sa.Column('warehouse_location', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('system_settings_id', sa.Uuid(), nullable=False), + sa.PrimaryKeyConstraint('system_settings_id') + ) + op.create_table('users', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=254), nullable=False), + sa.Column('address', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('auth_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index(op.f('ix_users_auth_id'), 'users', ['auth_id'], unique=True) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=False) + op.add_column('admin_info', sa.Column('user_id', sa.Uuid(), nullable=False)) + op.create_unique_constraint(None, 'admin_info', ['user_id']) + op.create_foreign_key(None, 'admin_info', 'users', ['user_id'], ['user_id']) + op.drop_column('admin_info', 'admin_email') + op.drop_column('admin_info', 'admin_name') + op.drop_column('admin_info', 'route_start_time') + op.drop_column('admin_info', 'warehouse_location') + op.drop_column('admin_info', 'default_cap') + op.add_column('drivers', sa.Column('user_id', sa.Uuid(), nullable=False)) + op.drop_index(op.f('ix_drivers_auth_id'), table_name='drivers') + op.drop_index(op.f('ix_drivers_email'), table_name='drivers') + op.create_unique_constraint(None, 'drivers', ['user_id']) + op.create_foreign_key(None, 'drivers', 'users', ['user_id'], ['user_id']) + op.drop_column('drivers', 'name') + op.drop_column('drivers', 'auth_id') + op.drop_column('drivers', 'email') + op.drop_column('drivers', 'address') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drivers', sa.Column('address', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + op.add_column('drivers', sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=False)) + op.add_column('drivers', sa.Column('auth_id', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('drivers', sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'drivers', type_='foreignkey') + op.drop_constraint(None, 'drivers', type_='unique') + op.create_index(op.f('ix_drivers_email'), 'drivers', ['email'], unique=True) + op.create_index(op.f('ix_drivers_auth_id'), 'drivers', ['auth_id'], unique=True) + op.drop_column('drivers', 'user_id') + op.add_column('admin_info', sa.Column('default_cap', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('warehouse_location', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('route_start_time', postgresql.TIME(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('admin_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False)) + op.add_column('admin_info', sa.Column('admin_email', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'admin_info', type_='foreignkey') + op.drop_constraint(None, 'admin_info', type_='unique') + op.drop_column('admin_info', 'user_id') + op.drop_index(op.f('ix_users_user_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_index(op.f('ix_users_auth_id'), table_name='users') + op.drop_table('users') + op.drop_table('system_settings') + # ### end Alembic commands ### From 1445aa6edf867d31c1b4d65b096f1f11a89f6c17 Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 16 Nov 2025 21:03:27 -0500 Subject: [PATCH 02/19] Move address from user to driver --- backend/python/app/models/driver.py | 4 ++-- backend/python/app/models/user.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/python/app/models/driver.py b/backend/python/app/models/driver.py index a8428964..30122ccc 100644 --- a/backend/python/app/models/driver.py +++ b/backend/python/app/models/driver.py @@ -16,6 +16,7 @@ class DriverBase(SQLModel): car_make_model: str = Field(min_length=1, max_length=255) active: bool = Field(default=True) notes: str = Field(default="", max_length=1024) + address: str = Field(min_length=1, max_length=255) @field_validator("phone") @classmethod @@ -42,7 +43,6 @@ class DriverRead(DriverBase): user_id: UUID name: str email: EmailStr - address: str role: str # comes from User @@ -62,13 +62,13 @@ class DriverRegister(SQLModel): # User fields name: str = Field(min_length=1, max_length=255) email: EmailStr = Field(max_length=254) - address: str = Field(min_length=1, max_length=255) password: str = Field(min_length=8, max_length=100) # Driver fields phone: str = Field(min_length=1, max_length=20) license_plate: str = Field(min_length=1, max_length=20) car_make_model: str = Field(min_length=1, max_length=255) + address: str = Field(min_length=1, max_length=255) @field_validator("phone") @classmethod diff --git a/backend/python/app/models/user.py b/backend/python/app/models/user.py index 9e836428..b980a6b1 100644 --- a/backend/python/app/models/user.py +++ b/backend/python/app/models/user.py @@ -9,7 +9,6 @@ class UserBase(SQLModel): name: str = Field(min_length=1, max_length=255) email: EmailStr = Field(unique=True, index=True, max_length=254) - address: str = Field(min_length=1, max_length=255) class User(UserBase, BaseModel, table=True): @@ -33,13 +32,10 @@ class UserRead(UserBase): class UserUpdate(SQLModel): name: str | None = Field(default=None, min_length=1, max_length=255) email: EmailStr | None = Field(default=None, max_length=254) - address: str | None = Field(default=None, min_length=1, max_length=255) - class UserRegister(SQLModel): """User registration request""" name: str = Field(min_length=1, max_length=255) email: EmailStr = Field(max_length=254) - address: str = Field(min_length=1, max_length=255) password: str = Field(min_length=8, max_length=100) From e6073031bb124ef274205ebdfd4949c9d0596f74 Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 16 Nov 2025 21:03:38 -0500 Subject: [PATCH 03/19] migration --- ...48dba8_move_address_from_user_to_driver.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py diff --git a/backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py b/backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py new file mode 100644 index 00000000..f210e7eb --- /dev/null +++ b/backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py @@ -0,0 +1,30 @@ +"""Move address from user to driver + +Revision ID: 2fb89d48dba8 +Revises: 988258439eae +Create Date: 2025-11-17 02:00:55.534425 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision = '2fb89d48dba8' +down_revision = '988258439eae' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drivers', sa.Column('address', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False)) + op.drop_column('users', 'address') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('address', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + op.drop_column('drivers', 'address') + # ### end Alembic commands ### From add47d263c14bdaa99b29c02c4419728b391e7ae Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 16 Nov 2025 21:43:23 -0500 Subject: [PATCH 04/19] add user service and update driver service :sob: --- backend/python/app/models/driver.py | 10 +- .../implementations/driver_service.py | 145 ++------- .../services/implementations/user_service.py | 289 ++++++++++++++++++ 3 files changed, 330 insertions(+), 114 deletions(-) create mode 100644 backend/python/app/services/implementations/user_service.py diff --git a/backend/python/app/models/driver.py b/backend/python/app/models/driver.py index 30122ccc..881e908f 100644 --- a/backend/python/app/models/driver.py +++ b/backend/python/app/models/driver.py @@ -45,8 +45,16 @@ class DriverRead(DriverBase): email: EmailStr role: str # comes from User - class DriverUpdate(SQLModel): + phone: str | None = Field(default=None, min_length=1, max_length=20) + address: str | None = Field(default=None, min_length=1, max_length=255) + license_plate: str | None = Field(default=None, min_length=1, max_length=20) + car_make_model: str | None = Field(default=None, min_length=1, max_length=255) + active: bool | None = Field(default=None) + notes: str | None = Field(default=None, max_length=1024) + + +class DriverUpdatePayload(SQLModel): name: str | None = Field(default=None, min_length=1, max_length=255) email: EmailStr | None = Field(default=None, max_length=254) phone: str | None = Field(default=None, min_length=1, max_length=20) diff --git a/backend/python/app/services/implementations/driver_service.py b/backend/python/app/services/implementations/driver_service.py index 8bf582d1..3c91763f 100644 --- a/backend/python/app/services/implementations/driver_service.py +++ b/backend/python/app/services/implementations/driver_service.py @@ -2,14 +2,12 @@ from typing import TYPE_CHECKING from uuid import UUID -import firebase_admin.auth from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from app.models.driver import Driver, DriverCreate, DriverUpdate +from app.models.user import User -if TYPE_CHECKING: - from firebase_admin.auth import UserRecord class DriverService: @@ -41,8 +39,11 @@ async def get_driver_by_email( ) -> Driver | None: """Get driver by email using Firebase""" try: - firebase_user: UserRecord = firebase_admin.auth.get_user_by_email(email) - statement = select(Driver).where(Driver.auth_id == firebase_user.uid) + statement = ( + select(Driver) + .join(Driver.user) + .where(User.email == email) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -60,7 +61,11 @@ async def get_driver_by_auth_id( ) -> Driver | None: """Get driver by auth_id""" try: - statement = select(Driver).where(Driver.auth_id == auth_id) + statement = ( + select(Driver) + .join(Driver.user) + .where(User.auth_id == auth_id) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -87,35 +92,17 @@ async def create_driver( self, session: AsyncSession, driver_data: DriverCreate, - auth_id: str | None = None, - signup_method: str = "PASSWORD", ) -> Driver: """Create new driver with Firebase integration""" - firebase_user: UserRecord | None = None - try: - # Create Firebase user - if signup_method == "PASSWORD": - firebase_user = firebase_admin.auth.create_user( - email=driver_data.email, password=driver_data.password - ) - elif signup_method == "GOOGLE": - firebase_user = firebase_admin.auth.get_user(uid=auth_id) - - # Create database driver - if firebase_user is None: - raise Exception("Failed to create Firebase user") - driver = Driver( - name=driver_data.name, - email=driver_data.email, - phone=driver_data.phone, + user_id=driver_data.user_id, address=driver_data.address, + phone=driver_data.phone, license_plate=driver_data.license_plate, car_make_model=driver_data.car_make_model, active=driver_data.active, notes=driver_data.notes, - auth_id=firebase_user.uid, ) try: @@ -125,13 +112,6 @@ async def create_driver( return driver except Exception as db_error: - # Rollback Firebase user creation - try: - firebase_admin.auth.delete_user(firebase_user.uid) - except Exception as firebase_error: - self.logger.error( - f"Failed to rollback Firebase user: {firebase_error!s}" - ) raise db_error except Exception as e: @@ -152,8 +132,6 @@ async def update_driver_by_id( return None # Store old values for rollback - old_name = driver.name - old_email = driver.email old_phone = driver.phone old_address = driver.address old_license_plate = driver.license_plate @@ -162,10 +140,6 @@ async def update_driver_by_id( old_notes = driver.notes # Update driver fields - if driver_data.name is not None: - driver.name = driver_data.name - if driver_data.email is not None: - driver.email = driver_data.email if driver_data.phone is not None: driver.phone = driver_data.phone if driver_data.address is not None: @@ -181,29 +155,15 @@ async def update_driver_by_id( await session.commit() - # Update Firebase email - try: - if driver_data.email is not None: - firebase_admin.auth.update_user( - driver.auth_id, email=driver_data.email - ) - await session.refresh(driver) - return driver - - except Exception as firebase_error: - # Rollback database changes - driver.name = old_name - driver.email = old_email - driver.phone = old_phone - driver.address = old_address - driver.license_plate = old_license_plate - driver.car_make_model = old_car_make_model - driver.active = old_active - driver.notes = old_notes - await session.commit() - raise firebase_error - except Exception as e: + # Rollback database changes + driver.phone = old_phone + driver.address = old_address + driver.license_plate = old_license_plate + driver.car_make_model = old_car_make_model + driver.active = old_active + driver.notes = old_notes + await session.commit() self.logger.error(f"Failed to update driver: {e!s}") raise e @@ -218,33 +178,9 @@ async def delete_driver_by_id(self, session: AsyncSession, driver_id: UUID) -> N self.logger.error(f"Driver with id {driver_id} not found") return - # Store for rollback - driver_data = { - "name": driver.name, - "email": driver.email, - "phone": driver.phone, - "address": driver.address, - "license_plate": driver.license_plate, - "car_make_model": driver.car_make_model, - "active": driver.active, - "notes": driver.notes, - "auth_id": driver.auth_id, - } - await session.delete(driver) await session.commit() - # Delete from Firebase - try: - firebase_admin.auth.delete_user(driver.auth_id) - - except Exception as firebase_error: - # Rollback database deletion - new_driver = Driver(**driver_data) - session.add(new_driver) - await session.commit() - raise firebase_error - except Exception as e: self.logger.error(f"Failed to delete driver: {e!s}") raise e @@ -262,7 +198,7 @@ async def get_auth_id_by_driver_id( self.logger.error(f"Driver with id {driver_id} not found") return None - return driver.auth_id + return driver.user.auth_id except Exception as e: self.logger.error(f"Failed to get auth_id by driver_id: {e!s}") raise e @@ -272,7 +208,11 @@ async def get_driver_id_by_auth_id( ) -> UUID | None: """Get driver_id by auth_id""" try: - statement = select(Driver).where(Driver.auth_id == auth_id) + statement = ( + select(Driver) + .join(Driver.user) + .where(User.auth_id == auth_id) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -288,8 +228,11 @@ async def get_driver_id_by_auth_id( async def delete_driver_by_email(self, session: AsyncSession, email: str) -> None: """Delete driver by email""" try: - firebase_user: UserRecord = firebase_admin.auth.get_user_by_email(email) - statement = select(Driver).where(Driver.auth_id == firebase_user.uid) + statement = ( + select(Driver) + .join(Driver.user) + .where(User.email == email) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -297,33 +240,9 @@ async def delete_driver_by_email(self, session: AsyncSession, email: str) -> Non self.logger.error(f"Driver with email {email} not found") return - # Store for rollback - driver_data = { - "name": driver.name, - "email": driver.email, - "phone": driver.phone, - "address": driver.address, - "license_plate": driver.license_plate, - "car_make_model": driver.car_make_model, - "active": driver.active, - "notes": driver.notes, - "auth_id": driver.auth_id, - } - await session.delete(driver) await session.commit() - # Delete from Firebase - try: - firebase_admin.auth.delete_user(driver.auth_id) - - except Exception as firebase_error: - # Rollback database deletion - new_driver = Driver(**driver_data) - session.add(new_driver) - await session.commit() - raise firebase_error - except Exception as e: self.logger.error(f"Failed to delete driver by email: {e!s}") raise e diff --git a/backend/python/app/services/implementations/user_service.py b/backend/python/app/services/implementations/user_service.py new file mode 100644 index 00000000..f4360538 --- /dev/null +++ b/backend/python/app/services/implementations/user_service.py @@ -0,0 +1,289 @@ +import logging +from typing import TYPE_CHECKING +from uuid import UUID + +import firebase_admin.auth +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from app.models.user import User, UserCreate, UserUpdate + +if TYPE_CHECKING: + from firebase_admin.auth import UserRecord + + +class UserService: + """Modern FastAPI-style user service""" + + def __init__(self, logger: logging.Logger): + self.logger = logger + + async def get_user_by_id( + self, session: AsyncSession, user_id: UUID + ) -> User | None: + """Get user by ID - returns SQLModel instance""" + try: + statement = select(User).where(User.user_id == user_id) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with id {user_id} not found") + return None + + return user + except Exception as e: + self.logger.error(f"Failed to get user by id: {e!s}") + raise e + + async def get_user_by_email( + self, session: AsyncSession, email: str + ) -> User | None: + """Get user by email using Firebase""" + try: + firebase_user: UserRecord = firebase_admin.auth.get_user_by_email(email) + statement = select(User).where(User.auth_id == firebase_user.uid) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with email {email} not found") + return None + + return user + except Exception as e: + self.logger.error(f"Failed to get user by email: {e!s}") + raise e + + async def get_user_by_auth_id( + self, session: AsyncSession, auth_id: str + ) -> User | None: + """Get user by auth_id""" + try: + statement = select(User).where(User.auth_id == auth_id) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with auth_id {auth_id} not found") + return None + + return user + except Exception as e: + self.logger.error(f"Failed to get user by auth_id: {e!s}") + raise e + + async def get_users(self, session: AsyncSession) -> list[User]: + """Get all users - returns SQLModel instances""" + try: + statement = select(User) + result = await session.execute(statement) + return list(result.scalars().all()) + except Exception as e: + self.logger.error(f"Failed to get users: {e!s}") + raise e + + async def create_user( + self, + session: AsyncSession, + user_data: UserCreate, + auth_id: str | None = None, + signup_method: str = "PASSWORD", + ) -> User: + """Create new user with Firebase integration""" + firebase_user: UserRecord | None = None + + try: + # Create Firebase user + if signup_method == "PASSWORD": + firebase_user = firebase_admin.auth.create_user( + email=user_data.email, password=user_data.password + ) + elif signup_method == "GOOGLE": + firebase_user = firebase_admin.auth.get_user(uid=auth_id) + + # Create database user + if firebase_user is None: + raise Exception("Failed to create Firebase user") + + user = User( + name=user_data.name, + email=user_data.email, + auth_id=firebase_user.uid, + ) + + try: + session.add(user) + await session.commit() + await session.refresh(user) + return user + + except Exception as db_error: + # Rollback Firebase user creation + try: + firebase_admin.auth.delete_user(firebase_user.uid) + except Exception as firebase_error: + self.logger.error( + f"Failed to rollback Firebase user: {firebase_error!s}" + ) + raise db_error + + except Exception as e: + self.logger.error(f"Failed to create user: {e!s}") + raise e + + async def update_user_by_id( + self, session: AsyncSession, user_id: UUID, user_data: UserUpdate + ) -> User | None: + """Update user by ID""" + try: + statement = select(User).where(User.user_id == user_id) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with id {user_id} not found") + return None + + # Store old values for rollback + old_name = user.name + old_email = user.email + + # Update user fields + if user_data.name is not None: + user.name = user_data.name + if user_data.email is not None: + user.email = user_data.email + + await session.commit() + + # Update Firebase email + try: + if user_data.email is not None: + firebase_admin.auth.update_user( + user.auth_id, email=user_data.email + ) + await session.refresh(user) + return user + + except Exception as firebase_error: + # Rollback database changes + user.name = old_name + user.email = old_email + await session.commit() + raise firebase_error + + except Exception as e: + self.logger.error(f"Failed to update user: {e!s}") + raise e + + async def delete_user_by_id(self, session: AsyncSession, user_id: UUID) -> None: + """Delete user by ID""" + try: + statement = select(User).where(User.user_id == user_id) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with id {user_id} not found") + return + + # Store for rollback + user_data = { + "name": user.name, + "email": user.email, + "auth_id": user.auth_id, + "role": user.role, + } + + await session.delete(user) + await session.commit() + + # Delete from Firebase + try: + firebase_admin.auth.delete_user(user.auth_id) + + except Exception as firebase_error: + # Rollback database deletion + new_user = User(**user_data) + session.add(new_user) + await session.commit() + raise firebase_error + + except Exception as e: + self.logger.error(f"Failed to delete user: {e!s}") + raise e + + async def get_auth_id_by_user_id( + self, session: AsyncSession, user_id: UUID + ) -> str | None: + """Get auth_id by user_id""" + try: + statement = select(User).where(User.user_id == user_id) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with id {user_id} not found") + return None + + return user.auth_id + except Exception as e: + self.logger.error(f"Failed to get auth_id by user_id: {e!s}") + raise e + + async def get_user_id_by_auth_id( + self, session: AsyncSession, auth_id: str + ) -> UUID | None: + """Get user_id by auth_id""" + try: + statement = select(User).where(User.auth_id == auth_id) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with auth_id {auth_id} not found") + return None + + return user.user_id + except Exception as e: + self.logger.error(f"Failed to get user_id by auth_id: {e!s}") + raise e + + async def delete_user_by_email(self, session: AsyncSession, email: str) -> None: + """Delete user by email""" + try: + firebase_user: UserRecord = firebase_admin.auth.get_user_by_email(email) + statement = select(User).where(User.auth_id == firebase_user.uid) + result = await session.execute(statement) + user = result.scalars().first() + + if not user: + self.logger.error(f"User with email {email} not found") + return + + # Store for rollback + user_data = { + "name": user.name, + "email": user.email, + "auth_id": user.auth_id, + "role": user.role, + } + + await session.delete(user) + await session.commit() + + # Delete from Firebase + try: + firebase_admin.auth.delete_user(user.auth_id) + + except Exception as firebase_error: + # Rollback database deletion + new_user = User(**user_data) + session.add(new_user) + await session.commit() + raise firebase_error + + except Exception as e: + self.logger.error(f"Failed to delete user by email: {e!s}") + raise e From a38cac44c6dd79f047d21856529bfe20ba3f2508 Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Thu, 20 Nov 2025 18:54:28 -0500 Subject: [PATCH 05/19] IT WORKS DWD AOWD OAWJD OAWDJ OAWJD OAWJDO WAJDOA --- backend/python/app/dependencies/auth.py | 4 +- backend/python/app/dependencies/services.py | 11 ++- backend/python/app/routers/auth_routes.py | 18 +++-- .../services/implementations/auth_service.py | 72 +++++++++++-------- 4 files changed, 68 insertions(+), 37 deletions(-) diff --git a/backend/python/app/dependencies/auth.py b/backend/python/app/dependencies/auth.py index 412c34a9..04e63f75 100644 --- a/backend/python/app/dependencies/auth.py +++ b/backend/python/app/dependencies/auth.py @@ -11,11 +11,13 @@ from app.models import get_session from app.services.implementations.auth_service import AuthService from app.services.implementations.driver_service import DriverService +from app.services.implementations.user_service import UserService from app.services.implementations.email_service import EmailService # Initialize services logger = logging.getLogger(__name__) driver_service = DriverService(logger) +user_service = UserService(logger) email_service = EmailService( logger, { @@ -27,7 +29,7 @@ settings.mailer_user, "Food4Kids", ) -auth_service = AuthService(logger, driver_service, email_service) +auth_service = AuthService(logger, user_service, driver_service, email_service) # Security scheme security = HTTPBearer() diff --git a/backend/python/app/dependencies/services.py b/backend/python/app/dependencies/services.py index f5dd5ebe..5c0f8834 100644 --- a/backend/python/app/dependencies/services.py +++ b/backend/python/app/dependencies/services.py @@ -23,6 +23,7 @@ from app.services.implementations.scheduler_service import SchedulerService from app.services.implementations.simple_entity_service import SimpleEntityService from app.services.protocols.routing_algorithm import RoutingAlgorithmProtocol +from app.services.implementations.user_service import UserService @lru_cache @@ -48,6 +49,13 @@ def get_email_service() -> EmailService: ) +@lru_cache +def get_user_service() -> UserService: + """Get user service instance""" + logger = get_logger() + return UserService(logger) + + @lru_cache def get_driver_service() -> DriverService: """Get driver service instance""" @@ -56,12 +64,13 @@ def get_driver_service() -> DriverService: def get_auth_service( + user_service: UserService = Depends(get_user_service), driver_service: DriverService = Depends(get_driver_service), email_service: EmailService = Depends(get_email_service), ) -> AuthService: """Get auth service instance""" logger = get_logger() - return AuthService(logger, driver_service, email_service) + return AuthService(logger, user_service, driver_service, email_service) @lru_cache diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index a2c45ca2..eaa71529 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -9,12 +9,14 @@ from app.config import settings from app.dependencies.auth import get_current_database_driver_id, get_current_user_email -from app.dependencies.services import get_auth_service, get_driver_service +from app.dependencies.services import get_auth_service, get_driver_service, get_user_service from app.models import get_session from app.models.driver import DriverCreate, DriverRegister +from app.models.user import UserCreate from app.schemas.auth import AuthResponse, LoginRequest, RefreshResponse from app.services.implementations.auth_service import AuthService from app.services.implementations.driver_service import DriverService +from app.services.implementations.user_service import UserService # Initialize logger logger = logging.getLogger(__name__) @@ -90,16 +92,24 @@ async def register( session: AsyncSession = Depends(get_session), auth_service: AuthService = Depends(get_auth_service), driver_service: DriverService = Depends(get_driver_service), + user_service: UserService = Depends(get_user_service) ) -> AuthResponse: """ Returns access token and driver info in response body and sets refreshToken as an httpOnly cookie """ try: - # Create driver - driver_data = register_request.model_dump() + #Create user first + user_data = register_request.model_dump(include=UserCreate.model_fields.keys()) + user_create = UserCreate(**user_data) + user = await user_service.create_user(session, user_create) + # Create driver after + driver_data = register_request.model_dump(include=DriverCreate.model_fields.keys()) + driver_data["user_id"] = user.user_id driver = DriverCreate(**driver_data) - await driver_service.create_driver(session, driver) + + #please work + auth_dto, refresh_token = await auth_service.generate_token( session, register_request.email, register_request.password ) diff --git a/backend/python/app/services/implementations/auth_service.py b/backend/python/app/services/implementations/auth_service.py index 8bf026cd..05e693ed 100644 --- a/backend/python/app/services/implementations/auth_service.py +++ b/backend/python/app/services/implementations/auth_service.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.driver import DriverCreate +from app.models.user import UserCreate from app.schemas.auth import AuthResponse, TokenResponse from app.utilities.firebase_rest_client import FirebaseRestClient @@ -13,6 +14,7 @@ from firebase_admin.auth import UserRecord from app.services.implementations.driver_service import DriverService + from app.services.implementations.user_service import UserService from app.services.implementations.email_service import EmailService @@ -24,6 +26,7 @@ class AuthService: def __init__( self, logger: Logger, + user_service: "UserService", driver_service: "DriverService", email_service: "EmailService | None" = None, ) -> None: @@ -32,12 +35,13 @@ def __init__( :param logger: application's logger instance :type logger: Logger - :param driver_service: a driver_service instance - :type driver_service: IDriverService + :param user_service: a user_service instance + :type user_service: IUserService :param email_service: an email_service instance :type email_service: Optional[IEmailService] """ self.logger: Logger = logger + self.user_service: UserService = user_service self.driver_service: DriverService = driver_service self.email_service: EmailService | None = email_service self.firebase_rest_client: FirebaseRestClient = FirebaseRestClient(logger) @@ -49,10 +53,10 @@ async def generate_token( # Always attempt Firebase authentication first token = self.firebase_rest_client.sign_in_with_password(email, password) - # If Firebase auth succeeds, get driver from database - driver = await self.driver_service.get_driver_by_email(session, email) + # If Firebase auth succeeds, get user from database + user = await self.user_service.get_user_by_email(session, email) - if driver is None: + if user is None: self.logger.warning( f"Firebase user {email} exists but not found in database - potential data inconsistency" ) @@ -61,9 +65,9 @@ async def generate_token( # Create AuthResponse with all required fields (refresh_token excluded - it goes in httpOnly cookie) auth_response = AuthResponse( access_token=token.access_token, - id=driver.driver_id, - name=driver.name, - email=driver.email, + id=user.user_id, + name=user.name, + email=user.email, ) return auth_response, token.refresh_token except Exception as e: @@ -83,12 +87,12 @@ async def generate_token_for_oauth( user_id = decoded_token["uid"] email = decoded_token["email"] - # If driver already has a login with this email, just return the token + # If user already has a login with this email, just return the token try: - # Note: an error message will be logged from DriverService if this lookup fails. - # You may want to silence the logger for this special OAuth driver lookup case - driver = await self.driver_service.get_driver_by_email(session, email) - if driver is None: + # Note: an error message will be logged from UserService if this lookup fails. + # You may want to silence the logger for this special OAuth user lookup case + user = await self.user_service.get_user_by_email(session, email) + if user is None: self.logger.warning( f"Firebase user {email} exists but not found in database - potential data inconsistency" ) @@ -96,35 +100,41 @@ async def generate_token_for_oauth( return AuthResponse( access_token=id_token, - id=driver.driver_id, - name=driver.name, - email=driver.email, + id=user.user_id, + name=user.name, + email=user.email, ) except Exception: pass - # Create new driver for OAuth - driver = await self.driver_service.create_driver( + # Create new user and driver for OAuth + user = await self.user_service.create_user( session, - DriverCreate( + UserCreate( name=decoded_token.get("name", "") if decoded_token.get("name") else "", email=email, + password="", + ), + auth_id=user_id, + signup_method="GOOGLE" + ) + driver = await self.driver_service.create_driver( + session, + DriverCreate( phone="", # OAuth users don't have phone initially address="", # OAuth users don't have address initially license_plate="", # OAuth users don't have license plate initially car_make_model="", # OAuth users don't have car info initially - password="", - ), - auth_id=user_id, - signup_method="GOOGLE", + user_id=user.user_id, + ) ) return AuthResponse( access_token=id_token, - id=driver.driver_id, - name=driver.name, - email=driver.email, + id=user.user_id, + name=user.name, + email=user.email, ) except Exception as e: reason = getattr(e, "message", None) @@ -133,16 +143,16 @@ async def generate_token_for_oauth( ) raise e - async def revoke_tokens(self, session: AsyncSession, driver_id: UUID) -> None: + async def revoke_tokens(self, session: AsyncSession, user_id: UUID) -> None: try: - auth_id = await self.driver_service.get_auth_id_by_driver_id( - session, driver_id + auth_id = await self.user_service.get_auth_id_by_user_id( + session, user_id ) firebase_admin.auth.revoke_refresh_tokens(auth_id) except Exception as e: reason = getattr(e, "message", None) error_message = [ - f"Failed to revoke refresh tokens of driver with id {driver_id}", + f"Failed to revoke refresh tokens of user with id {user_id}", "Reason =", (reason if reason else str(e)), ] @@ -216,7 +226,7 @@ def send_email_verification_link(self, email: str) -> None: async def is_authorized_by_role( self, _session: AsyncSession, access_token: str, _roles: set[str] ) -> bool: - # Since we removed roles, all drivers are authorized + # Since we removed roles, all users are authorized try: decoded_id_token = firebase_admin.auth.verify_id_token( access_token, check_revoked=True From 06ec709f19340dfed94bf177656572fa1afc6d08 Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Thu, 20 Nov 2025 20:29:41 -0500 Subject: [PATCH 06/19] fix logout button :sob: --- backend/python/app/dependencies/auth.py | 16 ++++++++-------- backend/python/app/models/admin.py | 2 -- backend/python/app/models/system_settings.py | 2 ++ backend/python/app/routers/auth_routes.py | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/python/app/dependencies/auth.py b/backend/python/app/dependencies/auth.py index 04e63f75..4ffd453a 100644 --- a/backend/python/app/dependencies/auth.py +++ b/backend/python/app/dependencies/auth.py @@ -172,16 +172,16 @@ def get_current_user_email(access_token: str = Depends(get_access_token)) -> str ) from e -async def get_current_database_driver_id( +async def get_current_database_user_id( access_token: str = Depends(get_access_token), session: AsyncSession = Depends(get_session), ) -> UUID: """ - Get the current database driver ID from the access token + Get the current database user ID from the access token :param access_token: JWT access token :param session: Database session - :return: Database driver ID (UUID) + :return: Database user ID (UUID) """ try: decoded_token: dict[str, str] = firebase_admin.auth.verify_id_token( @@ -190,16 +190,16 @@ async def get_current_database_driver_id( firebase_uid = decoded_token["uid"] # Convert Firebase UID to database driver ID - database_driver_id = await driver_service.get_driver_id_by_auth_id( + database_user_id = await user_service.get_user_id_by_auth_id( session, firebase_uid ) - if database_driver_id is None: + if database_user_id is None: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Driver not found" + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) - return database_driver_id + return database_user_id except Exception as e: - logger.error(f"Failed to get database driver ID from access token: {e}") + logger.error(f"Failed to get database user ID from access token: {e}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" ) from e diff --git a/backend/python/app/models/admin.py b/backend/python/app/models/admin.py index 65e31704..7585e2c4 100644 --- a/backend/python/app/models/admin.py +++ b/backend/python/app/models/admin.py @@ -48,7 +48,6 @@ class AdminRead(AdminBase): # pulled from User name: str email: EmailStr - address: str auth_id: str role: str @@ -61,4 +60,3 @@ class AdminUpdate(SQLModel): # user fields name: str | None = Field(default=None, min_length=1, max_length=255) email: EmailStr | None = Field(default=None) - address: str | None = Field(default=None, min_length=1, max_length=255) diff --git a/backend/python/app/models/system_settings.py b/backend/python/app/models/system_settings.py index 6c581c34..8aa17ce9 100644 --- a/backend/python/app/models/system_settings.py +++ b/backend/python/app/models/system_settings.py @@ -15,6 +15,8 @@ class SystemSettingsBase(SQLModel): default_cap: int | None = Field(default=None) route_start_time: datetime.time | None = Field(default=None) warehouse_location: str | None = Field(default=None, min_length=1) + warehouse_longitude: float | None = None + warehouse_latitude: float | None = None class SystemSettings(SystemSettingsBase, BaseModel, table=True): diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index eaa71529..d90d0220 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.dependencies.auth import get_current_database_driver_id, get_current_user_email +from app.dependencies.auth import get_current_database_user_id, get_current_user_email from app.dependencies.services import get_auth_service, get_driver_service, get_user_service from app.models import get_session from app.models.driver import DriverCreate, DriverRegister @@ -181,25 +181,25 @@ async def refresh( ) from e -@router.post("/logout/{driver_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.post("/logout/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def logout( - driver_id: UUID, + user_id: UUID, session: AsyncSession = Depends(get_session), - current_database_driver_id: UUID = Depends(get_current_database_driver_id), + current_database_user_id: UUID = Depends(get_current_database_user_id), auth_service: AuthService = Depends(get_auth_service), ) -> None: """ Revokes all of the specified driver's refresh tokens """ # Check if the driver is authorized to logout this driver_id - if driver_id != current_database_driver_id: + if user_id != current_database_user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="You are not authorized to logout this driver", ) try: - await auth_service.revoke_tokens(session, driver_id) + await auth_service.revoke_tokens(session, user_id) except Exception as e: error_message = getattr(e, "message", None) raise HTTPException( From 1d7cd18404cd13326d35c49b4b2a408641fc05ac Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Thu, 20 Nov 2025 21:19:22 -0500 Subject: [PATCH 07/19] ITS WORKING :sob: --- backend/python/app/routers/__init__.py | 2 ++ backend/python/app/routers/admin_routes.py | 27 +++++++++++++++++++ backend/python/app/routers/auth_routes.py | 3 +++ .../services/implementations/auth_service.py | 12 ++++++--- 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 backend/python/app/routers/admin_routes.py diff --git a/backend/python/app/routers/__init__.py b/backend/python/app/routers/__init__.py index 7bfbcbd7..3560fc5e 100644 --- a/backend/python/app/routers/__init__.py +++ b/backend/python/app/routers/__init__.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from . import ( + admin_routes, auth_routes, database_routes, driver_assignment_routes, @@ -18,6 +19,7 @@ def init_app(app: FastAPI) -> None: """Initialize all routers with the FastAPI app""" + app.include_router(admin_routes.router) app.include_router(database_routes.router) app.include_router(driver_assignment_routes.router) app.include_router(auth_routes.router) diff --git a/backend/python/app/routers/admin_routes.py b/backend/python/app/routers/admin_routes.py new file mode 100644 index 00000000..4905628e --- /dev/null +++ b/backend/python/app/routers/admin_routes.py @@ -0,0 +1,27 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.dependencies.auth import require_authorization_by_role + +from app.models import get_session +from app.models.driver import DriverCreate, DriverRead, DriverUpdate +from app.services.implementations.driver_service import DriverService + +# Initialize service +logger = logging.getLogger(__name__) +driver_service = DriverService(logger) + +router = APIRouter(prefix="/admins", tags=["admins"]) + + +@router.get("/test", response_model=str) +async def test( + session: AsyncSession = Depends(get_session), + _: bool = Depends(require_authorization_by_role({"driver"})), +) -> str: + """ + Admin only route example + """ + return "Admin only hehehehehehe - hy lac" diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index d90d0220..def01c64 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -2,6 +2,7 @@ import traceback from typing import Literal, cast from uuid import UUID +import firebase_admin.auth from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import EmailStr @@ -102,6 +103,8 @@ async def register( user_data = register_request.model_dump(include=UserCreate.model_fields.keys()) user_create = UserCreate(**user_data) user = await user_service.create_user(session, user_create) + firebase_admin.auth.set_custom_user_claims(user.auth_id, {"role": user.role}) + # Create driver after driver_data = register_request.model_dump(include=DriverCreate.model_fields.keys()) driver_data["user_id"] = user.user_id diff --git a/backend/python/app/services/implementations/auth_service.py b/backend/python/app/services/implementations/auth_service.py index 05e693ed..6add6530 100644 --- a/backend/python/app/services/implementations/auth_service.py +++ b/backend/python/app/services/implementations/auth_service.py @@ -231,10 +231,14 @@ async def is_authorized_by_role( decoded_id_token = firebase_admin.auth.verify_id_token( access_token, check_revoked=True ) - firebase_user: UserRecord = firebase_admin.auth.get_user( - decoded_id_token["uid"] - ) - return bool(firebase_user.email_verified) + user_role = decoded_id_token.get("role") + if not user_role: + self.logger.warning( + f"User {decoded_id_token['uid']} has no role claim set" + ) + return False + # Allow if role is in the authorized set + return user_role in _roles except Exception as e: self.logger.error(f"Authorization failed: {type(e).__name__}: {e!s}") return False From cecf320a1973d3d5349e5d30a73c8c616effc27f Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 23 Nov 2025 20:58:50 -0500 Subject: [PATCH 08/19] add admin to seed script --- backend/python/app/models/admin.py | 2 + backend/python/app/routers/admin_routes.py | 2 +- backend/python/app/seed_database.py | 50 ++++++++++++++++--- ...ion_model.py => 003_fix_location_model.py} | 0 ...48dba8_move_address_from_user_to_driver.py | 30 ----------- ....py => ba76119b3e4c_update_user_system.py} | 29 +++++------ 6 files changed, 60 insertions(+), 53 deletions(-) rename backend/python/migrations/versions/{7af7d4689b08_fix_location_model.py => 003_fix_location_model.py} (100%) delete mode 100644 backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py rename backend/python/migrations/versions/{988258439eae_update_driver_admin_and_add_user_.py => ba76119b3e4c_update_user_system.py} (91%) diff --git a/backend/python/app/models/admin.py b/backend/python/app/models/admin.py index 7585e2c4..bd9cf120 100644 --- a/backend/python/app/models/admin.py +++ b/backend/python/app/models/admin.py @@ -1,4 +1,5 @@ import datetime + from uuid import UUID, uuid4 from pydantic import EmailStr, field_validator @@ -14,6 +15,7 @@ class AdminBase(SQLModel): """Shared fields between table and API models""" + receive_email_notifications: bool = Field(default=True, nullable=False) admin_phone: str = Field(min_length=1, max_length=100, nullable=False) @field_validator("admin_phone") diff --git a/backend/python/app/routers/admin_routes.py b/backend/python/app/routers/admin_routes.py index 4905628e..8db97e99 100644 --- a/backend/python/app/routers/admin_routes.py +++ b/backend/python/app/routers/admin_routes.py @@ -19,7 +19,7 @@ @router.get("/test", response_model=str) async def test( session: AsyncSession = Depends(get_session), - _: bool = Depends(require_authorization_by_role({"driver"})), + _: bool = Depends(require_authorization_by_role({"admin"})), ) -> str: """ Admin only route example diff --git a/backend/python/app/seed_database.py b/backend/python/app/seed_database.py index 595b1dce..ffec3f92 100644 --- a/backend/python/app/seed_database.py +++ b/backend/python/app/seed_database.py @@ -18,6 +18,8 @@ from sqlmodel import Session, select # Import all models to register them with SQLModel +from app.models.user import User +from app.models.system_settings import SystemSettings from app.models.admin import Admin from app.models.base import BaseModel from app.models.driver import Driver @@ -35,6 +37,9 @@ # Initialize Faker fake = faker.Faker() +# Seeding sample admin with real Firebase +ADMIN_AUTH_ID = os.getenv("ADMIN_AUTH_ID") + # Configuration constants # Percentage of locations that will be unassigned to any location group UNASSIGNED_LOCATION_PERCENTAGE = 0.05 @@ -367,6 +372,8 @@ def main() -> None: "location_groups", "drivers", "admin_info", + "system_settings", + "users", ] for table in tables_to_clear: @@ -502,10 +509,16 @@ def main() -> None: for _ in range(num_drivers): # Create a single driver with fake data - driver = Driver( - auth_id=f"seed_driver_{uuid.uuid4().hex[:8]}", + user = User( name=fake.name(), email=fake.email(), + auth_id=f"seed_driver_{uuid.uuid4().hex[:8]}", + ) + set_timestamps(user) + session.add(user) + + driver = Driver( + user_id = user.user_id, phone=generate_valid_phone(), address=fake.address(), license_plate=fake.license_plate(), @@ -715,19 +728,40 @@ def main() -> None: session.commit() print(f"Created {len(past_route_groups) if past_route_groups else 0} jobs") - # Create admin info - print("Creating admin info...") + # Create system settings + print("Creating system settings info...") # Parse route_start_time string to time object route_start_time_obj = datetime.strptime( ROUTE_START_TIME, "%H:%M:%S" ).time() - admin = Admin( - admin_name=fake.name(), + system_settings = SystemSettings( default_cap=random.randint(DEFAULT_CAP_MIN, DEFAULT_CAP_MAX), - admin_phone=generate_valid_phone(), - admin_email=fake.email(), route_start_time=route_start_time_obj, warehouse_location=WAREHOUSE_ADDRESS, + warehouse_longitude=WAREHOUSE_LON, + warehouse_latitude=WAREHOUSE_LAT, + ) + set_timestamps(system_settings) + session.add(system_settings) + + session.commit() + print("Created system settings info") + + # Create admin info + print("Creating admin info...") + # Parse route_start_time string to time object + user = User( + name="Dev", + email="food4kids@uwblueprint.org", + auth_id=ADMIN_AUTH_ID, + role="admin", + ) + set_timestamps(user) + session.add(user) + + admin = Admin( + admin_phone=generate_valid_phone(), + user_id=user.user_id, ) set_timestamps(admin) session.add(admin) diff --git a/backend/python/migrations/versions/7af7d4689b08_fix_location_model.py b/backend/python/migrations/versions/003_fix_location_model.py similarity index 100% rename from backend/python/migrations/versions/7af7d4689b08_fix_location_model.py rename to backend/python/migrations/versions/003_fix_location_model.py diff --git a/backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py b/backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py deleted file mode 100644 index f210e7eb..00000000 --- a/backend/python/migrations/versions/2fb89d48dba8_move_address_from_user_to_driver.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Move address from user to driver - -Revision ID: 2fb89d48dba8 -Revises: 988258439eae -Create Date: 2025-11-17 02:00:55.534425 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel - -# revision identifiers, used by Alembic. -revision = '2fb89d48dba8' -down_revision = '988258439eae' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('drivers', sa.Column('address', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False)) - op.drop_column('users', 'address') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('address', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) - op.drop_column('drivers', 'address') - # ### end Alembic commands ### diff --git a/backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py similarity index 91% rename from backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py rename to backend/python/migrations/versions/ba76119b3e4c_update_user_system.py index f17bac92..fec959ce 100644 --- a/backend/python/migrations/versions/988258439eae_update_driver_admin_and_add_user_.py +++ b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py @@ -1,17 +1,17 @@ -"""Update Driver, Admin and Add User, SystemSettings +"""update_user_system -Revision ID: 988258439eae +Revision ID: ba76119b3e4c Revises: 7af7d4689b08 -Create Date: 2025-11-16 00:48:20.003510 +Create Date: 2025-11-24 01:25:48.941556 """ -import sqlmodel from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +import sqlmodel # revision identifiers, used by Alembic. -revision = '988258439eae' +revision = 'ba76119b3e4c' down_revision = '7af7d4689b08' branch_labels = None depends_on = None @@ -25,6 +25,8 @@ def upgrade(): sa.Column('default_cap', sa.Integer(), nullable=True), sa.Column('route_start_time', sa.Time(), nullable=True), sa.Column('warehouse_location', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('warehouse_longitude', sa.Float(), nullable=True), + sa.Column('warehouse_latitude', sa.Float(), nullable=True), sa.Column('system_settings_id', sa.Uuid(), nullable=False), sa.PrimaryKeyConstraint('system_settings_id') ) @@ -33,7 +35,6 @@ def upgrade(): sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=254), nullable=False), - sa.Column('address', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('auth_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), @@ -42,45 +43,45 @@ def upgrade(): op.create_index(op.f('ix_users_auth_id'), 'users', ['auth_id'], unique=True) op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=False) + op.add_column('admin_info', sa.Column('receive_email_notifications', sa.Boolean(), nullable=False)) op.add_column('admin_info', sa.Column('user_id', sa.Uuid(), nullable=False)) op.create_unique_constraint(None, 'admin_info', ['user_id']) op.create_foreign_key(None, 'admin_info', 'users', ['user_id'], ['user_id']) - op.drop_column('admin_info', 'admin_email') op.drop_column('admin_info', 'admin_name') + op.drop_column('admin_info', 'default_cap') op.drop_column('admin_info', 'route_start_time') op.drop_column('admin_info', 'warehouse_location') - op.drop_column('admin_info', 'default_cap') + op.drop_column('admin_info', 'admin_email') op.add_column('drivers', sa.Column('user_id', sa.Uuid(), nullable=False)) op.drop_index(op.f('ix_drivers_auth_id'), table_name='drivers') op.drop_index(op.f('ix_drivers_email'), table_name='drivers') op.create_unique_constraint(None, 'drivers', ['user_id']) op.create_foreign_key(None, 'drivers', 'users', ['user_id'], ['user_id']) op.drop_column('drivers', 'name') - op.drop_column('drivers', 'auth_id') op.drop_column('drivers', 'email') - op.drop_column('drivers', 'address') + op.drop_column('drivers', 'auth_id') # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('drivers', sa.Column('address', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) - op.add_column('drivers', sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=False)) op.add_column('drivers', sa.Column('auth_id', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('drivers', sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=False)) op.add_column('drivers', sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) op.drop_constraint(None, 'drivers', type_='foreignkey') op.drop_constraint(None, 'drivers', type_='unique') op.create_index(op.f('ix_drivers_email'), 'drivers', ['email'], unique=True) op.create_index(op.f('ix_drivers_auth_id'), 'drivers', ['auth_id'], unique=True) op.drop_column('drivers', 'user_id') - op.add_column('admin_info', sa.Column('default_cap', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('admin_email', sa.VARCHAR(), autoincrement=False, nullable=False)) op.add_column('admin_info', sa.Column('warehouse_location', sa.VARCHAR(), autoincrement=False, nullable=True)) op.add_column('admin_info', sa.Column('route_start_time', postgresql.TIME(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('default_cap', sa.INTEGER(), autoincrement=False, nullable=True)) op.add_column('admin_info', sa.Column('admin_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False)) - op.add_column('admin_info', sa.Column('admin_email', sa.VARCHAR(), autoincrement=False, nullable=False)) op.drop_constraint(None, 'admin_info', type_='foreignkey') op.drop_constraint(None, 'admin_info', type_='unique') op.drop_column('admin_info', 'user_id') + op.drop_column('admin_info', 'receive_email_notifications') op.drop_index(op.f('ix_users_user_id'), table_name='users') op.drop_index(op.f('ix_users_email'), table_name='users') op.drop_index(op.f('ix_users_auth_id'), table_name='users') From 02073406755c7425b031fb79eeff7d1d8481159e Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 23 Nov 2025 22:28:34 -0500 Subject: [PATCH 09/19] tests fix maybe ? --- backend/python/tests/conftest.py | 15 +++- backend/python/tests/test_models.py | 105 +++++++++++++++++----------- 2 files changed, 79 insertions(+), 41 deletions(-) diff --git a/backend/python/tests/conftest.py b/backend/python/tests/conftest.py index 3c4769df..1e75d155 100644 --- a/backend/python/tests/conftest.py +++ b/backend/python/tests/conftest.py @@ -251,9 +251,22 @@ async def test_driver( test_session: AsyncSession, sample_driver_data: dict[str, Any] ) -> Any: """Create a test driver in the database.""" + from app.models.user import User from app.models.driver import Driver - driver = Driver(**sample_driver_data) + user = User( + name=sample_driver_data["name"], + email=sample_driver_data['email'], + auth_id=sample_driver_data['auth_id'], + ) + test_session.add(user) + driver = Driver( + user_id=user.user_id, + phone=sample_driver_data["phone"], + address=sample_driver_data["address"], + license_plate=sample_driver_data["license_plate"], + car_make_model=sample_driver_data["car_make_model"], + ) test_session.add(driver) await test_session.commit() await test_session.refresh(driver) diff --git a/backend/python/tests/test_models.py b/backend/python/tests/test_models.py index bcab1a84..e5bf11e8 100644 --- a/backend/python/tests/test_models.py +++ b/backend/python/tests/test_models.py @@ -4,10 +4,13 @@ """ import pytest -from pydantic import ValidationError +from pydantic import ValidationError +from uuid import uuid4 # Initialize all models to ensure proper relationship resolution from app.models import init_app +from app.models.user import User, UserCreate +from app.models.system_settings import SystemSettings from app.models.admin import Admin from app.models.driver import ( Driver, @@ -50,23 +53,30 @@ def test_phone_validation_across_models(self) -> None: valid_phone = "+12125551234" # Test Driver phone validation - driver = Driver( + driver_user = User( name="Test Driver", email="test@example.com", + auth_id="test-123", + ) + driver = Driver( + user_id = driver_user.user_id, phone=valid_phone, address="123 Main St", license_plate="ABC123", car_make_model="Toyota Camry", - auth_id="test-123", ) # Phone gets formatted to E164 format assert driver.phone.startswith("+") # Test Admin phone validation + admin_user = User( + name="Test Admin", + email="admin@example.com", + auth_id="test-1234", + ) admin = Admin( - admin_name="Test Admin", + user_id=admin_user.user_id, admin_phone=valid_phone, - admin_email="admin@example.com", ) assert admin.admin_phone.startswith("+") @@ -96,29 +106,21 @@ def test_email_validation_across_models(self) -> None: ] for email in valid_emails: - driver = Driver( + driver_user = User( name="Test Driver", email=email, - phone="+12125551234", - address="123 Main St", - license_plate="ABC123", - car_make_model="Toyota Camry", auth_id="test-123", ) - assert driver.email == email + assert driver_user.email == email # Test invalid emails invalid_emails = ["invalid-email", "@domain.com", "user@", "user.domain.com"] for email in invalid_emails: with pytest.raises(ValidationError) as exc_info: - Driver( + driver_user = User( name="Test Driver", email=email, - phone="+12125551234", - address="123 Main St", - license_plate="ABC123", - car_make_model="Toyota Camry", auth_id="test-123", ) assert "email" in str(exc_info.value) @@ -292,35 +294,42 @@ class TestCoreModels: def test_driver_core_operations(self) -> None: """Test Driver model core operations.""" # Create - driver = Driver( + driver_user = User( name="John Doe", email="john.doe@example.com", + auth_id="auth-123", + ) + driver = Driver( + user_id=driver_user.user_id, phone="+12125551234", address="123 Main St, City, State 12345", license_plate="ABC123", car_make_model="Toyota Camry", - auth_id="auth-123", ) - assert driver.name == "John Doe" + assert driver_user.name == "John Doe" assert driver.active is True # Default value assert driver.created_at is not None # Create model - driver_create = DriverCreate( + user_create = UserCreate( name="Jane Doe", email="jane.doe@example.com", + password="securepassword123", + ) + driver_create = DriverCreate( + user_id=uuid4(), phone="+12125551234", address="456 Oak Ave, City, State 12345", license_plate="XYZ789", car_make_model="Honda Civic", - password="securepassword123", ) - assert driver_create.name == "Jane Doe" + assert user_create.name == "Jane Doe" + assert driver_create.license_plate == "XYZ789" # Update model - driver_update = DriverUpdate(name="Updated Name") - assert driver_update.name == "Updated Name" - assert driver_update.email is None + driver_update = DriverUpdate(address="456 Oak Ave, City, State 12345") + assert driver_update.address == "456 Oak Ave, City, State 12345" + assert driver_update.license_plate is None def test_location_core_operations(self) -> None: """Test Location model core operations.""" @@ -355,8 +364,6 @@ def test_location_core_operations(self) -> None: assert location_minimal.notes == "" # Default value # Read model - from uuid import uuid4 - location_read = LocationRead( location_id=uuid4(), contact_name="Jane Smith", @@ -563,7 +570,6 @@ def test_all_enum_values_and_serialization(self) -> None: assert SimpleEntityEnum.D.value == "D" # Test enum serialization in models - from uuid import uuid4 # Test ProgressEnum serialization job = Job( @@ -574,37 +580,49 @@ def test_all_enum_values_and_serialization(self) -> None: assert job_dict["progress"] == "Running" # Test RoleEnum serialization (if used in models) - driver = Driver( + user = User( name="Test Driver", email="test@example.com", + auth_id="test-123", + ) + driver = Driver( + user_id=user.user_id, phone="+12125551234", address="123 Main St", license_plate="ABC123", car_make_model="Toyota Camry", - auth_id="test-123", ) + user_dict = user.model_dump() driver_dict = driver.model_dump() - assert driver_dict["name"] == "Test Driver" - assert driver_dict["email"] == "test@example.com" + assert user_dict["name"] == "Test Driver" + assert user_dict["email"] == "test@example.com" + assert driver_dict["phone"] == "+12125551234" + assert driver_dict["license_plate"] == "ABC123" + def test_model_serialization_and_defaults(self) -> None: """Test model serialization and default value handling.""" # Test that model_dump works correctly - driver = Driver( + user = User( name="Test Driver", email="test@example.com", + auth_id="test-123", + ) + driver = Driver( + user_id = user.user_id, phone="+12125551234", address="123 Main St", license_plate="ABC123", car_make_model="Toyota Camry", - auth_id="test-123", ) + user_dict = user.model_dump() driver_dict = driver.model_dump() - assert "name" in driver_dict + assert "name" in user_dict assert "created_at" in driver_dict # updated_at should be None and might be excluded assert driver_dict["active"] is True # Default value + assert user_dict["role"] is "driver" # Test default values across models location = Location( @@ -664,14 +682,21 @@ def test_numeric_field_validation(self) -> None: def test_optional_field_handling(self) -> None: """Test that optional fields work correctly.""" # Test Admin with only required fields + user = User( + name="Jane Admin", + email="jane@example.com", + auth_id="test-123", + role="admin", + ) admin = Admin( - admin_name="Jane Admin", + user_id=user.user_id, admin_phone="+12125551234", - admin_email="jane@example.com", ) - assert admin.default_cap is None - assert admin.route_start_time is None - assert admin.warehouse_location is None + system_settings = SystemSettings() + assert admin.receive_email_notifications is True + assert system_settings.default_cap is None + assert system_settings.route_start_time is None + assert system_settings.warehouse_location is None # Test Job without route_group_id job = Job( From d34d6f74144719ed8297416615938c9b1f8966ad Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 23 Nov 2025 22:29:11 -0500 Subject: [PATCH 10/19] lint --- backend/python/app/dependencies/auth.py | 2 +- backend/python/app/dependencies/services.py | 2 +- backend/python/app/models/__init__.py | 4 ++-- backend/python/app/models/admin.py | 6 ++---- backend/python/app/models/driver.py | 5 ++--- backend/python/app/models/system_settings.py | 3 --- backend/python/app/models/user.py | 2 +- backend/python/app/routers/admin_routes.py | 6 ++---- backend/python/app/routers/auth_routes.py | 8 ++++++-- backend/python/app/seed_database.py | 7 ++++--- .../app/services/implementations/auth_service.py | 2 +- .../app/services/implementations/driver_service.py | 2 -- backend/python/tests/conftest.py | 6 +++--- backend/python/tests/test_models.py | 11 ++++++----- 14 files changed, 31 insertions(+), 35 deletions(-) diff --git a/backend/python/app/dependencies/auth.py b/backend/python/app/dependencies/auth.py index 4ffd453a..6ab4b671 100644 --- a/backend/python/app/dependencies/auth.py +++ b/backend/python/app/dependencies/auth.py @@ -11,8 +11,8 @@ from app.models import get_session from app.services.implementations.auth_service import AuthService from app.services.implementations.driver_service import DriverService -from app.services.implementations.user_service import UserService from app.services.implementations.email_service import EmailService +from app.services.implementations.user_service import UserService # Initialize services logger = logging.getLogger(__name__) diff --git a/backend/python/app/dependencies/services.py b/backend/python/app/dependencies/services.py index 5c0f8834..5d943c09 100644 --- a/backend/python/app/dependencies/services.py +++ b/backend/python/app/dependencies/services.py @@ -22,8 +22,8 @@ from app.services.implementations.route_group_service import RouteGroupService from app.services.implementations.scheduler_service import SchedulerService from app.services.implementations.simple_entity_service import SimpleEntityService -from app.services.protocols.routing_algorithm import RoutingAlgorithmProtocol from app.services.implementations.user_service import UserService +from app.services.protocols.routing_algorithm import RoutingAlgorithmProtocol @lru_cache diff --git a/backend/python/app/models/__init__.py b/backend/python/app/models/__init__.py index c4b5019b..62bc5e78 100644 --- a/backend/python/app/models/__init__.py +++ b/backend/python/app/models/__init__.py @@ -93,8 +93,8 @@ def init_app(_app: Any | None = None) -> None: from .route_group_membership import RouteGroupMembership # noqa: F401 from .route_stop import RouteStop # noqa: F401 from .simple_entity import SimpleEntity # noqa: F401 - from .system_settings import SystemSettings #noqa: F401 - from .user import User # noqa: F401 + from .system_settings import SystemSettings #noqa: F401 + from .user import User # noqa: F401 init_database() diff --git a/backend/python/app/models/admin.py b/backend/python/app/models/admin.py index bd9cf120..183991b3 100644 --- a/backend/python/app/models/admin.py +++ b/backend/python/app/models/admin.py @@ -1,13 +1,11 @@ -import datetime from uuid import UUID, uuid4 from pydantic import EmailStr, field_validator -from sqlmodel import Field, SQLModel, Relationship - -from app.utilities.utils import validate_phone +from sqlmodel import Field, Relationship, SQLModel from app.models.user import User +from app.utilities.utils import validate_phone from .base import BaseModel diff --git a/backend/python/app/models/driver.py b/backend/python/app/models/driver.py index 881e908f..6efcbffe 100644 --- a/backend/python/app/models/driver.py +++ b/backend/python/app/models/driver.py @@ -1,14 +1,13 @@ from uuid import UUID, uuid4 from pydantic import EmailStr, field_validator -from sqlmodel import Field, SQLModel, Relationship +from sqlmodel import Field, Relationship, SQLModel +from app.models.user import User from app.utilities.utils import validate_phone from .base import BaseModel -from app.models.user import User - class DriverBase(SQLModel): phone: str = Field(min_length=1, max_length=20) diff --git a/backend/python/app/models/system_settings.py b/backend/python/app/models/system_settings.py index 8aa17ce9..f11afc16 100644 --- a/backend/python/app/models/system_settings.py +++ b/backend/python/app/models/system_settings.py @@ -1,11 +1,8 @@ import datetime from uuid import UUID, uuid4 -from pydantic import EmailStr, field_validator from sqlmodel import Field, SQLModel -from app.utilities.utils import validate_phone - from .base import BaseModel diff --git a/backend/python/app/models/user.py b/backend/python/app/models/user.py index b980a6b1..e2a91de8 100644 --- a/backend/python/app/models/user.py +++ b/backend/python/app/models/user.py @@ -1,6 +1,6 @@ from uuid import UUID, uuid4 -from pydantic import EmailStr, field_validator +from pydantic import EmailStr from sqlmodel import Field, SQLModel from .base import BaseModel diff --git a/backend/python/app/routers/admin_routes.py b/backend/python/app/routers/admin_routes.py index 8db97e99..fc463efa 100644 --- a/backend/python/app/routers/admin_routes.py +++ b/backend/python/app/routers/admin_routes.py @@ -1,12 +1,10 @@ import logging -from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession -from app.dependencies.auth import require_authorization_by_role +from app.dependencies.auth import require_authorization_by_role from app.models import get_session -from app.models.driver import DriverCreate, DriverRead, DriverUpdate from app.services.implementations.driver_service import DriverService # Initialize service diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index def01c64..44723a17 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -2,15 +2,19 @@ import traceback from typing import Literal, cast from uuid import UUID -import firebase_admin.auth +import firebase_admin.auth from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import EmailStr from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.dependencies.auth import get_current_database_user_id, get_current_user_email -from app.dependencies.services import get_auth_service, get_driver_service, get_user_service +from app.dependencies.services import ( + get_auth_service, + get_driver_service, + get_user_service, +) from app.models import get_session from app.models.driver import DriverCreate, DriverRegister from app.models.user import UserCreate diff --git a/backend/python/app/seed_database.py b/backend/python/app/seed_database.py index ffec3f92..0b356e59 100644 --- a/backend/python/app/seed_database.py +++ b/backend/python/app/seed_database.py @@ -17,9 +17,6 @@ from sqlalchemy import create_engine, not_, text from sqlmodel import Session, select -# Import all models to register them with SQLModel -from app.models.user import User -from app.models.system_settings import SystemSettings from app.models.admin import Admin from app.models.base import BaseModel from app.models.driver import Driver @@ -33,6 +30,10 @@ from app.models.route_group import RouteGroup from app.models.route_group_membership import RouteGroupMembership from app.models.route_stop import RouteStop +from app.models.system_settings import SystemSettings + +# Import all models to register them with SQLModel +from app.models.user import User # Initialize Faker fake = faker.Faker() diff --git a/backend/python/app/services/implementations/auth_service.py b/backend/python/app/services/implementations/auth_service.py index 6add6530..65e324e4 100644 --- a/backend/python/app/services/implementations/auth_service.py +++ b/backend/python/app/services/implementations/auth_service.py @@ -14,8 +14,8 @@ from firebase_admin.auth import UserRecord from app.services.implementations.driver_service import DriverService - from app.services.implementations.user_service import UserService from app.services.implementations.email_service import EmailService + from app.services.implementations.user_service import UserService class AuthService: diff --git a/backend/python/app/services/implementations/driver_service.py b/backend/python/app/services/implementations/driver_service.py index 3c91763f..b4040dc3 100644 --- a/backend/python/app/services/implementations/driver_service.py +++ b/backend/python/app/services/implementations/driver_service.py @@ -1,5 +1,4 @@ import logging -from typing import TYPE_CHECKING from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession @@ -9,7 +8,6 @@ from app.models.user import User - class DriverService: """Modern FastAPI-style driver service""" diff --git a/backend/python/tests/conftest.py b/backend/python/tests/conftest.py index 1e75d155..5d776bea 100644 --- a/backend/python/tests/conftest.py +++ b/backend/python/tests/conftest.py @@ -251,13 +251,13 @@ async def test_driver( test_session: AsyncSession, sample_driver_data: dict[str, Any] ) -> Any: """Create a test driver in the database.""" - from app.models.user import User from app.models.driver import Driver + from app.models.user import User user = User( name=sample_driver_data["name"], - email=sample_driver_data['email'], - auth_id=sample_driver_data['auth_id'], + email=sample_driver_data["email"], + auth_id=sample_driver_data["auth_id"], ) test_session.add(user) driver = Driver( diff --git a/backend/python/tests/test_models.py b/backend/python/tests/test_models.py index e5bf11e8..faf63b06 100644 --- a/backend/python/tests/test_models.py +++ b/backend/python/tests/test_models.py @@ -3,14 +3,13 @@ Reduced from 92 tests to ~60 tests by removing redundancy and focusing on core business logic. """ -import pytest -from pydantic import ValidationError from uuid import uuid4 +import pytest +from pydantic import ValidationError + # Initialize all models to ensure proper relationship resolution from app.models import init_app -from app.models.user import User, UserCreate -from app.models.system_settings import SystemSettings from app.models.admin import Admin from app.models.driver import ( Driver, @@ -40,6 +39,8 @@ ) from app.models.route_group_membership import RouteGroupMembership from app.models.route_stop import RouteStop +from app.models.system_settings import SystemSettings +from app.models.user import User, UserCreate init_app() @@ -622,7 +623,7 @@ def test_model_serialization_and_defaults(self) -> None: assert "created_at" in driver_dict # updated_at should be None and might be excluded assert driver_dict["active"] is True # Default value - assert user_dict["role"] is "driver" + assert user_dict["role"] == "driver" # Test default values across models location = Location( From 89b0ce6c94a2b0aebc8a7419fef5227db4ec5f61 Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Sun, 23 Nov 2025 22:34:49 -0500 Subject: [PATCH 11/19] format --- backend/python/app/models/__init__.py | 2 +- backend/python/app/models/admin.py | 4 +++- backend/python/app/models/driver.py | 2 ++ backend/python/app/models/system_settings.py | 2 ++ backend/python/app/models/user.py | 1 + backend/python/app/routers/admin_routes.py | 3 --- backend/python/app/routers/auth_routes.py | 10 ++++---- backend/python/app/seed_database.py | 4 ++-- .../services/implementations/auth_service.py | 10 ++++---- .../implementations/driver_service.py | 24 ++++--------------- .../services/implementations/user_service.py | 12 +++------- backend/python/tests/test_models.py | 5 ++-- 12 files changed, 30 insertions(+), 49 deletions(-) diff --git a/backend/python/app/models/__init__.py b/backend/python/app/models/__init__.py index 62bc5e78..a406b7f0 100644 --- a/backend/python/app/models/__init__.py +++ b/backend/python/app/models/__init__.py @@ -93,7 +93,7 @@ def init_app(_app: Any | None = None) -> None: from .route_group_membership import RouteGroupMembership # noqa: F401 from .route_stop import RouteStop # noqa: F401 from .simple_entity import SimpleEntity # noqa: F401 - from .system_settings import SystemSettings #noqa: F401 + from .system_settings import SystemSettings # noqa: F401 from .user import User # noqa: F401 init_database() diff --git a/backend/python/app/models/admin.py b/backend/python/app/models/admin.py index 183991b3..fbe5ce21 100644 --- a/backend/python/app/models/admin.py +++ b/backend/python/app/models/admin.py @@ -1,4 +1,3 @@ - from uuid import UUID, uuid4 from pydantic import EmailStr, field_validator @@ -36,12 +35,14 @@ class Admin(AdminBase, BaseModel, table=True): class AdminCreate(AdminBase): """Create request model""" + user_id: UUID pass class AdminRead(AdminBase): """Read response model""" + admin_id: UUID user_id: UUID @@ -54,6 +55,7 @@ class AdminRead(AdminBase): class AdminUpdate(SQLModel): """Update request model - all optional""" + # admin-specific admin_phone: str | None = Field(default=None, min_length=1, max_length=100) diff --git a/backend/python/app/models/driver.py b/backend/python/app/models/driver.py index 6efcbffe..c9bfe7c0 100644 --- a/backend/python/app/models/driver.py +++ b/backend/python/app/models/driver.py @@ -44,6 +44,7 @@ class DriverRead(DriverBase): email: EmailStr role: str # comes from User + class DriverUpdate(SQLModel): phone: str | None = Field(default=None, min_length=1, max_length=20) address: str | None = Field(default=None, min_length=1, max_length=255) @@ -66,6 +67,7 @@ class DriverUpdatePayload(SQLModel): class DriverRegister(SQLModel): """Driver registration request""" + # User fields name: str = Field(min_length=1, max_length=255) email: EmailStr = Field(max_length=254) diff --git a/backend/python/app/models/system_settings.py b/backend/python/app/models/system_settings.py index f11afc16..f9c67e42 100644 --- a/backend/python/app/models/system_settings.py +++ b/backend/python/app/models/system_settings.py @@ -26,6 +26,7 @@ class SystemSettings(SystemSettingsBase, BaseModel, table=True): class SystemSettingsCreate(SystemSettingsBase): """Create request model""" + pass @@ -37,4 +38,5 @@ class SystemSettingsRead(SystemSettingsBase): class SystemSettingsUpdate(SystemSettingsBase): """Update request model - all optional""" + pass diff --git a/backend/python/app/models/user.py b/backend/python/app/models/user.py index e2a91de8..d468c413 100644 --- a/backend/python/app/models/user.py +++ b/backend/python/app/models/user.py @@ -33,6 +33,7 @@ class UserUpdate(SQLModel): name: str | None = Field(default=None, min_length=1, max_length=255) email: EmailStr | None = Field(default=None, max_length=254) + class UserRegister(SQLModel): """User registration request""" diff --git a/backend/python/app/routers/admin_routes.py b/backend/python/app/routers/admin_routes.py index fc463efa..d0cc70e8 100644 --- a/backend/python/app/routers/admin_routes.py +++ b/backend/python/app/routers/admin_routes.py @@ -1,10 +1,8 @@ import logging from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies.auth import require_authorization_by_role -from app.models import get_session from app.services.implementations.driver_service import DriverService # Initialize service @@ -16,7 +14,6 @@ @router.get("/test", response_model=str) async def test( - session: AsyncSession = Depends(get_session), _: bool = Depends(require_authorization_by_role({"admin"})), ) -> str: """ diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index 44723a17..780048f7 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -97,25 +97,27 @@ async def register( session: AsyncSession = Depends(get_session), auth_service: AuthService = Depends(get_auth_service), driver_service: DriverService = Depends(get_driver_service), - user_service: UserService = Depends(get_user_service) + user_service: UserService = Depends(get_user_service), ) -> AuthResponse: """ Returns access token and driver info in response body and sets refreshToken as an httpOnly cookie """ try: - #Create user first + # Create user first user_data = register_request.model_dump(include=UserCreate.model_fields.keys()) user_create = UserCreate(**user_data) user = await user_service.create_user(session, user_create) firebase_admin.auth.set_custom_user_claims(user.auth_id, {"role": user.role}) # Create driver after - driver_data = register_request.model_dump(include=DriverCreate.model_fields.keys()) + driver_data = register_request.model_dump( + include=DriverCreate.model_fields.keys() + ) driver_data["user_id"] = user.user_id driver = DriverCreate(**driver_data) await driver_service.create_driver(session, driver) - #please work + # please work auth_dto, refresh_token = await auth_service.generate_token( session, register_request.email, register_request.password diff --git a/backend/python/app/seed_database.py b/backend/python/app/seed_database.py index 0b356e59..d30acfde 100644 --- a/backend/python/app/seed_database.py +++ b/backend/python/app/seed_database.py @@ -518,8 +518,8 @@ def main() -> None: set_timestamps(user) session.add(user) - driver = Driver( - user_id = user.user_id, + driver = Driver( + user_id=user.user_id, phone=generate_valid_phone(), address=fake.address(), license_plate=fake.license_plate(), diff --git a/backend/python/app/services/implementations/auth_service.py b/backend/python/app/services/implementations/auth_service.py index 65e324e4..67b7da6b 100644 --- a/backend/python/app/services/implementations/auth_service.py +++ b/backend/python/app/services/implementations/auth_service.py @@ -118,9 +118,9 @@ async def generate_token_for_oauth( password="", ), auth_id=user_id, - signup_method="GOOGLE" + signup_method="GOOGLE", ) - driver = await self.driver_service.create_driver( + await self.driver_service.create_driver( session, DriverCreate( phone="", # OAuth users don't have phone initially @@ -128,7 +128,7 @@ async def generate_token_for_oauth( license_plate="", # OAuth users don't have license plate initially car_make_model="", # OAuth users don't have car info initially user_id=user.user_id, - ) + ), ) return AuthResponse( access_token=id_token, @@ -145,9 +145,7 @@ async def generate_token_for_oauth( async def revoke_tokens(self, session: AsyncSession, user_id: UUID) -> None: try: - auth_id = await self.user_service.get_auth_id_by_user_id( - session, user_id - ) + auth_id = await self.user_service.get_auth_id_by_user_id(session, user_id) firebase_admin.auth.revoke_refresh_tokens(auth_id) except Exception as e: reason = getattr(e, "message", None) diff --git a/backend/python/app/services/implementations/driver_service.py b/backend/python/app/services/implementations/driver_service.py index b4040dc3..05071793 100644 --- a/backend/python/app/services/implementations/driver_service.py +++ b/backend/python/app/services/implementations/driver_service.py @@ -37,11 +37,7 @@ async def get_driver_by_email( ) -> Driver | None: """Get driver by email using Firebase""" try: - statement = ( - select(Driver) - .join(Driver.user) - .where(User.email == email) - ) + statement = select(Driver).join(Driver.user).where(User.email == email) result = await session.execute(statement) driver = result.scalars().first() @@ -59,11 +55,7 @@ async def get_driver_by_auth_id( ) -> Driver | None: """Get driver by auth_id""" try: - statement = ( - select(Driver) - .join(Driver.user) - .where(User.auth_id == auth_id) - ) + statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) result = await session.execute(statement) driver = result.scalars().first() @@ -206,11 +198,7 @@ async def get_driver_id_by_auth_id( ) -> UUID | None: """Get driver_id by auth_id""" try: - statement = ( - select(Driver) - .join(Driver.user) - .where(User.auth_id == auth_id) - ) + statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) result = await session.execute(statement) driver = result.scalars().first() @@ -226,11 +214,7 @@ async def get_driver_id_by_auth_id( async def delete_driver_by_email(self, session: AsyncSession, email: str) -> None: """Delete driver by email""" try: - statement = ( - select(Driver) - .join(Driver.user) - .where(User.email == email) - ) + statement = select(Driver).join(Driver.user).where(User.email == email) result = await session.execute(statement) driver = result.scalars().first() diff --git a/backend/python/app/services/implementations/user_service.py b/backend/python/app/services/implementations/user_service.py index f4360538..1c2e0c73 100644 --- a/backend/python/app/services/implementations/user_service.py +++ b/backend/python/app/services/implementations/user_service.py @@ -18,9 +18,7 @@ class UserService: def __init__(self, logger: logging.Logger): self.logger = logger - async def get_user_by_id( - self, session: AsyncSession, user_id: UUID - ) -> User | None: + async def get_user_by_id(self, session: AsyncSession, user_id: UUID) -> User | None: """Get user by ID - returns SQLModel instance""" try: statement = select(User).where(User.user_id == user_id) @@ -36,9 +34,7 @@ async def get_user_by_id( self.logger.error(f"Failed to get user by id: {e!s}") raise e - async def get_user_by_email( - self, session: AsyncSession, email: str - ) -> User | None: + async def get_user_by_email(self, session: AsyncSession, email: str) -> User | None: """Get user by email using Firebase""" try: firebase_user: UserRecord = firebase_admin.auth.get_user_by_email(email) @@ -160,9 +156,7 @@ async def update_user_by_id( # Update Firebase email try: if user_data.email is not None: - firebase_admin.auth.update_user( - user.auth_id, email=user_data.email - ) + firebase_admin.auth.update_user(user.auth_id, email=user_data.email) await session.refresh(user) return user diff --git a/backend/python/tests/test_models.py b/backend/python/tests/test_models.py index faf63b06..d4ee55b4 100644 --- a/backend/python/tests/test_models.py +++ b/backend/python/tests/test_models.py @@ -60,7 +60,7 @@ def test_phone_validation_across_models(self) -> None: auth_id="test-123", ) driver = Driver( - user_id = driver_user.user_id, + user_id=driver_user.user_id, phone=valid_phone, address="123 Main St", license_plate="ABC123", @@ -600,7 +600,6 @@ def test_all_enum_values_and_serialization(self) -> None: assert driver_dict["phone"] == "+12125551234" assert driver_dict["license_plate"] == "ABC123" - def test_model_serialization_and_defaults(self) -> None: """Test model serialization and default value handling.""" # Test that model_dump works correctly @@ -610,7 +609,7 @@ def test_model_serialization_and_defaults(self) -> None: auth_id="test-123", ) driver = Driver( - user_id = user.user_id, + user_id=user.user_id, phone="+12125551234", address="123 Main St", license_plate="ABC123", From 361411596514976a9c5db22719ee3bcb84663551 Mon Sep 17 00:00:00 2001 From: Hy Lac Date: Thu, 27 Nov 2025 20:13:33 -0500 Subject: [PATCH 12/19] Fix type checking issues --- backend/python/app/routers/auth_routes.py | 6 +++-- .../implementations/driver_service.py | 11 +++++--- .../ba76119b3e4c_update_user_system.py | 8 ++---- backend/python/tests/test_models.py | 26 +++++++++++++------ 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index 780048f7..afd4c38a 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -104,14 +104,16 @@ async def register( """ try: # Create user first - user_data = register_request.model_dump(include=UserCreate.model_fields.keys()) + user_data = register_request.model_dump( + include=set(UserCreate.model_fields.keys()) + ) user_create = UserCreate(**user_data) user = await user_service.create_user(session, user_create) firebase_admin.auth.set_custom_user_claims(user.auth_id, {"role": user.role}) # Create driver after driver_data = register_request.model_dump( - include=DriverCreate.model_fields.keys() + include=set(DriverCreate.model_fields.keys()) ) driver_data["user_id"] = user.user_id driver = DriverCreate(**driver_data) diff --git a/backend/python/app/services/implementations/driver_service.py b/backend/python/app/services/implementations/driver_service.py index 05071793..ea6d359d 100644 --- a/backend/python/app/services/implementations/driver_service.py +++ b/backend/python/app/services/implementations/driver_service.py @@ -37,7 +37,7 @@ async def get_driver_by_email( ) -> Driver | None: """Get driver by email using Firebase""" try: - statement = select(Driver).join(Driver.user).where(User.email == email) + statement = select(Driver).join(Driver.user).where(User.email == email) # type: ignore[arg-type] result = await session.execute(statement) driver = result.scalars().first() @@ -55,7 +55,7 @@ async def get_driver_by_auth_id( ) -> Driver | None: """Get driver by auth_id""" try: - statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) + statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) # type: ignore[arg-type] result = await session.execute(statement) driver = result.scalars().first() @@ -144,9 +144,12 @@ async def update_driver_by_id( driver.notes = driver_data.notes await session.commit() + await session.refresh(driver) + return driver except Exception as e: # Rollback database changes + assert driver is not None driver.phone = old_phone driver.address = old_address driver.license_plate = old_license_plate @@ -198,7 +201,7 @@ async def get_driver_id_by_auth_id( ) -> UUID | None: """Get driver_id by auth_id""" try: - statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) + statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) # type: ignore[arg-type] result = await session.execute(statement) driver = result.scalars().first() @@ -214,7 +217,7 @@ async def get_driver_id_by_auth_id( async def delete_driver_by_email(self, session: AsyncSession, email: str) -> None: """Delete driver by email""" try: - statement = select(Driver).join(Driver.user).where(User.email == email) + statement = select(Driver).join(Driver.user).where(User.email == email) # type: ignore[arg-type] result = await session.execute(statement) driver = result.scalars().first() diff --git a/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py index fec959ce..aa3978b8 100644 --- a/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py +++ b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py @@ -17,7 +17,7 @@ depends_on = None -def upgrade(): +def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table('system_settings', sa.Column('created_at', sa.DateTime(), nullable=True), @@ -63,13 +63,11 @@ def upgrade(): # ### end Alembic commands ### -def downgrade(): +def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column('drivers', sa.Column('auth_id', sa.VARCHAR(), autoincrement=False, nullable=False)) op.add_column('drivers', sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=False)) op.add_column('drivers', sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) - op.drop_constraint(None, 'drivers', type_='foreignkey') - op.drop_constraint(None, 'drivers', type_='unique') op.create_index(op.f('ix_drivers_email'), 'drivers', ['email'], unique=True) op.create_index(op.f('ix_drivers_auth_id'), 'drivers', ['auth_id'], unique=True) op.drop_column('drivers', 'user_id') @@ -78,8 +76,6 @@ def downgrade(): op.add_column('admin_info', sa.Column('route_start_time', postgresql.TIME(), autoincrement=False, nullable=True)) op.add_column('admin_info', sa.Column('default_cap', sa.INTEGER(), autoincrement=False, nullable=True)) op.add_column('admin_info', sa.Column('admin_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False)) - op.drop_constraint(None, 'admin_info', type_='foreignkey') - op.drop_constraint(None, 'admin_info', type_='unique') op.drop_column('admin_info', 'user_id') op.drop_column('admin_info', 'receive_email_notifications') op.drop_index(op.f('ix_users_user_id'), table_name='users') diff --git a/backend/python/tests/test_models.py b/backend/python/tests/test_models.py index d4ee55b4..04bcc9c0 100644 --- a/backend/python/tests/test_models.py +++ b/backend/python/tests/test_models.py @@ -86,14 +86,17 @@ def test_phone_validation_across_models(self) -> None: for phone in invalid_phones: with pytest.raises(ValidationError) as exc_info: - Driver( + user = User( name="Test Driver", email="test@example.com", + auth_id="test-123", + ) + Driver( + user_id=user.user_id, phone=phone, address="123 Main St", license_plate="ABC123", car_make_model="Toyota Camry", - auth_id="test-123", ) assert "phone" in str(exc_info.value) @@ -647,24 +650,31 @@ def test_string_field_validation(self) -> None: """Test string field validation across models.""" # Test empty string validation with pytest.raises(ValidationError) as exc_info: - Driver( + user = User( name="", # Empty name should fail email="test@example.com", + auth_id="test-123", + ) + Driver( + user_id=user.user_id, phone="+12125551234", address="123 Main St", license_plate="ABC123", car_make_model="Toyota Camry", - auth_id="test-123", ) assert "name" in str(exc_info.value) with pytest.raises(ValidationError) as exc_info: + user = User( + name="test-admin", + email="admin@example.com", + auth_id="test-123", + ) Admin( - admin_name="", # Empty name should fail - admin_phone="+12125551234", - admin_email="admin@example.com", + user_id=user.user_id, + admin_phone="", # Empty phone fails ) - assert "admin_name" in str(exc_info.value) + assert "admin_phone" in str(exc_info.value) def test_numeric_field_validation(self) -> None: """Test numeric field validation.""" From cb65fd2135629d1acc85c5abce71d302d4fb8a33 Mon Sep 17 00:00:00 2001 From: David Lu Date: Sat, 27 Dec 2025 14:09:54 -0700 Subject: [PATCH 13/19] Edited Claude yml to debug --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/claude.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 48089420..6559d976 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ ## JIRA ticket link -[Ticket Name](https://www.notion.so/uwblueprintexecs/Task-Board-db95cd7b93f245f78ee85e3a8a6a316d) +[Ticket Name](https://f4kblueprint.atlassian.net/browse/F4KRP-21) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 1cba274f..2de5a869 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -37,6 +37,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + show_full_output: true # Add this line here # Optional: Customize the trigger phrase (default: @claude) # trigger_phrase: "/claude" From 6a4d7d0ec62dbe1570fe6e41a99bbfe8766c0be3 Mon Sep 17 00:00:00 2001 From: David Lu Date: Sat, 27 Dec 2025 14:21:40 -0700 Subject: [PATCH 14/19] removed secret --- .github/workflows/claude.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 2de5a869..ecb78266 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -36,7 +36,6 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} show_full_output: true # Add this line here # Optional: Customize the trigger phrase (default: @claude) From 716f128acc0dfb2dbf492115fa8537dd507af7d7 Mon Sep 17 00:00:00 2001 From: David Lu <151972620+ludavidca@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:48:22 -0700 Subject: [PATCH 15/19] fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- backend/python/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/python/tests/test_models.py b/backend/python/tests/test_models.py index 715b117e..f48af1b1 100644 --- a/backend/python/tests/test_models.py +++ b/backend/python/tests/test_models.py @@ -122,7 +122,7 @@ def test_email_validation_across_models(self) -> None: for email in invalid_emails: with pytest.raises(ValidationError) as exc_info: - driver_user = User( + User( name="Test Driver", email=email, auth_id="test-123", From 75627edc99a973c904dcd6384b7008822fcdc73a Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 30 Dec 2025 11:46:31 -0500 Subject: [PATCH 16/19] Order migration after existing migrations --- .../migrations/versions/ba76119b3e4c_update_user_system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py index aa3978b8..37885c35 100644 --- a/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py +++ b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py @@ -1,7 +1,7 @@ """update_user_system Revision ID: ba76119b3e4c -Revises: 7af7d4689b08 +Revises: b1c2d3e4f5a6 Create Date: 2025-11-24 01:25:48.941556 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = 'ba76119b3e4c' -down_revision = '7af7d4689b08' +down_revision = 'b1c2d3e4f5a6' branch_labels = None depends_on = None From 4cc45e5935818a6bc8ab2b5968f10c7ed9dca73b Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 30 Dec 2025 11:48:00 -0500 Subject: [PATCH 17/19] Add tests --- backend/python/app/routers/driver_routes.py | 32 +- .../python/app/routers/route_group_routes.py | 22 +- backend/python/app/seed_database.py | 15 +- .../implementations/driver_service.py | 44 +- .../implementations/route_group_service.py | 4 +- .../app/services/jobs/email_reminder_jobs.py | 7 +- backend/python/requirements.txt | 2 +- backend/python/tests/conftest.py | 16 +- backend/python/tests/data/test_locations.csv | 32 ++ backend/python/tests/test_routes.py | 421 ++++++++++++++++++ backend/python/tests/test_seed_database.py | 275 ++++++++++++ 11 files changed, 823 insertions(+), 47 deletions(-) create mode 100644 backend/python/tests/data/test_locations.csv create mode 100644 backend/python/tests/test_routes.py create mode 100644 backend/python/tests/test_seed_database.py diff --git a/backend/python/app/routers/driver_routes.py b/backend/python/app/routers/driver_routes.py index ee4c141f..22f7be8d 100644 --- a/backend/python/app/routers/driver_routes.py +++ b/backend/python/app/routers/driver_routes.py @@ -1,4 +1,5 @@ import logging +from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -15,6 +16,25 @@ router = APIRouter(prefix="/drivers", tags=["drivers"]) +def driver_to_driver_read(driver: Any) -> DriverRead: + """Convert a Driver model instance to DriverRead.""" + return DriverRead( + driver_id=driver.driver_id, + user_id=driver.user_id, + phone=driver.phone, + license_plate=driver.license_plate, + car_make_model=driver.car_make_model, + active=driver.active, + notes=driver.notes, + address=driver.address, + # User fields + auth_id=driver.user.auth_id, + name=driver.user.name, + email=driver.user.email, + role=driver.user.role, + ) + + @router.get("/", response_model=list[DriverRead]) async def get_drivers( session: AsyncSession = Depends(get_session), @@ -39,7 +59,7 @@ async def get_drivers( status_code=status.HTTP_404_NOT_FOUND, detail=f"Driver with id {driver_id} not found", ) - return [DriverRead.model_validate(driver)] + return [driver_to_driver_read(driver)] elif email: driver = await driver_service.get_driver_by_email(session, email) @@ -48,11 +68,11 @@ async def get_drivers( status_code=status.HTTP_404_NOT_FOUND, detail=f"Driver with email {email} not found", ) - return [DriverRead.model_validate(driver)] + return [driver_to_driver_read(driver)] else: drivers = await driver_service.get_drivers(session) - return [DriverRead.model_validate(driver) for driver in drivers] + return [driver_to_driver_read(driver) for driver in drivers] except HTTPException: raise @@ -77,7 +97,7 @@ async def get_driver( status_code=status.HTTP_404_NOT_FOUND, detail=f"Driver with id {driver_id} not found", ) - return DriverRead.model_validate(driver) + return driver_to_driver_read(driver) @router.post("/", response_model=DriverRead, status_code=status.HTTP_201_CREATED) @@ -91,7 +111,7 @@ async def create_driver( """ try: created_driver = await driver_service.create_driver(session, driver) - return DriverRead.model_validate(created_driver) + return driver_to_driver_read(created_driver) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) @@ -116,7 +136,7 @@ async def update_driver( status_code=status.HTTP_404_NOT_FOUND, detail=f"Driver with id {driver_id} not found", ) - return DriverRead.model_validate(updated_driver) + return driver_to_driver_read(updated_driver) @router.delete("/{driver_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/python/app/routers/route_group_routes.py b/backend/python/app/routers/route_group_routes.py index 80f2f040..e88fc106 100644 --- a/backend/python/app/routers/route_group_routes.py +++ b/backend/python/app/routers/route_group_routes.py @@ -33,9 +33,9 @@ async def get_route_groups( ) result = [] for route_group in route_groups: - data = RouteGroupRead.model_validate(route_group).model_dump() - membership_count = len(route_group.route_group_memberships) - data["num_routes"] = membership_count + data = RouteGroupRead.model_validate( + route_group, from_attributes=True + ).model_dump() if include_routes: data["routes"] = [ { @@ -76,15 +76,7 @@ async def create_route_group( created_route_group = await route_group_service.create_route_group( session, route_group ) - return RouteGroupRead( - route_group_id=created_route_group.route_group_id, - name=created_route_group.name, - notes=created_route_group.notes, - drive_date=created_route_group.drive_date, - created_at=created_route_group.created_at, - updated_at=created_route_group.updated_at, - num_routes=0, - ) + return RouteGroupRead.model_validate(created_route_group, from_attributes=True) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) @@ -110,7 +102,9 @@ async def update_route_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"RouteGroup with id {route_group_id} not found", ) - return RouteGroupRead.model_validate(updated_route_group) + return RouteGroupRead.model_validate(updated_route_group, from_attributes=True) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) @@ -133,6 +127,8 @@ async def delete_route_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"RouteGroup with id {route_group_id} not found", ) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) diff --git a/backend/python/app/seed_database.py b/backend/python/app/seed_database.py index 386b1c47..c1eb4210 100644 --- a/backend/python/app/seed_database.py +++ b/backend/python/app/seed_database.py @@ -405,7 +405,8 @@ def main() -> None: # Create locations from CSV print("Creating locations from CSV...") - csv_path = "app/data/locations.csv" + # Allow CSV path to be overridden via environment variable for testing + csv_path = os.getenv("LOCATIONS_CSV_PATH", "app/data/locations.csv") locations_created = 0 non_school_groups = [ @@ -507,12 +508,22 @@ def main() -> None: print("Creating drivers...") num_drivers = max(routes_created, MIN_DRIVERS) drivers_created = 0 + used_emails: set[str] = set() for _ in range(num_drivers): # Create a single driver with fake data + # Ensure unique email + email = fake.email() + max_email_attempts = 100 + email_attempts = 0 + while email in used_emails and email_attempts < max_email_attempts: + email = fake.email() + email_attempts += 1 + used_emails.add(email) + user = User( name=fake.name(), - email=fake.email(), + email=email, auth_id=f"seed_driver_{uuid.uuid4().hex[:8]}", ) set_timestamps(user) diff --git a/backend/python/app/services/implementations/driver_service.py b/backend/python/app/services/implementations/driver_service.py index ea6d359d..8422e0a8 100644 --- a/backend/python/app/services/implementations/driver_service.py +++ b/backend/python/app/services/implementations/driver_service.py @@ -2,6 +2,7 @@ from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from sqlmodel import select from app.models.driver import Driver, DriverCreate, DriverUpdate @@ -19,7 +20,11 @@ async def get_driver_by_id( ) -> Driver | None: """Get driver by ID - returns SQLModel instance""" try: - statement = select(Driver).where(Driver.driver_id == driver_id) + statement = ( + select(Driver) + .options(selectinload(Driver.user)) # type: ignore[arg-type] + .where(Driver.driver_id == driver_id) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -37,7 +42,12 @@ async def get_driver_by_email( ) -> Driver | None: """Get driver by email using Firebase""" try: - statement = select(Driver).join(Driver.user).where(User.email == email) # type: ignore[arg-type] + statement = ( + select(Driver) + .options(selectinload(Driver.user)) # type: ignore[arg-type] + .join(Driver.user) # type: ignore[arg-type] + .where(User.email == email) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -55,7 +65,11 @@ async def get_driver_by_auth_id( ) -> Driver | None: """Get driver by auth_id""" try: - statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) # type: ignore[arg-type] + statement = ( + select(Driver) + .join(Driver.user) # type: ignore[arg-type] + .where(User.auth_id == auth_id) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -71,7 +85,7 @@ async def get_driver_by_auth_id( async def get_drivers(self, session: AsyncSession) -> list[Driver]: """Get all drivers - returns SQLModel instances""" try: - statement = select(Driver) + statement = select(Driver).options(selectinload(Driver.user)) # type: ignore[arg-type] result = await session.execute(statement) return list(result.scalars().all()) except Exception as e: @@ -98,7 +112,7 @@ async def create_driver( try: session.add(driver) await session.commit() - await session.refresh(driver) + await session.refresh(driver, attribute_names=["user"]) return driver except Exception as db_error: @@ -113,7 +127,11 @@ async def update_driver_by_id( ) -> Driver | None: """Update driver by ID""" try: - statement = select(Driver).where(Driver.driver_id == driver_id) + statement = ( + select(Driver) + .options(selectinload(Driver.user)) # type: ignore[arg-type] + .where(Driver.driver_id == driver_id) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -144,7 +162,7 @@ async def update_driver_by_id( driver.notes = driver_data.notes await session.commit() - await session.refresh(driver) + await session.refresh(driver, attribute_names=["user"]) return driver except Exception as e: @@ -201,7 +219,11 @@ async def get_driver_id_by_auth_id( ) -> UUID | None: """Get driver_id by auth_id""" try: - statement = select(Driver).join(Driver.user).where(User.auth_id == auth_id) # type: ignore[arg-type] + statement = ( + select(Driver) + .join(Driver.user) # type: ignore[arg-type] + .where(User.auth_id == auth_id) + ) result = await session.execute(statement) driver = result.scalars().first() @@ -217,7 +239,11 @@ async def get_driver_id_by_auth_id( async def delete_driver_by_email(self, session: AsyncSession, email: str) -> None: """Delete driver by email""" try: - statement = select(Driver).join(Driver.user).where(User.email == email) # type: ignore[arg-type] + statement = ( + select(Driver) + .join(Driver.user) # type: ignore[arg-type] + .where(User.email == email) + ) result = await session.execute(statement) driver = result.scalars().first() diff --git a/backend/python/app/services/implementations/route_group_service.py b/backend/python/app/services/implementations/route_group_service.py index aae8770a..89640270 100644 --- a/backend/python/app/services/implementations/route_group_service.py +++ b/backend/python/app/services/implementations/route_group_service.py @@ -21,7 +21,7 @@ async def create_route_group( route_group = RouteGroup.model_validate(route_group_data) session.add(route_group) await session.commit() - await session.refresh(route_group) + await session.refresh(route_group, ["route_group_memberships"]) return route_group async def update_route_group( @@ -46,7 +46,7 @@ async def update_route_group( setattr(route_group, field, value) await session.commit() - await session.refresh(route_group) + await session.refresh(route_group, ["route_group_memberships"]) return route_group diff --git a/backend/python/app/services/jobs/email_reminder_jobs.py b/backend/python/app/services/jobs/email_reminder_jobs.py index 3ffeca24..98250973 100644 --- a/backend/python/app/services/jobs/email_reminder_jobs.py +++ b/backend/python/app/services/jobs/email_reminder_jobs.py @@ -12,6 +12,7 @@ from app.models.driver import Driver from app.models.driver_assignment import DriverAssignment from app.models.route import Route +from app.models.user import User from app.services.implementations.email_service import EmailService @@ -40,21 +41,21 @@ async def process_daily_reminder_emails() -> None: # Get all drivers assigned to routes tomorrow statement = ( select( - Driver.email, + User.email, DriverAssignment.time, Route.length, ) .join(Route, DriverAssignment.route_id == Route.route_id) # type: ignore[arg-type] .join(Driver, DriverAssignment.driver_id == Driver.driver_id) # type: ignore[arg-type] + .join(User, Driver.user_id == User.user_id) # type: ignore[arg-type] .where( and_( - Driver.email is not None, # type: ignore[arg-type] DriverAssignment.time >= start_of_day, # type: ignore[arg-type] DriverAssignment.time <= end_of_day, # type: ignore[arg-type] DriverAssignment.completed.is_(False), # type: ignore[attr-defined] ) ) - .order_by(Driver.email) + .order_by(User.email) ) result = await session.execute(statement) diff --git a/backend/python/requirements.txt b/backend/python/requirements.txt index eadf3d2c..6dcd59cc 100644 --- a/backend/python/requirements.txt +++ b/backend/python/requirements.txt @@ -74,7 +74,7 @@ numpy==1.26.4 scikit-learn==1.5.0 scikit-learn-extra==0.2.0 seaborn==0.13.2 -matplotlib==3.10.0 +matplotlib>=3.10.8 pandas==2.3.3 pandas-stubs types-seaborn diff --git a/backend/python/tests/conftest.py b/backend/python/tests/conftest.py index bfc4cc46..cd00b107 100644 --- a/backend/python/tests/conftest.py +++ b/backend/python/tests/conftest.py @@ -36,7 +36,7 @@ async def test_db_engine() -> AsyncGenerator[Any, None]: # Use PostgreSQL for testing to support ARRAY types database_url = os.getenv( - "TEST_DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/f4k" + "TEST_DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/f4k_test" ) engine = create_async_engine( @@ -105,11 +105,8 @@ def client(test_session: AsyncSession) -> Generator[TestClient, None, None]: app = create_app() # Override the database session dependency - def override_get_session() -> AsyncGenerator[AsyncSession, None]: - async def _get_session() -> AsyncGenerator[AsyncSession, None]: - yield test_session - - return _get_session() + async def override_get_session() -> AsyncGenerator[AsyncSession, None]: + yield test_session app.dependency_overrides[get_session] = override_get_session @@ -125,11 +122,8 @@ async def async_client( app = create_app() # Override the database session dependency - def override_get_session() -> AsyncGenerator[AsyncSession, None]: - async def _get_session() -> AsyncGenerator[AsyncSession, None]: - yield test_session - - return _get_session() + async def override_get_session() -> AsyncGenerator[AsyncSession, None]: + yield test_session app.dependency_overrides[get_session] = override_get_session diff --git a/backend/python/tests/data/test_locations.csv b/backend/python/tests/data/test_locations.csv new file mode 100644 index 00000000..7622031d --- /dev/null +++ b/backend/python/tests/data/test_locations.csv @@ -0,0 +1,32 @@ +address,city,postal_code,province,latitude,longitude,formatted_address,place_id,apartment_unit +123 Fake Street,Test City,N1A 1A1,Ontario,43.4500000,-80.5000000,"123 Fake St, Test City, ON N1A 1A1, Canada",TEST_PLACE_ID_001, +456 Mock Avenue,Test City,N1B 2B2,Ontario,43.4510000,-80.5010000,"456 Mock Ave, Test City, ON N1B 2B2, Canada",TEST_PLACE_ID_002, +789 Sample Road,Test City,N1C 3C3,Ontario,43.4520000,-80.5020000,"789 Sample Rd, Test City, ON N1C 3C3, Canada",TEST_PLACE_ID_003, +321 Test Boulevard,Test City,N1D 4D4,Ontario,43.4530000,-80.5030000,"321 Test Blvd, Test City, ON N1D 4D4, Canada",TEST_PLACE_ID_004, +654 Example Lane,Test City,N1E 5E5,Ontario,43.4540000,-80.5040000,"654 Example Ln, Test City, ON N1E 5E5, Canada",TEST_PLACE_ID_005, +987 Demo Drive,Test City,N1F 6F6,Ontario,43.4550000,-80.5050000,"987 Demo Dr, Test City, ON N1F 6F6, Canada",TEST_PLACE_ID_006, +147 Virtual Way,Test City,N1G 7G7,Ontario,43.4560000,-80.5060000,"147 Virtual Way, Test City, ON N1G 7G7, Canada",TEST_PLACE_ID_007, +258 Simulated Street,Test City,N1H 8H8,Ontario,43.4570000,-80.5070000,"258 Simulated St, Test City, ON N1H 8H8, Canada",TEST_PLACE_ID_008, +369 Dummy Avenue,Test City,N1I 9I9,Ontario,43.4580000,-80.5080000,"369 Dummy Ave, Test City, ON N1I 9I9, Canada",TEST_PLACE_ID_009, +741 Placeholder Road,Test City,N1J 0J0,Ontario,43.4590000,-80.5090000,"741 Placeholder Rd, Test City, ON N1J 0J0, Canada",TEST_PLACE_ID_010, +852 Synthetic Boulevard,Test City,N1K 1K1,Ontario,43.4600000,-80.5100000,"852 Synthetic Blvd, Test City, ON N1K 1K1, Canada",TEST_PLACE_ID_011, +963 Artificial Lane,Test City,N1L 2L2,Ontario,43.4610000,-80.5110000,"963 Artificial Ln, Test City, ON N1L 2L2, Canada",TEST_PLACE_ID_012, +159 Constructed Drive,Test City,N1M 3M3,Ontario,43.4620000,-80.5120000,"159 Constructed Dr, Test City, ON N1M 3M3, Canada",TEST_PLACE_ID_013, +357 Generated Way,Test City,N1N 4N4,Ontario,43.4630000,-80.5130000,"357 Generated Way, Test City, ON N1N 4N4, Canada",TEST_PLACE_ID_014, +468 Fabricated Street,Test City,N1O 5O5,Ontario,43.4640000,-80.5140000,"468 Fabricated St, Test City, ON N1O 5O5, Canada",TEST_PLACE_ID_015, +579 Invented Avenue,Test City,N1P 6P6,Ontario,43.4650000,-80.5150000,"579 Invented Ave, Test City, ON N1P 6P6, Canada",TEST_PLACE_ID_016, +680 Manufactured Road,Test City,N1Q 7Q7,Ontario,43.4660000,-80.5160000,"680 Manufactured Rd, Test City, ON N1Q 7Q7, Canada",TEST_PLACE_ID_017, +791 Created Boulevard,Test City,N1R 8R8,Ontario,43.4670000,-80.5170000,"791 Created Blvd, Test City, ON N1R 8R8, Canada",TEST_PLACE_ID_018, +802 Built Lane,Test City,N1S 9S9,Ontario,43.4680000,-80.5180000,"802 Built Ln, Test City, ON N1S 9S9, Canada",TEST_PLACE_ID_019, +913 Designed Drive,Test City,N1T 0T0,Ontario,43.4690000,-80.5190000,"913 Designed Dr, Test City, ON N1T 0T0, Canada",TEST_PLACE_ID_020, +124 Formed Way,Test City,N1U 1U1,Ontario,43.4700000,-80.5200000,"124 Formed Way, Test City, ON N1U 1U1, Canada",TEST_PLACE_ID_021, +235 Shaped Street,Test City,N1V 2V2,Ontario,43.4710000,-80.5210000,"235 Shaped St, Test City, ON N1V 2V2, Canada",TEST_PLACE_ID_022, +346 Molded Avenue,Test City,N1W 3W3,Ontario,43.4720000,-80.5220000,"346 Molded Ave, Test City, ON N1W 3W3, Canada",TEST_PLACE_ID_023, +457 Crafted Road,Test City,N1X 4X4,Ontario,43.4730000,-80.5230000,"457 Crafted Rd, Test City, ON N1X 4X4, Canada",TEST_PLACE_ID_024, +568 Forged Boulevard,Test City,N1Y 5Y5,Ontario,43.4740000,-80.5240000,"568 Forged Blvd, Test City, ON N1Y 5Y5, Canada",TEST_PLACE_ID_025, +679 Assembled Lane,Test City,N1Z 6Z6,Ontario,43.4750000,-80.5250000,"679 Assembled Ln, Test City, ON N1Z 6Z6, Canada",TEST_PLACE_ID_026, +780 Produced Drive,Test City,N2A 7A7,Ontario,43.4760000,-80.5260000,"780 Produced Dr, Test City, ON N2A 7A7, Canada",TEST_PLACE_ID_027, +891 Developed Way,Test City,N2B 8B8,Ontario,43.4770000,-80.5270000,"891 Developed Way, Test City, ON N2B 8B8, Canada",TEST_PLACE_ID_028, +902 Established Street,Test City,N2C 9C9,Ontario,43.4780000,-80.5280000,"902 Established St, Test City, ON N2C 9C9, Canada",TEST_PLACE_ID_029, +103 Organized Avenue,Test City,N2D 0D0,Ontario,43.4790000,-80.5290000,"103 Organized Ave, Test City, ON N2D 0D0, Canada",TEST_PLACE_ID_030, + diff --git a/backend/python/tests/test_routes.py b/backend/python/tests/test_routes.py new file mode 100644 index 00000000..01b9f836 --- /dev/null +++ b/backend/python/tests/test_routes.py @@ -0,0 +1,421 @@ +""" +Comprehensive integration tests for API routes. + +Tests cover: +- Driver routes (CRUD operations) +- Location routes (CRUD operations) +- Route routes (read and delete operations) +- Route group routes (CRUD operations) +- Error handling (404s, validation errors) +""" + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + + +class TestDriverRoutes: + """Test suite for driver API routes.""" + + @pytest.mark.asyncio + async def test_get_drivers_empty(self, async_client: AsyncClient) -> None: + """Test GET /drivers returns empty list when no drivers exist.""" + response = await async_client.get("/drivers/") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_create_driver( + self, + async_client: AsyncClient, + sample_driver_data: dict[str, Any], + test_session: Any, + ) -> None: + """Test POST /drivers creates a new driver.""" + # First create a user for the driver + from app.models.user import User + + user = User( + name=sample_driver_data["name"], + email="newdriver@example.com", + auth_id=sample_driver_data["auth_id"] + "_new", + ) + test_session.add(user) + await test_session.commit() + await test_session.refresh(user) + + # Now create driver with user_id + driver_create_data = { + "user_id": str(user.user_id), + "phone": sample_driver_data["phone"], + "address": sample_driver_data["address"], + "license_plate": sample_driver_data["license_plate"], + "car_make_model": sample_driver_data["car_make_model"], + } + response = await async_client.post("/drivers/", json=driver_create_data) + assert response.status_code == 201 + data = response.json() + assert data["phone"] == sample_driver_data["phone"] + assert data["license_plate"] == sample_driver_data["license_plate"] + assert "driver_id" in data + + @pytest.mark.asyncio + async def test_get_drivers_with_data( + self, async_client: AsyncClient, test_driver: Any + ) -> None: + """Test GET /drivers returns list of drivers.""" + response = await async_client.get("/drivers/") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert str(data[0]["driver_id"]) == str(test_driver.driver_id) + + @pytest.mark.asyncio + async def test_get_driver_by_id( + self, async_client: AsyncClient, test_driver: Any + ) -> None: + """Test GET /drivers/{driver_id} returns specific driver.""" + response = await async_client.get(f"/drivers/{test_driver.driver_id}") + assert response.status_code == 200 + data = response.json() + assert str(data["driver_id"]) == str(test_driver.driver_id) + assert data["phone"] == test_driver.phone + + @pytest.mark.asyncio + async def test_get_driver_not_found(self, async_client: AsyncClient) -> None: + """Test GET /drivers/{driver_id} returns 404 for non-existent driver.""" + fake_id = uuid4() + response = await async_client.get(f"/drivers/{fake_id}") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_update_driver( + self, async_client: AsyncClient, test_driver: Any + ) -> None: + """Test PUT /drivers/{driver_id} updates a driver.""" + update_data = {"address": "456 New Address St"} + response = await async_client.put( + f"/drivers/{test_driver.driver_id}", json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["address"] == "456 New Address St" + + @pytest.mark.asyncio + async def test_update_driver_not_found(self, async_client: AsyncClient) -> None: + """Test PUT /drivers/{driver_id} returns 404 for non-existent driver.""" + fake_id = uuid4() + update_data = {"address": "456 New Address St"} + response = await async_client.put(f"/drivers/{fake_id}", json=update_data) + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_delete_driver( + self, async_client: AsyncClient, test_driver: Any + ) -> None: + """Test DELETE /drivers/{driver_id} deletes a driver.""" + response = await async_client.delete(f"/drivers/{test_driver.driver_id}") + assert response.status_code == 204 + + # Verify deletion + get_response = await async_client.get(f"/drivers/{test_driver.driver_id}") + assert get_response.status_code == 404 + + @pytest.mark.asyncio + async def test_get_driver_by_email( + self, async_client: AsyncClient, test_driver: Any, test_session: AsyncSession + ) -> None: + """Test GET /drivers?email= filters by email.""" + # Get the user associated with test_driver to find the email + from sqlmodel import select + + from app.models.user import User + + result = await test_session.execute( + select(User).where(User.user_id == test_driver.user_id) + ) + user = result.scalar_one() + + response = await async_client.get(f"/drivers/?email={user.email}") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert str(data[0]["driver_id"]) == str(test_driver.driver_id) + + +class TestLocationRoutes: + """Test suite for location API routes.""" + + @pytest.mark.asyncio + async def test_get_locations_empty(self, async_client: AsyncClient) -> None: + """Test GET /locations returns empty list when no locations exist.""" + response = await async_client.get("/locations/") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_create_location( + self, async_client: AsyncClient, sample_location_data: dict[str, Any] + ) -> None: + """Test POST /locations creates a new location.""" + response = await async_client.post("/locations/", json=sample_location_data) + assert response.status_code == 201 + data = response.json() + assert data["contact_name"] == sample_location_data["contact_name"] + assert data["address"] == sample_location_data["address"] + assert "location_id" in data + + @pytest.mark.asyncio + async def test_get_locations_with_data( + self, async_client: AsyncClient, sample_location_data: dict[str, Any] + ) -> None: + """Test GET /locations returns list of locations.""" + # Create a location first + create_response = await async_client.post( + "/locations/", json=sample_location_data + ) + assert create_response.status_code == 201 + + # Get all locations + response = await async_client.get("/locations/") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + + @pytest.mark.asyncio + async def test_get_location_by_id( + self, async_client: AsyncClient, sample_location_data: dict[str, Any] + ) -> None: + """Test GET /locations/{location_id} returns specific location.""" + # Create a location first + create_response = await async_client.post( + "/locations/", json=sample_location_data + ) + location_id = create_response.json()["location_id"] + + # Get the location by ID + response = await async_client.get(f"/locations/{location_id}") + assert response.status_code == 200 + data = response.json() + assert data["location_id"] == location_id + assert data["contact_name"] == sample_location_data["contact_name"] + + @pytest.mark.asyncio + async def test_get_location_not_found(self, async_client: AsyncClient) -> None: + """Test GET /locations/{location_id} returns 404 for non-existent location.""" + fake_id = uuid4() + response = await async_client.get(f"/locations/{fake_id}") + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_update_location( + self, async_client: AsyncClient, sample_location_data: dict[str, Any] + ) -> None: + """Test PATCH /locations/{location_id} updates a location.""" + # Create a location first + create_response = await async_client.post( + "/locations/", json=sample_location_data + ) + location_id = create_response.json()["location_id"] + + # Update the location + update_data = {"notes": "Updated notes"} + response = await async_client.patch( + f"/locations/{location_id}", json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["notes"] == "Updated notes" + + @pytest.mark.asyncio + async def test_delete_location( + self, async_client: AsyncClient, sample_location_data: dict[str, Any] + ) -> None: + """Test DELETE /locations/{location_id} deletes a location.""" + # Create a location first + create_response = await async_client.post( + "/locations/", json=sample_location_data + ) + location_id = create_response.json()["location_id"] + + # Delete the location + response = await async_client.delete(f"/locations/{location_id}") + assert response.status_code == 204 + + # Verify deletion + get_response = await async_client.get(f"/locations/{location_id}") + assert get_response.status_code == 404 + + +class TestRouteRoutes: + """Test suite for route API routes.""" + + @pytest.mark.asyncio + async def test_get_routes_empty(self, async_client: AsyncClient) -> None: + """Test GET /routes returns empty list when no routes exist.""" + response = await async_client.get("/routes") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_get_routes_with_data( + self, + async_client: AsyncClient, + test_route: Any, # noqa: ARG002 + ) -> None: + """Test GET /routes returns list of routes.""" + response = await async_client.get("/routes") + assert response.status_code == 200 + # Routes may be empty if there are no route groups + data = response.json() + assert isinstance(data, list) + + +class TestRouteGroupRoutes: + """Test suite for route group API routes.""" + + @pytest.mark.asyncio + async def test_get_route_groups_empty(self, async_client: AsyncClient) -> None: + """Test GET /route-groups returns empty list when no route groups exist.""" + response = await async_client.get("/route-groups") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_create_route_group( + self, async_client: AsyncClient, sample_route_group_data: dict[str, Any] + ) -> None: + """Test POST /route-groups creates a new route group.""" + # Convert datetime to ISO format string + data = sample_route_group_data.copy() + data["drive_date"] = data["drive_date"].isoformat() + + response = await async_client.post("/route-groups", json=data) + assert response.status_code == 201 + result = response.json() + assert result["name"] == sample_route_group_data["name"] + assert "route_group_id" in result + + @pytest.mark.asyncio + async def test_get_route_groups_with_data( + self, async_client: AsyncClient, test_route_group: Any + ) -> None: + """Test GET /route-groups returns list of route groups.""" + response = await async_client.get("/route-groups") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert any( + str(rg["route_group_id"]) == str(test_route_group.route_group_id) + for rg in data + ) + + @pytest.mark.asyncio + async def test_update_route_group( + self, async_client: AsyncClient, test_route_group: Any + ) -> None: + """Test PATCH /route-groups/{route_group_id} updates a route group.""" + update_data = {"notes": "Updated notes for route group"} + response = await async_client.patch( + f"/route-groups/{test_route_group.route_group_id}", json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["notes"] == "Updated notes for route group" + + @pytest.mark.asyncio + async def test_update_route_group_not_found( + self, async_client: AsyncClient + ) -> None: + """Test PATCH /route-groups/{route_group_id} returns 404 for non-existent route group.""" + fake_id = uuid4() + update_data = {"notes": "Updated notes"} + response = await async_client.patch( + f"/route-groups/{fake_id}", json=update_data + ) + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_delete_route_group( + self, async_client: AsyncClient, test_route_group: Any + ) -> None: + """Test DELETE /route-groups/{route_group_id} deletes a route group.""" + response = await async_client.delete( + f"/route-groups/{test_route_group.route_group_id}" + ) + assert response.status_code == 204 + + # Verify deletion by trying to get all route groups + get_response = await async_client.get("/route-groups") + assert response.status_code == 204 + data = get_response.json() + assert not any( + str(rg["route_group_id"]) == str(test_route_group.route_group_id) + for rg in data + ) + + @pytest.mark.asyncio + async def test_get_route_groups_with_date_filter( + self, + async_client: AsyncClient, + test_route_group: Any, # noqa: ARG002 + ) -> None: + """Test GET /route-groups with date filters.""" + start_date = datetime(2024, 1, 1).isoformat() + end_date = datetime(2024, 12, 31).isoformat() + + response = await async_client.get( + f"/route-groups?start_date={start_date}&end_date={end_date}" + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + +class TestValidationErrors: + """Test suite for validation error handling across routes.""" + + @pytest.mark.asyncio + async def test_create_driver_invalid_phone(self, async_client: AsyncClient) -> None: + """Test POST /drivers with invalid phone number returns validation error.""" + invalid_data = { + "name": "Test Driver", + "email": "test@example.com", + "phone": "invalid-phone", # Invalid phone format + "address": "123 Main St", + "license_plate": "ABC123", + "car_make_model": "Toyota Camry", + "auth_id": "test-auth-123", + } + response = await async_client.post("/drivers/", json=invalid_data) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_create_location_missing_required_fields( + self, async_client: AsyncClient + ) -> None: + """Test POST /locations with missing required fields returns validation error.""" + invalid_data = { + "contact_name": "Jane Smith", + # Missing: address, phone_number, longitude, latitude, halal, num_boxes + } + response = await async_client.post("/locations/", json=invalid_data) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_create_route_group_invalid_date( + self, async_client: AsyncClient + ) -> None: + """Test POST /route-groups with invalid date format returns validation error.""" + invalid_data = { + "name": "Test Route Group", + "notes": "Test notes", + "drive_date": "not-a-date", # Invalid date format + } + response = await async_client.post("/route-groups", json=invalid_data) + assert response.status_code == 422 diff --git a/backend/python/tests/test_seed_database.py b/backend/python/tests/test_seed_database.py new file mode 100644 index 00000000..ede6184c --- /dev/null +++ b/backend/python/tests/test_seed_database.py @@ -0,0 +1,275 @@ +""" +Test suite for database seeding script. + +This test ensures: +1. The seeding script runs without errors +2. All major entity types are created properly +3. Schema changes cause test failures (by accessing specific fields) +""" + +import os +from unittest.mock import patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from app.models.admin import Admin +from app.models.driver import Driver +from app.models.driver_assignment import DriverAssignment +from app.models.driver_history import DriverHistory +from app.models.job import Job +from app.models.location import Location +from app.models.location_group import LocationGroup +from app.models.route import Route +from app.models.route_group import RouteGroup +from app.models.route_group_membership import RouteGroupMembership +from app.models.route_stop import RouteStop +from app.models.system_settings import SystemSettings +from app.models.user import User + + +@pytest.mark.asyncio +async def test_seed_database_execution(test_session: AsyncSession) -> None: + """ + Test that the seed database script runs successfully and creates all expected entities. + + This test also acts as a schema validation test: + - If model fields are renamed/removed, the seeding script will fail with AttributeError + - If new required fields are added without defaults, the seeding script will fail with validation errors + - By accessing specific fields, we ensure they exist in the schema + """ + + # Mock environment variables for admin auth ID and test CSV path + test_csv_path = os.path.join( + os.path.dirname(__file__), "data", "test_locations.csv" + ) + with patch.dict( + os.environ, + { + "ADMIN_AUTH_ID": "test-admin-auth-id", + "LOCATIONS_CSV_PATH": test_csv_path, + }, + ): + # Get the test database URL from environment (same as conftest.py uses) + # Convert from async (postgresql+asyncpg://) to sync (postgresql://) for the seeding script + async_db_url = os.getenv( + "TEST_DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@db:5432/f4k_test", + ) + # Convert async URL to sync URL for the synchronous seed script + test_db_url = async_db_url.replace("postgresql+asyncpg://", "postgresql://") + + # Import and run the seeding script + # Note: The seeding script uses synchronous SQLAlchemy, so we can't use our async test session + # Instead, we'll verify the results in our async test session after seeding completes + import app.seed_database as seed_module + + original_url = seed_module.DATABASE_URL + seed_module.DATABASE_URL = test_db_url + + try: + # Run the seeding script + seed_module.main() + finally: + # Restore original URL + seed_module.DATABASE_URL = original_url + + # Verify location groups were created + location_groups = await test_session.execute(select(LocationGroup)) + location_groups_list = list(location_groups.scalars().all()) + assert len(location_groups_list) > 0, "No location groups were created" + + # Verify location group has expected fields (schema validation) + first_group = location_groups_list[0] + assert hasattr(first_group, "name"), "LocationGroup missing 'name' field" + assert hasattr(first_group, "color"), "LocationGroup missing 'color' field" + assert first_group.name is not None + assert first_group.color is not None + + # Verify locations were created + locations = await test_session.execute(select(Location)) + locations_list = list(locations.scalars().all()) + assert len(locations_list) > 0, "No locations were created" + + # Verify location has expected fields (schema validation) + first_location = locations_list[0] + assert hasattr(first_location, "contact_name"), ( + "Location missing 'contact_name' field" + ) + assert hasattr(first_location, "address"), "Location missing 'address' field" + assert hasattr(first_location, "phone_number"), ( + "Location missing 'phone_number' field" + ) + assert hasattr(first_location, "latitude"), "Location missing 'latitude' field" + assert hasattr(first_location, "longitude"), "Location missing 'longitude' field" + assert hasattr(first_location, "halal"), "Location missing 'halal' field" + assert hasattr(first_location, "num_boxes"), "Location missing 'num_boxes' field" + assert first_location.contact_name is not None + assert first_location.address is not None + + # Verify routes were created + routes = await test_session.execute(select(Route)) + routes_list = list(routes.scalars().all()) + assert len(routes_list) > 0, "No routes were created" + + # Verify route has expected fields (schema validation) + first_route = routes_list[0] + assert hasattr(first_route, "name"), "Route missing 'name' field" + assert hasattr(first_route, "length"), "Route missing 'length' field" + assert first_route.name is not None + assert first_route.length is not None + + # Verify route stops were created + route_stops = await test_session.execute(select(RouteStop)) + route_stops_list = list(route_stops.scalars().all()) + assert len(route_stops_list) > 0, "No route stops were created" + + # Verify route stop has expected fields (schema validation) + first_stop = route_stops_list[0] + assert hasattr(first_stop, "route_id"), "RouteStop missing 'route_id' field" + assert hasattr(first_stop, "location_id"), "RouteStop missing 'location_id' field" + assert hasattr(first_stop, "stop_number"), "RouteStop missing 'stop_number' field" + assert first_stop.stop_number >= 1 + + # Verify users were created + users = await test_session.execute(select(User)) + users_list = list(users.scalars().all()) + assert len(users_list) > 0, "No users were created" + + # Verify user has expected fields (schema validation) + first_user = users_list[0] + assert hasattr(first_user, "name"), "User missing 'name' field" + assert hasattr(first_user, "email"), "User missing 'email' field" + assert hasattr(first_user, "auth_id"), "User missing 'auth_id' field" + assert first_user.name is not None + assert first_user.email is not None + + # Verify drivers were created + drivers = await test_session.execute(select(Driver)) + drivers_list = list(drivers.scalars().all()) + assert len(drivers_list) > 0, "No drivers were created" + + # Verify driver has expected fields (schema validation) + first_driver = drivers_list[0] + assert hasattr(first_driver, "user_id"), "Driver missing 'user_id' field" + assert hasattr(first_driver, "phone"), "Driver missing 'phone' field" + assert hasattr(first_driver, "address"), "Driver missing 'address' field" + assert hasattr(first_driver, "license_plate"), ( + "Driver missing 'license_plate' field" + ) + assert hasattr(first_driver, "car_make_model"), ( + "Driver missing 'car_make_model' field" + ) + assert first_driver.phone is not None + assert first_driver.license_plate is not None + + # Verify route groups were created + route_groups = await test_session.execute(select(RouteGroup)) + route_groups_list = list(route_groups.scalars().all()) + assert len(route_groups_list) > 0, "No route groups were created" + + # Verify route group has expected fields (schema validation) + first_route_group = route_groups_list[0] + assert hasattr(first_route_group, "name"), "RouteGroup missing 'name' field" + assert hasattr(first_route_group, "drive_date"), ( + "RouteGroup missing 'drive_date' field" + ) + assert first_route_group.name is not None + assert first_route_group.drive_date is not None + + # Verify route group memberships were created + memberships = await test_session.execute(select(RouteGroupMembership)) + memberships_list = list(memberships.scalars().all()) + assert len(memberships_list) > 0, "No route group memberships were created" + + # Verify membership has expected fields (schema validation) + first_membership = memberships_list[0] + assert hasattr(first_membership, "route_group_id"), ( + "RouteGroupMembership missing 'route_group_id' field" + ) + assert hasattr(first_membership, "route_id"), ( + "RouteGroupMembership missing 'route_id' field" + ) + + # Verify driver assignments were created + assignments = await test_session.execute(select(DriverAssignment)) + assignments_list = list(assignments.scalars().all()) + assert len(assignments_list) > 0, "No driver assignments were created" + + # Verify driver assignment has expected fields (schema validation) + first_assignment = assignments_list[0] + assert hasattr(first_assignment, "driver_id"), ( + "DriverAssignment missing 'driver_id' field" + ) + assert hasattr(first_assignment, "route_id"), ( + "DriverAssignment missing 'route_id' field" + ) + assert hasattr(first_assignment, "route_group_id"), ( + "DriverAssignment missing 'route_group_id' field" + ) + assert hasattr(first_assignment, "completed"), ( + "DriverAssignment missing 'completed' field" + ) + + # Verify driver history was created + history = await test_session.execute(select(DriverHistory)) + history_list = list(history.scalars().all()) + assert len(history_list) > 0, "No driver history entries were created" + + # Verify driver history has expected fields (schema validation) + first_history = history_list[0] + assert hasattr(first_history, "driver_id"), ( + "DriverHistory missing 'driver_id' field" + ) + assert hasattr(first_history, "year"), "DriverHistory missing 'year' field" + assert hasattr(first_history, "km"), "DriverHistory missing 'km' field" + assert first_history.year >= 2025 + assert first_history.km > 0 + + # Verify jobs were created (optional, may be 0) + jobs = await test_session.execute(select(Job)) + jobs_list = list(jobs.scalars().all()) + # Jobs may or may not be created depending on route groups, so we just check the schema if any exist + if len(jobs_list) > 0: + first_job = jobs_list[0] + assert hasattr(first_job, "progress"), "Job missing 'progress' field" + assert first_job.progress is not None + + # Verify system settings was created + settings = await test_session.execute(select(SystemSettings)) + settings_list = list(settings.scalars().all()) + assert len(settings_list) > 0, "No system settings were created" + + # Verify system settings has expected fields (schema validation) + first_settings = settings_list[0] + assert hasattr(first_settings, "warehouse_location"), ( + "SystemSettings missing 'warehouse_location' field" + ) + assert hasattr(first_settings, "warehouse_latitude"), ( + "SystemSettings missing 'warehouse_latitude' field" + ) + assert hasattr(first_settings, "warehouse_longitude"), ( + "SystemSettings missing 'warehouse_longitude' field" + ) + + # Verify admin was created + admins = await test_session.execute(select(Admin)) + admins_list = list(admins.scalars().all()) + assert len(admins_list) > 0, "No admin was created" + + # Verify admin has expected fields (schema validation) + first_admin = admins_list[0] + assert hasattr(first_admin, "user_id"), "Admin missing 'user_id' field" + assert hasattr(first_admin, "admin_phone"), "Admin missing 'admin_phone' field" + assert first_admin.admin_phone is not None + + +@pytest.mark.asyncio +async def test_seed_database_creates_relationships(test_session: AsyncSession) -> None: + """ + Test that the seeding script properly creates relationships between entities. + """ + # This test depends on test_seed_database_execution running first + # For now, we'll skip this as the main test above covers the basic relationships + pass From 280816a4eee86f7f3986dbe6f2b0cedfa0ca2761 Mon Sep 17 00:00:00 2001 From: ludavidca Date: Wed, 11 Feb 2026 23:22:58 -0500 Subject: [PATCH 18/19] Removing incorrect diffs --- backend/python/app/routers/auth_routes.py | 9 +- .../ba76119b3e4c_update_user_system.py | 192 ++++++++++++++++++ backend/python/tests/test_seed_database.py | 12 +- 3 files changed, 200 insertions(+), 13 deletions(-) diff --git a/backend/python/app/routers/auth_routes.py b/backend/python/app/routers/auth_routes.py index 55558050..bd143d05 100644 --- a/backend/python/app/routers/auth_routes.py +++ b/backend/python/app/routers/auth_routes.py @@ -102,6 +102,9 @@ async def register( """ Returns access token and driver info in response body and sets refreshToken as an httpOnly cookie """ + user = None + firebase_auth_id = None + try: # Create user first user_data = register_request.model_dump( @@ -109,6 +112,9 @@ async def register( ) user_create = UserCreate(**user_data) user = await user_service.create_user(session, user_create) + firebase_auth_id = user.auth_id + + # Set custom claims on Firebase user firebase_admin.auth.set_custom_user_claims(user.auth_id, {"role": user.role}) # Create driver after @@ -119,8 +125,7 @@ async def register( driver = DriverCreate(**driver_data) await driver_service.create_driver(session, driver) - # please work - + # Generate authentication tokens auth_dto, refresh_token = await auth_service.generate_token( session, register_request.email, register_request.password ) diff --git a/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py index e69de29b..6a0d836d 100644 --- a/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py +++ b/backend/python/migrations/versions/ba76119b3e4c_update_user_system.py @@ -0,0 +1,192 @@ +"""update_user_system + +Revision ID: ba76119b3e4c +Revises: b1c2d3e4f5a6 +Create Date: 2025-11-24 01:25:48.941556 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import sqlmodel + +# revision identifiers, used by Alembic. +revision = 'ba76119b3e4c' +down_revision = 'b1c2d3e4f5a6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Check if table exists before creating + from sqlalchemy import inspect + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = inspector.get_table_names() + + if 'system_settings' not in existing_tables: + op.create_table('system_settings', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('default_cap', sa.Integer(), nullable=True), + sa.Column('route_start_time', sa.Time(), nullable=True), + sa.Column('warehouse_location', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('warehouse_longitude', sa.Float(), nullable=True), + sa.Column('warehouse_latitude', sa.Float(), nullable=True), + sa.Column('system_settings_id', sa.Uuid(), nullable=False), + sa.PrimaryKeyConstraint('system_settings_id') + ) + + if 'users' not in existing_tables: + op.create_table('users', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=254), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('auth_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index(op.f('ix_users_auth_id'), 'users', ['auth_id'], unique=True) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=False) + + # Check existing columns in admin_info + admin_info_columns = [col['name'] for col in inspector.get_columns('admin_info')] + + if 'receive_email_notifications' not in admin_info_columns: + op.add_column('admin_info', sa.Column('receive_email_notifications', sa.Boolean(), nullable=False, server_default='true')) + + if 'user_id' not in admin_info_columns: + # Migrate existing admin_info records to users table + connection = op.get_bind() + + # Get existing admin records that need migration + if 'admin_email' in admin_info_columns and 'admin_name' in admin_info_columns: + existing_admins = connection.execute(sa.text(""" + SELECT admin_id, admin_name, admin_email + FROM admin_info + """)).fetchall() + + # Create user records for existing admins + for admin in existing_admins: + user_id = admin[0] # Use admin_id as user_id for consistency + connection.execute(sa.text(""" + INSERT INTO users (user_id, name, email, auth_id, role, created_at, updated_at) + VALUES (:user_id, :name, :email, :auth_id, 'admin', NOW(), NOW()) + ON CONFLICT DO NOTHING + """), { + 'user_id': user_id, + 'name': admin[1], + 'email': admin[2], + 'auth_id': f'admin_{user_id}' + }) + + # Add user_id column as nullable first + op.add_column('admin_info', sa.Column('user_id', sa.Uuid(), nullable=True)) + + # Populate user_id with admin_id for existing records + connection.execute(sa.text(""" + UPDATE admin_info SET user_id = admin_id WHERE user_id IS NULL + """)) + + # Now make it NOT NULL + op.alter_column('admin_info', 'user_id', nullable=False) + + op.create_unique_constraint(None, 'admin_info', ['user_id']) + op.create_foreign_key(None, 'admin_info', 'users', ['user_id'], ['user_id']) + + # Drop columns only if they exist + if 'admin_name' in admin_info_columns: + op.drop_column('admin_info', 'admin_name') + if 'default_cap' in admin_info_columns: + op.drop_column('admin_info', 'default_cap') + if 'route_start_time' in admin_info_columns: + op.drop_column('admin_info', 'route_start_time') + if 'warehouse_location' in admin_info_columns: + op.drop_column('admin_info', 'warehouse_location') + if 'admin_email' in admin_info_columns: + op.drop_column('admin_info', 'admin_email') + + # Check existing columns in drivers + drivers_columns = [col['name'] for col in inspector.get_columns('drivers')] + + if 'user_id' not in drivers_columns: + # Migrate existing driver records to users table + connection = op.get_bind() + + # Get existing driver records that need migration + if 'email' in drivers_columns and 'name' in drivers_columns: + existing_drivers = connection.execute(sa.text(""" + SELECT driver_id, name, email, auth_id + FROM drivers + """)).fetchall() + + # Create user records for existing drivers + for driver in existing_drivers: + user_id = driver[0] # Use driver_id as user_id for consistency + connection.execute(sa.text(""" + INSERT INTO users (user_id, name, email, auth_id, role, created_at, updated_at) + VALUES (:user_id, :name, :email, :auth_id, 'driver', NOW(), NOW()) + ON CONFLICT DO NOTHING + """), { + 'user_id': user_id, + 'name': driver[1], + 'email': driver[2], + 'auth_id': driver[3] if driver[3] else f'driver_{user_id}' + }) + + # Add user_id column as nullable first + op.add_column('drivers', sa.Column('user_id', sa.Uuid(), nullable=True)) + + # Populate user_id with driver_id for existing records + connection.execute(sa.text(""" + UPDATE drivers SET user_id = driver_id WHERE user_id IS NULL + """)) + + # Now make it NOT NULL + op.alter_column('drivers', 'user_id', nullable=False) + + # Drop indexes only if they exist + existing_indexes = [idx['name'] for idx in inspector.get_indexes('drivers')] + if 'ix_drivers_auth_id' in existing_indexes: + op.drop_index(op.f('ix_drivers_auth_id'), table_name='drivers') + if 'ix_drivers_email' in existing_indexes: + op.drop_index(op.f('ix_drivers_email'), table_name='drivers') + + op.create_unique_constraint(None, 'drivers', ['user_id']) + op.create_foreign_key(None, 'drivers', 'users', ['user_id'], ['user_id']) + + # Drop columns only if they exist + if 'name' in drivers_columns: + op.drop_column('drivers', 'name') + if 'email' in drivers_columns: + op.drop_column('drivers', 'email') + if 'auth_id' in drivers_columns: + op.drop_column('drivers', 'auth_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drivers', sa.Column('auth_id', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('drivers', sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=False)) + op.add_column('drivers', sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + op.create_index(op.f('ix_drivers_email'), 'drivers', ['email'], unique=True) + op.create_index(op.f('ix_drivers_auth_id'), 'drivers', ['auth_id'], unique=True) + op.drop_column('drivers', 'user_id') + op.add_column('admin_info', sa.Column('admin_email', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('admin_info', sa.Column('warehouse_location', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('route_start_time', postgresql.TIME(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('default_cap', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('admin_info', sa.Column('admin_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False)) + op.drop_column('admin_info', 'user_id') + op.drop_column('admin_info', 'receive_email_notifications') + op.drop_index(op.f('ix_users_user_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_index(op.f('ix_users_auth_id'), table_name='users') + op.drop_table('users') + op.drop_table('system_settings') + # ### end Alembic commands ### diff --git a/backend/python/tests/test_seed_database.py b/backend/python/tests/test_seed_database.py index ede6184c..0921f4c7 100644 --- a/backend/python/tests/test_seed_database.py +++ b/backend/python/tests/test_seed_database.py @@ -262,14 +262,4 @@ async def test_seed_database_execution(test_session: AsyncSession) -> None: first_admin = admins_list[0] assert hasattr(first_admin, "user_id"), "Admin missing 'user_id' field" assert hasattr(first_admin, "admin_phone"), "Admin missing 'admin_phone' field" - assert first_admin.admin_phone is not None - - -@pytest.mark.asyncio -async def test_seed_database_creates_relationships(test_session: AsyncSession) -> None: - """ - Test that the seeding script properly creates relationships between entities. - """ - # This test depends on test_seed_database_execution running first - # For now, we'll skip this as the main test above covers the basic relationships - pass + assert first_admin.admin_phone is not None \ No newline at end of file From 27c3246d2f031941bfe3cb69bc761e6ed307c101 Mon Sep 17 00:00:00 2001 From: ludavidca Date: Wed, 11 Feb 2026 23:32:54 -0500 Subject: [PATCH 19/19] Moved to Python 3.11 and fixed remaining bugs) --- .github/workflows/lint.yml | 2 +- .github/workflows/pytest.yml | 2 +- README.md | 2 +- backend/python/Dockerfile | 2 +- backend/python/app/seed_database.py | 1 - backend/python/app/services/implementations/driver_service.py | 2 +- backend/python/mypy.ini | 2 +- backend/python/pyproject.toml | 2 +- backend/python/tests/test_seed_database.py | 2 +- 9 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1046fade..d3f10ab8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -60,7 +60,7 @@ jobs: if: steps.changes.outputs.python-backend == 'true' uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.11' - name: Cache pip dependencies if: steps.changes.outputs.python-backend == 'true' diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index edb90333..17a45d3f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -44,7 +44,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.11" cache: "pip" cache-dependency-path: backend/python/requirements.txt diff --git a/README.md b/README.md index 5e1a77ea..635eaaf1 100644 --- a/README.md +++ b/README.md @@ -691,7 +691,7 @@ The project uses **GitHub Actions** for continuous integration. All workflows ar - **Triggers:** Push or PR to `main` for `backend/python/**` paths - Sets up PostgreSQL service container - Runs `pytest -q --disable-warnings -ra` - - Python 3.10 + - Python 3.11 3. **`claude-code-review.yml`** - Automated Code Review - **Triggers:** PR ready for review, or `@claude review` comment diff --git a/backend/python/Dockerfile b/backend/python/Dockerfile index 9d1ed422..6dfa718a 100644 --- a/backend/python/Dockerfile +++ b/backend/python/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10 +FROM python:3.11 WORKDIR /app diff --git a/backend/python/app/seed_database.py b/backend/python/app/seed_database.py index 4ba261c2..cf5b7318 100644 --- a/backend/python/app/seed_database.py +++ b/backend/python/app/seed_database.py @@ -508,7 +508,6 @@ def main() -> None: print("Creating drivers...") num_drivers = max(routes_created, MIN_DRIVERS) drivers_created = 0 - used_emails: set[str] = set() for _ in range(num_drivers): # Create a single driver with fake data diff --git a/backend/python/app/services/implementations/driver_service.py b/backend/python/app/services/implementations/driver_service.py index 46f01645..03097385 100644 --- a/backend/python/app/services/implementations/driver_service.py +++ b/backend/python/app/services/implementations/driver_service.py @@ -163,7 +163,7 @@ async def update_driver_by_id( driver.notes = driver_data.notes await session.commit() - await session.refresh(driver) + await session.refresh(driver, attribute_names=["user"]) return driver except Exception as e: diff --git a/backend/python/mypy.ini b/backend/python/mypy.ini index b9627e4b..99db8017 100644 --- a/backend/python/mypy.ini +++ b/backend/python/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.10 +python_version = 3.11 incremental = True cache_dir = .mypy_cache warn_return_any = True diff --git a/backend/python/pyproject.toml b/backend/python/pyproject.toml index b728f969..7a960e50 100644 --- a/backend/python/pyproject.toml +++ b/backend/python/pyproject.toml @@ -29,7 +29,7 @@ exclude = [ line-length = 88 indent-width = 4 -# Assume Python 3.10+ +# Assume Python 3.11+ target-version = "py310" [tool.ruff.lint] diff --git a/backend/python/tests/test_seed_database.py b/backend/python/tests/test_seed_database.py index 0921f4c7..2c6e0d7f 100644 --- a/backend/python/tests/test_seed_database.py +++ b/backend/python/tests/test_seed_database.py @@ -262,4 +262,4 @@ async def test_seed_database_execution(test_session: AsyncSession) -> None: first_admin = admins_list[0] assert hasattr(first_admin, "user_id"), "Admin missing 'user_id' field" assert hasattr(first_admin, "admin_phone"), "Admin missing 'admin_phone' field" - assert first_admin.admin_phone is not None \ No newline at end of file + assert first_admin.admin_phone is not None