Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LLSC-28] Create user endpoint #7

Merged
merged 16 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
**/venv
**/__pycache__
**/*.log
**/firebaseServiceAccount.json
**/serviceAccountKey.json
**/.DS_Store
**/*.cache
**/*.egg-info
2 changes: 1 addition & 1 deletion backend/app/interfaces/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Empty file added backend/app/routes/auth.py
Empty file.
31 changes: 31 additions & 0 deletions backend/app/routes/user.py
Original file line number Diff line number Diff line change
@@ -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))
89 changes: 89 additions & 0 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
@@ -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
mmiqball marked this conversation as resolved.
Show resolved Hide resolved
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")
mmiqball marked this conversation as resolved.
Show resolved Hide resolved
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)
mmiqball marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 7 additions & 2 deletions backend/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@

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")


@asynccontextmanager
async def lifespan(_: FastAPI):
log.info("Starting up...")
models.run_migrations()
initialize_firebase()
yield
log.info("Shutting down...")


# 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)

Expand Down
105 changes: 105 additions & 0 deletions backend/app/services/implementations/user_service.py
Original file line number Diff line number Diff line change
@@ -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()
mmiqball marked this conversation as resolved.
Show resolved Hide resolved

return UserCreateResponse.model_validate(db_user)

except firebase_admin.exceptions.FirebaseError as firebase_error:
mmiqball marked this conversation as resolved.
Show resolved Hide resolved
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
21 changes: 21 additions & 0 deletions backend/app/utilities/db_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import os

from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

mmiqball marked this conversation as resolved.
Show resolved Hide resolved
load_dotenv()

DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


mmiqball marked this conversation as resolved.
Show resolved Hide resolved
# 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()
11 changes: 11 additions & 0 deletions backend/app/utilities/firebase_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import os

import firebase_admin
from firebase_admin import credentials


def initialize_firebase():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add INFO level logs for starting and successful initialization of firebase.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not working, so creating a ticket for this to push it.

cwd = os.getcwd()
service_account_path = os.path.join(cwd, "serviceAccountKey.json")
cred = credentials.Certificate(service_account_path)
firebase_admin.initialize_app(cred)
12 changes: 12 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Binary file added backend/test.db
Binary file not shown.
Loading