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)
|
- **Backend API**: FastAPI (Python 3.12)
|
||||||
- **Task Processing**: Celery with Redis broker
|
- **Task Processing**: Celery with Redis broker
|
||||||
- **Document Processing**: EasyOCR, PyMuPDF, python-docx, pdfplumber
|
- **Document Processing**: EasyOCR, PyMuPDF, python-docx, pdfplumber
|
||||||
- **Database**: MongoDB
|
- **Database**: MongoDB (pymongo)
|
||||||
- **Frontend**: React
|
- **Frontend**: React
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **Containerization**: Docker & Docker Compose
|
||||||
- **File Monitoring**: Python watchdog library
|
- **File Monitoring**: Python watchdog library
|
||||||
@@ -109,16 +109,18 @@ MyDocManager/
|
|||||||
│ │ │ │ └── types.py # PyObjectId and other useful types
|
│ │ │ │ └── types.py # PyObjectId and other useful types
|
||||||
│ │ │ ├── database/
|
│ │ │ ├── database/
|
||||||
│ │ │ │ ├── __init__.py
|
│ │ │ │ ├── __init__.py
|
||||||
│ │ │ │ ├── connection.py # MongoDB connection
|
│ │ │ │ ├── connection.py # MongoDB connection (pymongo)
|
||||||
│ │ │ │ └── repositories/
|
│ │ │ │ └── repositories/
|
||||||
│ │ │ │ ├── __init__.py
|
│ │ │ │ ├── __init__.py
|
||||||
│ │ │ │ ├── user_repository.py # User CRUD operations
|
│ │ │ │ ├── user_repository.py # User CRUD operations (synchronous)
|
||||||
│ │ │ │ └── document_repository.py # User CRUD operations
|
│ │ │ │ ├── document_repository.py # Document CRUD operations (synchronous)
|
||||||
|
│ │ │ │ └── job_repository.py # Job CRUD operations (synchronous)
|
||||||
│ │ │ ├── services/
|
│ │ │ ├── services/
|
||||||
│ │ │ │ ├── __init__.py
|
│ │ │ │ ├── __init__.py
|
||||||
│ │ │ │ ├── auth_service.py # JWT & password logic
|
│ │ │ │ ├── auth_service.py # JWT & password logic (synchronous)
|
||||||
│ │ │ │ ├── user_service.py # User business logic
|
│ │ │ │ ├── user_service.py # User business logic (synchronous)
|
||||||
│ │ │ │ ├── document_service.py # Document business logic
|
│ │ │ │ ├── document_service.py # Document business logic (synchronous)
|
||||||
|
│ │ │ │ ├── job_service.py # Job processing logic (synchronous)
|
||||||
│ │ │ │ └── init_service.py # Admin creation at startup
|
│ │ │ │ └── init_service.py # Admin creation at startup
|
||||||
│ │ │ ├── api/
|
│ │ │ ├── api/
|
||||||
│ │ │ │ ├── __init__.py
|
│ │ │ │ ├── __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
|
- **Rationale**: MongoDB is not meant for large files, better performance. Files remain in the file system for easy
|
||||||
access.
|
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
|
1. ✅ Pydantic models for MongoDB collections
|
||||||
2. UNDER PROGRESS : Repository layer for data access (files + processing_jobs)
|
2. ✅ Repository layer for data access (files + processing_jobs + users + documents) - synchronous
|
||||||
3. TODO : Celery tasks for document processing
|
3. ✅ Service layer for business logic (auth, user, document, job) - synchronous
|
||||||
4. TODO : Watchdog file monitoring implementation
|
4. ✅ Celery tasks for document processing
|
||||||
5. TODO : FastAPI integration and startup coordination
|
5. ✅ Watchdog file monitoring implementation
|
||||||
|
6. ✅ FastAPI integration and startup coordination
|
||||||
|
|
||||||
## Job Management Layer
|
## Job Management Layer
|
||||||
|
|
||||||
@@ -350,7 +359,7 @@ The job management system follows the repository pattern for clean separation be
|
|||||||
|
|
||||||
#### JobRepository
|
#### JobRepository
|
||||||
|
|
||||||
Handles direct MongoDB operations for processing jobs:
|
Handles direct MongoDB operations for processing jobs using synchronous pymongo:
|
||||||
|
|
||||||
**CRUD Operations:**
|
**CRUD Operations:**
|
||||||
- `create_job()` - Create new processing job with automatic `created_at` timestamp
|
- `create_job()` - Create new processing job with automatic `created_at` timestamp
|
||||||
@@ -367,7 +376,7 @@ Handles direct MongoDB operations for processing jobs:
|
|||||||
|
|
||||||
#### JobService
|
#### JobService
|
||||||
|
|
||||||
Provides business logic layer with strict status transition validation:
|
Provides synchronous business logic layer with strict status transition validation:
|
||||||
|
|
||||||
**Status Transition Methods:**
|
**Status Transition Methods:**
|
||||||
- `mark_job_as_started()` - PENDING → PROCESSING
|
- `mark_job_as_started()` - PENDING → PROCESSING
|
||||||
@@ -381,7 +390,6 @@ Provides business logic layer with strict status transition validation:
|
|||||||
|
|
||||||
#### Custom Exceptions
|
#### Custom Exceptions
|
||||||
|
|
||||||
**JobNotFoundError**: Raised when job ID doesn't exist
|
|
||||||
**InvalidStatusTransitionError**: Raised for invalid status transitions
|
**InvalidStatusTransitionError**: Raised for invalid status transitions
|
||||||
**JobRepositoryError**: Raised for MongoDB operation failures
|
**JobRepositoryError**: Raised for MongoDB operation failures
|
||||||
|
|
||||||
@@ -400,11 +408,17 @@ All other transitions are forbidden and will raise `InvalidStatusTransitionError
|
|||||||
```
|
```
|
||||||
src/file-processor/app/
|
src/file-processor/app/
|
||||||
├── database/repositories/
|
├── 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/
|
├── 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/
|
└── exceptions/
|
||||||
└── job_exceptions.py # Custom exceptions
|
└── job_exceptions.py # Custom exceptions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Processing Pipeline Features
|
### Processing Pipeline Features
|
||||||
@@ -414,6 +428,7 @@ src/file-processor/app/
|
|||||||
- **Status Tracking**: Real-time processing status via `processing_jobs` collection
|
- **Status Tracking**: Real-time processing status via `processing_jobs` collection
|
||||||
- **Extensible Metadata**: Flexible metadata storage per file type
|
- **Extensible Metadata**: Flexible metadata storage per file type
|
||||||
- **Multiple Extraction Methods**: Support for direct text, OCR, and hybrid approaches
|
- **Multiple Extraction Methods**: Support for direct text, OCR, and hybrid approaches
|
||||||
|
- **Synchronous Operations**: All database operations use pymongo for Celery compatibility
|
||||||
|
|
||||||
## Key Implementation Notes
|
## Key Implementation Notes
|
||||||
|
|
||||||
@@ -436,6 +451,7 @@ src/file-processor/app/
|
|||||||
- **Package Manager**: pip (standard)
|
- **Package Manager**: pip (standard)
|
||||||
- **External Dependencies**: Listed in each service's requirements.txt
|
- **External Dependencies**: Listed in each service's requirements.txt
|
||||||
- **Standard Library First**: Prefer standard library when possible
|
- **Standard Library First**: Prefer standard library when possible
|
||||||
|
- **Database Driver**: pymongo for synchronous MongoDB operations
|
||||||
|
|
||||||
### Testing Strategy
|
### Testing Strategy
|
||||||
|
|
||||||
@@ -460,6 +476,7 @@ src/file-processor/app/
|
|||||||
12. **Content in Files Collection**: Extracted content stored with file metadata
|
12. **Content in Files Collection**: Extracted content stored with file metadata
|
||||||
13. **Direct Task Dispatch**: File watcher directly creates Celery tasks
|
13. **Direct Task Dispatch**: File watcher directly creates Celery tasks
|
||||||
14. **SHA256 Duplicate Detection**: Prevents reprocessing identical files
|
14. **SHA256 Duplicate Detection**: Prevents reprocessing identical files
|
||||||
|
15. **Synchronous Implementation**: All repositories and services use pymongo for Celery compatibility
|
||||||
|
|
||||||
### Development Process Requirements
|
### Development Process Requirements
|
||||||
|
|
||||||
@@ -470,12 +487,13 @@ src/file-processor/app/
|
|||||||
|
|
||||||
### Next Implementation Steps
|
### Next Implementation Steps
|
||||||
|
|
||||||
1. **IN PROGRESS**: Implement file processing pipeline =>
|
1. **TODO**: Complete file processing pipeline =>
|
||||||
1. Create Pydantic models for files and processing_jobs collections
|
1. ✅ Create Pydantic models for files and processing_jobs collections
|
||||||
2. Implement repository layer for file and processing job data access
|
2. ✅ Implement repository layer for file and processing job data access (synchronous)
|
||||||
3. Create Celery tasks for document processing (.txt, .pdf, .docx)
|
3. ✅ Implement service layer for business logic (synchronous)
|
||||||
4. Implement Watchdog file monitoring with dedicated observer
|
4. ✅ Create Celery tasks for document processing (.txt, .pdf, .docx)
|
||||||
5. Integrate file watcher with FastAPI startup
|
5. ✅ Implement Watchdog file monitoring with dedicated observer
|
||||||
|
6. ✅ Integrate file watcher with FastAPI startup
|
||||||
2. Create protected API routes for user management
|
2. Create protected API routes for user management
|
||||||
3. Build React monitoring interface with authentication
|
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
|
- **file-processor**: Hot-reload enabled via `--reload` flag
|
||||||
- Code changes in `src/file-processor/app/` automatically restart FastAPI
|
- Code changes in `src/file-processor/app/` automatically restart FastAPI
|
||||||
- **worker**: No hot-reload (manual restart required for stability)
|
- **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
|
bcrypt==4.3.0
|
||||||
billiard==4.2.1
|
billiard==4.2.1
|
||||||
celery==5.5.3
|
celery==5.5.3
|
||||||
|
certifi==2025.8.3
|
||||||
|
cffi==2.0.0
|
||||||
click==8.2.1
|
click==8.2.1
|
||||||
click-didyoumean==0.3.1
|
click-didyoumean==0.3.1
|
||||||
click-plugins==1.1.1.2
|
click-plugins==1.1.1.2
|
||||||
click-repl==0.3.0
|
click-repl==0.3.0
|
||||||
|
cryptography==46.0.1
|
||||||
dnspython==2.8.0
|
dnspython==2.8.0
|
||||||
|
ecdsa==0.19.1
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
fastapi==0.116.1
|
fastapi==0.116.1
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
hiredis==3.2.1
|
hiredis==3.2.1
|
||||||
|
httpcore==1.0.9
|
||||||
httptools==0.6.4
|
httptools==0.6.4
|
||||||
|
httpx==0.28.1
|
||||||
idna==3.10
|
idna==3.10
|
||||||
importlib_metadata==8.7.0
|
importlib_metadata==8.7.0
|
||||||
iniconfig==2.1.0
|
iniconfig==2.1.0
|
||||||
@@ -27,6 +33,8 @@ packaging==25.0
|
|||||||
pipdeptree==2.28.0
|
pipdeptree==2.28.0
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
prompt_toolkit==3.0.52
|
prompt_toolkit==3.0.52
|
||||||
|
pyasn1==0.6.1
|
||||||
|
pycparser==2.23
|
||||||
pycron==3.2.0
|
pycron==3.2.0
|
||||||
pydantic==2.11.9
|
pydantic==2.11.9
|
||||||
pydantic_core==2.33.2
|
pydantic_core==2.33.2
|
||||||
@@ -42,6 +50,7 @@ python-magic==0.4.27
|
|||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
redis==6.4.0
|
redis==6.4.0
|
||||||
|
rsa==4.9.1
|
||||||
sentinels==1.1.1
|
sentinels==1.1.1
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
sniffio==1.3.1
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.config import settings
|
||||||
from app.database.connection import get_database
|
from app.database.connection import get_database
|
||||||
from app.file_watcher import create_file_watcher, FileWatcher
|
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.job_service import JobService
|
||||||
from app.services.user_service import UserService
|
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
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -112,10 +108,9 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
# app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||||
# app.include_router(users_router, prefix="/users", tags=["User Management"])
|
app.include_router(users_router, prefix="/users", tags=["User Management"])
|
||||||
# app.include_router(documents_router, prefix="/documents", tags=["Documents"])
|
# app.include_router(documents_router, prefix="/documents", tags=["Documents"])
|
||||||
# app.include_router(jobs_router, prefix="/jobs", tags=["Processing Jobs"])
|
# 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.
|
Contains user roles enumeration and authentication-related Pydantic models.
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.types import PyObjectId
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, Enum):
|
class UserRole(str, Enum):
|
||||||
"""User roles enumeration with string values."""
|
"""User roles enumeration with string values."""
|
||||||
|
|
||||||
USER = "user"
|
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
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from pydantic import BaseModel, Field, field_validator, EmailStr
|
from pydantic import BaseModel, Field, field_validator, EmailStr
|
||||||
from pydantic_core import core_schema
|
|
||||||
|
|
||||||
from app.models.auth import UserRole
|
from app.models.auth import UserRole
|
||||||
from app.models.types import PyObjectId
|
from app.models.types import PyObjectId
|
||||||
@@ -138,21 +138,3 @@ class UserInDB(BaseModel):
|
|||||||
"arbitrary_types_allowed": True,
|
"arbitrary_types_allowed": True,
|
||||||
"json_encoders": {ObjectId: str}
|
"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
|
This module provides authentication-related functionality including
|
||||||
password hashing, verification, and JWT token management.
|
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
|
from app.utils.security import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
@@ -55,4 +59,26 @@ class AuthService:
|
|||||||
>>> auth.verify_user_password("wrongpassword", hashed)
|
>>> auth.verify_user_password("wrongpassword", hashed)
|
||||||
False
|
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
|
fastapi==0.116.1
|
||||||
httptools==0.6.4
|
httptools==0.6.4
|
||||||
motor==3.7.1
|
motor==3.7.1
|
||||||
pymongo==4.15.0
|
|
||||||
pydantic==2.11.9
|
pydantic==2.11.9
|
||||||
|
PyJWT==2.10.1
|
||||||
|
pymongo==4.15.0
|
||||||
redis==6.4.0
|
redis==6.4.0
|
||||||
uvicorn==0.35.0
|
uvicorn==0.35.0
|
||||||
python-magic==0.4.27
|
python-magic==0.4.27
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ celery_app.conf.update(
|
|||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
task_track_started=True,
|
task_track_started=True,
|
||||||
task_time_limit=300, # 5 minutes
|
task_time_limit=300, # 5 minutes
|
||||||
task_soft_time_limit=240, # 4 minutes
|
task_soft_time_limit=240, # 4 minutes
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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 datetime import datetime
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
|
||||||
from app.models.user import UserCreate, UserUpdate, UserInDB, UserResponse
|
from app.models.user import UserCreate, UserUpdate, UserInDB
|
||||||
from app.models.auth import UserRole
|
from app.models.auth import UserRole, UserResponse
|
||||||
|
|
||||||
|
|
||||||
class TestUserCreateModel:
|
class TestUserCreateModel:
|
||||||
@@ -349,7 +349,7 @@ class TestUserResponseModel:
|
|||||||
|
|
||||||
# Convert to response model (excluding password_hash)
|
# Convert to response model (excluding password_hash)
|
||||||
user_response = UserResponse(
|
user_response = UserResponse(
|
||||||
id=user_in_db.id,
|
_id=user_in_db.id,
|
||||||
username=user_in_db.username,
|
username=user_in_db.username,
|
||||||
email=user_in_db.email,
|
email=user_in_db.email,
|
||||||
role=user_in_db.role,
|
role=user_in_db.role,
|
||||||
|
|||||||
Reference in New Issue
Block a user