diff --git a/README.md b/README.md index 2b14883..8b72b1b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A FastAPI backend for an AI-powered video dubbing tool that allows content creat ## Features 🔐 **Authentication**: JWT-based user registration and login +👤 **User Profiles**: Complete profile management with settings 📁 **Video Upload**: Upload MP4/MOV files to Amazon S3 (max 200MB) 🧠 **Transcription**: Audio transcription using OpenAI Whisper API 🌍 **Translation**: Text translation using GPT-4 API @@ -78,6 +79,13 @@ The API will be available at: - `POST /auth/register` - User registration - `POST /auth/login` - User login +### Profile Management +- `GET /profile/` - Get user profile +- `PUT /profile/` - Update profile information +- `PUT /profile/password` - Update password +- `PUT /profile/email` - Update email address +- `DELETE /profile/` - Delete user account + ### Video Management - `POST /videos/upload` - Upload video with language settings - `GET /videos/` - Get user's videos diff --git a/alembic/versions/002_add_profile_fields.py b/alembic/versions/002_add_profile_fields.py new file mode 100644 index 0000000..f28c4a5 --- /dev/null +++ b/alembic/versions/002_add_profile_fields.py @@ -0,0 +1,43 @@ +"""Add profile fields to users table + +Revision ID: 002 +Revises: 001 +Create Date: 2024-01-01 00:00:01.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: Union[str, None] = '001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add new profile fields to users table + op.add_column('users', sa.Column('first_name', sa.String(), nullable=True)) + op.add_column('users', sa.Column('last_name', sa.String(), nullable=True)) + op.add_column('users', sa.Column('phone', sa.String(), nullable=True)) + op.add_column('users', sa.Column('bio', sa.String(), nullable=True)) + op.add_column('users', sa.Column('preferred_language', sa.String(), nullable=True)) + op.add_column('users', sa.Column('timezone', sa.String(), nullable=True)) + op.add_column('users', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True)) + + # Set default values for existing users + op.execute("UPDATE users SET preferred_language = 'en' WHERE preferred_language IS NULL") + op.execute("UPDATE users SET timezone = 'UTC' WHERE timezone IS NULL") + + +def downgrade() -> None: + # Remove the added columns + op.drop_column('users', 'updated_at') + op.drop_column('users', 'timezone') + op.drop_column('users', 'preferred_language') + op.drop_column('users', 'bio') + op.drop_column('users', 'phone') + op.drop_column('users', 'last_name') + op.drop_column('users', 'first_name') \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 3ba6b12..3737515 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -9,4 +9,11 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True, nullable=False) password_hash = Column(String, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) \ No newline at end of file + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + phone = Column(String, nullable=True) + bio = Column(String, nullable=True) + preferred_language = Column(String, default="en") + timezone = Column(String, default="UTC") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) \ No newline at end of file diff --git a/app/routes/profile.py b/app/routes/profile.py new file mode 100644 index 0000000..ba5a682 --- /dev/null +++ b/app/routes/profile.py @@ -0,0 +1,232 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import Optional +import logging +from app.db.session import get_db +from app.models.user import User +from app.utils.auth import get_current_user, get_password_hash, verify_password + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class ProfileResponse(BaseModel): + id: int + email: str + first_name: Optional[str] + last_name: Optional[str] + phone: Optional[str] + bio: Optional[str] + preferred_language: str + timezone: str + created_at: str + updated_at: str + + class Config: + orm_mode = True + + +class ProfileUpdate(BaseModel): + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + bio: Optional[str] = None + preferred_language: Optional[str] = None + timezone: Optional[str] = None + + +class PasswordUpdate(BaseModel): + current_password: str + new_password: str + + +class EmailUpdate(BaseModel): + new_email: EmailStr + password: str + + +@router.get("/", response_model=ProfileResponse) +async def get_profile( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + logger.info(f"Profile requested for user: {current_user.email}") + + return ProfileResponse( + id=current_user.id, + email=current_user.email, + first_name=current_user.first_name, + last_name=current_user.last_name, + phone=current_user.phone, + bio=current_user.bio, + preferred_language=current_user.preferred_language or "en", + timezone=current_user.timezone or "UTC", + created_at=str(current_user.created_at), + updated_at=str(current_user.updated_at) if hasattr(current_user, 'updated_at') and current_user.updated_at else str(current_user.created_at) + ) + + +@router.put("/", response_model=ProfileResponse) +async def update_profile( + profile_data: ProfileUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + try: + logger.info(f"Profile update requested for user: {current_user.email}") + + # Update only provided fields + if profile_data.first_name is not None: + current_user.first_name = profile_data.first_name + if profile_data.last_name is not None: + current_user.last_name = profile_data.last_name + if profile_data.phone is not None: + current_user.phone = profile_data.phone + if profile_data.bio is not None: + current_user.bio = profile_data.bio + if profile_data.preferred_language is not None: + current_user.preferred_language = profile_data.preferred_language + if profile_data.timezone is not None: + current_user.timezone = profile_data.timezone + + db.commit() + db.refresh(current_user) + + logger.info(f"Profile updated successfully for user: {current_user.email}") + + return ProfileResponse( + id=current_user.id, + email=current_user.email, + first_name=current_user.first_name, + last_name=current_user.last_name, + phone=current_user.phone, + bio=current_user.bio, + preferred_language=current_user.preferred_language or "en", + timezone=current_user.timezone or "UTC", + created_at=str(current_user.created_at), + updated_at=str(current_user.updated_at) if hasattr(current_user, 'updated_at') and current_user.updated_at else str(current_user.created_at) + ) + + except Exception as e: + logger.error(f"Profile update error for {current_user.email}: {str(e)}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during profile update" + ) + + +@router.put("/password") +async def update_password( + password_data: PasswordUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + try: + logger.info(f"Password update requested for user: {current_user.email}") + + # Verify current password + if not verify_password(password_data.current_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect" + ) + + # Validate new password + if len(password_data.new_password) < 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password must be at least 6 characters long" + ) + + # Update password + current_user.password_hash = get_password_hash(password_data.new_password) + db.commit() + + logger.info(f"Password updated successfully for user: {current_user.email}") + + return {"message": "Password updated successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Password update error for {current_user.email}: {str(e)}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during password update" + ) + + +@router.put("/email") +async def update_email( + email_data: EmailUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + try: + logger.info(f"Email update requested for user: {current_user.email}") + + # Verify password + if not verify_password(email_data.password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password is incorrect" + ) + + # Check if new email is already taken + existing_user = db.query(User).filter(User.email == email_data.new_email).first() + if existing_user and existing_user.id != current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + old_email = current_user.email + current_user.email = email_data.new_email + db.commit() + + logger.info(f"Email updated successfully from {old_email} to {email_data.new_email}") + + return {"message": "Email updated successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Email update error for {current_user.email}: {str(e)}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during email update" + ) + + +@router.delete("/") +async def delete_account( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + try: + logger.info(f"Account deletion requested for user: {current_user.email}") + + # Note: In a production environment, you might want to: + # 1. Soft delete instead of hard delete + # 2. Clean up related data (videos, transcriptions, etc.) + # 3. Add confirmation mechanisms + + user_email = current_user.email + db.delete(current_user) + db.commit() + + logger.info(f"Account deleted successfully for user: {user_email}") + + return {"message": "Account deleted successfully"} + + except Exception as e: + logger.error(f"Account deletion error for {current_user.email}: {str(e)}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during account deletion" + ) \ No newline at end of file diff --git a/main.py b/main.py index cf4b52e..e1b3752 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,7 @@ import logging from app.db.session import engine from app.db.base import Base -from app.routes import auth, videos, transcription, translation, voice_cloning, video_processing +from app.routes import auth, videos, transcription, translation, voice_cloning, video_processing, profile # Configure logging logging.basicConfig(level=logging.INFO) @@ -48,6 +48,7 @@ app.add_middleware( # Include routers app.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +app.include_router(profile.router, prefix="/profile", tags=["Profile"]) app.include_router(videos.router, prefix="/videos", tags=["Videos"]) app.include_router(transcription.router, prefix="/transcription", tags=["Transcription"]) app.include_router(translation.router, prefix="/translation", tags=["Translation"])