From fa2fc6faba4568f089f5d2e04f8510c3716c9e44 Mon Sep 17 00:00:00 2001 From: Mujtaba Iqbal Date: Wed, 16 Oct 2024 17:53:11 -0400 Subject: [PATCH] firebase integration --- backend/app/__init__.py | 2 +- backend/app/database.py | 12 ++++ .../versions/59bb2488a76b_insert_roles.py | 40 ++++++++++++ .../79de0b981dd8_add_auth_id_to_user_model.py | 30 +++++++++ ...36_update_users_id_to_uuid_with_default.py | 40 ++++++++++++ backend/app/models/User.py | 7 ++- backend/app/models/__init__.py | 1 + backend/app/routes/user.py | 11 +++- backend/app/schemas/user.py | 24 +++++-- backend/app/server.py | 3 + .../services/implementations/user_service.py | 62 ++++++++++++++----- .../app/services/interfaces/user_service.py | 2 +- backend/app/utilities/db_utils.py | 15 +++++ backend/app/utilities/firebase_init.py | 23 +++++++ backend/schemas/user.py | 0 15 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 backend/app/database.py create mode 100644 backend/app/migrations/versions/59bb2488a76b_insert_roles.py create mode 100644 backend/app/migrations/versions/79de0b981dd8_add_auth_id_to_user_model.py create mode 100644 backend/app/migrations/versions/c9bc2b4d1036_update_users_id_to_uuid_with_default.py create mode 100644 backend/app/utilities/db_utils.py create mode 100644 backend/app/utilities/firebase_init.py delete mode 100644 backend/schemas/user.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 55952da..3007e95 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -87,7 +87,7 @@ def create_app(): ), } ), - {"storageBucket": os.getenv("FIREBASE_STORAGE_DEFAULT_BUCKET")}, + # {"storageBucket": os.getenv("FIREBASE_STORAGE_DEFAULT_BUCKET")}, ) # from . import models, rest diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..82c59fa --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,12 @@ +from sqlalchemy.orm import sessionmaker, Session +from app.models import Base + +# Create a SessionLocal class +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=Base.metadata.bind) + +def get_db() -> Session: + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/backend/app/migrations/versions/59bb2488a76b_insert_roles.py b/backend/app/migrations/versions/59bb2488a76b_insert_roles.py new file mode 100644 index 0000000..238f181 --- /dev/null +++ b/backend/app/migrations/versions/59bb2488a76b_insert_roles.py @@ -0,0 +1,40 @@ +"""insert roles + +Revision ID: 59bb2488a76b +Revises: 4ba3479cb8df +Create Date: 2024-10-16 16:55:42.324525 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '59bb2488a76b' +down_revision: Union[str, None] = '4ba3479cb8df' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.bulk_insert( + sa.table('roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + ), + [ + {'id': 1, 'name': 'participant'}, + {'id': 2, 'name': 'volunteer'}, + {'id': 3, 'name': 'admin'}, + ] + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/migrations/versions/79de0b981dd8_add_auth_id_to_user_model.py b/backend/app/migrations/versions/79de0b981dd8_add_auth_id_to_user_model.py new file mode 100644 index 0000000..cbb7bfe --- /dev/null +++ b/backend/app/migrations/versions/79de0b981dd8_add_auth_id_to_user_model.py @@ -0,0 +1,30 @@ +"""Add auth_id to User model + +Revision ID: 79de0b981dd8 +Revises: 59bb2488a76b +Create Date: 2024-10-16 17:06:45.820859 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '79de0b981dd8' +down_revision: Union[str, None] = '59bb2488a76b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('auth_id', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'auth_id') + # ### end Alembic commands ### diff --git a/backend/app/migrations/versions/c9bc2b4d1036_update_users_id_to_uuid_with_default.py b/backend/app/migrations/versions/c9bc2b4d1036_update_users_id_to_uuid_with_default.py new file mode 100644 index 0000000..786a1d5 --- /dev/null +++ b/backend/app/migrations/versions/c9bc2b4d1036_update_users_id_to_uuid_with_default.py @@ -0,0 +1,40 @@ +"""Update users id to UUID with default + +Revision ID: c9bc2b4d1036 +Revises: 79de0b981dd8 +Create Date: 2024-10-16 17:13:53.820521 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c9bc2b4d1036' +down_revision: Union[str, None] = '79de0b981dd8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'id', + existing_type=sa.VARCHAR(), + type_=sa.UUID(), + postgresql_using="id::uuid", + server_default=sa.text("gen_random_uuid()"), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'id', + existing_type=sa.UUID(), + type_=sa.VARCHAR(), + postgresql_using="id::text", + server_default=None, + existing_nullable=False) + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/models/User.py b/backend/app/models/User.py index 2fa0404..ca64ef2 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -1,12 +1,15 @@ from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship +import uuid from .Base import Base - class User(Base): __tablename__ = "users" - id = Column(String, primary_key=True) # UUID + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + auth_id = Column(String, nullable=True) # Firebase Auth ID first_name = Column(String(80), nullable=True) last_name = Column(String(80), nullable=True) email = Column(String(120), unique=True, nullable=False) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8dedaab..6030f56 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,3 +15,4 @@ def init_app(): engine = create_engine(os.environ["POSTGRES_DATABASE_URL"]) Base.metadata.create_all(bind=engine) + Base.metadata.bind = engine diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index 514d2d5..4f71d46 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -1,18 +1,25 @@ from fastapi import APIRouter, Depends, HTTPException from app.schemas.user import UserCreate, User from app.services.implementations.user_service import UserService +from app.utilities.db_utils import get_db +from sqlalchemy.orm import Session router = APIRouter( prefix="/users", tags=["users"], ) -# to do: send email verification via auth_service +# to do: +# send email verification via auth_service +# allow signup methods other than email (like sign up w Google)?? @router.post("/", response_model=User) -async def create_user(user: UserCreate, user_service: UserService = Depends()): +async def create_user(user: UserCreate, db: Session = Depends(get_db)): + user_service = UserService(db) try: created_user = await user_service.create_user(user) return created_user + except HTTPException as http_ex: + raise http_ex except Exception as e: raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 9519848..20791ae 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,15 +1,24 @@ from pydantic import BaseModel, EmailStr, Field, validator from enum import Enum +from uuid import UUID -class Role(str, Enum): +class UserRole(str, Enum): PARTICIPANT = "participant" VOLUNTEER = "volunteer" ADMIN = "admin" + +class RoleBase(BaseModel): + id: int + name: UserRole + + class Config: + from_attributes = True + class UserBase(BaseModel): first_name: str = Field(..., min_length=1, max_length=50) last_name: str = Field(..., min_length=1, max_length=50) email: EmailStr - role: Role + role: RoleBase class UserCreate(UserBase): password: str = Field(..., min_length=8) @@ -25,20 +34,23 @@ def password_complexity(cls, v): return v class UserInDB(UserBase): - id: str + id: UUID auth_id: str + class Config: + from_attributes = True + class User(UserBase): id: str class Config: - orm_mode = True + from_attributes = True class UserUpdate(BaseModel): first_name: str | None = Field(None, min_length=1, max_length=50) last_name: str | None = Field(None, min_length=1, max_length=50) email: EmailStr | None = None - role: Role | None = None + role: RoleBase | None = None class Config: - orm_mode = True \ No newline at end of file + from_attributes = True \ No newline at end of file diff --git a/backend/app/server.py b/backend/app/server.py index 72066ca..683da9e 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -5,10 +5,13 @@ from . import models from .routes import user +from .utilities.firebase_init import initialize_firebase + load_dotenv() app = FastAPI() models.init_app() +initialize_firebase() app.include_router(user.router) diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index a8a5541..8d02e92 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -1,16 +1,17 @@ import firebase_admin.auth from fastapi import HTTPException from sqlalchemy.orm import Session -from app.models.user import User +from app.models import Role, User from app.schemas.user import UserCreate, UserInDB from app.services.interfaces.user_service import IUserService +from firebase_admin.exceptions import FirebaseError import logging class UserService(IUserService): def __init__(self, db: Session): self.db = db self.logger = logging.getLogger(__name__) - + async def create_user(self, user: UserCreate) -> UserInDB: firebase_user = None try: @@ -20,35 +21,68 @@ async def create_user(self, user: UserCreate) -> UserInDB: password=user.password ) - # Create user in database + all_roles = self.db.query(Role).all() + print("Available roles:", [role.name for role in all_roles]) + + db_role = self.db.query(Role).filter(Role.name == user.role.value).first() + if not db_role: + raise HTTPException(status_code=400, detail="Invalid role") + + # create user in database db_user = User( first_name=user.first_name, last_name=user.last_name, email=user.email, - role=user.role, + role=db_role, auth_id=firebase_user.uid ) + self.db.add(db_user) self.db.commit() self.db.refresh(db_user) - return UserInDB.from_orm(db_user) - - except firebase_admin.auth.AuthError as firebase_error: - # If Firebase failed to add, the user wasn't added to the db so nothing to rollback - self.logger.error(f"Firebase authentication error: {str(firebase_error)}") - raise HTTPException(status_code=400, detail=str(firebase_error)) + return UserInDB.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") + else: + raise HTTPException(status_code=400, detail=str(firebase_error)) except Exception as e: - # If database insertion fails, we need to delete the Firebase user if firebase_user: try: firebase_admin.auth.delete_user(firebase_user.uid) except firebase_admin.auth.AuthError as firebase_error: - # Log the error if we couldn't delete the Firebase user self.logger.error(f"Failed to delete Firebase user after database insertion failed. Firebase UID: {firebase_user.uid}. Error: {str(firebase_error)}") - # Rollback the database session self.db.rollback() self.logger.error(f"Error creating user: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/app/services/interfaces/user_service.py b/backend/app/services/interfaces/user_service.py index 46cdaec..2a22b5f 100644 --- a/backend/app/services/interfaces/user_service.py +++ b/backend/app/services/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, auth_id=None): """ Create a user, email verification configurable diff --git a/backend/app/utilities/db_utils.py b/backend/app/utilities/db_utils.py new file mode 100644 index 0000000..09df296 --- /dev/null +++ b/backend/app/utilities/db_utils.py @@ -0,0 +1,15 @@ +from sqlalchemy.orm import sessionmaker, Session +from app.models import Base, init_app + +# Ensure the database is initialized +init_app() + +# Create a SessionLocal class +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=Base.metadata.bind) + +def get_db() -> Session: + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/backend/app/utilities/firebase_init.py b/backend/app/utilities/firebase_init.py new file mode 100644 index 0000000..fbf8199 --- /dev/null +++ b/backend/app/utilities/firebase_init.py @@ -0,0 +1,23 @@ +import os +import firebase_admin +from firebase_admin import credentials + +def initialize_firebase(): + private_key = os.getenv("FIREBASE_SVC_ACCOUNT_PRIVATE_KEY") + if private_key: + private_key = private_key.replace("\\n", "\n") + + cred = credentials.Certificate({ + "type": "service_account", + "project_id": os.getenv("FIREBASE_PROJECT_ID"), + "private_key_id": os.getenv("FIREBASE_SVC_ACCOUNT_PRIVATE_KEY_ID"), + "private_key": private_key, + "client_email": os.getenv("FIREBASE_SVC_ACCOUNT_CLIENT_EMAIL"), + "client_id": os.getenv("FIREBASE_SVC_ACCOUNT_CLIENT_ID"), + "auth_uri": os.getenv("FIREBASE_SVC_ACCOUNT_AUTH_URI"), + "token_uri": os.getenv("FIREBASE_SVC_ACCOUNT_TOKEN_URI"), + "auth_provider_x509_cert_url": os.getenv("FIREBASE_SVC_ACCOUNT_AUTH_PROVIDER_X509_CERT_URL"), + "client_x509_cert_url": os.getenv("FIREBASE_SVC_ACCOUNT_CLIENT_X509_CERT_URL"), + }) + + firebase_admin.initialize_app(cred) \ No newline at end of file diff --git a/backend/schemas/user.py b/backend/schemas/user.py deleted file mode 100644 index e69de29..0000000