Skip to content

Commit a635e0b

Browse files
mmiqballmslwang
andauthored
[LLSC-28] Create user endpoint (#7)
## Notion ticket link [Create User endpoint (POST)] https://www.notion.so/uwblueprintexecs/Create-User-endpoint-POST-11410f3fb1dc8005b5e3f36238631399?pvs=4 ## Implementation description User Creation Flow (app/services/implementations/user_service.py): - Implemented Firebase user creation with database integration - Added rollback handling if either Firebase or database operations fail - Proper role validation and mapping Schema and Model Updates (app/schemas/user.py, app/models/User.py) - Added Pydantic models with validation for user CRUD requests ## Setup To set this up, set up a Firebase console project > project settings > service accounts, generate a certificate and add in the fields used in utilities/firebase_init.py to the env ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR --------- Co-authored-by: Matthew Wang <[email protected]> Co-authored-by: Matthew Wang <[email protected]>
1 parent 544823b commit a635e0b

File tree

14 files changed

+448
-193
lines changed

14 files changed

+448
-193
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
**/venv
55
**/__pycache__
66
**/*.log
7-
**/firebaseServiceAccount.json
7+
**/serviceAccountKey.json
88
**/.DS_Store
99
**/*.cache
1010
**/*.egg-info

backend/app/interfaces/user_service.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def get_users(self):
8383
pass
8484

8585
@abstractmethod
86-
def create_user(self, user, auth_id=None, signup_method="PASSWORD"):
86+
def create_user(self, user, signup_method="PASSWORD"):
8787
"""
8888
Create a user, email verification configurable
8989

backend/app/routes/auth.py

Whitespace-only changes.

backend/app/routes/user.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from fastapi import APIRouter, Depends, HTTPException
2+
from sqlalchemy.orm import Session
3+
4+
from app.schemas.user import UserCreateRequest, UserCreateResponse
5+
from app.services.implementations.user_service import UserService
6+
from app.utilities.db_utils import get_db
7+
8+
router = APIRouter(
9+
prefix="/users",
10+
tags=["users"],
11+
)
12+
13+
# TODO:
14+
# send email verification via auth_service
15+
# allow signup methods other than email (like sign up w Google)??
16+
17+
18+
def get_user_service(db: Session = Depends(get_db)):
19+
return UserService(db)
20+
21+
22+
@router.post("/", response_model=UserCreateResponse)
23+
async def create_user(
24+
user: UserCreateRequest, user_service: UserService = Depends(get_user_service)
25+
):
26+
try:
27+
return await user_service.create_user(user)
28+
except HTTPException as http_ex:
29+
raise http_ex
30+
except Exception as e:
31+
raise HTTPException(status_code=500, detail=str(e))

backend/app/schemas/user.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Pydantic schemas for user-related data validation and serialization.
3+
Handles user CRUD and response models for the API.
4+
"""
5+
6+
from enum import Enum
7+
from typing import Optional
8+
from uuid import UUID
9+
10+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
11+
12+
# TODO:
13+
# confirm complexity rules for fields (such as password)
14+
15+
16+
class SignUpMethod(str, Enum):
17+
"""Authentication methods supported for user signup"""
18+
19+
PASSWORD = "PASSWORD"
20+
GOOGLE = "GOOGLE"
21+
22+
23+
class UserRole(str, Enum):
24+
"""
25+
Enum for possible user roles.
26+
"""
27+
28+
PARTICIPANT = "participant"
29+
VOLUNTEER = "volunteer"
30+
ADMIN = "admin"
31+
32+
@classmethod
33+
def to_role_id(cls, role: "UserRole") -> int:
34+
role_map = {cls.PARTICIPANT: 1, cls.VOLUNTEER: 2, cls.ADMIN: 3}
35+
return role_map[role]
36+
37+
38+
class UserBase(BaseModel):
39+
"""
40+
Base schema for user model with common attributes shared across schemas.
41+
"""
42+
43+
first_name: str = Field(..., min_length=1, max_length=50)
44+
last_name: str = Field(..., min_length=1, max_length=50)
45+
email: EmailStr
46+
role: UserRole
47+
48+
49+
class UserCreateRequest(UserBase):
50+
"""
51+
Request schema for user creation with conditional password validation
52+
"""
53+
54+
password: Optional[str] = Field(None, min_length=8)
55+
auth_id: Optional[str] = Field(None) # for signup with google sso
56+
signup_method: SignUpMethod = Field(default=SignUpMethod.PASSWORD)
57+
58+
@field_validator("password")
59+
def validate_password(cls, password: Optional[str], info):
60+
signup_method = info.data.get("signup_method")
61+
62+
if signup_method == SignUpMethod.PASSWORD and not password:
63+
raise ValueError("Password is required for password signup")
64+
65+
if password:
66+
if not any(char.isdigit() for char in password):
67+
raise ValueError("Password must contain at least one digit")
68+
if not any(char.isupper() for char in password):
69+
raise ValueError("Password must contain at least one uppercase letter")
70+
if not any(char.islower() for char in password):
71+
raise ValueError("Password must contain at least one lowercase letter")
72+
73+
return password
74+
75+
76+
class UserCreateResponse(BaseModel):
77+
"""
78+
Response schema for user creation, maps directly from ORM User object.
79+
"""
80+
81+
id: UUID
82+
first_name: str
83+
last_name: str
84+
email: EmailStr
85+
role_id: int
86+
auth_id: str
87+
88+
# from_attributes enables automatic mapping from SQLAlchemy model to Pydantic model
89+
model_config = ConfigDict(from_attributes=True)

backend/app/server.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,29 @@
77

88
from app.routes import email
99

10-
from . import models
11-
1210
load_dotenv()
1311

12+
# we need to load env variables before initialization code runs
13+
from . import models # noqa: E402
14+
from .routes import user # noqa: E402
15+
from .utilities.firebase_init import initialize_firebase # noqa: E402
16+
1417
log = logging.getLogger("uvicorn")
1518

1619

1720
@asynccontextmanager
1821
async def lifespan(_: FastAPI):
1922
log.info("Starting up...")
2023
models.run_migrations()
24+
initialize_firebase()
2125
yield
2226
log.info("Shutting down...")
2327

2428

2529
# Source: https://stackoverflow.com/questions/77170361/
2630
# running-alembic-migrations-on-fastapi-startup
2731
app = FastAPI(lifespan=lifespan)
32+
app.include_router(user.router)
2833

2934
app.include_router(email.router)
3035

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import logging
2+
3+
import firebase_admin.auth
4+
from fastapi import HTTPException
5+
from sqlalchemy.orm import Session
6+
7+
from app.interfaces.user_service import IUserService
8+
from app.models import User
9+
from app.schemas.user import (
10+
SignUpMethod,
11+
UserCreateRequest,
12+
UserCreateResponse,
13+
UserRole,
14+
)
15+
16+
17+
class UserService(IUserService):
18+
def __init__(self, db: Session):
19+
self.db = db
20+
self.logger = logging.getLogger(__name__)
21+
22+
async def create_user(self, user: UserCreateRequest) -> UserCreateResponse:
23+
firebase_user = None
24+
try:
25+
if user.signup_method == SignUpMethod.PASSWORD:
26+
firebase_user = firebase_admin.auth.create_user(
27+
email=user.email, password=user.password
28+
)
29+
## TO DO: SSO functionality depends a lot on frontend implementation,
30+
## so we may need to update this when we have a better idea of what
31+
## that looks like
32+
elif user.signup_method == SignUpMethod.GOOGLE:
33+
# For signup with Google, Firebase users are automatically created
34+
firebase_user = firebase_admin.auth.get_user(user.auth_id)
35+
36+
role_id = UserRole.to_role_id(user.role)
37+
38+
# Create user in database
39+
db_user = User(
40+
first_name=user.first_name,
41+
last_name=user.last_name,
42+
email=user.email,
43+
role_id=role_id,
44+
auth_id=firebase_user.uid,
45+
)
46+
47+
self.db.add(db_user)
48+
# Finish database transaction and run previously defined
49+
# database operations (ie. db.add)
50+
self.db.commit()
51+
52+
return UserCreateResponse.model_validate(db_user)
53+
54+
except firebase_admin.exceptions.FirebaseError as firebase_error:
55+
self.logger.error(f"Firebase error: {str(firebase_error)}")
56+
57+
if isinstance(firebase_error, firebase_admin.auth.EmailAlreadyExistsError):
58+
raise HTTPException(status_code=409, detail="Email already exists")
59+
60+
raise HTTPException(status_code=400, detail=str(firebase_error))
61+
62+
except Exception as e:
63+
# Clean up Firebase user if a database exception occurs
64+
if firebase_user:
65+
try:
66+
firebase_admin.auth.delete_user(firebase_user.uid)
67+
except firebase_admin.auth.AuthError as firebase_error:
68+
self.logger.error(
69+
"Failed to delete Firebase user after database insertion failed"
70+
f"Firebase UID: {firebase_user.uid}. "
71+
f"Error: {str(firebase_error)}"
72+
)
73+
74+
# Rollback database changes
75+
self.db.rollback()
76+
self.logger.error(f"Error creating user: {str(e)}")
77+
78+
raise HTTPException(status_code=500, detail=str(e))
79+
80+
def delete_user_by_email(self, email: str):
81+
pass
82+
83+
def delete_user_by_id(self, user_id: str):
84+
pass
85+
86+
def get_auth_id_by_user_id(self, user_id: str) -> str:
87+
pass
88+
89+
def get_user_by_email(self, email: str):
90+
pass
91+
92+
def get_user_by_id(self, user_id: str):
93+
pass
94+
95+
def get_user_id_by_auth_id(self, auth_id: str) -> str:
96+
pass
97+
98+
def get_user_role_by_auth_id(self, auth_id: str) -> str:
99+
pass
100+
101+
def get_users(self):
102+
pass
103+
104+
def update_user_by_id(self, user_id: str, user):
105+
pass

backend/app/utilities/db_utils.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import os
2+
3+
from dotenv import load_dotenv
4+
from sqlalchemy import create_engine
5+
from sqlalchemy.orm import Session, sessionmaker
6+
7+
load_dotenv()
8+
9+
DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL")
10+
engine = create_engine(DATABASE_URL)
11+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
12+
13+
14+
# explanation for using yield to get local db session:
15+
# https://stackoverflow.com/questions/64763770/why-we-use-yield-to-get-sessionlocal-in-fastapi-with-sqlalchemy
16+
def get_db() -> Session:
17+
db = SessionLocal()
18+
try:
19+
yield db
20+
finally:
21+
db.close()
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import os
2+
3+
import firebase_admin
4+
from firebase_admin import credentials
5+
6+
7+
def initialize_firebase():
8+
cwd = os.getcwd()
9+
service_account_path = os.path.join(cwd, "serviceAccountKey.json")
10+
cred = credentials.Certificate(service_account_path)
11+
firebase_admin.initialize_app(cred)

backend/pyproject.toml

+12
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ precommit-install = "pre-commit install"
3333
revision = "alembic revision --autogenerate"
3434
upgrade = "alembic upgrade head"
3535

36+
[tool.pytest.ini_options]
37+
asyncio_mode = "strict"
38+
asyncio_default_fixture_loop_scope = "function"
39+
pythonpath = ["."]
40+
41+
[tool.pdm.dev-dependencies]
42+
test = [
43+
"pytest>=7.0.0",
44+
"pytest-asyncio>=0.24.0",
45+
"pytest-mock>=3.10.0",
46+
]
47+
3648
[tool.ruff]
3749
target-version = "py312"
3850
# Read more here https://docs.astral.sh/ruff/rules/

backend/test.db

20 KB
Binary file not shown.

0 commit comments

Comments
 (0)