Compare commits
6 Commits
master
...
48f5b009ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 48f5b009ae | |||
| e17c4c7e7b | |||
| 010ef56f63 | |||
| 34f7854b3c | |||
| 98c43feadf | |||
| 9564cfadd5 |
74
Readme.md
74
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 (pymongo)
|
- **Database**: MongoDB
|
||||||
- **Frontend**: React
|
- **Frontend**: React
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **Containerization**: Docker & Docker Compose
|
||||||
- **File Monitoring**: Python watchdog library
|
- **File Monitoring**: Python watchdog library
|
||||||
@@ -109,18 +109,16 @@ 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 (pymongo)
|
│ │ │ │ ├── connection.py # MongoDB connection
|
||||||
│ │ │ │ └── repositories/
|
│ │ │ │ └── repositories/
|
||||||
│ │ │ │ ├── __init__.py
|
│ │ │ │ ├── __init__.py
|
||||||
│ │ │ │ ├── user_repository.py # User CRUD operations (synchronous)
|
│ │ │ │ ├── user_repository.py # User CRUD operations
|
||||||
│ │ │ │ ├── document_repository.py # Document CRUD operations (synchronous)
|
│ │ │ │ └── document_repository.py # User CRUD operations
|
||||||
│ │ │ │ └── job_repository.py # Job CRUD operations (synchronous)
|
|
||||||
│ │ │ ├── services/
|
│ │ │ ├── services/
|
||||||
│ │ │ │ ├── __init__.py
|
│ │ │ │ ├── __init__.py
|
||||||
│ │ │ │ ├── auth_service.py # JWT & password logic (synchronous)
|
│ │ │ │ ├── auth_service.py # JWT & password logic
|
||||||
│ │ │ │ ├── user_service.py # User business logic (synchronous)
|
│ │ │ │ ├── user_service.py # User business logic
|
||||||
│ │ │ │ ├── document_service.py # Document business logic (synchronous)
|
│ │ │ │ ├── document_service.py # Document business logic
|
||||||
│ │ │ │ ├── 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
|
||||||
@@ -140,13 +138,7 @@ MyDocManager/
|
|||||||
│ └── frontend/
|
│ └── frontend/
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── index.html
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── assets/
|
|
||||||
│ ├── App.css
|
|
||||||
│ ├── App.jsx
|
|
||||||
│ ├── main.css
|
|
||||||
│ └── main.jsx
|
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── file-processor/
|
│ ├── file-processor/
|
||||||
│ │ ├── test_auth/
|
│ │ ├── test_auth/
|
||||||
@@ -342,20 +334,13 @@ 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.
|
||||||
|
|
||||||
#### Repository and Services Implementation
|
### Implementation Order
|
||||||
|
|
||||||
- **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. ✅ Repository layer for data access (files + processing_jobs + users + documents) - synchronous
|
2. UNDER PROGRESS : Repository layer for data access (files + processing_jobs)
|
||||||
3. ✅ Service layer for business logic (auth, user, document, job) - synchronous
|
3. TODO : Celery tasks for document processing
|
||||||
4. ✅ Celery tasks for document processing
|
4. TODO : Watchdog file monitoring implementation
|
||||||
5. ✅ Watchdog file monitoring implementation
|
5. TODO : FastAPI integration and startup coordination
|
||||||
6. ✅ FastAPI integration and startup coordination
|
|
||||||
|
|
||||||
## Job Management Layer
|
## Job Management Layer
|
||||||
|
|
||||||
@@ -365,7 +350,7 @@ The job management system follows the repository pattern for clean separation be
|
|||||||
|
|
||||||
#### JobRepository
|
#### JobRepository
|
||||||
|
|
||||||
Handles direct MongoDB operations for processing jobs using synchronous pymongo:
|
Handles direct MongoDB operations for processing jobs:
|
||||||
|
|
||||||
**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
|
||||||
@@ -382,7 +367,7 @@ Handles direct MongoDB operations for processing jobs using synchronous pymongo:
|
|||||||
|
|
||||||
#### JobService
|
#### JobService
|
||||||
|
|
||||||
Provides synchronous business logic layer with strict status transition validation:
|
Provides 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
|
||||||
@@ -396,6 +381,7 @@ Provides synchronous business logic layer with strict status transition validati
|
|||||||
|
|
||||||
#### 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
|
||||||
|
|
||||||
@@ -414,17 +400,11 @@ 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 (synchronous)
|
│ └── job_repository.py # JobRepository class
|
||||||
│ ├── 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 (synchronous)
|
│ └── job_service.py # JobService class
|
||||||
│ ├── 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
|
||||||
@@ -434,7 +414,6 @@ 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
|
||||||
|
|
||||||
@@ -457,7 +436,6 @@ 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
|
||||||
|
|
||||||
@@ -482,7 +460,6 @@ 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
|
||||||
|
|
||||||
@@ -493,13 +470,12 @@ src/file-processor/app/
|
|||||||
|
|
||||||
### Next Implementation Steps
|
### Next Implementation Steps
|
||||||
|
|
||||||
1. **TODO**: Complete file processing pipeline =>
|
1. **IN PROGRESS**: Implement 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 (synchronous)
|
2. Implement repository layer for file and processing job data access
|
||||||
3. ✅ Implement service layer for business logic (synchronous)
|
3. Create Celery tasks for document processing (.txt, .pdf, .docx)
|
||||||
4. ✅ Create Celery tasks for document processing (.txt, .pdf, .docx)
|
4. Implement Watchdog file monitoring with dedicated observer
|
||||||
5. ✅ Implement Watchdog file monitoring with dedicated observer
|
5. Integrate file watcher with FastAPI startup
|
||||||
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
|
||||||
|
|
||||||
@@ -590,4 +566,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`
|
||||||
|
|||||||
@@ -68,19 +68,6 @@ services:
|
|||||||
- mydocmanager-network
|
- mydocmanager-network
|
||||||
command: celery -A tasks.main worker --loglevel=info
|
command: celery -A tasks.main worker --loglevel=info
|
||||||
|
|
||||||
# Frontend - React application with Vite
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./src/frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: mydocmanager-frontend
|
|
||||||
ports:
|
|
||||||
- "5173:5173"
|
|
||||||
volumes:
|
|
||||||
- ./src/frontend:/app
|
|
||||||
- /app/node_modules # Anonymous volume to prevent node_modules override
|
|
||||||
networks:
|
|
||||||
- mydocmanager-network
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongodb-data:
|
mongodb-data:
|
||||||
|
|||||||
@@ -5,22 +5,16 @@ 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
|
||||||
@@ -33,8 +27,6 @@ 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
|
||||||
@@ -50,7 +42,6 @@ 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
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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,8 +15,6 @@ 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
|
||||||
@@ -25,6 +23,12 @@ 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__)
|
||||||
@@ -102,15 +106,16 @@ app = FastAPI(
|
|||||||
# Configure CORS
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173"], # React frontend
|
allow_origins=["http://localhost:3000"], # React frontend
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
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,45 +3,12 @@ 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
|
from typing import Optional, Any
|
||||||
|
|
||||||
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,3 +138,21 @@ 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,11 +4,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -59,26 +55,4 @@ 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,9 +5,8 @@ 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
|
||||||
pydantic==2.11.9
|
|
||||||
PyJWT==2.10.1
|
|
||||||
pymongo==4.15.0
|
pymongo==4.15.0
|
||||||
|
pydantic==2.11.9
|
||||||
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
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# IDE files
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS generated files
|
|
||||||
.DS_Store
|
|
||||||
.DS_Store?
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
ehthumbs.db
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Use Node.js 20 Alpine for lightweight container
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package.json and package-lock.json (if available)
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose Vite default port
|
|
||||||
EXPOSE 5173
|
|
||||||
|
|
||||||
# Start development server with host 0.0.0.0 to accept external connections
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import status, HTTPException
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from mongomock.mongo_client import MongoClient
|
|
||||||
|
|
||||||
from app.api.dependencies import get_auth_service, get_user_service, get_current_user
|
|
||||||
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=datetime(2025, 1, 1),
|
|
||||||
updated_at=datetime(2025, 1, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def override_get_database():
|
|
||||||
def _override():
|
|
||||||
client = MongoClient()
|
|
||||||
db = client.test_database
|
|
||||||
return db
|
|
||||||
|
|
||||||
return _override
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------- TESTS FOR /auth/login ----------------------
|
|
||||||
class TestLogin:
|
|
||||||
def test_i_can_login_with_valid_credentials(self, client, fake_user):
|
|
||||||
auth_service = override_auth_service()
|
|
||||||
user_service = override_user_service(fake_user)
|
|
||||||
|
|
||||||
client.app.dependency_overrides[get_auth_service] = lambda: auth_service
|
|
||||||
client.app.dependency_overrides[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):
|
|
||||||
auth_service = override_auth_service()
|
|
||||||
user_service = MagicMock(spec=UserService)
|
|
||||||
user_service.get_user_by_username.return_value = None
|
|
||||||
|
|
||||||
client.app.dependency_overrides[get_auth_service] = lambda: auth_service
|
|
||||||
client.app.dependency_overrides[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):
|
|
||||||
fake_user.is_active = False
|
|
||||||
auth_service = override_auth_service()
|
|
||||||
user_service = override_user_service(fake_user)
|
|
||||||
client.app.dependency_overrides[get_auth_service] = lambda: auth_service
|
|
||||||
client.app.dependency_overrides[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):
|
|
||||||
auth_service = override_auth_service()
|
|
||||||
auth_service.verify_user_password.return_value = False
|
|
||||||
user_service = override_user_service(fake_user)
|
|
||||||
client.app.dependency_overrides[get_auth_service] = lambda: auth_service
|
|
||||||
client.app.dependency_overrides[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):
|
|
||||||
client.app.dependency_overrides[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)
|
|
||||||
|
|
||||||
client.app.dependency_overrides[get_current_user] = raise_http_exception
|
|
||||||
|
|
||||||
response = client.get("/auth/me")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# File: tests/api/test_users.py
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import status
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.api.dependencies import get_admin_user, get_user_service
|
|
||||||
from app.main import app
|
|
||||||
from app.models.auth import UserRole
|
|
||||||
from app.models.types import PyObjectId
|
|
||||||
from app.models.user import UserInDB, UserCreate
|
|
||||||
from app.services.user_service import UserService
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------
|
|
||||||
# Fixtures
|
|
||||||
# -----------------------
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_user_admin():
|
|
||||||
return UserInDB(
|
|
||||||
_id=PyObjectId(),
|
|
||||||
username="admin",
|
|
||||||
email="admin@example.com",
|
|
||||||
role=UserRole.ADMIN,
|
|
||||||
is_active=True,
|
|
||||||
hashed_password="hashed-secret",
|
|
||||||
created_at=datetime(2025, 1, 1),
|
|
||||||
updated_at=datetime(2025, 1, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_user_response():
|
|
||||||
return UserInDB(
|
|
||||||
_id=PyObjectId(),
|
|
||||||
username="other",
|
|
||||||
email="other@example.com",
|
|
||||||
role=UserRole.USER,
|
|
||||||
is_active=True,
|
|
||||||
hashed_password="hashed-secret-2",
|
|
||||||
created_at=datetime(2025, 1, 1),
|
|
||||||
updated_at=datetime(2025, 1, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(fake_user_admin):
|
|
||||||
# Fake admin dependency
|
|
||||||
def get_admin_user_override():
|
|
||||||
return fake_user_admin
|
|
||||||
|
|
||||||
# Fake user service
|
|
||||||
user_service_mock = MagicMock(spec=UserService)
|
|
||||||
|
|
||||||
def get_user_service_override():
|
|
||||||
return user_service_mock
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
client.app.dependency_overrides = {
|
|
||||||
get_admin_user: get_admin_user_override,
|
|
||||||
get_user_service: get_user_service_override
|
|
||||||
}
|
|
||||||
|
|
||||||
client.user_service_mock = user_service_mock
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------
|
|
||||||
# Tests
|
|
||||||
# -----------------------
|
|
||||||
|
|
||||||
class TestListUsers:
|
|
||||||
|
|
||||||
def test_i_can_list_users(self, client, fake_user_admin, fake_user_response):
|
|
||||||
client.user_service_mock.list_users.return_value = [fake_user_admin, fake_user_response]
|
|
||||||
response = client.get("/users")
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) == 2
|
|
||||||
assert data[0]["username"] == "admin"
|
|
||||||
|
|
||||||
def test_i_can_list_users_when_empty(self, client):
|
|
||||||
client.user_service_mock.list_users.return_value = []
|
|
||||||
response = client.get("/users")
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetUserById:
|
|
||||||
|
|
||||||
def test_i_can_get_user_by_id(self, client, fake_user_response):
|
|
||||||
client.user_service_mock.get_user_by_id.return_value = fake_user_response
|
|
||||||
response = client.get(f"/users/{fake_user_response.id}")
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == fake_user_response.username
|
|
||||||
|
|
||||||
def test_i_cannot_get_user_by_id_not_found(self, client):
|
|
||||||
client.user_service_mock.get_user_by_id.return_value = None
|
|
||||||
response = client.get("/users/64f0c9f4b0d1c8b7b8e1f0a2")
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
||||||
assert response.json()["detail"] == "User not found"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateUser:
|
|
||||||
|
|
||||||
def test_i_can_create_user(self, client, fake_user_response):
|
|
||||||
user_data = UserCreate(username="newuser",
|
|
||||||
email="new@example.com",
|
|
||||||
password="#Passw0rd!",
|
|
||||||
role=UserRole.USER)
|
|
||||||
|
|
||||||
client.user_service_mock.create_user.return_value = fake_user_response
|
|
||||||
response = client.post("/users", json=user_data.model_dump(mode="json"))
|
|
||||||
assert response.status_code == status.HTTP_201_CREATED
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == fake_user_response.username
|
|
||||||
|
|
||||||
def test_i_cannot_create_user_when_service_raises_value_error(self, client):
|
|
||||||
user_data = {"username": "baduser", "email": "bad@example.com", "role": "user", "password": "password"}
|
|
||||||
client.user_service_mock.create_user.side_effect = ValueError("Invalid data")
|
|
||||||
response = client.post("/users", json=user_data)
|
|
||||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateUser:
|
|
||||||
|
|
||||||
def test_i_can_update_user(self, client, fake_user_response):
|
|
||||||
user_data = {"username": "updateduser", "email": "updated@example.com"}
|
|
||||||
client.user_service_mock.update_user.return_value = fake_user_response
|
|
||||||
response = client.put(f"/users/{fake_user_response.id}", json=user_data)
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
data = response.json()
|
|
||||||
assert data["username"] == fake_user_response.username
|
|
||||||
|
|
||||||
def test_i_cannot_update_user_not_found(self, client):
|
|
||||||
client.user_service_mock.update_user.return_value = None
|
|
||||||
user_data = {"username": "updateduser"}
|
|
||||||
response = client.put("/users/64f0c9f4b0d1c8b7b8e1f0a2", json=user_data)
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
||||||
assert response.json()["detail"] == "User not found"
|
|
||||||
|
|
||||||
def test_i_cannot_update_user_when_service_raises_value_error(self, client):
|
|
||||||
client.user_service_mock.update_user.side_effect = ValueError("Invalid update")
|
|
||||||
user_data = {"username": "badupdate"}
|
|
||||||
response = client.put("/users/64f0c9f4b0d1c8b7b8e1f0a2", json=user_data)
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
assert response.json()["detail"] == "Invalid update"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteUser:
|
|
||||||
|
|
||||||
def test_i_can_delete_user(self, client):
|
|
||||||
client.user_service_mock.delete_user.return_value = True
|
|
||||||
response = client.delete("/users/64f0c9f4b0d1c8b7b8e1f0a1")
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
data = response.json()
|
|
||||||
assert data["message"] == "User successfully deleted"
|
|
||||||
|
|
||||||
def test_i_cannot_delete_user_not_found(self, client):
|
|
||||||
client.user_service_mock.delete_user.return_value = False
|
|
||||||
response = client.delete("/users/64f0c9f4b0d1c8b7b8e1f0a2")
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
||||||
assert response.json()["detail"] == "User not found"
|
|
||||||
@@ -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
|
from app.models.user import UserCreate, UserUpdate, UserInDB, UserResponse
|
||||||
from app.models.auth import UserRole, UserResponse
|
from app.models.auth import UserRole
|
||||||
|
|
||||||
|
|
||||||
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