diff --git a/.gitignore b/.gitignore index c022680..416602c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ **/venv **/__pycache__ **/*.log -**/firebaseServiceAccount.json +**/serviceAccountKey.json **/.DS_Store **/*.cache **/*.egg-info diff --git a/backend/app/interfaces/user_service.py b/backend/app/interfaces/user_service.py index 46cdaec..c1585e1 100644 --- a/backend/app/interfaces/user_service.py +++ b/backend/app/interfaces/user_service.py @@ -83,7 +83,7 @@ def get_users(self): pass @abstractmethod - def create_user(self, user, auth_id=None, signup_method="PASSWORD"): + def create_user(self, user, signup_method="PASSWORD"): """ Create a user, email verification configurable diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py new file mode 100644 index 0000000..0314341 --- /dev/null +++ b/backend/app/routes/user.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.schemas.user import UserCreateRequest, UserCreateResponse +from app.services.implementations.user_service import UserService +from app.utilities.db_utils import get_db + +router = APIRouter( + prefix="/users", + tags=["users"], +) + +# TODO: +# send email verification via auth_service +# allow signup methods other than email (like sign up w Google)?? + + +def get_user_service(db: Session = Depends(get_db)): + return UserService(db) + + +@router.post("/", response_model=UserCreateResponse) +async def create_user( + user: UserCreateRequest, user_service: UserService = Depends(get_user_service) +): + try: + return await user_service.create_user(user) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..5d4f360 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,89 @@ +""" +Pydantic schemas for user-related data validation and serialization. +Handles user CRUD and response models for the API. +""" + +from enum import Enum +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +# TODO: +# confirm complexity rules for fields (such as password) + + +class SignUpMethod(str, Enum): + """Authentication methods supported for user signup""" + + PASSWORD = "PASSWORD" + GOOGLE = "GOOGLE" + + +class UserRole(str, Enum): + """ + Enum for possible user roles. + """ + + PARTICIPANT = "participant" + VOLUNTEER = "volunteer" + ADMIN = "admin" + + @classmethod + def to_role_id(cls, role: "UserRole") -> int: + role_map = {cls.PARTICIPANT: 1, cls.VOLUNTEER: 2, cls.ADMIN: 3} + return role_map[role] + + +class UserBase(BaseModel): + """ + Base schema for user model with common attributes shared across schemas. + """ + + first_name: str = Field(..., min_length=1, max_length=50) + last_name: str = Field(..., min_length=1, max_length=50) + email: EmailStr + role: UserRole + + +class UserCreateRequest(UserBase): + """ + Request schema for user creation with conditional password validation + """ + + password: Optional[str] = Field(None, min_length=8) + auth_id: Optional[str] = Field(None) # for signup with google sso + signup_method: SignUpMethod = Field(default=SignUpMethod.PASSWORD) + + @field_validator("password") + def validate_password(cls, password: Optional[str], info): + signup_method = info.data.get("signup_method") + + if signup_method == SignUpMethod.PASSWORD and not password: + raise ValueError("Password is required for password signup") + + if password: + if not any(char.isdigit() for char in password): + raise ValueError("Password must contain at least one digit") + if not any(char.isupper() for char in password): + raise ValueError("Password must contain at least one uppercase letter") + if not any(char.islower() for char in password): + raise ValueError("Password must contain at least one lowercase letter") + + return password + + +class UserCreateResponse(BaseModel): + """ + Response schema for user creation, maps directly from ORM User object. + """ + + id: UUID + first_name: str + last_name: str + email: EmailStr + role_id: int + auth_id: str + + # from_attributes enables automatic mapping from SQLAlchemy model to Pydantic model + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/server.py b/backend/app/server.py index a3830f0..9405741 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -7,10 +7,13 @@ from app.routes import email -from . import models - load_dotenv() +# we need to load env variables before initialization code runs +from . import models # noqa: E402 +from .routes import user # noqa: E402 +from .utilities.firebase_init import initialize_firebase # noqa: E402 + log = logging.getLogger("uvicorn") @@ -18,6 +21,7 @@ async def lifespan(_: FastAPI): log.info("Starting up...") models.run_migrations() + initialize_firebase() yield log.info("Shutting down...") @@ -25,6 +29,7 @@ async def lifespan(_: FastAPI): # Source: https://stackoverflow.com/questions/77170361/ # running-alembic-migrations-on-fastapi-startup app = FastAPI(lifespan=lifespan) +app.include_router(user.router) app.include_router(email.router) diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py new file mode 100644 index 0000000..d3081ce --- /dev/null +++ b/backend/app/services/implementations/user_service.py @@ -0,0 +1,105 @@ +import logging + +import firebase_admin.auth +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.interfaces.user_service import IUserService +from app.models import User +from app.schemas.user import ( + SignUpMethod, + UserCreateRequest, + UserCreateResponse, + UserRole, +) + + +class UserService(IUserService): + def __init__(self, db: Session): + self.db = db + self.logger = logging.getLogger(__name__) + + async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: + firebase_user = None + try: + if user.signup_method == SignUpMethod.PASSWORD: + firebase_user = firebase_admin.auth.create_user( + email=user.email, password=user.password + ) + ## TO DO: SSO functionality depends a lot on frontend implementation, + ## so we may need to update this when we have a better idea of what + ## that looks like + elif user.signup_method == SignUpMethod.GOOGLE: + # For signup with Google, Firebase users are automatically created + firebase_user = firebase_admin.auth.get_user(user.auth_id) + + role_id = UserRole.to_role_id(user.role) + + # Create user in database + db_user = User( + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + role_id=role_id, + auth_id=firebase_user.uid, + ) + + self.db.add(db_user) + # Finish database transaction and run previously defined + # database operations (ie. db.add) + self.db.commit() + + return UserCreateResponse.model_validate(db_user) + + except firebase_admin.exceptions.FirebaseError as firebase_error: + self.logger.error(f"Firebase error: {str(firebase_error)}") + + if isinstance(firebase_error, firebase_admin.auth.EmailAlreadyExistsError): + raise HTTPException(status_code=409, detail="Email already exists") + + raise HTTPException(status_code=400, detail=str(firebase_error)) + + except Exception as e: + # Clean up Firebase user if a database exception occurs + if firebase_user: + try: + firebase_admin.auth.delete_user(firebase_user.uid) + except firebase_admin.auth.AuthError as firebase_error: + self.logger.error( + "Failed to delete Firebase user after database insertion failed" + f"Firebase UID: {firebase_user.uid}. " + f"Error: {str(firebase_error)}" + ) + + # Rollback database changes + self.db.rollback() + self.logger.error(f"Error creating user: {str(e)}") + + raise HTTPException(status_code=500, detail=str(e)) + + def delete_user_by_email(self, email: str): + pass + + def delete_user_by_id(self, user_id: str): + pass + + def get_auth_id_by_user_id(self, user_id: str) -> str: + pass + + def get_user_by_email(self, email: str): + pass + + def get_user_by_id(self, user_id: str): + pass + + def get_user_id_by_auth_id(self, auth_id: str) -> str: + pass + + def get_user_role_by_auth_id(self, auth_id: str) -> str: + pass + + def get_users(self): + pass + + def update_user_by_id(self, user_id: str, user): + pass diff --git a/backend/app/utilities/db_utils.py b/backend/app/utilities/db_utils.py new file mode 100644 index 0000000..986bdbc --- /dev/null +++ b/backend/app/utilities/db_utils.py @@ -0,0 +1,21 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +load_dotenv() + +DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +# explanation for using yield to get local db session: +# https://stackoverflow.com/questions/64763770/why-we-use-yield-to-get-sessionlocal-in-fastapi-with-sqlalchemy +def get_db() -> Session: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/utilities/firebase_init.py b/backend/app/utilities/firebase_init.py new file mode 100644 index 0000000..7ebc809 --- /dev/null +++ b/backend/app/utilities/firebase_init.py @@ -0,0 +1,11 @@ +import os + +import firebase_admin +from firebase_admin import credentials + + +def initialize_firebase(): + cwd = os.getcwd() + service_account_path = os.path.join(cwd, "serviceAccountKey.json") + cred = credentials.Certificate(service_account_path) + firebase_admin.initialize_app(cred) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 20833d9..9ccef99 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -33,6 +33,18 @@ precommit-install = "pre-commit install" revision = "alembic revision --autogenerate" upgrade = "alembic upgrade head" +[tool.pytest.ini_options] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" +pythonpath = ["."] + +[tool.pdm.dev-dependencies] +test = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.24.0", + "pytest-mock>=3.10.0", +] + [tool.ruff] target-version = "py312" # Read more here https://docs.astral.sh/ruff/rules/ diff --git a/backend/test.db b/backend/test.db new file mode 100644 index 0000000..b75c4f9 Binary files /dev/null and b/backend/test.db differ diff --git a/backend/tests/unit/test_csv.py b/backend/tests/unit/test_csv.py deleted file mode 100644 index 8ae3b50..0000000 --- a/backend/tests/unit/test_csv.py +++ /dev/null @@ -1,164 +0,0 @@ -from app.utilities.csv_utils import generate_csv_from_list - -""" -Test Cases for generate_csv - -Current Issues: -1. Note that unwind only unwinds at the current level -2. List of dictionaries must be of the same type - -TODO: Re-assess this file as necessary -""" - -person = [ - { - "name": "Person1", - "age": 20, - "pets": [ - {"name": "Beans", "type": "Cat"}, - {"name": "Spot", "type": "Dog"}, - ], - }, - { - "name": "Person2", - "age": 25, - "pets": [{"name": "Splash", "type": "Fish"}], - }, -] - -person2 = [ - { - "name": "Person1", - "age": 20, - "pets": [ - {"name": "Beans", "type": "Cat"}, - {"name": "Spot", "type": "Dog"}, - ], - }, -] - -person3 = [ - { - "name": "Person1", - "age": 20, - "pets": [ - {"name": "Beans", "type": "Cat"}, - {"name": "Spot", "type": "Dog"}, - ], - "num_pets": 5, - }, -] - -person4 = [ - { - "name": "Person1", - "age": 20, - "pet": {"name": "Beans", "type": "Cat"}, - }, -] - - -def transform_person(person): - transformed = person.copy() - transformed["num_pets"] = len(transformed["pets"]) - return transformed - - -options = { - "header": True, - "transform": transform_person, - "flatten_lists": False, - "flatten_objects": False, -} - -unwind_options = { - "header": True, - "unwind": "pets", - "flatten_lists": False, - "flatten_objects": False, -} - - -flatten_list_options = { - "header": True, - "flatten_lists": True, - "flatten_objects": False, -} - - -flatten_objects_options = { - "header": True, - "flatten_lists": False, - "flatten_objects": True, -} - - -flatten_both_options = { - "header": True, - "flatten_lists": True, - "flatten_objects": True, -} - - -no_header_options = { - "header": False, - "flatten_lists": False, - "flatten_objects": False, -} - - -def test_basic(): - result = generate_csv_from_list(person) - assert ( - result - == "Person1,20,\"[{'name': 'Beans', 'type': 'Cat'}, " - + "{'name': 'Spot', 'type': 'Dog'}]\",2\r\nPerson2,25,\"" - + "[{'name': 'Splash', 'type': 'Fish'}]\",1\r\n" - ) - - -def test_transform(): - result = generate_csv_from_list(person2, **options) - assert ( - result - == "name,age,pets,num_pets\r\nPerson1,20,\"[{'name': 'Beans', 'type': 'Cat'}, " - + "{'name': 'Spot', 'type': 'Dog'}]\",2\r\n" - ) - - -def test_nested(): - result = generate_csv_from_list(person4, **no_header_options) - assert result == "Person1,20,\"{'name': 'Beans', 'type': 'Cat'}\"\r\n" - - -def test_flatten_objects(): - result = generate_csv_from_list(person4, **flatten_objects_options) - assert result == "name,age,pet.name,pet.type\r\nPerson1,20,Beans,Cat\r\n" - - -def test_flatten_lists(): - result = generate_csv_from_list(person2, **flatten_list_options) - assert ( - result - == "name,age,pets.0,pets.1\r\nPerson1,20,\"{'name': 'Beans', 'type': 'Cat'}" - + '","' - "{'name': 'Spot', 'type': 'Dog'}\"\r\n" - ) - - -def test_flatten_both(): - result = generate_csv_from_list(person2, **flatten_both_options) - assert ( - result - == "name,age,pets.0.name,pets.0.type,pets.1.name,pets.1.type\r\n" - + "Person1,20,Beans,Cat,Spot,Dog\r\n" - ) - - -def test_unwind(): - result = generate_csv_from_list(person2, **unwind_options) - assert ( - result - == "name,age,pets\r\nPerson1,20,\"{'name': 'Beans', 'type': 'Cat'}\"\r\n" - + "Person1,20,\"{'name': 'Spot', 'type': 'Dog'}\"\r\n" - ) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py deleted file mode 100644 index ba3bb91..0000000 --- a/backend/tests/unit/test_models.py +++ /dev/null @@ -1,25 +0,0 @@ -from app.models import db -from app.models.user import User - -""" -Sample python test. -For more information on pytest, visit: -https://docs.pytest.org/en/6.2.x/reference.html -""" - - -def test_create_user(): - user = { - "first_name": "Jane", - "last_name": "Doe", - "auth_id": "abc", - "role": "Admin", - } - - user = User(**user) - db.session.add(user) - db.session.commit() - assert user.first_name == "Jane" - assert user.last_name == "Doe" - assert user.auth_id == "abc" - assert user.role == "Admin" diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py new file mode 100644 index 0000000..aa082be --- /dev/null +++ b/backend/tests/unit/test_user.py @@ -0,0 +1,170 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.models import Role +from app.models.Base import Base +from app.models.User import User +from app.schemas.user import ( + SignUpMethod, + UserCreateRequest, + UserCreateResponse, + UserRole, +) +from app.services.implementations.user_service import UserService + +# Test DB Configuration +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class MockFirebaseUser: + """Mock Firebase user response""" + + def __init__(self): + self.uid = "test_firebase_uid" + self.email = "test@example.com" + + +class MockFirebaseError(Exception): + """Mock Firebase error""" + + pass + + +class MockAuthError(MockFirebaseError): + """Mock Firebase auth error""" + + def __init__(self, code, message): + self.code = code + self.message = message + super().__init__(f"{code}: {message}") + + +@pytest.fixture +def mock_firebase_auth(monkeypatch): + """Mock Firebase authentication service""" + + class MockAuth: + def create_user(self, email, password): + return MockFirebaseUser() + + def get_user(self, uid): + return MockFirebaseUser() + + def delete_user(self, uid): + pass + + AuthError = MockAuthError + + mock_auth = MockAuth() + monkeypatch.setattr("firebase_admin.auth", mock_auth) + return mock_auth + + +@pytest.fixture(scope="function") +def db_session(): + """Provide a clean database session for each test""" + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + + try: + # Clean up any existing data first + session.query(User).delete() + session.query(Role).delete() + session.commit() + + # Create test role + test_role = Role(id=1, name=UserRole.PARTICIPANT) + session.add(test_role) + session.commit() + + yield session + finally: + session.rollback() + session.close() + # Clean up + session = TestingSessionLocal() + session.query(User).delete() + session.query(Role).delete() + session.commit() + session.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.mark.asyncio +async def test_create_user_service(mock_firebase_auth, db_session): + """Test user creation flow including Firebase auth and database storage""" + try: + # Arrange + user_service = UserService(db_session) + user_data = UserCreateRequest( + first_name="Test", + last_name="User", + email="test@example.com", + password="TestPass@123", + role=UserRole.PARTICIPANT, + signup_method=SignUpMethod.PASSWORD, + ) + + # Act + created_user = await user_service.create_user(user_data) + + # Assert response + assert isinstance(created_user, UserCreateResponse) + assert created_user.first_name == "Test" + assert created_user.last_name == "User" + assert created_user.email == "test@example.com" + assert created_user.role_id == 1 + assert created_user.auth_id == "test_firebase_uid" + + # Assert database state + db_user = db_session.query(User).filter_by(email="test@example.com").first() + assert db_user is not None + assert db_user.auth_id == "test_firebase_uid" + assert db_user.role_id == 1 + + db_session.commit() # Commit successful test + except Exception: + db_session.rollback() # Rollback on error + raise + + +@pytest.mark.asyncio +async def test_create_user_with_google(mock_firebase_auth, db_session): + """Test user creation flow with Google authentication""" + try: + # Arrange + user_service = UserService(db_session) + user_data = UserCreateRequest( + first_name="Google", + last_name="User", + email="google@example.com", + role=UserRole.PARTICIPANT, + signup_method=SignUpMethod.GOOGLE, + ) + + # Act + created_user = await user_service.create_user(user_data) + + # Assert response + assert isinstance(created_user, UserCreateResponse) + assert created_user.first_name == "Google" + assert created_user.last_name == "User" + assert created_user.email == "google@example.com" + assert created_user.role_id == 1 + assert created_user.auth_id == "test_firebase_uid" + + # Assert database state + db_user = db_session.query(User).filter_by(email="google@example.com").first() + assert db_user is not None + assert db_user.auth_id == "test_firebase_uid" + assert db_user.role_id == 1 + + db_session.commit() # Commit successful test + except Exception: + db_session.rollback() # Rollback on error + raise