Working on API
This commit is contained in:
68
Readme.md
68
Readme.md
@@ -13,7 +13,7 @@ architecture with Redis for task queuing and MongoDB for data persistence.
|
||||
- **Backend API**: FastAPI (Python 3.12)
|
||||
- **Task Processing**: Celery with Redis broker
|
||||
- **Document Processing**: EasyOCR, PyMuPDF, python-docx, pdfplumber
|
||||
- **Database**: MongoDB
|
||||
- **Database**: MongoDB (pymongo)
|
||||
- **Frontend**: React
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **File Monitoring**: Python watchdog library
|
||||
@@ -109,16 +109,18 @@ MyDocManager/
|
||||
│ │ │ │ └── types.py # PyObjectId and other useful types
|
||||
│ │ │ ├── database/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── connection.py # MongoDB connection
|
||||
│ │ │ │ ├── connection.py # MongoDB connection (pymongo)
|
||||
│ │ │ │ └── repositories/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── user_repository.py # User CRUD operations
|
||||
│ │ │ │ └── document_repository.py # User CRUD operations
|
||||
│ │ │ │ ├── user_repository.py # User CRUD operations (synchronous)
|
||||
│ │ │ │ ├── document_repository.py # Document CRUD operations (synchronous)
|
||||
│ │ │ │ └── job_repository.py # Job CRUD operations (synchronous)
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── auth_service.py # JWT & password logic
|
||||
│ │ │ │ ├── user_service.py # User business logic
|
||||
│ │ │ │ ├── document_service.py # Document business logic
|
||||
│ │ │ │ ├── auth_service.py # JWT & password logic (synchronous)
|
||||
│ │ │ │ ├── user_service.py # User business logic (synchronous)
|
||||
│ │ │ │ ├── document_service.py # Document business logic (synchronous)
|
||||
│ │ │ │ ├── job_service.py # Job processing logic (synchronous)
|
||||
│ │ │ │ └── init_service.py # Admin creation at startup
|
||||
│ │ │ ├── api/
|
||||
│ │ │ │ ├── __init__.py
|
||||
@@ -334,13 +336,20 @@ class ProcessingJob(BaseModel):
|
||||
- **Rationale**: MongoDB is not meant for large files, better performance. Files remain in the file system for easy
|
||||
access.
|
||||
|
||||
### Implementation Order
|
||||
#### Repository and Services Implementation
|
||||
|
||||
- **Choice**: Synchronous implementation using pymongo
|
||||
- **Rationale**: Full compatibility with Celery workers and simplified workflow
|
||||
- **Implementation**: All repositories and services operate synchronously for seamless integration
|
||||
|
||||
### Implementation Status
|
||||
|
||||
1. ✅ Pydantic models for MongoDB collections
|
||||
2. UNDER PROGRESS : Repository layer for data access (files + processing_jobs)
|
||||
3. TODO : Celery tasks for document processing
|
||||
4. TODO : Watchdog file monitoring implementation
|
||||
5. TODO : FastAPI integration and startup coordination
|
||||
2. ✅ Repository layer for data access (files + processing_jobs + users + documents) - synchronous
|
||||
3. ✅ Service layer for business logic (auth, user, document, job) - synchronous
|
||||
4. ✅ Celery tasks for document processing
|
||||
5. ✅ Watchdog file monitoring implementation
|
||||
6. ✅ FastAPI integration and startup coordination
|
||||
|
||||
## Job Management Layer
|
||||
|
||||
@@ -350,7 +359,7 @@ The job management system follows the repository pattern for clean separation be
|
||||
|
||||
#### JobRepository
|
||||
|
||||
Handles direct MongoDB operations for processing jobs:
|
||||
Handles direct MongoDB operations for processing jobs using synchronous pymongo:
|
||||
|
||||
**CRUD Operations:**
|
||||
- `create_job()` - Create new processing job with automatic `created_at` timestamp
|
||||
@@ -367,7 +376,7 @@ Handles direct MongoDB operations for processing jobs:
|
||||
|
||||
#### JobService
|
||||
|
||||
Provides business logic layer with strict status transition validation:
|
||||
Provides synchronous business logic layer with strict status transition validation:
|
||||
|
||||
**Status Transition Methods:**
|
||||
- `mark_job_as_started()` - PENDING → PROCESSING
|
||||
@@ -381,7 +390,6 @@ Provides business logic layer with strict status transition validation:
|
||||
|
||||
#### Custom Exceptions
|
||||
|
||||
**JobNotFoundError**: Raised when job ID doesn't exist
|
||||
**InvalidStatusTransitionError**: Raised for invalid status transitions
|
||||
**JobRepositoryError**: Raised for MongoDB operation failures
|
||||
|
||||
@@ -400,11 +408,17 @@ All other transitions are forbidden and will raise `InvalidStatusTransitionError
|
||||
```
|
||||
src/file-processor/app/
|
||||
├── database/repositories/
|
||||
│ └── job_repository.py # JobRepository class
|
||||
│ ├── job_repository.py # JobRepository class (synchronous)
|
||||
│ ├── user_repository.py # UserRepository class (synchronous)
|
||||
│ ├── document_repository.py # DocumentRepository class (synchronous)
|
||||
│ └── file_repository.py # FileRepository class (synchronous)
|
||||
├── services/
|
||||
│ └── job_service.py # JobService class
|
||||
│ ├── job_service.py # JobService class (synchronous)
|
||||
│ ├── auth_service.py # AuthService class (synchronous)
|
||||
│ ├── user_service.py # UserService class (synchronous)
|
||||
│ └── document_service.py # DocumentService class (synchronous)
|
||||
└── exceptions/
|
||||
└── job_exceptions.py # Custom exceptions
|
||||
└── job_exceptions.py # Custom exceptions
|
||||
```
|
||||
|
||||
### Processing Pipeline Features
|
||||
@@ -414,6 +428,7 @@ src/file-processor/app/
|
||||
- **Status Tracking**: Real-time processing status via `processing_jobs` collection
|
||||
- **Extensible Metadata**: Flexible metadata storage per file type
|
||||
- **Multiple Extraction Methods**: Support for direct text, OCR, and hybrid approaches
|
||||
- **Synchronous Operations**: All database operations use pymongo for Celery compatibility
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
@@ -436,6 +451,7 @@ src/file-processor/app/
|
||||
- **Package Manager**: pip (standard)
|
||||
- **External Dependencies**: Listed in each service's requirements.txt
|
||||
- **Standard Library First**: Prefer standard library when possible
|
||||
- **Database Driver**: pymongo for synchronous MongoDB operations
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
@@ -460,6 +476,7 @@ src/file-processor/app/
|
||||
12. **Content in Files Collection**: Extracted content stored with file metadata
|
||||
13. **Direct Task Dispatch**: File watcher directly creates Celery tasks
|
||||
14. **SHA256 Duplicate Detection**: Prevents reprocessing identical files
|
||||
15. **Synchronous Implementation**: All repositories and services use pymongo for Celery compatibility
|
||||
|
||||
### Development Process Requirements
|
||||
|
||||
@@ -470,12 +487,13 @@ src/file-processor/app/
|
||||
|
||||
### Next Implementation Steps
|
||||
|
||||
1. **IN PROGRESS**: Implement file processing pipeline =>
|
||||
1. Create Pydantic models for files and processing_jobs collections
|
||||
2. Implement repository layer for file and processing job data access
|
||||
3. Create Celery tasks for document processing (.txt, .pdf, .docx)
|
||||
4. Implement Watchdog file monitoring with dedicated observer
|
||||
5. Integrate file watcher with FastAPI startup
|
||||
1. **TODO**: Complete file processing pipeline =>
|
||||
1. ✅ Create Pydantic models for files and processing_jobs collections
|
||||
2. ✅ Implement repository layer for file and processing job data access (synchronous)
|
||||
3. ✅ Implement service layer for business logic (synchronous)
|
||||
4. ✅ Create Celery tasks for document processing (.txt, .pdf, .docx)
|
||||
5. ✅ Implement Watchdog file monitoring with dedicated observer
|
||||
6. ✅ Integrate file watcher with FastAPI startup
|
||||
2. Create protected API routes for user management
|
||||
3. Build React monitoring interface with authentication
|
||||
|
||||
@@ -566,4 +584,4 @@ docker-compose up --scale worker=3
|
||||
- **file-processor**: Hot-reload enabled via `--reload` flag
|
||||
- Code changes in `src/file-processor/app/` automatically restart FastAPI
|
||||
- **worker**: No hot-reload (manual restart required for stability)
|
||||
- Code changes in `src/worker/tasks/` require: `docker-compose restart worker`
|
||||
- Code changes in `src/worker/tasks/` require: `docker-compose restart worker`
|
||||
@@ -5,16 +5,22 @@ asgiref==3.9.1
|
||||
bcrypt==4.3.0
|
||||
billiard==4.2.1
|
||||
celery==5.5.3
|
||||
certifi==2025.8.3
|
||||
cffi==2.0.0
|
||||
click==8.2.1
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1.2
|
||||
click-repl==0.3.0
|
||||
cryptography==46.0.1
|
||||
dnspython==2.8.0
|
||||
ecdsa==0.19.1
|
||||
email-validator==2.3.0
|
||||
fastapi==0.116.1
|
||||
h11==0.16.0
|
||||
hiredis==3.2.1
|
||||
httpcore==1.0.9
|
||||
httptools==0.6.4
|
||||
httpx==0.28.1
|
||||
idna==3.10
|
||||
importlib_metadata==8.7.0
|
||||
iniconfig==2.1.0
|
||||
@@ -27,6 +33,8 @@ packaging==25.0
|
||||
pipdeptree==2.28.0
|
||||
pluggy==1.6.0
|
||||
prompt_toolkit==3.0.52
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pycron==3.2.0
|
||||
pydantic==2.11.9
|
||||
pydantic_core==2.33.2
|
||||
@@ -42,6 +50,7 @@ python-magic==0.4.27
|
||||
pytz==2025.2
|
||||
PyYAML==6.0.2
|
||||
redis==6.4.0
|
||||
rsa==4.9.1
|
||||
sentinels==1.1.1
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
|
||||
0
src/file-processor/app/api/__init__.py
Normal file
0
src/file-processor/app/api/__init__.py
Normal file
100
src/file-processor/app/api/dependencies.py
Normal file
100
src/file-processor/app/api/dependencies.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jwt import InvalidTokenError
|
||||
from starlette import status
|
||||
|
||||
from app.config import settings
|
||||
from app.database.connection import get_database
|
||||
from app.models.auth import UserRole
|
||||
from app.models.user import UserInDB
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_auth_service() -> AuthService:
|
||||
"""Dependency to get AuthService instance."""
|
||||
return AuthService()
|
||||
|
||||
|
||||
def get_user_service() -> UserService:
|
||||
"""Dependency to get UserService instance."""
|
||||
database = get_database()
|
||||
return UserService(database)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
) -> UserInDB:
|
||||
"""
|
||||
Dependency to get current authenticated user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer credentials
|
||||
user_service: Auth service instance
|
||||
|
||||
Returns:
|
||||
User: Current authenticated user
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or user not found
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.get_jwt_secret_key(),
|
||||
algorithms=[settings.get_jwt_algorithm()]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = user_service.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_admin_user(current_user: UserInDB = Depends(get_current_user)) -> UserInDB:
|
||||
"""
|
||||
Dependency to ensure current user has admin role.
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
User: Current user if admin
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not admin
|
||||
"""
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
0
src/file-processor/app/api/routes/__init__.py
Normal file
0
src/file-processor/app/api/routes/__init__.py
Normal file
80
src/file-processor/app/api/routes/auth.py
Normal file
80
src/file-processor/app/api/routes/auth.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.api.dependencies import get_auth_service, get_current_user, get_user_service
|
||||
from app.models.auth import LoginResponse, UserResponse
|
||||
from app.models.user import UserInDB
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(tags=["authentication"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Authenticate user and return JWT token.
|
||||
|
||||
Args:
|
||||
form_data: OAuth2 password form data
|
||||
auth_service: Auth service instance
|
||||
user_service: User service instance
|
||||
|
||||
Returns:
|
||||
LoginResponse: JWT token and user info
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
incorrect_username_or_pwd = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = user_service.get_user_by_username(form_data.username)
|
||||
if (not user or
|
||||
not user.is_active or
|
||||
not auth_service.verify_user_password(form_data.password, user.hashed_password)):
|
||||
raise incorrect_username_or_pwd
|
||||
|
||||
access_token = auth_service.create_access_token(data={"sub": user.username})
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
user=UserResponse(
|
||||
_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_user_profile(current_user: UserInDB = Depends(get_current_user)):
|
||||
"""
|
||||
Get current user profile.
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
UserResponse: Current user profile without sensitive data
|
||||
"""
|
||||
return UserResponse(
|
||||
_id=current_user.id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
role=current_user.role,
|
||||
is_active=current_user.is_active,
|
||||
created_at=current_user.created_at,
|
||||
updated_at=current_user.updated_at
|
||||
)
|
||||
172
src/file-processor/app/api/routes/users.py
Normal file
172
src/file-processor/app/api/routes/users.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from starlette import status
|
||||
|
||||
from app.api.dependencies import get_admin_user, get_user_service
|
||||
from app.models.auth import UserResponse, MessageResponse
|
||||
from app.models.types import PyObjectId
|
||||
from app.models.user import UserInDB, UserCreate, UserUpdate
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserInDB])
|
||||
def list_users(
|
||||
admin_user: UserInDB = Depends(get_admin_user),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
List all users (admin only).
|
||||
|
||||
Args:
|
||||
admin_user: Current admin user
|
||||
user_service: User service instance
|
||||
|
||||
Returns:
|
||||
List[UserResponse]: List of all users without sensitive data
|
||||
"""
|
||||
return user_service.list_users()
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
def get_user_by_id(
|
||||
user_id: PyObjectId,
|
||||
admin_user: UserInDB = Depends(get_admin_user),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Get specific user by ID (admin only).
|
||||
|
||||
Args:
|
||||
user_id: User ID to retrieve
|
||||
admin_user: Current admin user
|
||||
user_service: User service instance
|
||||
|
||||
Returns:
|
||||
UserResponse: User information without sensitive data
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not found
|
||||
"""
|
||||
user = user_service.get_user_by_id(str(user_id))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
user_data: UserCreate,
|
||||
admin_user: UserInDB = Depends(get_admin_user),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Create new user (admin only).
|
||||
|
||||
Args:
|
||||
user_data: User creation data
|
||||
admin_user: Current admin user
|
||||
user_service: User service instance
|
||||
|
||||
Returns:
|
||||
UserResponse: Created user information without sensitive data
|
||||
|
||||
Raises:
|
||||
HTTPException: If user creation fails
|
||||
"""
|
||||
try:
|
||||
user = user_service.create_user(user_data)
|
||||
return UserResponse(
|
||||
_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
user_id: PyObjectId,
|
||||
user_data: UserUpdate,
|
||||
admin_user: UserInDB = Depends(get_admin_user),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Update existing user (admin only).
|
||||
|
||||
Args:
|
||||
user_id: User ID to update
|
||||
user_data: User update data
|
||||
admin_user: Current admin user
|
||||
user_service: User service instance
|
||||
|
||||
Returns:
|
||||
UserResponse: Updated user information without sensitive data
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not found or update fails
|
||||
"""
|
||||
try:
|
||||
user = user_service.update_user(str(user_id), user_data)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=MessageResponse)
|
||||
def delete_user(
|
||||
user_id: PyObjectId,
|
||||
admin_user: UserInDB = Depends(get_admin_user),
|
||||
user_service: UserService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Delete user by ID (admin only).
|
||||
|
||||
Args:
|
||||
user_id: User ID to delete
|
||||
admin_user: Current admin user
|
||||
user_service: User service instance
|
||||
|
||||
Returns:
|
||||
MessageResponse: Success message
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not found or deletion fails
|
||||
"""
|
||||
success = user_service.delete_user(str(user_id))
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return MessageResponse(message="User successfully deleted")
|
||||
@@ -15,6 +15,8 @@ from typing import AsyncGenerator
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.users import router as users_router
|
||||
from app.config import settings
|
||||
from app.database.connection import get_database
|
||||
from app.file_watcher import create_file_watcher, FileWatcher
|
||||
@@ -23,12 +25,6 @@ from app.services.init_service import InitializationService
|
||||
from app.services.job_service import JobService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
# from api.routes.auth import router as auth_router
|
||||
# from api.routes.users import router as users_router
|
||||
# from api.routes.documents import router as documents_router
|
||||
# from api.routes.jobs import router as jobs_router
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -112,10 +108,9 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
# app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
# app.include_router(users_router, prefix="/users", tags=["User Management"])
|
||||
app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
app.include_router(users_router, prefix="/users", tags=["User Management"])
|
||||
# app.include_router(documents_router, prefix="/documents", tags=["Documents"])
|
||||
# app.include_router(jobs_router, prefix="/jobs", tags=["Processing Jobs"])
|
||||
|
||||
|
||||
@@ -3,12 +3,45 @@ Authentication models and enums for user management.
|
||||
|
||||
Contains user roles enumeration and authentication-related Pydantic models.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.types import PyObjectId
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""User roles enumeration with string values."""
|
||||
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Model for user data in API responses (excludes password_hash)."""
|
||||
|
||||
id: PyObjectId = Field(alias="_id")
|
||||
username: str
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {
|
||||
"populate_by_name": True,
|
||||
"arbitrary_types_allowed": True,
|
||||
}
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Response model for successful login."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Generic message response."""
|
||||
message: str
|
||||
|
||||
@@ -7,10 +7,10 @@ and API responses with proper validation and type safety.
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from typing import Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from pydantic import BaseModel, Field, field_validator, EmailStr
|
||||
from pydantic_core import core_schema
|
||||
|
||||
from app.models.auth import UserRole
|
||||
from app.models.types import PyObjectId
|
||||
@@ -138,21 +138,3 @@ class UserInDB(BaseModel):
|
||||
"arbitrary_types_allowed": True,
|
||||
"json_encoders": {ObjectId: str}
|
||||
}
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Model for user data in API responses (excludes password_hash)."""
|
||||
|
||||
id: PyObjectId = Field(alias="_id")
|
||||
username: str
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {
|
||||
"populate_by_name": True,
|
||||
"arbitrary_types_allowed": True,
|
||||
"json_encoders": {ObjectId: str}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ Authentication service for password hashing and verification.
|
||||
This module provides authentication-related functionality including
|
||||
password hashing, verification, and JWT token management.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.security import hash_password, verify_password
|
||||
|
||||
|
||||
@@ -55,4 +59,26 @@ class AuthService:
|
||||
>>> auth.verify_user_password("wrongpassword", hashed)
|
||||
False
|
||||
"""
|
||||
return verify_password(password, hashed_password)
|
||||
return verify_password(password, hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data=dict) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
data (dict): Payload data to include in the token.
|
||||
|
||||
Returns:
|
||||
str: Encoded JWT token.
|
||||
"""
|
||||
# Copy data to avoid modifying the original dict
|
||||
to_encode = data.copy()
|
||||
|
||||
# Add expiration time
|
||||
expire = datetime.now() + timedelta(hours=settings.get_jwt_expire_hours())
|
||||
to_encode.update({"exp": expire})
|
||||
|
||||
# Encode JWT
|
||||
encoded_jwt = jwt.encode(to_encode, settings.get_jwt_secret_key(), algorithm=settings.get_jwt_algorithm())
|
||||
return encoded_jwt
|
||||
|
||||
@@ -5,8 +5,9 @@ email-validator==2.3.0
|
||||
fastapi==0.116.1
|
||||
httptools==0.6.4
|
||||
motor==3.7.1
|
||||
pymongo==4.15.0
|
||||
pydantic==2.11.9
|
||||
PyJWT==2.10.1
|
||||
pymongo==4.15.0
|
||||
redis==6.4.0
|
||||
uvicorn==0.35.0
|
||||
python-magic==0.4.27
|
||||
|
||||
@@ -28,8 +28,8 @@ celery_app.conf.update(
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=300, # 5 minutes
|
||||
task_soft_time_limit=240, # 4 minutes
|
||||
task_time_limit=300, # 5 minutes
|
||||
task_soft_time_limit=240, # 4 minutes
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
139
tests/api/test_auth_routes.py
Normal file
139
tests/api/test_auth_routes.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app # Assuming you have FastAPI app defined in app/main.py
|
||||
from app.models.auth import UserRole
|
||||
from app.models.types import PyObjectId
|
||||
from app.models.user import UserInDB
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_user():
|
||||
return UserInDB(
|
||||
_id=PyObjectId(),
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
role=UserRole.USER,
|
||||
is_active=True,
|
||||
hashed_password="hashed-secret",
|
||||
created_at="2023-01-01T00:00:00",
|
||||
updated_at="2023-01-01T00:00:00",
|
||||
)
|
||||
|
||||
|
||||
def override_auth_service():
|
||||
mock = MagicMock(spec=AuthService)
|
||||
mock.verify_user_password.return_value = True
|
||||
mock.create_access_token.return_value = "fake-jwt-token"
|
||||
return mock
|
||||
|
||||
|
||||
def override_user_service(fake_user):
|
||||
mock = MagicMock(spec=UserService)
|
||||
mock.get_user_by_username.return_value = fake_user
|
||||
return mock
|
||||
|
||||
|
||||
def override_get_current_user(fake_user):
|
||||
def _override():
|
||||
return fake_user
|
||||
|
||||
return _override
|
||||
|
||||
|
||||
# ---------------------- TESTS FOR /auth/login ----------------------
|
||||
class TestLogin:
|
||||
def test_i_can_login_with_valid_credentials(self, client, fake_user, monkeypatch):
|
||||
auth_service = override_auth_service()
|
||||
user_service = override_user_service(fake_user)
|
||||
|
||||
monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service)
|
||||
monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service)
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "testuser", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["user"]["username"] == "testuser"
|
||||
|
||||
def test_i_cannot_login_with_invalid_username(self, client, monkeypatch):
|
||||
auth_service = override_auth_service()
|
||||
user_service = MagicMock(spec=UserService)
|
||||
user_service.get_user_by_username.return_value = None
|
||||
|
||||
monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service)
|
||||
monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service)
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "unknown", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_i_cannot_login_with_inactive_user(self, client, fake_user, monkeypatch):
|
||||
fake_user.is_active = False
|
||||
auth_service = override_auth_service()
|
||||
user_service = override_user_service(fake_user)
|
||||
|
||||
monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service)
|
||||
monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service)
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "testuser", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_i_cannot_login_with_wrong_password(self, client, fake_user, monkeypatch):
|
||||
auth_service = override_auth_service()
|
||||
auth_service.verify_user_password.return_value = False
|
||||
user_service = override_user_service(fake_user)
|
||||
|
||||
monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service)
|
||||
monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service)
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "testuser", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------- TESTS FOR /auth/me ----------------------
|
||||
class TesteMe:
|
||||
def test_i_can_get_current_user_profile(self, client, fake_user, monkeypatch):
|
||||
monkeypatch.setattr("app.api.routes.auth.get_current_user", override_get_current_user(fake_user))
|
||||
|
||||
response = client.get("/auth/me")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["username"] == fake_user.username
|
||||
assert data["email"] == fake_user.email
|
||||
|
||||
def test_i_cannot_get_profile_without_authentication(self, client, monkeypatch):
|
||||
def raise_http_exception():
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
monkeypatch.setattr("app.api.routes.auth.get_current_user", raise_http_exception)
|
||||
|
||||
response = client.get("/auth/me")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -10,8 +10,8 @@ from pydantic import ValidationError
|
||||
from datetime import datetime
|
||||
from bson import ObjectId
|
||||
|
||||
from app.models.user import UserCreate, UserUpdate, UserInDB, UserResponse
|
||||
from app.models.auth import UserRole
|
||||
from app.models.user import UserCreate, UserUpdate, UserInDB
|
||||
from app.models.auth import UserRole, UserResponse
|
||||
|
||||
|
||||
class TestUserCreateModel:
|
||||
@@ -349,7 +349,7 @@ class TestUserResponseModel:
|
||||
|
||||
# Convert to response model (excluding password_hash)
|
||||
user_response = UserResponse(
|
||||
id=user_in_db.id,
|
||||
_id=user_in_db.id,
|
||||
username=user_in_db.username,
|
||||
email=user_in_db.email,
|
||||
role=user_in_db.role,
|
||||
|
||||
Reference in New Issue
Block a user