Compare commits
No commits in common. "a866e5bddcf3a3e776d8445cff91e2b768d066f9" and "a26bd08d9c76ac391f4510dd83346254e24586fb" have entirely different histories.
a866e5bddc
...
a26bd08d9c
@ -7,11 +7,11 @@ CORS_ORIGINS=["*"]
|
|||||||
|
|
||||||
# Firestore settings
|
# Firestore settings
|
||||||
FIRESTORE_PROJECT_ID=gen-lang-client-0424120530
|
FIRESTORE_PROJECT_ID=gen-lang-client-0424120530
|
||||||
FIRESTORE_DATABASE_NAME=contoso-imagedb
|
FIRESTORE_DATABASE_NAME=sereact-imagedb
|
||||||
FIRESTORE_CREDENTIALS_FILE=firestore-credentials.json
|
FIRESTORE_CREDENTIALS_FILE=firestore-credentials.json
|
||||||
|
|
||||||
# Google Cloud Storage settings
|
# Google Cloud Storage settings
|
||||||
GCS_BUCKET_NAME=contoso-images
|
GCS_BUCKET_NAME=sereact-images
|
||||||
GCS_CREDENTIALS_FILE=firestore-credentials.json
|
GCS_CREDENTIALS_FILE=firestore-credentials.json
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
Contoso GmbH
|
Sereact GmbH
|
||||||
Assignment
|
Assignment
|
||||||
|
|
||||||
Image Management API - Coding Challenge
|
Image Management API - Coding Challenge
|
||||||
|
|||||||
39
README.md
39
README.md
@ -186,11 +186,11 @@ Uses Google's Vertex AI multimodal embedding model for generating high-quality i
|
|||||||
|
|
||||||
# Firestore settings
|
# Firestore settings
|
||||||
FIRESTORE_PROJECT_ID=gen-lang-client-0424120530
|
FIRESTORE_PROJECT_ID=gen-lang-client-0424120530
|
||||||
FIRESTORE_DATABASE_NAME=contoso-imagedb
|
FIRESTORE_DATABASE_NAME=sereact-imagedb
|
||||||
FIRESTORE_CREDENTIALS_FILE=firestore-credentials.json
|
FIRESTORE_CREDENTIALS_FILE=firestore-credentials.json
|
||||||
|
|
||||||
# Google Cloud Storage settings
|
# Google Cloud Storage settings
|
||||||
GCS_BUCKET_NAME=contoso-images
|
GCS_BUCKET_NAME=sereact-images
|
||||||
GCS_CREDENTIALS_FILE=firestore-credentials.json
|
GCS_CREDENTIALS_FILE=firestore-credentials.json
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
@ -234,11 +234,7 @@ The API provides the following main endpoints with their authentication and pagi
|
|||||||
#### Team Management
|
#### Team Management
|
||||||
- `/api/v1/teams/*` - **Complete team management (no authentication required)**
|
- `/api/v1/teams/*` - **Complete team management (no authentication required)**
|
||||||
- `POST /api/v1/teams` - Create new team
|
- `POST /api/v1/teams` - Create new team
|
||||||
- `GET /api/v1/teams` - List all teams with **pagination support**
|
- `GET /api/v1/teams` - List all teams (no pagination - returns all teams)
|
||||||
- **Query Parameters:**
|
|
||||||
- `skip` (default: 0, min: 0) - Number of items to skip
|
|
||||||
- `limit` (default: 50, min: 1, max: 100) - Number of items per page
|
|
||||||
- **Response includes:** `teams`, `total`, `skip`, `limit`
|
|
||||||
- `GET /api/v1/teams/{team_id}` - Get team by ID
|
- `GET /api/v1/teams/{team_id}` - Get team by ID
|
||||||
- `PUT /api/v1/teams/{team_id}` - Update team
|
- `PUT /api/v1/teams/{team_id}` - Update team
|
||||||
- `DELETE /api/v1/teams/{team_id}` - Delete team
|
- `DELETE /api/v1/teams/{team_id}` - Delete team
|
||||||
@ -246,12 +242,7 @@ The API provides the following main endpoints with their authentication and pagi
|
|||||||
#### User Management
|
#### User Management
|
||||||
- `/api/v1/users/*` - **Complete user management (no authentication required)**
|
- `/api/v1/users/*` - **Complete user management (no authentication required)**
|
||||||
- `POST /api/v1/users` - Create new user (requires `team_id`)
|
- `POST /api/v1/users` - Create new user (requires `team_id`)
|
||||||
- `GET /api/v1/users` - List users with **pagination support**
|
- `GET /api/v1/users` - List users (no pagination - returns all users, optionally filtered by team)
|
||||||
- **Query Parameters:**
|
|
||||||
- `skip` (default: 0, min: 0) - Number of items to skip
|
|
||||||
- `limit` (default: 50, min: 1, max: 100) - Number of items per page
|
|
||||||
- `team_id` (optional) - Filter by team
|
|
||||||
- **Response includes:** `users`, `total`, `skip`, `limit`
|
|
||||||
- `GET /api/v1/users/{user_id}` - Get user by ID
|
- `GET /api/v1/users/{user_id}` - Get user by ID
|
||||||
- `PUT /api/v1/users/{user_id}` - Update user
|
- `PUT /api/v1/users/{user_id}` - Update user
|
||||||
- `DELETE /api/v1/users/{user_id}` - Delete user
|
- `DELETE /api/v1/users/{user_id}` - Delete user
|
||||||
@ -261,11 +252,7 @@ The API provides the following main endpoints with their authentication and pagi
|
|||||||
### 🔐 **Protected Endpoints (API Key Authentication Required)**
|
### 🔐 **Protected Endpoints (API Key Authentication Required)**
|
||||||
|
|
||||||
#### API Key Management (Authenticated)
|
#### API Key Management (Authenticated)
|
||||||
- `/api/v1/auth/api-keys` (GET) - List API keys for current user with **pagination support**
|
- `/api/v1/auth/api-keys` (GET) - List API keys for current user
|
||||||
- **Query Parameters:**
|
|
||||||
- `skip` (default: 0, min: 0) - Number of items to skip
|
|
||||||
- `limit` (default: 50, min: 1, max: 100) - Number of items per page
|
|
||||||
- **Response includes:** `api_keys`, `total`, `skip`, `limit`
|
|
||||||
- `/api/v1/auth/api-keys/{key_id}` (DELETE) - Revoke API key
|
- `/api/v1/auth/api-keys/{key_id}` (DELETE) - Revoke API key
|
||||||
- `/api/v1/auth/admin/api-keys/{user_id}` (POST) - Create API key for another user (admin only)
|
- `/api/v1/auth/admin/api-keys/{user_id}` (POST) - Create API key for another user (admin only)
|
||||||
- `/api/v1/auth/verify` - Verify current authentication
|
- `/api/v1/auth/verify` - Verify current authentication
|
||||||
@ -284,11 +271,10 @@ The API provides the following main endpoints with their authentication and pagi
|
|||||||
- `GET /api/v1/search` - Search images with **pagination support**
|
- `GET /api/v1/search` - Search images with **pagination support**
|
||||||
- **Query Parameters:**
|
- **Query Parameters:**
|
||||||
- `q` (required) - Search query
|
- `q` (required) - Search query
|
||||||
- `skip` (default: 0, min: 0) - Number of items to skip
|
|
||||||
- `limit` (default: 10, min: 1, max: 50) - Number of results
|
- `limit` (default: 10, min: 1, max: 50) - Number of results
|
||||||
- `similarity_threshold` (default: 0.7, min: 0.0, max: 1.0) - Similarity threshold
|
- `similarity_threshold` (default: 0.7, min: 0.0, max: 1.0) - Similarity threshold
|
||||||
- `collection_id` (optional) - Filter by collection
|
- `collection_id` (optional) - Filter by collection
|
||||||
- **Response includes:** `results`, `total`, `skip`, `limit`, `similarity_threshold`, `query`
|
- **Response includes:** `results`, `total`, `limit`, `similarity_threshold`, `query`
|
||||||
- `POST /api/v1/search` - Advanced search with same pagination
|
- `POST /api/v1/search` - Advanced search with same pagination
|
||||||
|
|
||||||
### 🔑 **Authentication Model**
|
### 🔑 **Authentication Model**
|
||||||
@ -303,14 +289,14 @@ A **hybrid authentication model**:
|
|||||||
|
|
||||||
| Endpoint Category | Authentication | Pagination Status | Notes |
|
| Endpoint Category | Authentication | Pagination Status | Notes |
|
||||||
|------------------|----------------|------------------|-------|
|
|------------------|----------------|------------------|-------|
|
||||||
| **Users Management** | 🔓 **Public** | ✅ **Fully Implemented** | `skip`, `limit`, `total` with team filtering |
|
| **Users Management** | 🔓 **Public** | ❌ **Not Implemented** | Complete CRUD operations, no auth required |
|
||||||
| **Teams Management** | 🔓 **Public** | ✅ **Fully Implemented** | `skip`, `limit`, `total` with proper validation |
|
| **Teams Management** | 🔓 **Public** | ❌ **Not Implemented** | Complete CRUD operations, no auth required |
|
||||||
| **API Key Creation** | 🔓 **Public** | N/A | Requires `user_id` and `team_id` parameters |
|
| **API Key Creation** | 🔓 **Public** | N/A | Requires `user_id` and `team_id` parameters |
|
||||||
| **Images API** | 🔐 **Protected** | ✅ **Fully Implemented** | `skip`, `limit`, `total` with proper validation |
|
| **Images API** | 🔐 **Protected** | ✅ **Fully Implemented** | `skip`, `limit`, `total` with proper validation |
|
||||||
| **Search API** | 🔐 **Protected** | ✅ **Fully Implemented** | `skip`, `limit`, `total` with similarity scoring |
|
| **Search API** | 🔐 **Protected** | ✅ **Fully Implemented** | `limit`, `total` with similarity scoring |
|
||||||
| **API Key Management** | 🔐 **Protected** | ✅ **Fully Implemented** | `skip`, `limit`, `total` for user's API keys |
|
| **API Key Management** | 🔐 **Protected** | ❌ **Not Implemented** | List/revoke existing keys (small datasets) |
|
||||||
|
|
||||||
**Note:** All endpoints now implement consistent pagination with `skip` and `limit` parameters for optimal performance and user experience.
|
**Note:** Public endpoints (users, teams) don't implement pagination as they typically return small datasets and are designed for management use cases where full data visibility is preferred.
|
||||||
|
|
||||||
Refer to the Swagger UI documentation at `/docs` for detailed endpoint information.
|
Refer to the Swagger UI documentation at `/docs` for detailed endpoint information.
|
||||||
|
|
||||||
@ -324,7 +310,7 @@ source venv/Scripts/activate && python scripts/run_tests.py all
|
|||||||
|
|
||||||
## API Modules Architecture
|
## API Modules Architecture
|
||||||
|
|
||||||
The CONTOSO API is organized into the following key modules to ensure separation of concerns and maintainable code:
|
The SEREACT API is organized into the following key modules to ensure separation of concerns and maintainable code:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
@ -451,6 +437,7 @@ This modular architecture provides several benefits:
|
|||||||
### Medium Priority
|
### Medium Priority
|
||||||
- [ ] Implement caching layer for frequently accessed embeddings
|
- [ ] Implement caching layer for frequently accessed embeddings
|
||||||
- [ ] Implement caching for frequently accessed data
|
- [ ] Implement caching for frequently accessed data
|
||||||
|
- [ ] Consider adding pagination to admin endpoints (users, teams, API keys) if datasets grow large
|
||||||
|
|
||||||
### Low Priority
|
### Low Priority
|
||||||
- [ ] Move all auth logic to auth module
|
- [ ] Move all auth logic to auth module
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Contoso Frontend Client
|
# SeReact Frontend Client
|
||||||
|
|
||||||
A modern, responsive web frontend for the Contoso AI-powered image management platform. This is a pure frontend application that communicates directly with your Contoso backend API.
|
A modern, responsive web frontend for the SeReact AI-powered image management platform. This is a pure frontend application that communicates directly with your SeReact backend API.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ A modern, responsive web frontend for the Contoso AI-powered image management pl
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- A running Contoso backend API server
|
- A running SeReact backend API server
|
||||||
- Modern web browser (Chrome, Firefox, Safari, Edge)
|
- Modern web browser (Chrome, Firefox, Safari, Edge)
|
||||||
- Web server to serve static files (optional for development)
|
- Web server to serve static files (optional for development)
|
||||||
|
|
||||||
@ -32,8 +32,8 @@ A modern, responsive web frontend for the Contoso AI-powered image management pl
|
|||||||
|
|
||||||
1. **Download/Clone the frontend files**:
|
1. **Download/Clone the frontend files**:
|
||||||
```bash
|
```bash
|
||||||
# If you have the full Contoso repository
|
# If you have the full SeReact repository
|
||||||
cd contoso/client
|
cd sereact/client
|
||||||
|
|
||||||
# Or download just the client folder
|
# Or download just the client folder
|
||||||
```
|
```
|
||||||
@ -67,7 +67,7 @@ A modern, responsive web frontend for the Contoso AI-powered image management pl
|
|||||||
|
|
||||||
1. **Configure API Connection**:
|
1. **Configure API Connection**:
|
||||||
- Click "Configure Now" in the welcome dialog
|
- Click "Configure Now" in the welcome dialog
|
||||||
- Enter your Contoso API base URL (e.g., `http://localhost:8000`)
|
- Enter your SeReact API base URL (e.g., `http://localhost:8000`)
|
||||||
- Enter your API key
|
- Enter your API key
|
||||||
- Test the connection
|
- Test the connection
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ A modern, responsive web frontend for the Contoso AI-powered image management pl
|
|||||||
|
|
||||||
The frontend stores configuration in browser localStorage:
|
The frontend stores configuration in browser localStorage:
|
||||||
|
|
||||||
- **API Base URL**: The URL of your Contoso backend (e.g., `http://localhost:8000`)
|
- **API Base URL**: The URL of your SeReact backend (e.g., `http://localhost:8000`)
|
||||||
- **API Key**: Your authentication key for the backend API
|
- **API Key**: Your authentication key for the backend API
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
@ -176,7 +176,7 @@ For proper routing with hash-based navigation, no special server configuration i
|
|||||||
|
|
||||||
### CORS Configuration
|
### CORS Configuration
|
||||||
|
|
||||||
Ensure your Contoso backend API is configured to allow requests from your frontend domain:
|
Ensure your SeReact backend API is configured to allow requests from your frontend domain:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In your backend CORS configuration
|
# In your backend CORS configuration
|
||||||
@ -263,8 +263,8 @@ For issues and questions:
|
|||||||
1. Check the browser console for error messages
|
1. Check the browser console for error messages
|
||||||
2. Verify backend API is running and accessible
|
2. Verify backend API is running and accessible
|
||||||
3. Check network connectivity and CORS configuration
|
3. Check network connectivity and CORS configuration
|
||||||
4. Review the Contoso backend documentation
|
4. Review the SeReact backend documentation
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This frontend client is part of the Contoso project. See the main project license for details.
|
This frontend client is part of the SeReact project. See the main project license for details.
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Contoso Debug</title>
|
<title>SeReact Debug</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
button { margin: 5px; padding: 10px; }
|
button { margin: 5px; padding: 10px; }
|
||||||
@ -12,7 +12,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Contoso Debug Page</h1>
|
<h1>SeReact Debug Page</h1>
|
||||||
|
|
||||||
<div class="debug">
|
<div class="debug">
|
||||||
<h3>Debug Controls</h3>
|
<h3>Debug Controls</h3>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Contoso - AI-Powered Image Management</title>
|
<title>SeReact - AI-Powered Image Management</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
<link href="styles.css" rel="stylesheet">
|
<link href="styles.css" rel="stylesheet">
|
||||||
@ -13,7 +13,7 @@
|
|||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="#home" onclick="showPage('home'); return false;">
|
<a class="navbar-brand" href="#home" onclick="showPage('home'); return false;">
|
||||||
<i class="fas fa-search me-2"></i>Contoso
|
<i class="fas fa-search me-2"></i>SeReact
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
@ -72,7 +72,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="jumbotron bg-light p-5 rounded">
|
<div class="jumbotron bg-light p-5 rounded">
|
||||||
<h1 class="display-4">Welcome to Contoso</h1>
|
<h1 class="display-4">Welcome to SeReact</h1>
|
||||||
<p class="lead">AI-powered image management and semantic search platform</p>
|
<p class="lead">AI-powered image management and semantic search platform</p>
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<p>Upload images, manage your team, and search using natural language queries.</p>
|
<p>Upload images, manage your team, and search using natural language queries.</p>
|
||||||
@ -127,7 +127,7 @@
|
|||||||
<label for="apiBaseUrl" class="form-label">API Base URL</label>
|
<label for="apiBaseUrl" class="form-label">API Base URL</label>
|
||||||
<input type="url" class="form-control" id="apiBaseUrl"
|
<input type="url" class="form-control" id="apiBaseUrl"
|
||||||
placeholder="http://localhost:8000" required>
|
placeholder="http://localhost:8000" required>
|
||||||
<div class="form-text">The base URL of your Contoso API server</div>
|
<div class="form-text">The base URL of your SeReact API server</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="apiKey" class="form-label">API Key</label>
|
<label for="apiKey" class="form-label">API Key</label>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const app = {
|
|||||||
// Initialize the application
|
// Initialize the application
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.log('App.js DOMContentLoaded fired');
|
console.log('App.js DOMContentLoaded fired');
|
||||||
console.log('Contoso Frontend v' + app.version + ' - Initializing...');
|
console.log('SeReact Frontend v' + app.version + ' - Initializing...');
|
||||||
|
|
||||||
// Initialize configuration
|
// Initialize configuration
|
||||||
initializeApp();
|
initializeApp();
|
||||||
@ -22,7 +22,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
checkInitialConfiguration();
|
checkInitialConfiguration();
|
||||||
|
|
||||||
app.initialized = true;
|
app.initialized = true;
|
||||||
console.log('Contoso Frontend - Initialization complete');
|
console.log('SeReact Frontend - Initialization complete');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize the application
|
// Initialize the application
|
||||||
@ -98,7 +98,7 @@ function showWelcomeMessage() {
|
|||||||
const modalBody = `
|
const modalBody = `
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<i class="fas fa-rocket fa-3x text-primary mb-3"></i>
|
<i class="fas fa-rocket fa-3x text-primary mb-3"></i>
|
||||||
<h4>Welcome to Contoso!</h4>
|
<h4>Welcome to SeReact!</h4>
|
||||||
<p class="lead">AI-powered image management and semantic search platform</p>
|
<p class="lead">AI-powered image management and semantic search platform</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ function showWelcomeMessage() {
|
|||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const modal = createModal('welcomeModal', 'Welcome to Contoso', modalBody, modalFooter);
|
const modal = createModal('welcomeModal', 'Welcome to SeReact', modalBody, modalFooter);
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,10 +366,10 @@ window.addEventListener('unhandledrejection', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Export app object for debugging
|
// Export app object for debugging
|
||||||
window.ContosoApp = app;
|
window.SeReactApp = app;
|
||||||
|
|
||||||
// Add helpful console messages
|
// Add helpful console messages
|
||||||
console.log('%cContoso Frontend', 'color: #0d6efd; font-size: 24px; font-weight: bold;');
|
console.log('%cSeReact Frontend', 'color: #0d6efd; font-size: 24px; font-weight: bold;');
|
||||||
console.log('%cVersion: ' + app.version, 'color: #6c757d; font-size: 14px;');
|
console.log('%cVersion: ' + app.version, 'color: #6c757d; font-size: 14px;');
|
||||||
console.log('%cKeyboard Shortcuts:', 'color: #198754; font-size: 16px; font-weight: bold;');
|
console.log('%cKeyboard Shortcuts:', 'color: #198754; font-size: 16px; font-weight: bold;');
|
||||||
console.log('%c Ctrl+K: Search', 'color: #6c757d;');
|
console.log('%c Ctrl+K: Search', 'color: #6c757d;');
|
||||||
|
|||||||
@ -114,6 +114,11 @@ async function displaySearchResults(response, query) {
|
|||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="position-absolute top-0 end-0 m-2">
|
||||||
|
<span class="badge bg-primary similarity-score">
|
||||||
|
${Math.round(similarity * 100)}% match
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title">${escapeHtml(truncateText(image.description || 'Untitled', 60))}</h6>
|
<h6 class="card-title">${escapeHtml(truncateText(image.description || 'Untitled', 60))}</h6>
|
||||||
@ -269,7 +274,7 @@ async function shareSearchResults(query) {
|
|||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: 'Contoso Search Results',
|
title: 'SeReact Search Results',
|
||||||
text: text,
|
text: text,
|
||||||
url: url
|
url: url
|
||||||
});
|
});
|
||||||
|
|||||||
@ -43,8 +43,8 @@ function showPage(pageId) {
|
|||||||
updateNavActiveState(pageId);
|
updateNavActiveState(pageId);
|
||||||
|
|
||||||
// Update app state
|
// Update app state
|
||||||
if (window.ContosoApp) {
|
if (window.SeReactApp) {
|
||||||
window.ContosoApp.currentPage = pageId;
|
window.SeReactApp.currentPage = pageId;
|
||||||
}
|
}
|
||||||
app.currentPage = pageId; // Also update the local app state
|
app.currentPage = pageId; // Also update the local app state
|
||||||
|
|
||||||
@ -329,8 +329,8 @@ function initializeUI() {
|
|||||||
console.log('Initial page:', initialPage);
|
console.log('Initial page:', initialPage);
|
||||||
|
|
||||||
// Set initial app state
|
// Set initial app state
|
||||||
if (window.ContosoApp) {
|
if (window.SeReactApp) {
|
||||||
window.ContosoApp.currentPage = initialPage;
|
window.SeReactApp.currentPage = initialPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
showPage(initialPage);
|
showPage(initialPage);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Contoso Frontend Client
|
# SeReact Frontend Client
|
||||||
#
|
#
|
||||||
# This is a pure frontend application that runs in the browser.
|
# This is a pure frontend application that runs in the browser.
|
||||||
# No Python dependencies are required.
|
# No Python dependencies are required.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Simple HTTP server for serving the Contoso frontend during development.
|
Simple HTTP server for serving the SeReact frontend during development.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import http.server
|
import http.server
|
||||||
@ -44,7 +44,7 @@ def main():
|
|||||||
with socketserver.TCPServer((HOST, PORT), CustomHTTPRequestHandler) as httpd:
|
with socketserver.TCPServer((HOST, PORT), CustomHTTPRequestHandler) as httpd:
|
||||||
server_url = f"http://{HOST}:{PORT}"
|
server_url = f"http://{HOST}:{PORT}"
|
||||||
|
|
||||||
print(f"🚀 Contoso Frontend Development Server")
|
print(f"🚀 SeReact Frontend Development Server")
|
||||||
print(f"📁 Serving files from: {os.getcwd()}")
|
print(f"📁 Serving files from: {os.getcwd()}")
|
||||||
print(f"🌐 Server running at: {server_url}")
|
print(f"🌐 Server running at: {server_url}")
|
||||||
print(f"📱 Open in browser: {server_url}")
|
print(f"📱 Open in browser: {server_url}")
|
||||||
@ -69,7 +69,7 @@ def main():
|
|||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n🛑 Server stopped by user")
|
print("\n🛑 Server stopped by user")
|
||||||
print("👋 Thanks for using Contoso!")
|
print("👋 Thanks for using SeReact!")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@ -1,4 +1,4 @@
|
|||||||
/* Custom styles for Contoso Frontend */
|
/* Custom styles for SeReact Frontend */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #0d6efd;
|
--primary-color: #0d6efd;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Contoso Test</title>
|
<title>SeReact Test</title>
|
||||||
<style>
|
<style>
|
||||||
.page { display: none; padding: 20px; border: 1px solid #ccc; margin: 10px; }
|
.page { display: none; padding: 20px; border: 1px solid #ccc; margin: 10px; }
|
||||||
.page.active { display: block; }
|
.page.active { display: block; }
|
||||||
@ -11,7 +11,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Contoso Navigation Test</h1>
|
<h1>SeReact Navigation Test</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button onclick="showPage('home')">Home</button>
|
<button onclick="showPage('home')">Home</button>
|
||||||
|
|||||||
@ -44,7 +44,7 @@ else:
|
|||||||
storage_client = storage.Client()
|
storage_client = storage.Client()
|
||||||
|
|
||||||
# Get bucket name from environment variable
|
# Get bucket name from environment variable
|
||||||
GCS_BUCKET_NAME = os.environ.get('GCS_BUCKET_NAME', 'contoso-images')
|
GCS_BUCKET_NAME = os.environ.get('GCS_BUCKET_NAME', 'sereact-images')
|
||||||
|
|
||||||
# Initialize Qdrant
|
# Initialize Qdrant
|
||||||
QDRANT_HOST = os.environ.get('QDRANT_HOST', 'localhost')
|
QDRANT_HOST = os.environ.get('QDRANT_HOST', 'localhost')
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
IMAGE_NAME="contoso-api"
|
IMAGE_NAME="sereact-api"
|
||||||
REGION="us-central1"
|
REGION="us-central1"
|
||||||
SERVICE_NAME="contoso"
|
SERVICE_NAME="sereact"
|
||||||
|
|
||||||
# Get project ID from terraform.tfvars if it exists, otherwise use gcloud
|
# Get project ID from terraform.tfvars if it exists, otherwise use gcloud
|
||||||
if [ -f "$(dirname "$0")/terraform/terraform.tfvars" ]; then
|
if [ -f "$(dirname "$0")/terraform/terraform.tfvars" ]; then
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{"ID":"f7ebd466-aa1e-1c15-bedf-0f9c92044463","Operation":"OperationTypeApply","Info":"","Who":"DESKTOP\\habal@Desktop","Version":"1.10.1","Created":"2025-05-26T16:46:06.4288884Z","Path":"terraform.tfstate"}
|
|
||||||
@ -50,11 +50,11 @@ resource "google_firestore_database" "database" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Container Registry - no explicit resource needed, just enable the API
|
# Container Registry - no explicit resource needed, just enable the API
|
||||||
# You'll push images to gcr.io/${var.project_id}/contoso-api
|
# You'll push images to gcr.io/${var.project_id}/sereact-api
|
||||||
|
|
||||||
# Cloud Run service
|
# Cloud Run service
|
||||||
resource "google_cloud_run_service" "contoso" {
|
resource "google_cloud_run_service" "sereact" {
|
||||||
name = "contoso"
|
name = "sereact"
|
||||||
location = var.region
|
location = var.region
|
||||||
|
|
||||||
metadata {
|
metadata {
|
||||||
@ -77,7 +77,7 @@ resource "google_cloud_run_service" "contoso" {
|
|||||||
spec {
|
spec {
|
||||||
containers {
|
containers {
|
||||||
# Use our optimized image
|
# Use our optimized image
|
||||||
image = "gcr.io/${var.project_id}/contoso-api:${var.image_tag}"
|
image = "gcr.io/${var.project_id}/sereact-api:${var.image_tag}"
|
||||||
|
|
||||||
ports {
|
ports {
|
||||||
container_port = 8000
|
container_port = 8000
|
||||||
@ -154,8 +154,8 @@ resource "google_cloud_run_service" "contoso" {
|
|||||||
|
|
||||||
# Make the Cloud Run service publicly accessible
|
# Make the Cloud Run service publicly accessible
|
||||||
resource "google_cloud_run_service_iam_member" "public_access" {
|
resource "google_cloud_run_service_iam_member" "public_access" {
|
||||||
service = google_cloud_run_service.contoso.name
|
service = google_cloud_run_service.sereact.name
|
||||||
location = google_cloud_run_service.contoso.location
|
location = google_cloud_run_service.sereact.location
|
||||||
role = "roles/run.invoker"
|
role = "roles/run.invoker"
|
||||||
member = "allUsers"
|
member = "allUsers"
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
output "cloud_run_url" {
|
output "cloud_run_url" {
|
||||||
value = google_cloud_run_service.contoso.status[0].url
|
value = google_cloud_run_service.sereact.status[0].url
|
||||||
description = "The URL of the deployed Cloud Run service"
|
description = "The URL of the deployed Cloud Run service"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ output "firestore_database_id" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
output "container_registry_url" {
|
output "container_registry_url" {
|
||||||
value = "gcr.io/${var.project_id}/contoso"
|
value = "gcr.io/${var.project_id}/sereact"
|
||||||
description = "The URL of the Container Registry repository"
|
description = "The URL of the Container Registry repository"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ output "cloud_run_qdrant_host_internal" {
|
|||||||
|
|
||||||
output "deployment_summary" {
|
output "deployment_summary" {
|
||||||
value = {
|
value = {
|
||||||
cloud_run_url = google_cloud_run_service.contoso.status[0].url
|
cloud_run_url = google_cloud_run_service.sereact.status[0].url
|
||||||
qdrant_endpoint = "http://${google_compute_instance.vector_db_vm.network_interface[0].access_config[0].nat_ip}:6333"
|
qdrant_endpoint = "http://${google_compute_instance.vector_db_vm.network_interface[0].access_config[0].nat_ip}:6333"
|
||||||
qdrant_host_ip = google_compute_instance.vector_db_vm.network_interface[0].access_config[0].nat_ip
|
qdrant_host_ip = google_compute_instance.vector_db_vm.network_interface[0].access_config[0].nat_ip
|
||||||
firestore_database = var.firestore_db_name
|
firestore_database = var.firestore_db_name
|
||||||
|
|||||||
@ -10,7 +10,7 @@ resource "google_pubsub_topic" "image_processing" {
|
|||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
environment = var.environment
|
environment = var.environment
|
||||||
service = "contoso"
|
service = "sereact"
|
||||||
component = "image-processing"
|
component = "image-processing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -21,7 +21,7 @@ resource "google_pubsub_topic" "image_processing_dlq" {
|
|||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
environment = var.environment
|
environment = var.environment
|
||||||
service = "contoso"
|
service = "sereact"
|
||||||
component = "image-processing-dlq"
|
component = "image-processing-dlq"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,7 +37,7 @@ resource "google_pubsub_subscription" "image_processing_dlq" {
|
|||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
environment = var.environment
|
environment = var.environment
|
||||||
service = "contoso"
|
service = "sereact"
|
||||||
component = "image-processing-dlq"
|
component = "image-processing-dlq"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -2,7 +2,7 @@ project_id = "your-gcp-project-id"
|
|||||||
region = "us-central1"
|
region = "us-central1"
|
||||||
zone = "us-central1-a"
|
zone = "us-central1-a"
|
||||||
storage_bucket_name = "your-app-storage-bucket"
|
storage_bucket_name = "your-app-storage-bucket"
|
||||||
firestore_db_name = "contoso-imagedb"
|
firestore_db_name = "sereact-imagedb"
|
||||||
|
|
||||||
# Vector Database Configuration
|
# Vector Database Configuration
|
||||||
qdrant_api_key = "your-secure-api-key-here" # Optional: leave empty for no authentication
|
qdrant_api_key = "your-secure-api-key-here" # Optional: leave empty for no authentication
|
||||||
|
|||||||
@ -23,7 +23,7 @@ variable "storage_bucket_name" {
|
|||||||
variable "firestore_db_name" {
|
variable "firestore_db_name" {
|
||||||
description = "The name of the Firestore database"
|
description = "The name of the Firestore database"
|
||||||
type = string
|
type = string
|
||||||
default = "contoso-imagedb"
|
default = "sereact-imagedb"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "environment" {
|
variable "environment" {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# VM instance for vector database
|
# VM instance for vector database
|
||||||
resource "google_compute_instance" "vector_db_vm" {
|
resource "google_compute_instance" "vector_db_vm" {
|
||||||
name = "contoso-vector-db"
|
name = "sereact-vector-db"
|
||||||
machine_type = "e2-standard-2" # 2 vCPUs, 8GB RAM
|
machine_type = "e2-standard-2" # 2 vCPUs, 8GB RAM
|
||||||
zone = var.zone
|
zone = var.zone
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Build and Deployment Scripts
|
# Build and Deployment Scripts
|
||||||
|
|
||||||
This directory contains scripts for building and deploying the Contoso API application.
|
This directory contains scripts for building and deploying the Sereact API application.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ This directory contains scripts for building and deploying the Contoso API appli
|
|||||||
|
|
||||||
### Build Script (`build.sh`)
|
### Build Script (`build.sh`)
|
||||||
|
|
||||||
Builds the Docker image for the Contoso API.
|
Builds the Docker image for the Sereact API.
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
```bash
|
```bash
|
||||||
@ -30,7 +30,7 @@ REGISTRY=gcr.io/my-project ./scripts/build.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
- `IMAGE_NAME`: Name for the Docker image (default: "contoso-api")
|
- `IMAGE_NAME`: Name for the Docker image (default: "sereact-api")
|
||||||
- `IMAGE_TAG`: Tag for the Docker image (default: "latest")
|
- `IMAGE_TAG`: Tag for the Docker image (default: "latest")
|
||||||
- `REGISTRY`: Container registry to use (default: empty, using DockerHub)
|
- `REGISTRY`: Container registry to use (default: empty, using DockerHub)
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ DEPLOY_TO_CLOUD_RUN=true PROJECT_ID=my-project-id REGION=us-west1 SERVICE_NAME=m
|
|||||||
All variables from the build script, plus:
|
All variables from the build script, plus:
|
||||||
- `PROJECT_ID`: Google Cloud project ID (required for Cloud Run deployment)
|
- `PROJECT_ID`: Google Cloud project ID (required for Cloud Run deployment)
|
||||||
- `REGION`: Google Cloud region (default: "us-central1")
|
- `REGION`: Google Cloud region (default: "us-central1")
|
||||||
- `SERVICE_NAME`: Name for the Cloud Run service (default: "contoso-api")
|
- `SERVICE_NAME`: Name for the Cloud Run service (default: "sereact-api")
|
||||||
|
|
||||||
### Cloud Run Deployment Script (`deploy-to-cloud-run.sh`)
|
### Cloud Run Deployment Script (`deploy-to-cloud-run.sh`)
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ PROJECT_ID=my-project-id REGION=us-west1 IMAGE_TAG=v1.0.0 ./scripts/deploy-to-cl
|
|||||||
- `PROJECT_ID`: Google Cloud project ID (required)
|
- `PROJECT_ID`: Google Cloud project ID (required)
|
||||||
- `REGION`: Google Cloud region (default: "us-central1")
|
- `REGION`: Google Cloud region (default: "us-central1")
|
||||||
- `SERVICE_CONFIG`: Path to the service configuration file (default: "deployment/cloud-run/service.yaml")
|
- `SERVICE_CONFIG`: Path to the service configuration file (default: "deployment/cloud-run/service.yaml")
|
||||||
- `IMAGE_NAME`: Name for the Docker image (default: "contoso-api")
|
- `IMAGE_NAME`: Name for the Docker image (default: "sereact-api")
|
||||||
- `IMAGE_TAG`: Tag for the Docker image (default: "latest")
|
- `IMAGE_TAG`: Tag for the Docker image (default: "latest")
|
||||||
- `REGISTRY`: Container registry to use (default: "gcr.io")
|
- `REGISTRY`: Container registry to use (default: "gcr.io")
|
||||||
- `BUILD`: Set to "true" to build the image before deployment (default: "false")
|
- `BUILD`: Set to "true" to build the image before deployment (default: "false")
|
||||||
@ -84,7 +84,7 @@ PROJECT_ID=my-project-id REGION=us-west1 IMAGE_TAG=v1.0.0 ./scripts/deploy-to-cl
|
|||||||
|
|
||||||
### Frontend Client Script (`client.sh`)
|
### Frontend Client Script (`client.sh`)
|
||||||
|
|
||||||
Manages the Contoso frontend client development, building, and deployment.
|
Manages the SeReact frontend client development, building, and deployment.
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
```bash
|
```bash
|
||||||
@ -170,13 +170,13 @@ DEPLOY_TARGET=netlify ./scripts/client.sh deploy
|
|||||||
|
|
||||||
# Scripts Documentation
|
# Scripts Documentation
|
||||||
|
|
||||||
This directory contains utility scripts for the CONTOSO application.
|
This directory contains utility scripts for the SEREACT application.
|
||||||
|
|
||||||
## Database Seeding Scripts
|
## Database Seeding Scripts
|
||||||
|
|
||||||
### `seed_firestore.py`
|
### `seed_firestore.py`
|
||||||
|
|
||||||
This script initializes and seeds a Google Cloud Firestore database with initial data for the CONTOSO application. It creates teams, users, API keys, and sample image metadata.
|
This script initializes and seeds a Google Cloud Firestore database with initial data for the SEREACT application. It creates teams, users, API keys, and sample image metadata.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
@ -195,9 +195,9 @@ This script initializes and seeds a Google Cloud Firestore database with initial
|
|||||||
|
|
||||||
2. If not using application default credentials, create a service account key file:
|
2. If not using application default credentials, create a service account key file:
|
||||||
```bash
|
```bash
|
||||||
gcloud iam service-accounts create contoso-app
|
gcloud iam service-accounts create sereact-app
|
||||||
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID --member="serviceAccount:contoso-app@YOUR_PROJECT_ID.iam.gserviceaccount.com" --role="roles/datastore.user"
|
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID --member="serviceAccount:sereact-app@YOUR_PROJECT_ID.iam.gserviceaccount.com" --role="roles/datastore.user"
|
||||||
gcloud iam service-accounts keys create credentials.json --iam-account=contoso-app@YOUR_PROJECT_ID.iam.gserviceaccount.com
|
gcloud iam service-accounts keys create credentials.json --iam-account=sereact-app@YOUR_PROJECT_ID.iam.gserviceaccount.com
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Set environment variables:
|
3. Set environment variables:
|
||||||
@ -233,13 +233,13 @@ python scripts/seed_firestore.py
|
|||||||
The script will create the following data:
|
The script will create the following data:
|
||||||
|
|
||||||
1. **Teams**:
|
1. **Teams**:
|
||||||
- Contoso Development
|
- Sereact Development
|
||||||
- Marketing Team
|
- Marketing Team
|
||||||
- Customer Support
|
- Customer Support
|
||||||
|
|
||||||
2. **Users**:
|
2. **Users**:
|
||||||
- Admin User (team: Contoso Development)
|
- Admin User (team: Sereact Development)
|
||||||
- Developer User (team: Contoso Development)
|
- Developer User (team: Sereact Development)
|
||||||
- Marketing User (team: Marketing Team)
|
- Marketing User (team: Marketing Team)
|
||||||
- Support User (team: Customer Support)
|
- Support User (team: Customer Support)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Set defaults
|
# Set defaults
|
||||||
IMAGE_NAME=${IMAGE_NAME:-"contoso-api"}
|
IMAGE_NAME=${IMAGE_NAME:-"sereact-api"}
|
||||||
IMAGE_TAG=${IMAGE_TAG:-"latest"}
|
IMAGE_TAG=${IMAGE_TAG:-"latest"}
|
||||||
PROJECT_ID=${PROJECT_ID:-"gen-lang-client-0424120530"}
|
PROJECT_ID=${PROJECT_ID:-"gen-lang-client-0424120530"}
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ print_color() {
|
|||||||
|
|
||||||
print_header() {
|
print_header() {
|
||||||
echo
|
echo
|
||||||
print_color $CYAN "🚀 Contoso Frontend Client Manager"
|
print_color $CYAN "🚀 SeReact Frontend Client Manager"
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Test runner script for CONTOSO API
|
Test runner script for SEREACT API
|
||||||
|
|
||||||
This script provides a convenient way to run different types of tests
|
This script provides a convenient way to run different types of tests
|
||||||
with proper environment setup and reporting.
|
with proper environment setup and reporting.
|
||||||
@ -159,7 +159,7 @@ def run_coverage_tests():
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main function"""
|
"""Main function"""
|
||||||
parser = argparse.ArgumentParser(description="Run CONTOSO API tests")
|
parser = argparse.ArgumentParser(description="Run SEREACT API tests")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"test_type",
|
"test_type",
|
||||||
choices=["unit", "integration", "e2e", "all", "coverage"],
|
choices=["unit", "integration", "e2e", "all", "coverage"],
|
||||||
@ -173,7 +173,7 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("🧪 CONTOSO API Test Runner")
|
print("🧪 SEREACT API Test Runner")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Check environment unless skipped
|
# Check environment unless skipped
|
||||||
|
|||||||
@ -141,7 +141,7 @@ async def seed_teams():
|
|||||||
|
|
||||||
teams_data = [
|
teams_data = [
|
||||||
{
|
{
|
||||||
"name": "Contoso Development",
|
"name": "Sereact Development",
|
||||||
"description": "Internal development team"
|
"description": "Internal development team"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -169,23 +169,23 @@ async def seed_users(team_ids):
|
|||||||
|
|
||||||
users_data = [
|
users_data = [
|
||||||
{
|
{
|
||||||
"email": "admin@contoso.com",
|
"email": "admin@sereact.com",
|
||||||
"name": "Admin User",
|
"name": "Admin User",
|
||||||
"team_id": team_ids[0],
|
"team_id": team_ids[0],
|
||||||
"is_admin": True
|
"is_admin": True
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "developer@contoso.com",
|
"email": "developer@sereact.com",
|
||||||
"name": "Developer User",
|
"name": "Developer User",
|
||||||
"team_id": team_ids[0]
|
"team_id": team_ids[0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "marketing@contoso.com",
|
"email": "marketing@sereact.com",
|
||||||
"name": "Marketing User",
|
"name": "Marketing User",
|
||||||
"team_id": team_ids[1]
|
"team_id": team_ids[1]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "support@contoso.com",
|
"email": "support@sereact.com",
|
||||||
"name": "Support User",
|
"name": "Support User",
|
||||||
"team_id": team_ids[2]
|
"team_id": team_ids[2]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
echo "Stopping Contoso API server..."
|
echo "Stopping Sereact API server..."
|
||||||
|
|
||||||
# Find and kill uvicorn processes
|
# Find and kill uvicorn processes
|
||||||
PIDS=$(ps aux | grep "uvicorn main:app" | grep -v grep | awk '{print $2}')
|
PIDS=$(ps aux | grep "uvicorn main:app" | grep -v grep | awk '{print $2}')
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
from src.dependencies import AuthServiceDep
|
from src.services.auth_service import AuthService
|
||||||
from src.schemas.api_key import ApiKeyCreate, ApiKeyResponse, ApiKeyWithValueResponse, ApiKeyListResponse
|
from src.schemas.api_key import ApiKeyCreate, ApiKeyResponse, ApiKeyWithValueResponse, ApiKeyListResponse
|
||||||
from src.schemas.team import TeamCreate
|
from src.schemas.team import TeamCreate
|
||||||
from src.schemas.user import UserCreate
|
from src.schemas.user import UserCreate
|
||||||
@ -12,191 +13,121 @@ from src.models.api_key import ApiKeyModel
|
|||||||
from src.models.team import TeamModel
|
from src.models.team import TeamModel
|
||||||
from src.models.user import UserModel
|
from src.models.user import UserModel
|
||||||
from src.utils.logging import log_request
|
from src.utils.logging import log_request
|
||||||
from src.utils.authorization import (
|
|
||||||
require_admin,
|
|
||||||
create_auth_context,
|
|
||||||
log_authorization_context,
|
|
||||||
AuthorizationError
|
|
||||||
)
|
|
||||||
from src.api.v1.error_handlers import handle_service_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["Authentication"], prefix="/auth")
|
router = APIRouter(tags=["Authentication"], prefix="/auth")
|
||||||
|
|
||||||
@router.post("/api-keys", response_model=ApiKeyWithValueResponse, status_code=status.HTTP_201_CREATED)
|
# Initialize service
|
||||||
async def create_api_key(
|
auth_service = AuthService()
|
||||||
key_data: ApiKeyCreate,
|
|
||||||
request: Request,
|
|
||||||
auth_service: AuthServiceDep,
|
|
||||||
user_id: str = Query(..., description="User ID for the API key"),
|
|
||||||
team_id: str = Query(..., description="Team ID for the API key")
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create a new API key for a specific user and team
|
|
||||||
|
|
||||||
This endpoint creates an API key without requiring authentication.
|
@router.post("/api-keys", response_model=ApiKeyWithValueResponse, status_code=201)
|
||||||
Both user_id and team_id must be provided as query parameters.
|
async def create_api_key(key_data: ApiKeyCreate, request: Request, user_id: str, team_id: str):
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
Create a new API key
|
||||||
user=None, # No authenticated user for this endpoint
|
|
||||||
resource_type="api_key",
|
This endpoint no longer requires authentication - user_id and team_id must be provided
|
||||||
action="create",
|
"""
|
||||||
target_user_id=user_id,
|
log_request(
|
||||||
target_team_id=team_id,
|
{"path": request.url.path, "method": request.method, "key_data": key_data.dict(), "user_id": user_id, "team_id": team_id}
|
||||||
path=request.url.path,
|
|
||||||
method=request.method,
|
|
||||||
key_data=key_data.dict()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await auth_service.create_api_key_for_user_and_team(user_id, team_id, key_data)
|
response = await auth_service.create_api_key_for_user_and_team(user_id, team_id, key_data)
|
||||||
logger.info(f"API key created successfully for user {user_id} in team {team_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "API key creation")
|
logger.error(f"Unexpected error creating API key: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.post("/admin/api-keys/{user_id}", response_model=ApiKeyWithValueResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/admin/api-keys/{user_id}", response_model=ApiKeyWithValueResponse, status_code=201)
|
||||||
async def create_api_key_for_user(
|
async def create_api_key_for_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
key_data: ApiKeyCreate,
|
key_data: ApiKeyCreate,
|
||||||
request: Request,
|
request: Request,
|
||||||
auth_service: AuthServiceDep,
|
current_user = Depends(get_current_user)
|
||||||
current_user: UserModel = Depends(get_current_user)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a new API key for a specific user (admin only)
|
Create a new API key for a specific user (admin only)
|
||||||
|
|
||||||
This endpoint requires admin authentication and allows creating API keys
|
|
||||||
for any user in the system.
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "target_user_id": user_id, "key_data": key_data.dict()},
|
||||||
resource_type="api_key",
|
user_id=str(current_user.id),
|
||||||
action="admin_create",
|
team_id=str(current_user.team_id)
|
||||||
target_user_id=user_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method,
|
|
||||||
key_data=key_data.dict()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Centralized admin authorization check
|
|
||||||
require_admin(current_user, "create API keys for other users")
|
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
response = await auth_service.create_api_key_for_user_by_admin(user_id, key_data, current_user)
|
response = await auth_service.create_api_key_for_user_by_admin(user_id, key_data, current_user)
|
||||||
logger.info(f"Admin {current_user.id} created API key for user {user_id}")
|
|
||||||
return response
|
return response
|
||||||
except AuthorizationError:
|
except PermissionError as e:
|
||||||
log_authorization_context(auth_context, success=False)
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
raise
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "admin API key creation")
|
logger.error(f"Unexpected error creating API key for user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("/api-keys", response_model=ApiKeyListResponse)
|
@router.get("/api-keys", response_model=ApiKeyListResponse)
|
||||||
async def list_api_keys(
|
async def list_api_keys(request: Request, current_user = Depends(get_current_user)):
|
||||||
request: Request,
|
|
||||||
auth_service: AuthServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user),
|
|
||||||
skip: int = Query(0, ge=0, description="Number of records to skip for pagination"),
|
|
||||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return (1-100)")
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
List API keys for the current authenticated user
|
List API keys for the current user
|
||||||
|
|
||||||
Returns a paginated list of all active and inactive API keys belonging
|
|
||||||
to the authenticated user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return, 1-100 (default: 50)
|
|
||||||
current_user: The authenticated user
|
|
||||||
auth_service: Injected auth service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ApiKeyListResponse: Paginated list of API keys with total count
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid pagination parameters
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method},
|
||||||
resource_type="api_key",
|
user_id=str(current_user.id),
|
||||||
action="list",
|
team_id=str(current_user.team_id)
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await auth_service.list_user_api_keys(current_user, skip, limit)
|
response = await auth_service.list_user_api_keys(current_user)
|
||||||
logger.info(f"Listed {len(response.api_keys)} API keys (total: {response.total}) for user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "API key listing")
|
logger.error(f"Unexpected error listing API keys: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/api-keys/{key_id}", status_code=204)
|
||||||
async def revoke_api_key(
|
async def revoke_api_key(key_id: str, request: Request, current_user = Depends(get_current_user)):
|
||||||
key_id: str,
|
|
||||||
request: Request,
|
|
||||||
auth_service: AuthServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Revoke (deactivate) an API key
|
Revoke (deactivate) an API key
|
||||||
|
|
||||||
Deactivates the specified API key. Only the key owner or an admin can revoke keys.
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "key_id": key_id},
|
||||||
resource_type="api_key",
|
user_id=str(current_user.id),
|
||||||
action="revoke",
|
team_id=str(current_user.team_id)
|
||||||
key_id=key_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Authorization is handled in the service layer for this endpoint
|
|
||||||
# since it needs to check key ownership
|
|
||||||
await auth_service.revoke_api_key(key_id, current_user)
|
await auth_service.revoke_api_key(key_id, current_user)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
logger.info(f"API key {key_id} revoked by user {current_user.id}")
|
|
||||||
return None
|
return None
|
||||||
except AuthorizationError:
|
except ValueError as e:
|
||||||
log_authorization_context(auth_context, success=False)
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
raise
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "API key revocation")
|
logger.error(f"Unexpected error revoking API key: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("/verify", status_code=status.HTTP_200_OK)
|
@router.get("/verify", status_code=200)
|
||||||
async def verify_authentication(
|
async def verify_authentication(request: Request, current_user = Depends(get_current_user)):
|
||||||
request: Request,
|
|
||||||
auth_service: AuthServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Verify the current authentication status
|
Verify the current authentication (API key)
|
||||||
|
|
||||||
Validates the current API key and returns user information.
|
|
||||||
Useful for checking if an API key is still valid and active.
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method},
|
||||||
resource_type="authentication",
|
user_id=str(current_user.id),
|
||||||
action="verify",
|
team_id=str(current_user.team_id)
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await auth_service.verify_user_authentication(current_user)
|
response = await auth_service.verify_user_authentication(current_user)
|
||||||
logger.info(f"Authentication verified for user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "authentication verification")
|
logger.error(f"Unexpected error verifying authentication: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
@ -1,113 +0,0 @@
|
|||||||
"""
|
|
||||||
Shared error handling utilities for API endpoints.
|
|
||||||
|
|
||||||
This module provides centralized error handling to ensure consistent
|
|
||||||
HTTP status codes and error responses across all API endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Type
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# HTTP Status Code Mapping for consistent error responses
|
|
||||||
HTTP_ERROR_MAP: Dict[Type[Exception], int] = {
|
|
||||||
ValueError: status.HTTP_400_BAD_REQUEST,
|
|
||||||
RuntimeError: status.HTTP_404_NOT_FOUND,
|
|
||||||
PermissionError: status.HTTP_403_FORBIDDEN,
|
|
||||||
FileNotFoundError: status.HTTP_404_NOT_FOUND,
|
|
||||||
TimeoutError: status.HTTP_408_REQUEST_TIMEOUT,
|
|
||||||
ConnectionError: status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
Exception: status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle_service_error(error: Exception, operation: str) -> HTTPException:
|
|
||||||
"""
|
|
||||||
Centralized error handling for service layer exceptions
|
|
||||||
|
|
||||||
This function maps service layer exceptions to appropriate HTTP status codes
|
|
||||||
and provides consistent error logging and response formatting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: The exception raised by the service layer
|
|
||||||
operation: Description of the operation that failed (for logging)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTTPException: Properly formatted HTTP exception with appropriate status code
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> try:
|
|
||||||
... await some_service_method()
|
|
||||||
... except Exception as e:
|
|
||||||
... raise handle_service_error(e, "user creation")
|
|
||||||
"""
|
|
||||||
error_type = type(error)
|
|
||||||
status_code = HTTP_ERROR_MAP.get(error_type, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
# Log errors appropriately based on severity
|
|
||||||
if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
|
|
||||||
logger.error(f"Unexpected error in {operation}: {error}", exc_info=True)
|
|
||||||
detail = "Internal server error"
|
|
||||||
elif status_code >= 500:
|
|
||||||
logger.error(f"Server error in {operation}: {error}")
|
|
||||||
detail = "Service temporarily unavailable"
|
|
||||||
else:
|
|
||||||
logger.warning(f"Client error in {operation}: {error}")
|
|
||||||
detail = str(error)
|
|
||||||
|
|
||||||
return HTTPException(status_code=status_code, detail=detail)
|
|
||||||
|
|
||||||
def handle_validation_error(error: Exception, field_name: str = None) -> HTTPException:
|
|
||||||
"""
|
|
||||||
Handle validation errors with specific formatting
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: The validation exception
|
|
||||||
field_name: Optional field name that failed validation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTTPException: Formatted validation error response
|
|
||||||
"""
|
|
||||||
detail = f"Validation error"
|
|
||||||
if field_name:
|
|
||||||
detail += f" for field '{field_name}'"
|
|
||||||
detail += f": {str(error)}"
|
|
||||||
|
|
||||||
logger.warning(f"Validation error: {detail}")
|
|
||||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
|
||||||
|
|
||||||
def handle_authentication_error(error: Exception) -> HTTPException:
|
|
||||||
"""
|
|
||||||
Handle authentication-related errors
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: The authentication exception
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTTPException: Formatted authentication error response
|
|
||||||
"""
|
|
||||||
logger.warning(f"Authentication error: {error}")
|
|
||||||
return HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Authentication failed",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_authorization_error(error: Exception, resource: str = None) -> HTTPException:
|
|
||||||
"""
|
|
||||||
Handle authorization-related errors
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: The authorization exception
|
|
||||||
resource: Optional resource name that access was denied to
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTTPException: Formatted authorization error response
|
|
||||||
"""
|
|
||||||
detail = "Access denied"
|
|
||||||
if resource:
|
|
||||||
detail += f" to {resource}"
|
|
||||||
|
|
||||||
logger.warning(f"Authorization error: {detail} - {error}")
|
|
||||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
|
||||||
@ -1,216 +1,131 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from src.auth.security import get_current_user
|
from src.auth.security import get_current_user
|
||||||
from src.dependencies import ImageServiceDep
|
from src.services.image_service import ImageService
|
||||||
from src.models.user import UserModel
|
from src.models.user import UserModel
|
||||||
from src.schemas.image import ImageResponse, ImageListResponse, ImageCreate, ImageUpdate
|
from src.schemas.image import ImageResponse, ImageListResponse, ImageCreate, ImageUpdate
|
||||||
from src.utils.logging import log_request
|
from src.utils.logging import log_request
|
||||||
from src.utils.authorization import (
|
|
||||||
create_auth_context,
|
|
||||||
log_authorization_context,
|
|
||||||
get_team_filter,
|
|
||||||
AuthorizationError
|
|
||||||
)
|
|
||||||
from src.api.v1.error_handlers import handle_service_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["Images"], prefix="/images")
|
router = APIRouter(tags=["Images"], prefix="/images")
|
||||||
|
|
||||||
@router.post("", response_model=ImageResponse, status_code=status.HTTP_201_CREATED)
|
# Initialize service
|
||||||
|
image_service = ImageService()
|
||||||
|
|
||||||
|
@router.post("", response_model=ImageResponse, status_code=201)
|
||||||
async def upload_image(
|
async def upload_image(
|
||||||
request: Request,
|
request: Request,
|
||||||
image_service: ImageServiceDep,
|
file: UploadFile = File(...),
|
||||||
file: UploadFile = File(..., description="Image file to upload"),
|
description: Optional[str] = None,
|
||||||
description: Optional[str] = Query(None, description="Optional description for the image"),
|
collection_id: Optional[str] = None,
|
||||||
collection_id: Optional[str] = Query(None, description="Optional collection ID to associate with the image"),
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Upload a new image
|
Upload a new image
|
||||||
|
|
||||||
Uploads an image file and processes it for storage and indexing.
|
|
||||||
The image will be associated with the current user's team and can
|
|
||||||
optionally be added to a specific collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file: The image file to upload (supports common image formats)
|
|
||||||
description: Optional description for the image
|
|
||||||
collection_id: Optional collection ID to organize the image
|
|
||||||
current_user: The authenticated user uploading the image
|
|
||||||
image_service: Injected image service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ImageResponse: The uploaded image metadata and processing status
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid file format or validation errors
|
|
||||||
500: Upload or processing errors
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "filename": file.filename},
|
||||||
resource_type="image",
|
user_id=str(current_user.id),
|
||||||
action="upload",
|
team_id=str(current_user.team_id)
|
||||||
image_filename=file.filename,
|
|
||||||
content_type=file.content_type,
|
|
||||||
has_description=description is not None,
|
|
||||||
collection_id=collection_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await image_service.upload_image(file, current_user, request, description, collection_id)
|
response = await image_service.upload_image(file, current_user, request, description, collection_id)
|
||||||
logger.info(f"Image uploaded successfully: {file.filename} by user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image upload")
|
logger.error(f"Unexpected error uploading image: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("", response_model=ImageListResponse)
|
@router.get("", response_model=ImageListResponse)
|
||||||
async def list_images(
|
async def list_images(
|
||||||
request: Request,
|
request: Request,
|
||||||
image_service: ImageServiceDep,
|
skip: int = Query(0, ge=0),
|
||||||
skip: int = Query(0, ge=0, description="Number of records to skip for pagination"),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return (1-100)"),
|
collection_id: Optional[str] = None,
|
||||||
collection_id: Optional[str] = Query(None, description="Filter by collection ID"),
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List images for the current user's team or all images if admin
|
List images for the current user's team, or all images if user is admin.
|
||||||
|
|
||||||
Retrieves a paginated list of images. Regular users can only see images
|
Regular users can only see images from their own team.
|
||||||
from their own team, while admin users can see all images across all teams.
|
Admin users can see all images across all teams.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
skip: Number of records to skip for pagination
|
||||||
limit: Maximum number of records to return, 1-100 (default: 50)
|
limit: Maximum number of records to return (1-100)
|
||||||
collection_id: Optional filter by collection ID
|
collection_id: Optional filter by collection ID
|
||||||
current_user: The authenticated user
|
|
||||||
image_service: Injected image service
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImageListResponse: Paginated list of images with metadata
|
List of images with pagination metadata
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid pagination parameters
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "skip": skip, "limit": limit, "is_admin": current_user.is_admin},
|
||||||
resource_type="image",
|
user_id=str(current_user.id),
|
||||||
action="list",
|
team_id=str(current_user.team_id)
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
collection_id=collection_id,
|
|
||||||
team_filter=get_team_filter(current_user),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await image_service.list_images(current_user, request, skip, limit, collection_id)
|
response = await image_service.list_images(current_user, request, skip, limit, collection_id)
|
||||||
logger.info(f"Listed {len(response.images)} images for user {current_user.id} (admin: {current_user.is_admin})")
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image listing")
|
logger.error(f"Unexpected error listing images: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("/{image_id}", response_model=ImageResponse)
|
@router.get("/{image_id}", response_model=ImageResponse)
|
||||||
async def get_image(
|
async def get_image(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
image_service: ImageServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get image metadata by ID
|
Get image metadata by ID
|
||||||
|
|
||||||
Retrieves detailed metadata for a specific image. Users can only
|
|
||||||
access images from their own team unless they are admin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: The image ID to retrieve
|
|
||||||
current_user: The authenticated user
|
|
||||||
image_service: Injected image service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ImageResponse: Complete image metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid image ID format
|
|
||||||
403: Insufficient permissions to access this image
|
|
||||||
404: Image not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin},
|
||||||
resource_type="image",
|
user_id=str(current_user.id),
|
||||||
action="get",
|
team_id=str(current_user.team_id)
|
||||||
image_id=image_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Authorization is handled in the service layer since it needs to check the image's team
|
|
||||||
response = await image_service.get_image(image_id, current_user, request)
|
response = await image_service.get_image(image_id, current_user, request)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
logger.info(f"Retrieved image {image_id} for user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
except AuthorizationError:
|
except ValueError as e:
|
||||||
log_authorization_context(auth_context, success=False)
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
raise
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image retrieval")
|
logger.error(f"Unexpected error getting image: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("/{image_id}/download")
|
@router.get("/{image_id}/download")
|
||||||
async def download_image(
|
async def download_image(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
image_service: ImageServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Download image file
|
Download image file
|
||||||
|
|
||||||
Downloads the actual image file. Users can only download images
|
|
||||||
from their own team unless they are admin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: The image ID to download
|
|
||||||
current_user: The authenticated user
|
|
||||||
image_service: Injected image service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
StreamingResponse: The image file as a download
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid image ID format
|
|
||||||
403: Insufficient permissions to download this image
|
|
||||||
404: Image not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin},
|
||||||
resource_type="image",
|
user_id=str(current_user.id),
|
||||||
action="download",
|
team_id=str(current_user.team_id)
|
||||||
image_id=image_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Authorization is handled in the service layer since it needs to check the image's team
|
|
||||||
file_content, content_type, filename = await image_service.download_image(image_id, current_user)
|
file_content, content_type, filename = await image_service.download_image(image_id, current_user)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
logger.info(f"Image {image_id} downloaded by user {current_user.id}")
|
|
||||||
|
|
||||||
# Return file as streaming response
|
# Return file as streaming response
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
@ -218,107 +133,69 @@ async def download_image(
|
|||||||
media_type=content_type,
|
media_type=content_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
)
|
)
|
||||||
except AuthorizationError:
|
except ValueError as e:
|
||||||
log_authorization_context(auth_context, success=False)
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
raise
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image download")
|
logger.error(f"Unexpected error downloading image: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.put("/{image_id}", response_model=ImageResponse)
|
@router.put("/{image_id}", response_model=ImageResponse)
|
||||||
async def update_image(
|
async def update_image(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
image_data: ImageUpdate,
|
image_data: ImageUpdate,
|
||||||
request: Request,
|
request: Request,
|
||||||
image_service: ImageServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update image metadata
|
Update image metadata
|
||||||
|
|
||||||
Updates the metadata for a specific image. Users can only update
|
|
||||||
images from their own team unless they are admin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: The image ID to update
|
|
||||||
image_data: The image update data
|
|
||||||
current_user: The authenticated user
|
|
||||||
image_service: Injected image service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ImageResponse: Updated image metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid image ID format or validation errors
|
|
||||||
403: Insufficient permissions to update this image
|
|
||||||
404: Image not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin},
|
||||||
resource_type="image",
|
user_id=str(current_user.id),
|
||||||
action="update",
|
team_id=str(current_user.team_id)
|
||||||
image_id=image_id,
|
|
||||||
update_data=image_data.dict(),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Authorization is handled in the service layer since it needs to check the image's team
|
|
||||||
response = await image_service.update_image(image_id, image_data, current_user, request)
|
response = await image_service.update_image(image_id, image_data, current_user, request)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
logger.info(f"Image {image_id} updated by user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
except AuthorizationError:
|
except ValueError as e:
|
||||||
log_authorization_context(auth_context, success=False)
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
raise
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image update")
|
logger.error(f"Unexpected error updating image: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{image_id}", status_code=204)
|
||||||
async def delete_image(
|
async def delete_image(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
image_service: ImageServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete an image
|
Delete an image
|
||||||
|
|
||||||
Permanently removes an image and its associated data. Users can only
|
|
||||||
delete images from their own team unless they are admin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: The image ID to delete
|
|
||||||
current_user: The authenticated user
|
|
||||||
image_service: Injected image service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None (204 No Content)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid image ID format
|
|
||||||
403: Insufficient permissions to delete this image
|
|
||||||
404: Image not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin},
|
||||||
resource_type="image",
|
user_id=str(current_user.id),
|
||||||
action="delete",
|
team_id=str(current_user.team_id)
|
||||||
image_id=image_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Authorization is handled in the service layer since it needs to check the image's team
|
|
||||||
await image_service.delete_image(image_id, current_user)
|
await image_service.delete_image(image_id, current_user)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
logger.info(f"Image {image_id} deleted by user {current_user.id}")
|
|
||||||
return None
|
return None
|
||||||
except AuthorizationError:
|
except ValueError as e:
|
||||||
log_authorization_context(auth_context, success=False)
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
raise
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image deletion")
|
logger.error(f"Unexpected error deleting image: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
@ -1,72 +1,43 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from fastapi import APIRouter, Depends, Query, Request, HTTPException, status
|
from fastapi import APIRouter, Depends, Query, Request, HTTPException
|
||||||
|
|
||||||
from src.auth.security import get_current_user
|
from src.auth.security import get_current_user
|
||||||
from src.dependencies import SearchServiceDep
|
from src.services.search_service import SearchService
|
||||||
from src.models.user import UserModel
|
from src.models.user import UserModel
|
||||||
from src.schemas.search import SearchResponse, SearchRequest
|
from src.schemas.search import SearchResponse, SearchRequest
|
||||||
from src.utils.logging import log_request
|
from src.utils.logging import log_request
|
||||||
from src.utils.authorization import (
|
|
||||||
create_auth_context,
|
|
||||||
log_authorization_context,
|
|
||||||
get_team_filter,
|
|
||||||
AuthorizationError
|
|
||||||
)
|
|
||||||
from src.api.v1.error_handlers import handle_service_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["Search"], prefix="/search")
|
router = APIRouter(tags=["Search"], prefix="/search")
|
||||||
|
|
||||||
|
# Initialize service
|
||||||
|
search_service = SearchService()
|
||||||
|
|
||||||
@router.get("", response_model=SearchResponse)
|
@router.get("", response_model=SearchResponse)
|
||||||
async def search_images(
|
async def search_images(
|
||||||
request: Request,
|
request: Request,
|
||||||
search_service: SearchServiceDep,
|
q: str = Query(..., description="Search query"),
|
||||||
q: str = Query(..., description="Search query for semantic image search"),
|
limit: int = Query(10, ge=1, le=50, description="Number of results to return"),
|
||||||
skip: int = Query(0, ge=0, description="Number of records to skip for pagination"),
|
similarity_threshold: float = Query(0.65, ge=0.0, le=1.0, description="Similarity threshold"),
|
||||||
limit: int = Query(10, ge=1, le=50, description="Number of results to return (1-50)"),
|
collection_id: Optional[str] = Query(None, description="Filter by collection ID"),
|
||||||
similarity_threshold: float = Query(0.65, ge=0.0, le=1.0, description="Similarity threshold (0.0-1.0)"),
|
|
||||||
collection_id: Optional[str] = Query(None, description="Filter results by collection ID"),
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search for images using semantic similarity
|
Search for images using semantic similarity
|
||||||
|
|
||||||
Performs a semantic search across images using AI-powered similarity matching.
|
|
||||||
Regular users can only search within their team's images, while admin users
|
|
||||||
can search across all teams.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
q: The search query text to find similar images
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of results to return (1-50, default: 10)
|
|
||||||
similarity_threshold: Minimum similarity score (0.0-1.0, default: 0.65)
|
|
||||||
collection_id: Optional filter to search within a specific collection
|
|
||||||
current_user: The authenticated user performing the search
|
|
||||||
search_service: Injected search service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SearchResponse: List of matching images with similarity scores
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid search parameters or query format
|
|
||||||
500: Search service errors
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{
|
||||||
resource_type="image",
|
"path": request.url.path,
|
||||||
action="search",
|
"method": request.method,
|
||||||
query=q,
|
"query": q,
|
||||||
skip=skip,
|
"limit": limit,
|
||||||
limit=limit,
|
"similarity_threshold": similarity_threshold
|
||||||
similarity_threshold=similarity_threshold,
|
},
|
||||||
collection_id=collection_id,
|
user_id=str(current_user.id),
|
||||||
team_filter=get_team_filter(current_user),
|
team_id=str(current_user.team_id)
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await search_service.search_images(
|
response = await search_service.search_images(
|
||||||
@ -77,47 +48,33 @@ async def search_images(
|
|||||||
similarity_threshold=similarity_threshold,
|
similarity_threshold=similarity_threshold,
|
||||||
collection_id=collection_id
|
collection_id=collection_id
|
||||||
)
|
)
|
||||||
logger.info(f"Search completed: '{q}' returned {len(response.results)} results for user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "image search")
|
logger.error(f"Unexpected error in search: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.post("", response_model=SearchResponse)
|
@router.post("", response_model=SearchResponse)
|
||||||
async def search_images_advanced(
|
async def search_images_advanced(
|
||||||
search_request: SearchRequest,
|
search_request: SearchRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
search_service: SearchServiceDep,
|
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Advanced search for images with extended options
|
Advanced search for images with more options
|
||||||
|
|
||||||
Provides advanced search capabilities with more filtering and configuration
|
|
||||||
options than the basic search endpoint. Supports complex queries and
|
|
||||||
multiple search parameters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
search_request: Advanced search request with detailed parameters
|
|
||||||
current_user: The authenticated user performing the search
|
|
||||||
search_service: Injected search service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SearchResponse: List of matching images with similarity scores and metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid search request or validation errors
|
|
||||||
500: Search service errors
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=current_user,
|
{
|
||||||
resource_type="image",
|
"path": request.url.path,
|
||||||
action="advanced_search",
|
"method": request.method,
|
||||||
search_request=search_request.dict(),
|
"search_request": search_request.dict()
|
||||||
team_filter=get_team_filter(current_user),
|
},
|
||||||
path=request.url.path,
|
user_id=str(current_user.id),
|
||||||
method=request.method
|
team_id=str(current_user.team_id)
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await search_service.search_images_advanced(
|
response = await search_service.search_images_advanced(
|
||||||
@ -125,7 +82,11 @@ async def search_images_advanced(
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
logger.info(f"Advanced search completed: '{search_request.query}' returned {len(response.results)} results for user {current_user.id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "advanced image search")
|
logger.error(f"Unexpected error in advanced search: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|||||||
@ -1,207 +1,116 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
|
||||||
from src.dependencies import TeamServiceDep
|
from src.services.team_service import TeamService
|
||||||
from src.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamListResponse
|
from src.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamListResponse
|
||||||
from src.utils.logging import log_request
|
from src.utils.logging import log_request
|
||||||
from src.utils.authorization import (
|
|
||||||
create_auth_context,
|
|
||||||
log_authorization_context,
|
|
||||||
AuthorizationError
|
|
||||||
)
|
|
||||||
from src.api.v1.error_handlers import handle_service_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["Teams"], prefix="/teams")
|
router = APIRouter(tags=["Teams"], prefix="/teams")
|
||||||
|
|
||||||
@router.post("", response_model=TeamResponse, status_code=status.HTTP_201_CREATED)
|
# Initialize service
|
||||||
async def create_team(
|
team_service = TeamService()
|
||||||
team_data: TeamCreate,
|
|
||||||
request: Request,
|
@router.post("", response_model=TeamResponse, status_code=201)
|
||||||
team_service: TeamServiceDep
|
async def create_team(team_data: TeamCreate, request: Request):
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Create a new team
|
Create a new team
|
||||||
|
|
||||||
Creates a new team with the provided information. Teams are used to
|
This endpoint no longer requires authentication
|
||||||
organize users and control access to resources.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
team_data: Team creation data including name and description
|
|
||||||
team_service: Injected team service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TeamResponse: The created team information
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for team creation
|
{"path": request.url.path, "method": request.method, "team_data": team_data.dict()}
|
||||||
resource_type="team",
|
|
||||||
action="create",
|
|
||||||
team_data=team_data.dict(),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await team_service.create_team(team_data)
|
response = await team_service.create_team(team_data)
|
||||||
logger.info(f"Created new team: {team_data.name}")
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "team creation")
|
logger.error(f"Unexpected error creating team: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("", response_model=TeamListResponse)
|
@router.get("", response_model=TeamListResponse)
|
||||||
async def list_teams(
|
async def list_teams(request: Request):
|
||||||
request: Request,
|
|
||||||
team_service: TeamServiceDep,
|
|
||||||
skip: int = Query(0, ge=0, description="Number of records to skip for pagination"),
|
|
||||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return (1-100)")
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
List all teams
|
List all teams
|
||||||
|
|
||||||
Retrieves a paginated list of all teams in the system with their
|
This endpoint no longer requires authentication
|
||||||
basic information and member counts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return, 1-100 (default: 50)
|
|
||||||
team_service: Injected team service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TeamListResponse: Paginated list of teams with total count
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid pagination parameters
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for listing teams
|
{"path": request.url.path, "method": request.method}
|
||||||
resource_type="team",
|
|
||||||
action="list",
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await team_service.list_teams(skip, limit)
|
response = await team_service.list_teams()
|
||||||
logger.info(f"Listed {len(response.teams)} teams (total: {response.total})")
|
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "team listing")
|
logger.error(f"Unexpected error listing teams: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("/{team_id}", response_model=TeamResponse)
|
@router.get("/{team_id}", response_model=TeamResponse)
|
||||||
async def get_team(
|
async def get_team(team_id: str, request: Request):
|
||||||
team_id: str,
|
|
||||||
request: Request,
|
|
||||||
team_service: TeamServiceDep
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get a team by ID
|
Get a team by ID
|
||||||
|
|
||||||
Retrieves detailed information for a specific team including
|
This endpoint no longer requires authentication
|
||||||
member count and team settings.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
team_id: The team ID to retrieve
|
|
||||||
team_service: Injected team service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TeamResponse: Complete team information
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for getting team info
|
{"path": request.url.path, "method": request.method, "team_id": team_id}
|
||||||
resource_type="team",
|
|
||||||
action="get",
|
|
||||||
team_id=team_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await team_service.get_team(team_id)
|
response = await team_service.get_team(team_id)
|
||||||
logger.info(f"Retrieved team {team_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "team retrieval")
|
logger.error(f"Unexpected error getting team: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.put("/{team_id}", response_model=TeamResponse)
|
@router.put("/{team_id}", response_model=TeamResponse)
|
||||||
async def update_team(
|
async def update_team(team_id: str, team_data: TeamUpdate, request: Request):
|
||||||
team_id: str,
|
|
||||||
team_data: TeamUpdate,
|
|
||||||
request: Request,
|
|
||||||
team_service: TeamServiceDep
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Update a team
|
Update a team
|
||||||
|
|
||||||
Updates the specified team's information. Only the provided fields
|
This endpoint no longer requires authentication
|
||||||
will be updated, others remain unchanged.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
team_id: The team ID to update
|
|
||||||
team_data: The team update data
|
|
||||||
team_service: Injected team service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TeamResponse: Updated team information
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for team updates
|
{"path": request.url.path, "method": request.method, "team_id": team_id, "team_data": team_data.dict()}
|
||||||
resource_type="team",
|
|
||||||
action="update",
|
|
||||||
team_id=team_id,
|
|
||||||
team_data=team_data.dict(),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await team_service.update_team(team_id, team_data)
|
response = await team_service.update_team(team_id, team_data)
|
||||||
logger.info(f"Updated team {team_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "team update")
|
logger.error(f"Unexpected error updating team: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{team_id}", status_code=204)
|
||||||
async def delete_team(
|
async def delete_team(team_id: str, request: Request):
|
||||||
team_id: str,
|
|
||||||
request: Request,
|
|
||||||
team_service: TeamServiceDep
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Delete a team
|
Delete a team
|
||||||
|
|
||||||
Permanently removes a team from the system. This action cannot be undone.
|
This endpoint no longer requires authentication
|
||||||
All users associated with this team should be reassigned before deletion.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
team_id: The team ID to delete
|
|
||||||
team_service: Injected team service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None (204 No Content)
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for team deletion
|
{"path": request.url.path, "method": request.method, "team_id": team_id}
|
||||||
resource_type="team",
|
|
||||||
action="delete",
|
|
||||||
team_id=team_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await team_service.delete_team(team_id)
|
await team_service.delete_team(team_id)
|
||||||
logger.info(f"Deleted team {team_id}")
|
|
||||||
return None
|
return None
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "team deletion")
|
logger.error(f"Unexpected error deleting team: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
@ -1,314 +1,181 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
from src.dependencies import UserServiceDep
|
from src.services.user_service import UserService
|
||||||
from src.schemas.user import UserResponse, UserListResponse, UserCreate, UserUpdate
|
from src.schemas.user import UserResponse, UserListResponse, UserCreate, UserUpdate
|
||||||
from src.utils.logging import log_request
|
from src.utils.logging import log_request
|
||||||
from src.utils.authorization import (
|
|
||||||
create_auth_context,
|
|
||||||
log_authorization_context,
|
|
||||||
get_team_filter,
|
|
||||||
AuthorizationError
|
|
||||||
)
|
|
||||||
from src.api.v1.error_handlers import handle_service_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["Users"], prefix="/users")
|
router = APIRouter(tags=["Users"], prefix="/users")
|
||||||
|
|
||||||
|
# Initialize service
|
||||||
|
user_service = UserService()
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def read_users_me(
|
async def read_users_me(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_service: UserServiceDep,
|
user_id: str # Now requires user_id as a query parameter
|
||||||
user_id: str = Query(..., description="User ID to retrieve information for")
|
|
||||||
):
|
):
|
||||||
"""
|
"""Get user information by user ID"""
|
||||||
Get user information by user ID
|
log_request(
|
||||||
|
{"path": request.url.path, "method": request.method, "user_id": user_id}
|
||||||
Retrieves detailed information for a specific user. This endpoint
|
|
||||||
requires the user_id as a query parameter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user ID to retrieve information for
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserResponse: Complete user information including profile data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid user ID format
|
|
||||||
404: User not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
|
||||||
auth_context = create_auth_context(
|
|
||||||
user=None, # No authentication required for this endpoint
|
|
||||||
resource_type="user",
|
|
||||||
action="get_by_id",
|
|
||||||
user_id=user_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await user_service.get_user_by_id(user_id)
|
response = await user_service.get_user_by_id(user_id)
|
||||||
logger.info(f"Retrieved user information for user {user_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user retrieval by ID")
|
logger.error(f"Unexpected error getting user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.put("/me", response_model=UserResponse)
|
@router.put("/me", response_model=UserResponse)
|
||||||
async def update_current_user(
|
async def update_current_user(
|
||||||
user_data: UserUpdate,
|
user_data: UserUpdate,
|
||||||
request: Request,
|
request: Request,
|
||||||
user_service: UserServiceDep,
|
user_id: str # Now requires user_id as a query parameter
|
||||||
user_id: str = Query(..., description="User ID to update")
|
|
||||||
):
|
):
|
||||||
"""
|
"""Update user information by user ID"""
|
||||||
Update user information by user ID
|
log_request(
|
||||||
|
{"path": request.url.path, "method": request.method, "user_data": user_data.dict(), "user_id": user_id}
|
||||||
Updates the specified user's profile information. Only provided fields
|
|
||||||
will be updated, others will remain unchanged.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_data: The user update data containing fields to modify
|
|
||||||
user_id: The user ID to update
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserResponse: Updated user information
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid user ID format or validation errors
|
|
||||||
404: User not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
|
||||||
auth_context = create_auth_context(
|
|
||||||
user=None, # No authentication required for this endpoint
|
|
||||||
resource_type="user",
|
|
||||||
action="update_by_id",
|
|
||||||
user_id=user_id,
|
|
||||||
user_data=user_data.dict(),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await user_service.update_user_by_id(user_id, user_data)
|
response = await user_service.update_user_by_id(user_id, user_data)
|
||||||
logger.info(f"Updated user information for user {user_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user update by ID")
|
logger.error(f"Unexpected error updating user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=UserResponse, status_code=201)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
request: Request,
|
request: Request
|
||||||
user_service: UserServiceDep
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a new user
|
Create a new user
|
||||||
|
|
||||||
Creates a new user account with the provided information. The user
|
This endpoint no longer requires authentication
|
||||||
will be associated with the specified team.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_data: User creation data including name, email, and team assignment
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserResponse: The created user information
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid input data or user already exists
|
|
||||||
404: Referenced team not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for user creation
|
{"path": request.url.path, "method": request.method, "user_data": user_data.dict()}
|
||||||
resource_type="user",
|
|
||||||
action="create",
|
|
||||||
user_data=user_data.dict(),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await user_service.create_user(user_data)
|
response = await user_service.create_user(user_data)
|
||||||
logger.info(f"Created new user with email {user_data.email}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user creation")
|
logger.error(f"Unexpected error creating user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("", response_model=UserListResponse)
|
@router.get("", response_model=UserListResponse)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_service: UserServiceDep,
|
team_id: Optional[str] = None
|
||||||
skip: int = Query(0, ge=0, description="Number of records to skip for pagination"),
|
|
||||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return (1-100)"),
|
|
||||||
team_id: Optional[str] = Query(None, description="Filter users by team ID")
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List users with optional team filtering
|
List users
|
||||||
|
|
||||||
Retrieves a paginated list of all users in the system. Can be filtered by team
|
This endpoint no longer requires authentication
|
||||||
to show only users belonging to a specific team.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return, 1-100 (default: 50)
|
|
||||||
team_id: Optional team ID to filter users by
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserListResponse: Paginated list of users with total count
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid pagination parameters or team ID format
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for listing users
|
{"path": request.url.path, "method": request.method, "team_id": team_id}
|
||||||
resource_type="user",
|
|
||||||
action="list",
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
team_id=team_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await user_service.list_users(skip, limit, team_id)
|
response = await user_service.list_users(team_id)
|
||||||
logger.info(f"Listed {len(response.users)} users (total: {response.total})" + (f" for team {team_id}" if team_id else ""))
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user listing")
|
logger.error(f"Unexpected error listing users: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponse)
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
request: Request,
|
request: Request
|
||||||
user_service: UserServiceDep
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get user by ID
|
Get user by ID
|
||||||
|
|
||||||
Retrieves detailed information for a specific user by their ID.
|
This endpoint no longer requires authentication
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user ID to retrieve
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserResponse: Complete user information
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid user ID format
|
|
||||||
404: User not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for getting user info
|
{"path": request.url.path, "method": request.method, "user_id": user_id}
|
||||||
resource_type="user",
|
|
||||||
action="get",
|
|
||||||
user_id=user_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await user_service.get_user(user_id)
|
response = await user_service.get_user(user_id)
|
||||||
logger.info(f"Retrieved user {user_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user retrieval")
|
logger.error(f"Unexpected error getting user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.put("/{user_id}", response_model=UserResponse)
|
@router.put("/{user_id}", response_model=UserResponse)
|
||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
user_data: UserUpdate,
|
user_data: UserUpdate,
|
||||||
request: Request,
|
request: Request
|
||||||
user_service: UserServiceDep
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update user by ID
|
Update user by ID
|
||||||
|
|
||||||
Updates a specific user's information. Only the provided fields
|
This endpoint no longer requires authentication
|
||||||
will be updated, others remain unchanged.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user ID to update
|
|
||||||
user_data: The user update data
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserResponse: Updated user information
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid user ID format or validation errors
|
|
||||||
404: User not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for user updates
|
{"path": request.url.path, "method": request.method, "user_id": user_id, "user_data": user_data.dict()}
|
||||||
resource_type="user",
|
|
||||||
action="update",
|
|
||||||
user_id=user_id,
|
|
||||||
user_data=user_data.dict(),
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await user_service.update_user(user_id, user_data)
|
response = await user_service.update_user(user_id, user_data)
|
||||||
logger.info(f"Updated user {user_id}")
|
|
||||||
return response
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user update")
|
logger.error(f"Unexpected error updating user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{user_id}", status_code=204)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
request: Request,
|
request: Request
|
||||||
user_service: UserServiceDep
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete user by ID
|
Delete user by ID
|
||||||
|
|
||||||
Permanently removes a user from the system. This action cannot be undone.
|
This endpoint no longer requires authentication
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user ID to delete
|
|
||||||
user_service: Injected user service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None (204 No Content)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
400: Invalid user ID format
|
|
||||||
404: User not found
|
|
||||||
500: Internal server error
|
|
||||||
"""
|
"""
|
||||||
auth_context = create_auth_context(
|
log_request(
|
||||||
user=None, # No authentication required for user deletion
|
{"path": request.url.path, "method": request.method, "user_id": user_id}
|
||||||
resource_type="user",
|
|
||||||
action="delete",
|
|
||||||
user_id=user_id,
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method
|
|
||||||
)
|
)
|
||||||
log_authorization_context(auth_context, success=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await user_service.delete_user(user_id)
|
await user_service.delete_user(user_id)
|
||||||
logger.info(f"Deleted user {user_id}")
|
|
||||||
return None
|
return None
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise handle_service_error(e, "user deletion")
|
logger.error(f"Unexpected error deleting user: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
@ -5,7 +5,7 @@ from pydantic import AnyHttpUrl, field_validator
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# Project settings
|
# Project settings
|
||||||
PROJECT_NAME: str = "CONTOSO - Secure Image Management API"
|
PROJECT_NAME: str = "SEREACT - Secure Image Management API"
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
@ -66,7 +66,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Firestore settings
|
# Firestore settings
|
||||||
FIRESTORE_PROJECT_ID: str = os.getenv("FIRESTORE_PROJECT_ID", "")
|
FIRESTORE_PROJECT_ID: str = os.getenv("FIRESTORE_PROJECT_ID", "")
|
||||||
FIRESTORE_DATABASE_NAME: str = os.getenv("FIRESTORE_DATABASE_NAME", "contoso-db")
|
FIRESTORE_DATABASE_NAME: str = os.getenv("FIRESTORE_DATABASE_NAME", "sereact-db")
|
||||||
FIRESTORE_CREDENTIALS_FILE: str = os.getenv("FIRESTORE_CREDENTIALS_FILE", "firestore-credentials.json")
|
FIRESTORE_CREDENTIALS_FILE: str = os.getenv("FIRESTORE_CREDENTIALS_FILE", "firestore-credentials.json")
|
||||||
|
|
||||||
# Google Cloud Storage settings
|
# Google Cloud Storage settings
|
||||||
|
|||||||
@ -168,14 +168,12 @@ class FirestoreProvider:
|
|||||||
logger.error(f"Error getting document from {collection_name}: {e}")
|
logger.error(f"Error getting document from {collection_name}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def list_documents(self, collection_name: str, skip: int = 0, limit: int = None) -> List[Dict[str, Any]]:
|
async def list_documents(self, collection_name: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List documents in a collection with optional pagination
|
List all documents in a collection
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
collection_name: Collection name
|
collection_name: Collection name
|
||||||
skip: Number of documents to skip (default: 0)
|
|
||||||
limit: Maximum number of documents to return (default: None for all)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of documents
|
List of documents
|
||||||
@ -189,15 +187,8 @@ class FirestoreProvider:
|
|||||||
# Debug log to understand the client state
|
# Debug log to understand the client state
|
||||||
logger.debug(f"Firestore client: {self.client}, Collection ref: {collection_ref}")
|
logger.debug(f"Firestore client: {self.client}, Collection ref: {collection_ref}")
|
||||||
|
|
||||||
# Build query with pagination
|
|
||||||
query = collection_ref
|
|
||||||
if skip > 0:
|
|
||||||
query = query.offset(skip)
|
|
||||||
if limit is not None:
|
|
||||||
query = query.limit(limit)
|
|
||||||
|
|
||||||
# Properly get the stream of documents
|
# Properly get the stream of documents
|
||||||
docs = query.stream()
|
docs = collection_ref.stream()
|
||||||
results = []
|
results = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
@ -207,13 +198,7 @@ class FirestoreProvider:
|
|||||||
except Exception as stream_error:
|
except Exception as stream_error:
|
||||||
logger.error(f"Error streaming documents: {stream_error}")
|
logger.error(f"Error streaming documents: {stream_error}")
|
||||||
# Fallback method - try listing documents differently
|
# Fallback method - try listing documents differently
|
||||||
query = collection_ref
|
docs = list(collection_ref.get())
|
||||||
if skip > 0:
|
|
||||||
query = query.offset(skip)
|
|
||||||
if limit is not None:
|
|
||||||
query = query.limit(limit)
|
|
||||||
|
|
||||||
docs = list(query.get())
|
|
||||||
results = []
|
results = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
@ -225,37 +210,6 @@ class FirestoreProvider:
|
|||||||
# Return empty list instead of raising to avoid API failures
|
# Return empty list instead of raising to avoid API failures
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def count_documents(self, collection_name: str) -> int:
|
|
||||||
"""
|
|
||||||
Count total number of documents in a collection
|
|
||||||
|
|
||||||
Args:
|
|
||||||
collection_name: Collection name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total number of documents
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
collection_ref = self.get_collection(collection_name)
|
|
||||||
|
|
||||||
# Use aggregation query to count documents efficiently
|
|
||||||
# Note: This requires Firestore to support count aggregation
|
|
||||||
try:
|
|
||||||
from google.cloud.firestore_v1.aggregation import AggregationQuery
|
|
||||||
query = collection_ref.select([]) # Select no fields for efficiency
|
|
||||||
aggregation_query = AggregationQuery(query)
|
|
||||||
result = aggregation_query.count().get()
|
|
||||||
return result[0].value
|
|
||||||
except (ImportError, AttributeError):
|
|
||||||
# Fallback: count by getting all documents (less efficient)
|
|
||||||
logger.warning(f"Using fallback count method for {collection_name}")
|
|
||||||
docs = list(collection_ref.stream())
|
|
||||||
return len(docs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error counting documents in {collection_name}: {e}")
|
|
||||||
# Return 0 instead of raising to avoid API failures
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def update_document(self, collection_name: str, doc_id: str, data: Dict[str, Any]) -> bool:
|
async def update_document(self, collection_name: str, doc_id: str, data: Dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
Update a document
|
Update a document
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from src.db.repositories.firestore_repository import FirestoreRepository
|
from src.db.repositories.firestore_repository import FirestoreRepository
|
||||||
from src.models.api_key import ApiKeyModel
|
from src.models.api_key import ApiKeyModel
|
||||||
@ -13,7 +12,7 @@ class FirestoreApiKeyRepository(FirestoreRepository[ApiKeyModel]):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("api_keys", ApiKeyModel)
|
super().__init__("api_keys", ApiKeyModel)
|
||||||
|
|
||||||
async def get_by_key_hash(self, key_hash: str) -> Optional[ApiKeyModel]:
|
async def get_by_key_hash(self, key_hash: str) -> ApiKeyModel:
|
||||||
"""
|
"""
|
||||||
Get API key by hash
|
Get API key by hash
|
||||||
|
|
||||||
@ -35,7 +34,7 @@ class FirestoreApiKeyRepository(FirestoreRepository[ApiKeyModel]):
|
|||||||
logger.error(f"Error getting API key by hash: {e}")
|
logger.error(f"Error getting API key by hash: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_by_user_id(self, user_id: str) -> List[ApiKeyModel]:
|
async def get_by_user_id(self, user_id: str) -> list[ApiKeyModel]:
|
||||||
"""
|
"""
|
||||||
Get API keys by user ID
|
Get API keys by user ID
|
||||||
|
|
||||||
@ -54,53 +53,17 @@ class FirestoreApiKeyRepository(FirestoreRepository[ApiKeyModel]):
|
|||||||
logger.error(f"Error getting API keys by user ID: {e}")
|
logger.error(f"Error getting API keys by user ID: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_by_user(self, user_id: ObjectId, skip: int = 0, limit: int = None) -> List[ApiKeyModel]:
|
async def get_by_user(self, user_id: ObjectId) -> list[ApiKeyModel]:
|
||||||
"""
|
"""
|
||||||
Get API keys by user with pagination
|
Get API keys by user (alias for get_by_user_id with ObjectId)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User ID as ObjectId
|
user_id: User ID as ObjectId
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return (default: None for all)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of API keys
|
List of API keys
|
||||||
"""
|
"""
|
||||||
try:
|
return await self.get_by_user_id(str(user_id))
|
||||||
# For now, we'll get all API keys and filter in memory
|
|
||||||
# In a production system, this should use Firestore queries for efficiency
|
|
||||||
api_keys = await self.get_all()
|
|
||||||
filtered_keys = [api_key for api_key in api_keys if api_key.user_id == user_id]
|
|
||||||
|
|
||||||
# Apply pagination
|
|
||||||
if skip > 0:
|
|
||||||
filtered_keys = filtered_keys[skip:]
|
|
||||||
if limit is not None:
|
|
||||||
filtered_keys = filtered_keys[:limit]
|
|
||||||
|
|
||||||
return filtered_keys
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting API keys by user with pagination: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def count_by_user(self, user_id: ObjectId) -> int:
|
|
||||||
"""
|
|
||||||
Count API keys by user ID
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID as ObjectId
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of API keys for the user
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# For now, we'll get all API keys and filter in memory
|
|
||||||
# In a production system, this should use Firestore count queries
|
|
||||||
api_keys = await self.get_all()
|
|
||||||
return len([api_key for api_key in api_keys if api_key.user_id == user_id])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error counting API keys by user: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def update_last_used(self, api_key_id: ObjectId) -> bool:
|
async def update_last_used(self, api_key_id: ObjectId) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -60,19 +60,15 @@ class FirestoreRepository(Generic[T]):
|
|||||||
logger.error(f"Error getting {self.collection_name} document by ID: {e}")
|
logger.error(f"Error getting {self.collection_name} document by ID: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_all(self, skip: int = 0, limit: int = None) -> List[T]:
|
async def get_all(self) -> List[T]:
|
||||||
"""
|
"""
|
||||||
Get all documents from the collection with optional pagination
|
Get all documents from the collection
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of documents to skip (default: 0)
|
|
||||||
limit: Maximum number of documents to return (default: None for all)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of model instances
|
List of model instances
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
docs = await self.provider.list_documents(self.collection_name, skip=skip, limit=limit)
|
docs = await self.provider.list_documents(self.collection_name)
|
||||||
|
|
||||||
# Transform data to handle legacy format issues
|
# Transform data to handle legacy format issues
|
||||||
transformed_docs = []
|
transformed_docs = []
|
||||||
@ -164,16 +160,3 @@ class FirestoreRepository(Generic[T]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting {self.collection_name} document: {e}")
|
logger.error(f"Error deleting {self.collection_name} document: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def count(self) -> int:
|
|
||||||
"""
|
|
||||||
Get total count of documents in the collection
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total number of documents
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await self.provider.count_documents(self.collection_name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error counting {self.collection_name} documents: {e}")
|
|
||||||
raise
|
|
||||||
@ -24,18 +24,14 @@ class FirestoreTeamRepository(FirestoreRepository[TeamModel]):
|
|||||||
"""
|
"""
|
||||||
return await super().get_by_id(team_id)
|
return await super().get_by_id(team_id)
|
||||||
|
|
||||||
async def get_all(self, skip: int = 0, limit: int = None) -> List[TeamModel]:
|
async def get_all(self) -> List[TeamModel]:
|
||||||
"""
|
"""
|
||||||
Get all teams with pagination
|
Get all teams
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return (default: None for all)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of teams
|
List of teams
|
||||||
"""
|
"""
|
||||||
return await super().get_all(skip=skip, limit=limit)
|
return await super().get_all()
|
||||||
|
|
||||||
async def update(self, team_id: str, team_data: dict) -> Optional[TeamModel]:
|
async def update(self, team_id: str, team_data: dict) -> Optional[TeamModel]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
|
||||||
from bson import ObjectId
|
|
||||||
from src.db.repositories.firestore_repository import FirestoreRepository
|
from src.db.repositories.firestore_repository import FirestoreRepository
|
||||||
from src.models.user import UserModel
|
from src.models.user import UserModel
|
||||||
|
|
||||||
@ -12,7 +10,7 @@ class FirestoreUserRepository(FirestoreRepository[UserModel]):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("users", UserModel)
|
super().__init__("users", UserModel)
|
||||||
|
|
||||||
async def get_by_email(self, email: str) -> Optional[UserModel]:
|
async def get_by_email(self, email: str) -> UserModel:
|
||||||
"""
|
"""
|
||||||
Get user by email
|
Get user by email
|
||||||
|
|
||||||
@ -34,7 +32,7 @@ class FirestoreUserRepository(FirestoreRepository[UserModel]):
|
|||||||
logger.error(f"Error getting user by email: {e}")
|
logger.error(f"Error getting user by email: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_by_team_id(self, team_id: str) -> List[UserModel]:
|
async def get_by_team_id(self, team_id: str) -> list[UserModel]:
|
||||||
"""
|
"""
|
||||||
Get users by team ID
|
Get users by team ID
|
||||||
|
|
||||||
@ -53,53 +51,5 @@ class FirestoreUserRepository(FirestoreRepository[UserModel]):
|
|||||||
logger.error(f"Error getting users by team ID: {e}")
|
logger.error(f"Error getting users by team ID: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_by_team(self, team_id: ObjectId, skip: int = 0, limit: int = None) -> List[UserModel]:
|
|
||||||
"""
|
|
||||||
Get users by team ID with pagination
|
|
||||||
|
|
||||||
Args:
|
|
||||||
team_id: Team ID as ObjectId
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return (default: None for all)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of users
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# For now, we'll get all users and filter in memory
|
|
||||||
# In a production system, this should use Firestore queries for efficiency
|
|
||||||
users = await self.get_all()
|
|
||||||
filtered_users = [user for user in users if user.team_id == team_id]
|
|
||||||
|
|
||||||
# Apply pagination
|
|
||||||
if skip > 0:
|
|
||||||
filtered_users = filtered_users[skip:]
|
|
||||||
if limit is not None:
|
|
||||||
filtered_users = filtered_users[:limit]
|
|
||||||
|
|
||||||
return filtered_users
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting users by team with pagination: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def count_by_team(self, team_id: ObjectId) -> int:
|
|
||||||
"""
|
|
||||||
Count users by team ID
|
|
||||||
|
|
||||||
Args:
|
|
||||||
team_id: Team ID as ObjectId
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of users in the team
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# For now, we'll get all users and filter in memory
|
|
||||||
# In a production system, this should use Firestore count queries
|
|
||||||
users = await self.get_all()
|
|
||||||
return len([user for user in users if user.team_id == team_id])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error counting users by team: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Create a singleton repository
|
# Create a singleton repository
|
||||||
firestore_user_repository = FirestoreUserRepository()
|
firestore_user_repository = FirestoreUserRepository()
|
||||||
@ -1,89 +0,0 @@
|
|||||||
"""
|
|
||||||
Dependency injection module for services.
|
|
||||||
|
|
||||||
This module provides dependency injection for all services used across the API.
|
|
||||||
It follows the dependency injection pattern to improve testability and maintainability.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
from src.services.auth_service import AuthService
|
|
||||||
from src.services.image_service import ImageService
|
|
||||||
from src.services.search_service import SearchService
|
|
||||||
from src.services.team_service import TeamService
|
|
||||||
from src.services.user_service import UserService
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_auth_service() -> AuthService:
|
|
||||||
"""
|
|
||||||
Get AuthService instance.
|
|
||||||
|
|
||||||
Uses LRU cache to ensure singleton behavior for the service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AuthService: The authentication service instance
|
|
||||||
"""
|
|
||||||
return AuthService()
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_image_service() -> ImageService:
|
|
||||||
"""
|
|
||||||
Get ImageService instance.
|
|
||||||
|
|
||||||
Uses LRU cache to ensure singleton behavior for the service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ImageService: The image service instance
|
|
||||||
"""
|
|
||||||
return ImageService()
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_search_service() -> SearchService:
|
|
||||||
"""
|
|
||||||
Get SearchService instance.
|
|
||||||
|
|
||||||
Uses LRU cache to ensure singleton behavior for the service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SearchService: The search service instance
|
|
||||||
"""
|
|
||||||
return SearchService()
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_team_service() -> TeamService:
|
|
||||||
"""
|
|
||||||
Get TeamService instance.
|
|
||||||
|
|
||||||
Uses LRU cache to ensure singleton behavior for the service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TeamService: The team service instance
|
|
||||||
"""
|
|
||||||
return TeamService()
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_user_service() -> UserService:
|
|
||||||
"""
|
|
||||||
Get UserService instance.
|
|
||||||
|
|
||||||
Uses LRU cache to ensure singleton behavior for the service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
UserService: The user service instance
|
|
||||||
"""
|
|
||||||
return UserService()
|
|
||||||
|
|
||||||
|
|
||||||
# Type aliases for dependency injection
|
|
||||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
|
||||||
ImageServiceDep = Annotated[ImageService, Depends(get_image_service)]
|
|
||||||
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
|
||||||
TeamServiceDep = Annotated[TeamService, Depends(get_team_service)]
|
|
||||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
|
||||||
@ -1,19 +1,16 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
|
from datetime import datetime
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
|
||||||
from src.models.api_key import ApiKeyModel
|
|
||||||
from src.models.user import UserModel
|
|
||||||
from src.schemas.api_key import ApiKeyCreate, ApiKeyResponse, ApiKeyWithValueResponse, ApiKeyListResponse
|
|
||||||
from src.db.repositories.api_key_repository import api_key_repository
|
from src.db.repositories.api_key_repository import api_key_repository
|
||||||
from src.db.repositories.user_repository import user_repository
|
from src.db.repositories.user_repository import user_repository
|
||||||
from src.db.repositories.team_repository import team_repository
|
from src.db.repositories.team_repository import team_repository
|
||||||
from src.auth.security import generate_api_key, calculate_expiry_date
|
from src.schemas.api_key import ApiKeyCreate, ApiKeyResponse, ApiKeyWithValueResponse, ApiKeyListResponse
|
||||||
from src.utils.authorization import (
|
from src.auth.security import generate_api_key, verify_api_key, calculate_expiry_date, is_expired, hash_api_key
|
||||||
require_admin,
|
from src.models.api_key import ApiKeyModel
|
||||||
require_resource_owner_or_admin,
|
from src.models.team import TeamModel
|
||||||
AuthorizationError
|
from src.models.user import UserModel
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -31,28 +28,29 @@ class AuthService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The user ID to create the key for
|
user_id: The user ID to create the key for
|
||||||
team_id: The team ID to associate the key with
|
team_id: The team ID the user belongs to
|
||||||
key_data: The API key creation data
|
key_data: The API key creation data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ApiKeyWithValueResponse: The created API key with the raw key value
|
ApiKeyWithValueResponse: The created API key with the raw key value
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If user_id or team_id is invalid
|
ValueError: If user_id or team_id are invalid
|
||||||
RuntimeError: If user or team not found, or user doesn't belong to team
|
RuntimeError: If user or team not found, or user doesn't belong to team
|
||||||
"""
|
"""
|
||||||
|
# Validate user_id and team_id
|
||||||
try:
|
try:
|
||||||
target_user_id = ObjectId(user_id)
|
target_user_id = ObjectId(user_id)
|
||||||
target_team_id = ObjectId(team_id)
|
target_team_id = ObjectId(team_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError("Invalid user ID or team ID")
|
raise ValueError("Invalid user ID or team ID")
|
||||||
|
|
||||||
# Get the target user
|
# Verify user exists
|
||||||
target_user = await user_repository.get_by_id(target_user_id)
|
target_user = await user_repository.get_by_id(target_user_id)
|
||||||
if not target_user:
|
if not target_user:
|
||||||
raise RuntimeError("User not found")
|
raise RuntimeError("User not found")
|
||||||
|
|
||||||
# Check if team exists
|
# Verify team exists
|
||||||
team = await team_repository.get_by_id(target_team_id)
|
team = await team_repository.get_by_id(target_team_id)
|
||||||
if not team:
|
if not team:
|
||||||
raise RuntimeError("Team not found")
|
raise RuntimeError("Team not found")
|
||||||
@ -117,12 +115,13 @@ class AuthService:
|
|||||||
ApiKeyWithValueResponse: The created API key with the raw key value
|
ApiKeyWithValueResponse: The created API key with the raw key value
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
AuthorizationError: If the admin user doesn't have admin privileges
|
PermissionError: If the admin user doesn't have admin privileges
|
||||||
ValueError: If target_user_id is invalid
|
ValueError: If target_user_id is invalid
|
||||||
RuntimeError: If target user or team not found
|
RuntimeError: If target user or team not found
|
||||||
"""
|
"""
|
||||||
# Centralized admin authorization check
|
# Check if current user is admin
|
||||||
require_admin(admin_user, "create API keys for other users")
|
if not admin_user.is_admin:
|
||||||
|
raise PermissionError("Admin access required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_user_obj_id = ObjectId(target_user_id)
|
target_user_obj_id = ObjectId(target_user_id)
|
||||||
@ -170,23 +169,18 @@ class AuthService:
|
|||||||
is_active=created_key.is_active
|
is_active=created_key.is_active
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_user_api_keys(self, user: UserModel, skip: int = 0, limit: int = 50) -> ApiKeyListResponse:
|
async def list_user_api_keys(self, user: UserModel) -> ApiKeyListResponse:
|
||||||
"""
|
"""
|
||||||
List API keys for a specific user with pagination
|
List API keys for a specific user
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: The user to list API keys for
|
user: The user to list API keys for
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return (default: 50)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ApiKeyListResponse: Paginated list of API keys for the user
|
ApiKeyListResponse: List of API keys for the user
|
||||||
"""
|
"""
|
||||||
# Get API keys for user with pagination
|
# Get API keys for user
|
||||||
keys = await api_key_repository.get_by_user(user.id, skip=skip, limit=limit)
|
keys = await api_key_repository.get_by_user(user.id)
|
||||||
|
|
||||||
# Get total count for pagination
|
|
||||||
total_count = await api_key_repository.count_by_user(user.id)
|
|
||||||
|
|
||||||
# Convert to response models
|
# Convert to response models
|
||||||
response_keys = []
|
response_keys = []
|
||||||
@ -203,7 +197,7 @@ class AuthService:
|
|||||||
is_active=key.is_active
|
is_active=key.is_active
|
||||||
))
|
))
|
||||||
|
|
||||||
return ApiKeyListResponse(api_keys=response_keys, total=total_count)
|
return ApiKeyListResponse(api_keys=response_keys, total=len(response_keys))
|
||||||
|
|
||||||
async def revoke_api_key(self, key_id: str, user: UserModel) -> bool:
|
async def revoke_api_key(self, key_id: str, user: UserModel) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -219,7 +213,7 @@ class AuthService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If key_id is invalid
|
ValueError: If key_id is invalid
|
||||||
RuntimeError: If key not found
|
RuntimeError: If key not found
|
||||||
AuthorizationError: If user not authorized to revoke the key
|
PermissionError: If user not authorized to revoke the key
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
obj_id = ObjectId(key_id)
|
obj_id = ObjectId(key_id)
|
||||||
@ -231,8 +225,9 @@ class AuthService:
|
|||||||
if not key:
|
if not key:
|
||||||
raise RuntimeError("API key not found")
|
raise RuntimeError("API key not found")
|
||||||
|
|
||||||
# Centralized authorization check - user must own the key or be admin
|
# Check if user owns the key or is an admin
|
||||||
require_resource_owner_or_admin(user, str(key.user_id), "API key", "revoke")
|
if key.user_id != user.id and not user.is_admin:
|
||||||
|
raise PermissionError("Not authorized to revoke this API key")
|
||||||
|
|
||||||
# Deactivate the key
|
# Deactivate the key
|
||||||
result = await api_key_repository.deactivate(obj_id)
|
result = await api_key_repository.deactivate(obj_id)
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import UploadFile, Request
|
from fastapi import UploadFile, Request
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
import io
|
||||||
|
|
||||||
from src.models.image import ImageModel
|
|
||||||
from src.models.user import UserModel
|
|
||||||
from src.schemas.image import ImageResponse, ImageListResponse
|
|
||||||
from src.db.repositories.image_repository import image_repository
|
from src.db.repositories.image_repository import image_repository
|
||||||
from src.services.storage import StorageService
|
from src.services.storage import StorageService
|
||||||
|
from src.services.image_processor import ImageProcessor
|
||||||
from src.services.embedding_service import EmbeddingService
|
from src.services.embedding_service import EmbeddingService
|
||||||
from src.utils.authorization import require_team_access, get_team_filter, AuthorizationError
|
from src.services.pubsub_service import pubsub_service
|
||||||
|
from src.models.image import ImageModel
|
||||||
|
from src.models.user import UserModel
|
||||||
|
from src.schemas.image import ImageResponse, ImageListResponse, ImageCreate, ImageUpdate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -20,11 +20,13 @@ class ImageService:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.storage_service = StorageService()
|
self.storage_service = StorageService()
|
||||||
|
self.image_processor = ImageProcessor()
|
||||||
self.embedding_service = EmbeddingService()
|
self.embedding_service = EmbeddingService()
|
||||||
|
|
||||||
def _generate_api_download_url(self, request: Request, image_id: str) -> str:
|
def _generate_api_download_url(self, request: Request, image_id: str) -> str:
|
||||||
"""Generate API download URL for an image"""
|
"""Generate API download URL for an image"""
|
||||||
return f"{request.url.scheme}://{request.url.netloc}/api/v1/images/{image_id}/download"
|
base_url = str(request.base_url).rstrip('/')
|
||||||
|
return f"{base_url}/api/v1/images/{image_id}/download"
|
||||||
|
|
||||||
async def upload_image(
|
async def upload_image(
|
||||||
self,
|
self,
|
||||||
@ -35,7 +37,7 @@ class ImageService:
|
|||||||
collection_id: Optional[str] = None
|
collection_id: Optional[str] = None
|
||||||
) -> ImageResponse:
|
) -> ImageResponse:
|
||||||
"""
|
"""
|
||||||
Upload and process an image
|
Upload a new image
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: The uploaded file
|
file: The uploaded file
|
||||||
@ -45,72 +47,74 @@ class ImageService:
|
|||||||
collection_id: Optional collection ID to associate with the image
|
collection_id: Optional collection ID to associate with the image
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImageResponse: The uploaded image metadata
|
ImageResponse: The created image metadata
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If file is invalid
|
ValueError: If file validation fails
|
||||||
RuntimeError: If upload or processing fails
|
RuntimeError: If upload fails
|
||||||
"""
|
"""
|
||||||
# Validate file
|
# Validate file type
|
||||||
if not file.filename:
|
|
||||||
raise ValueError("No filename provided")
|
|
||||||
|
|
||||||
if not file.content_type or not file.content_type.startswith('image/'):
|
if not file.content_type or not file.content_type.startswith('image/'):
|
||||||
raise ValueError("File must be an image")
|
raise ValueError("File must be an image")
|
||||||
|
|
||||||
# Read file content
|
# Validate file size (10MB limit)
|
||||||
file_content = await file.read()
|
max_size = 10 * 1024 * 1024 # 10MB
|
||||||
if not file_content:
|
content = await file.read()
|
||||||
raise ValueError("Empty file")
|
if len(content) > max_size:
|
||||||
|
raise ValueError("File size exceeds 10MB limit")
|
||||||
|
|
||||||
# Generate storage path
|
# Reset file pointer
|
||||||
file_extension = os.path.splitext(file.filename)[1]
|
await file.seek(0)
|
||||||
storage_filename = f"{ObjectId()}{file_extension}"
|
|
||||||
storage_path = f"images/{user.team_id}/{storage_filename}"
|
|
||||||
|
|
||||||
# Store file
|
|
||||||
try:
|
|
||||||
self.storage_service.store_file(storage_path, file_content, file.content_type)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to store file: {e}")
|
|
||||||
raise RuntimeError("Failed to store image file")
|
|
||||||
|
|
||||||
# Create image record
|
|
||||||
image_data = {
|
|
||||||
"filename": storage_filename,
|
|
||||||
"original_filename": file.filename,
|
|
||||||
"file_size": len(file_content),
|
|
||||||
"content_type": file.content_type,
|
|
||||||
"storage_path": storage_path,
|
|
||||||
"team_id": user.team_id,
|
|
||||||
"uploader_id": user.id,
|
|
||||||
"upload_date": datetime.utcnow(),
|
|
||||||
"description": description,
|
|
||||||
"metadata": {},
|
|
||||||
"has_embedding": False,
|
|
||||||
"collection_id": ObjectId(collection_id) if collection_id else None
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create ImageModel instance first
|
# Upload to storage
|
||||||
image_model = ImageModel(**image_data)
|
storage_path, content_type, file_size, metadata = await self.storage_service.upload_file(
|
||||||
image = await image_repository.create(image_model)
|
file, str(user.team_id)
|
||||||
except Exception as e:
|
)
|
||||||
# Clean up stored file if database creation fails
|
|
||||||
|
# Create image record
|
||||||
|
image = ImageModel(
|
||||||
|
filename=file.filename,
|
||||||
|
original_filename=file.filename,
|
||||||
|
file_size=file_size,
|
||||||
|
content_type=content_type,
|
||||||
|
storage_path=storage_path,
|
||||||
|
public_url=None, # Will be set after we have the image ID
|
||||||
|
team_id=user.team_id,
|
||||||
|
uploader_id=user.id,
|
||||||
|
description=description,
|
||||||
|
metadata=metadata,
|
||||||
|
collection_id=ObjectId(collection_id) if collection_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
created_image = await image_repository.create(image)
|
||||||
|
|
||||||
|
# Generate API download URL now that we have the image ID
|
||||||
|
api_download_url = self._generate_api_download_url(request, str(created_image.id))
|
||||||
|
|
||||||
|
# Update the image with the API download URL
|
||||||
|
await image_repository.update(created_image.id, {"public_url": api_download_url})
|
||||||
|
created_image.public_url = api_download_url
|
||||||
|
|
||||||
|
# Publish image processing task to Pub/Sub
|
||||||
try:
|
try:
|
||||||
self.storage_service.delete_file(storage_path)
|
task_published = await pubsub_service.publish_image_processing_task(
|
||||||
except:
|
image_id=str(created_image.id),
|
||||||
pass
|
storage_path=storage_path,
|
||||||
logger.error(f"Failed to create image record: {e}")
|
team_id=str(user.team_id)
|
||||||
raise RuntimeError("Failed to create image record")
|
)
|
||||||
|
if not task_published:
|
||||||
|
logger.warning(f"Failed to publish processing task for image {created_image.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to publish image processing task: {e}")
|
||||||
|
|
||||||
|
# Convert to response
|
||||||
|
return self._convert_to_response(created_image, request)
|
||||||
|
|
||||||
# Generate embedding asynchronously (fire and forget)
|
|
||||||
try:
|
|
||||||
await self.embedding_service.generate_image_embedding(str(image.id), file_content)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to generate embedding for image {image.id}: {e}")
|
logger.error(f"Error uploading image: {e}")
|
||||||
|
raise RuntimeError("Failed to upload image")
|
||||||
return self._convert_to_response(image, request)
|
|
||||||
|
|
||||||
async def list_images(
|
async def list_images(
|
||||||
self,
|
self,
|
||||||
@ -121,51 +125,49 @@ class ImageService:
|
|||||||
collection_id: Optional[str] = None
|
collection_id: Optional[str] = None
|
||||||
) -> ImageListResponse:
|
) -> ImageListResponse:
|
||||||
"""
|
"""
|
||||||
List images with team-based filtering
|
List images for the user's team or all images if user is admin
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: The requesting user
|
user: The requesting user
|
||||||
request: The FastAPI request object for URL generation
|
request: The FastAPI request object for URL generation
|
||||||
skip: Number of records to skip
|
skip: Number of records to skip for pagination
|
||||||
limit: Maximum number of records to return
|
limit: Maximum number of records to return
|
||||||
collection_id: Optional collection filter
|
collection_id: Optional filter by collection ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImageListResponse: List of images with metadata
|
ImageListResponse: List of images with pagination metadata
|
||||||
"""
|
"""
|
||||||
# Apply team filtering based on user permissions
|
# Check if user is admin - if so, get all images across all teams
|
||||||
team_filter = get_team_filter(user)
|
if user.is_admin:
|
||||||
|
images = await image_repository.get_all_with_pagination(
|
||||||
# Convert collection_id to ObjectId if provided
|
skip=skip,
|
||||||
collection_obj_id = ObjectId(collection_id) if collection_id else None
|
limit=limit,
|
||||||
|
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||||
# Get images based on user permissions
|
)
|
||||||
if team_filter:
|
total = await image_repository.count_all(
|
||||||
# Regular user - filter by team
|
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||||
team_obj_id = ObjectId(team_filter)
|
)
|
||||||
images = await image_repository.get_by_team(team_obj_id, skip, limit, collection_obj_id)
|
|
||||||
total = await image_repository.count_by_team(team_obj_id, collection_obj_id)
|
|
||||||
else:
|
else:
|
||||||
# Admin user - can see all images
|
# Regular users only see images from their team
|
||||||
images = await image_repository.get_all_with_pagination(skip, limit, collection_obj_id)
|
images = await image_repository.get_by_team(
|
||||||
total = await image_repository.count_all(collection_obj_id)
|
user.team_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||||
|
)
|
||||||
|
total = await image_repository.count_by_team(
|
||||||
|
user.team_id,
|
||||||
|
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||||
|
)
|
||||||
|
|
||||||
# Convert to responses
|
# Convert to response
|
||||||
image_responses = [
|
response_images = [self._convert_to_response(image, request) for image in images]
|
||||||
self._convert_to_response(image, request)
|
|
||||||
for image in images
|
|
||||||
]
|
|
||||||
|
|
||||||
return ImageListResponse(
|
return ImageListResponse(images=response_images, total=total, skip=skip, limit=limit)
|
||||||
images=image_responses,
|
|
||||||
total=total,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_image(self, image_id: str, user: UserModel, request: Request) -> ImageResponse:
|
async def get_image(self, image_id: str, user: UserModel, request: Request) -> ImageResponse:
|
||||||
"""
|
"""
|
||||||
Get image metadata by ID with authorization check
|
Get image metadata by ID
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_id: The image ID to retrieve
|
image_id: The image ID to retrieve
|
||||||
@ -178,7 +180,7 @@ class ImageService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If image_id is invalid
|
ValueError: If image_id is invalid
|
||||||
RuntimeError: If image not found
|
RuntimeError: If image not found
|
||||||
AuthorizationError: If user not authorized to access the image
|
PermissionError: If user not authorized to access the image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
obj_id = ObjectId(image_id)
|
obj_id = ObjectId(image_id)
|
||||||
@ -190,14 +192,15 @@ class ImageService:
|
|||||||
if not image:
|
if not image:
|
||||||
raise RuntimeError("Image not found")
|
raise RuntimeError("Image not found")
|
||||||
|
|
||||||
# Centralized team access check
|
# Check team access (admins can access any image)
|
||||||
require_team_access(user, str(image.team_id), "image", "access")
|
if not user.is_admin and image.team_id != user.team_id:
|
||||||
|
raise PermissionError("Not authorized to access this image")
|
||||||
|
|
||||||
return self._convert_to_response(image, request, include_last_accessed=True)
|
return self._convert_to_response(image, request, include_last_accessed=True)
|
||||||
|
|
||||||
async def download_image(self, image_id: str, user: UserModel) -> Tuple[bytes, str, str]:
|
async def download_image(self, image_id: str, user: UserModel) -> Tuple[bytes, str, str]:
|
||||||
"""
|
"""
|
||||||
Download image file with authorization check
|
Download image file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_id: The image ID to download
|
image_id: The image ID to download
|
||||||
@ -209,7 +212,7 @@ class ImageService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If image_id is invalid
|
ValueError: If image_id is invalid
|
||||||
RuntimeError: If image not found or file not found in storage
|
RuntimeError: If image not found or file not found in storage
|
||||||
AuthorizationError: If user not authorized to access the image
|
PermissionError: If user not authorized to access the image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
obj_id = ObjectId(image_id)
|
obj_id = ObjectId(image_id)
|
||||||
@ -221,8 +224,9 @@ class ImageService:
|
|||||||
if not image:
|
if not image:
|
||||||
raise RuntimeError("Image not found")
|
raise RuntimeError("Image not found")
|
||||||
|
|
||||||
# Centralized team access check
|
# Check team access (admins can access any image)
|
||||||
require_team_access(user, str(image.team_id), "image", "download")
|
if not user.is_admin and image.team_id != user.team_id:
|
||||||
|
raise PermissionError("Not authorized to access this image")
|
||||||
|
|
||||||
# Get file from storage
|
# Get file from storage
|
||||||
file_content = self.storage_service.get_file(image.storage_path)
|
file_content = self.storage_service.get_file(image.storage_path)
|
||||||
@ -237,12 +241,12 @@ class ImageService:
|
|||||||
async def update_image(
|
async def update_image(
|
||||||
self,
|
self,
|
||||||
image_id: str,
|
image_id: str,
|
||||||
image_data,
|
image_data: ImageUpdate,
|
||||||
user: UserModel,
|
user: UserModel,
|
||||||
request: Request
|
request: Request
|
||||||
) -> ImageResponse:
|
) -> ImageResponse:
|
||||||
"""
|
"""
|
||||||
Update image metadata with authorization check
|
Update image metadata
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_id: The image ID to update
|
image_id: The image ID to update
|
||||||
@ -256,7 +260,7 @@ class ImageService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If image_id is invalid
|
ValueError: If image_id is invalid
|
||||||
RuntimeError: If image not found or update fails
|
RuntimeError: If image not found or update fails
|
||||||
AuthorizationError: If user not authorized to update the image
|
PermissionError: If user not authorized to update the image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
obj_id = ObjectId(image_id)
|
obj_id = ObjectId(image_id)
|
||||||
@ -268,8 +272,9 @@ class ImageService:
|
|||||||
if not image:
|
if not image:
|
||||||
raise RuntimeError("Image not found")
|
raise RuntimeError("Image not found")
|
||||||
|
|
||||||
# Centralized team access check
|
# Check team access (admins can update any image)
|
||||||
require_team_access(user, str(image.team_id), "image", "update")
|
if not user.is_admin and image.team_id != user.team_id:
|
||||||
|
raise PermissionError("Not authorized to update this image")
|
||||||
|
|
||||||
# Update image
|
# Update image
|
||||||
update_data = image_data.dict(exclude_unset=True)
|
update_data = image_data.dict(exclude_unset=True)
|
||||||
@ -285,7 +290,7 @@ class ImageService:
|
|||||||
|
|
||||||
async def delete_image(self, image_id: str, user: UserModel) -> bool:
|
async def delete_image(self, image_id: str, user: UserModel) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete an image with authorization check
|
Delete an image
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_id: The image ID to delete
|
image_id: The image ID to delete
|
||||||
@ -297,7 +302,7 @@ class ImageService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If image_id is invalid
|
ValueError: If image_id is invalid
|
||||||
RuntimeError: If image not found or deletion fails
|
RuntimeError: If image not found or deletion fails
|
||||||
AuthorizationError: If user not authorized to delete the image
|
PermissionError: If user not authorized to delete the image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
obj_id = ObjectId(image_id)
|
obj_id = ObjectId(image_id)
|
||||||
@ -309,8 +314,9 @@ class ImageService:
|
|||||||
if not image:
|
if not image:
|
||||||
raise RuntimeError("Image not found")
|
raise RuntimeError("Image not found")
|
||||||
|
|
||||||
# Centralized team access check
|
# Check team access (admins can delete any image)
|
||||||
require_team_access(user, str(image.team_id), "image", "delete")
|
if not user.is_admin and image.team_id != user.team_id:
|
||||||
|
raise PermissionError("Not authorized to delete this image")
|
||||||
|
|
||||||
# Delete from storage
|
# Delete from storage
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -135,44 +135,6 @@ class StorageService:
|
|||||||
logger.error(f"Error uploading file: {e}")
|
logger.error(f"Error uploading file: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def store_file(self, storage_path: str, content: bytes, content_type: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Store raw bytes content to Google Cloud Storage
|
|
||||||
|
|
||||||
Args:
|
|
||||||
storage_path: The storage path where the file should be stored
|
|
||||||
content: Raw bytes content to store
|
|
||||||
content_type: Optional content type for the file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if file was stored successfully
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If storage operation fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create a blob in the bucket
|
|
||||||
blob = self.bucket.blob(storage_path)
|
|
||||||
|
|
||||||
# Set content type if provided
|
|
||||||
if content_type:
|
|
||||||
blob.content_type = content_type
|
|
||||||
|
|
||||||
# Set basic metadata
|
|
||||||
blob.metadata = {
|
|
||||||
'upload_time': datetime.utcnow().isoformat(),
|
|
||||||
'file_size': str(len(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Upload the content
|
|
||||||
blob.upload_from_string(content, content_type=content_type)
|
|
||||||
|
|
||||||
logger.info(f"File stored: {storage_path}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error storing file: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_file(self, storage_path: str) -> Optional[bytes]:
|
def get_file(self, storage_path: str) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
Get a file from Google Cloud Storage
|
Get a file from Google Cloud Storage
|
||||||
|
|||||||
@ -38,22 +38,15 @@ class TeamService:
|
|||||||
updated_at=created_team.updated_at
|
updated_at=created_team.updated_at
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_teams(self, skip: int = 0, limit: int = 50) -> TeamListResponse:
|
async def list_teams(self) -> TeamListResponse:
|
||||||
"""
|
"""
|
||||||
List all teams with pagination
|
List all teams
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return (default: 50)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TeamListResponse: Paginated list of teams
|
TeamListResponse: List of all teams
|
||||||
"""
|
"""
|
||||||
# Get teams with pagination
|
# Get all teams
|
||||||
teams = await team_repository.get_all(skip=skip, limit=limit)
|
teams = await team_repository.get_all()
|
||||||
|
|
||||||
# Get total count for pagination
|
|
||||||
total_count = await team_repository.count()
|
|
||||||
|
|
||||||
# Convert to response models
|
# Convert to response models
|
||||||
response_teams = []
|
response_teams = []
|
||||||
@ -66,7 +59,7 @@ class TeamService:
|
|||||||
updated_at=team.updated_at
|
updated_at=team.updated_at
|
||||||
))
|
))
|
||||||
|
|
||||||
return TeamListResponse(teams=response_teams, total=total_count)
|
return TeamListResponse(teams=response_teams, total=len(response_teams))
|
||||||
|
|
||||||
async def get_team(self, team_id: str) -> TeamResponse:
|
async def get_team(self, team_id: str) -> TeamResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -150,17 +150,15 @@ class UserService:
|
|||||||
updated_at=created_user.updated_at
|
updated_at=created_user.updated_at
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_users(self, skip: int = 0, limit: int = 50, team_id: Optional[str] = None) -> UserListResponse:
|
async def list_users(self, team_id: Optional[str] = None) -> UserListResponse:
|
||||||
"""
|
"""
|
||||||
List users with pagination, optionally filtered by team
|
List users, optionally filtered by team
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skip: Number of records to skip for pagination (default: 0)
|
|
||||||
limit: Maximum number of records to return (default: 50)
|
|
||||||
team_id: Optional team ID to filter by
|
team_id: Optional team ID to filter by
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
UserListResponse: Paginated list of users
|
UserListResponse: List of users
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If team_id is invalid
|
ValueError: If team_id is invalid
|
||||||
@ -169,13 +167,11 @@ class UserService:
|
|||||||
if team_id:
|
if team_id:
|
||||||
try:
|
try:
|
||||||
filter_team_id = ObjectId(team_id)
|
filter_team_id = ObjectId(team_id)
|
||||||
users = await user_repository.get_by_team(filter_team_id, skip=skip, limit=limit)
|
users = await user_repository.get_by_team(filter_team_id)
|
||||||
total_count = await user_repository.count_by_team(filter_team_id)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError("Invalid team ID")
|
raise ValueError("Invalid team ID")
|
||||||
else:
|
else:
|
||||||
users = await user_repository.get_all(skip=skip, limit=limit)
|
users = await user_repository.get_all()
|
||||||
total_count = await user_repository.count()
|
|
||||||
|
|
||||||
# Convert to response
|
# Convert to response
|
||||||
response_users = []
|
response_users = []
|
||||||
@ -191,7 +187,7 @@ class UserService:
|
|||||||
updated_at=user.updated_at
|
updated_at=user.updated_at
|
||||||
))
|
))
|
||||||
|
|
||||||
return UserListResponse(users=response_users, total=total_count)
|
return UserListResponse(users=response_users, total=len(response_users))
|
||||||
|
|
||||||
async def get_user(self, user_id: str) -> UserResponse:
|
async def get_user(self, user_id: str) -> UserResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,212 +0,0 @@
|
|||||||
"""
|
|
||||||
Centralized authorization utilities to eliminate scattered access control logic.
|
|
||||||
|
|
||||||
This module provides reusable authorization functions that can be used across
|
|
||||||
all services and API endpoints to ensure consistent access control.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional, Any, Dict
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from src.models.user import UserModel
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class AuthorizationError(HTTPException):
|
|
||||||
"""Custom exception for authorization failures"""
|
|
||||||
|
|
||||||
def __init__(self, detail: str, status_code: int = status.HTTP_403_FORBIDDEN):
|
|
||||||
super().__init__(status_code=status_code, detail=detail)
|
|
||||||
|
|
||||||
class AuthorizationContext:
|
|
||||||
"""Context object for authorization decisions"""
|
|
||||||
|
|
||||||
def __init__(self, user: UserModel, resource_type: str, action: str, **kwargs):
|
|
||||||
self.user = user
|
|
||||||
self.resource_type = resource_type
|
|
||||||
self.action = action
|
|
||||||
self.metadata = kwargs
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""Convert context to dictionary for logging"""
|
|
||||||
if self.user is None:
|
|
||||||
return {
|
|
||||||
"user_id": None,
|
|
||||||
"team_id": None,
|
|
||||||
"is_admin": False,
|
|
||||||
"resource_type": self.resource_type,
|
|
||||||
"action": self.action,
|
|
||||||
**self.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"user_id": str(self.user.id),
|
|
||||||
"team_id": str(self.user.team_id),
|
|
||||||
"is_admin": self.user.is_admin,
|
|
||||||
"resource_type": self.resource_type,
|
|
||||||
"action": self.action,
|
|
||||||
**self.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
def require_admin(user: UserModel, action: str = "perform admin action") -> None:
|
|
||||||
"""
|
|
||||||
Ensure user has admin privileges
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user to check
|
|
||||||
action: Description of the action being performed (for error messages)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthorizationError: If user is not an admin
|
|
||||||
"""
|
|
||||||
if not user.is_admin:
|
|
||||||
logger.warning(f"Non-admin user {user.id} attempted to {action}")
|
|
||||||
raise AuthorizationError(f"Admin privileges required to {action}")
|
|
||||||
|
|
||||||
def require_team_access(user: UserModel, resource_team_id: str, resource_type: str, action: str = "access") -> None:
|
|
||||||
"""
|
|
||||||
Ensure user can access resources from the specified team
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user requesting access
|
|
||||||
resource_team_id: The team ID of the resource
|
|
||||||
resource_type: Type of resource being accessed (for error messages)
|
|
||||||
action: Action being performed (for error messages)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthorizationError: If user cannot access the resource
|
|
||||||
"""
|
|
||||||
if not user.is_admin and str(user.team_id) != str(resource_team_id):
|
|
||||||
logger.warning(
|
|
||||||
f"User {user.id} from team {user.team_id} attempted to {action} "
|
|
||||||
f"{resource_type} from team {resource_team_id}"
|
|
||||||
)
|
|
||||||
raise AuthorizationError(f"Cannot {action} {resource_type} from different team")
|
|
||||||
|
|
||||||
def require_resource_owner_or_admin(user: UserModel, resource_user_id: str, resource_type: str, action: str = "access") -> None:
|
|
||||||
"""
|
|
||||||
Ensure user owns the resource or is an admin
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user requesting access
|
|
||||||
resource_user_id: The user ID who owns the resource
|
|
||||||
resource_type: Type of resource being accessed
|
|
||||||
action: Action being performed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthorizationError: If user is not the owner and not an admin
|
|
||||||
"""
|
|
||||||
if not user.is_admin and str(user.id) != str(resource_user_id):
|
|
||||||
logger.warning(
|
|
||||||
f"User {user.id} attempted to {action} {resource_type} "
|
|
||||||
f"owned by user {resource_user_id}"
|
|
||||||
)
|
|
||||||
raise AuthorizationError(f"Cannot {action} {resource_type} owned by another user")
|
|
||||||
|
|
||||||
def can_access_team_resource(user: UserModel, resource_team_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user can access a team resource (non-throwing version)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user requesting access
|
|
||||||
resource_team_id: The team ID of the resource
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if user can access the resource
|
|
||||||
"""
|
|
||||||
return user.is_admin or str(user.team_id) == str(resource_team_id)
|
|
||||||
|
|
||||||
def can_access_user_resource(user: UserModel, resource_user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user can access a user resource (non-throwing version)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user requesting access
|
|
||||||
resource_user_id: The user ID who owns the resource
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if user can access the resource
|
|
||||||
"""
|
|
||||||
return user.is_admin or str(user.id) == str(resource_user_id)
|
|
||||||
|
|
||||||
def get_team_filter(user: UserModel) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Get team filter for queries based on user permissions
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user making the request
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Team ID to filter by, or None if admin (can see all teams)
|
|
||||||
"""
|
|
||||||
return None if user.is_admin else str(user.team_id)
|
|
||||||
|
|
||||||
def log_authorization_context(context: AuthorizationContext, success: bool = True) -> None:
|
|
||||||
"""
|
|
||||||
Log authorization context for audit purposes
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Authorization context
|
|
||||||
success: Whether the authorization was successful
|
|
||||||
"""
|
|
||||||
log_data = context.to_dict()
|
|
||||||
log_data["authorization_success"] = success
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"Authorization granted for {context.action} on {context.resource_type}", extra=log_data)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Authorization denied for {context.action} on {context.resource_type}", extra=log_data)
|
|
||||||
|
|
||||||
def create_auth_context(user: UserModel, resource_type: str, action: str, **kwargs) -> AuthorizationContext:
|
|
||||||
"""
|
|
||||||
Create an authorization context for logging and tracking
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user making the request
|
|
||||||
resource_type: Type of resource being accessed
|
|
||||||
action: Action being performed
|
|
||||||
**kwargs: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AuthorizationContext object
|
|
||||||
"""
|
|
||||||
return AuthorizationContext(user, resource_type, action, **kwargs)
|
|
||||||
|
|
||||||
# Decorator for common authorization patterns
|
|
||||||
def authorize_team_resource(resource_type: str, action: str = "access"):
|
|
||||||
"""
|
|
||||||
Decorator to authorize team resource access
|
|
||||||
|
|
||||||
Args:
|
|
||||||
resource_type: Type of resource
|
|
||||||
action: Action being performed
|
|
||||||
"""
|
|
||||||
def decorator(func):
|
|
||||||
async def wrapper(*args, **kwargs):
|
|
||||||
# Extract user and resource from function arguments
|
|
||||||
# This assumes the function signature includes user and a resource with team_id
|
|
||||||
user = None
|
|
||||||
resource_team_id = None
|
|
||||||
|
|
||||||
# Find user in arguments
|
|
||||||
for arg in args:
|
|
||||||
if isinstance(arg, UserModel):
|
|
||||||
user = arg
|
|
||||||
break
|
|
||||||
|
|
||||||
# Find resource team_id in arguments or kwargs
|
|
||||||
for arg in args:
|
|
||||||
if hasattr(arg, 'team_id'):
|
|
||||||
resource_team_id = arg.team_id
|
|
||||||
break
|
|
||||||
|
|
||||||
if 'team_id' in kwargs:
|
|
||||||
resource_team_id = kwargs['team_id']
|
|
||||||
|
|
||||||
if user and resource_team_id:
|
|
||||||
require_team_access(user, resource_team_id, resource_type, action)
|
|
||||||
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
@ -39,7 +39,7 @@ async def test_create_collection(client: TestClient, admin_api_key: tuple, test_
|
|||||||
"description": "A collection for testing images",
|
"description": "A collection for testing images",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"category": "test",
|
"category": "test",
|
||||||
"project": "contoso"
|
"project": "sereact"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -480,7 +480,7 @@ async def test_collection_export(client: TestClient, admin_api_key: tuple):
|
|||||||
json={
|
json={
|
||||||
"name": "Export Collection",
|
"name": "Export Collection",
|
||||||
"description": "Collection for export testing",
|
"description": "Collection for export testing",
|
||||||
"metadata": {"category": "test", "project": "contoso"}
|
"metadata": {"category": "test", "project": "sereact"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
collection_id = collection_response.json()["id"]
|
collection_id = collection_response.json()["id"]
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Global test configuration and fixtures for CONTOSO tests.
|
Global test configuration and fixtures for SEREACT tests.
|
||||||
|
|
||||||
This file provides shared fixtures and configuration for:
|
This file provides shared fixtures and configuration for:
|
||||||
- Unit tests (with mocked dependencies)
|
- Unit tests (with mocked dependencies)
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
"""Integration tests package for CONTOSO database layer"""
|
"""Integration tests package for SEREACT database layer"""
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
End-to-End Tests for CONTOSO API
|
End-to-End Tests for SEREACT API
|
||||||
|
|
||||||
These tests cover the complete user workflows described in the README:
|
These tests cover the complete user workflows described in the README:
|
||||||
1. Use pre-seeded API key for authentication
|
1. Use pre-seeded API key for authentication
|
||||||
@ -148,41 +148,36 @@ class TestE2EWorkflows:
|
|||||||
response = client.get("/api/v1/auth/verify", headers=headers)
|
response = client.get("/api/v1/auth/verify", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
auth_data = response.json()
|
auth_data = response.json()
|
||||||
assert "user_id" in auth_data
|
assert auth_data["valid"] is True
|
||||||
assert "team_id" in auth_data
|
|
||||||
assert auth_data["team_id"] == env["team_id"]
|
assert auth_data["team_id"] == env["team_id"]
|
||||||
assert auth_data["user_id"] == env["admin_user_id"]
|
assert auth_data["user_id"] == env["admin_user_id"]
|
||||||
print("✅ API key verification successful")
|
print("✅ API key verification successful")
|
||||||
|
|
||||||
# Test 2: List teams (should see our team)
|
# Test 2: List teams (should see our team)
|
||||||
response = client.get("/api/v1/teams")
|
response = client.get("/api/v1/teams", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
teams_data = response.json()
|
teams = response.json()
|
||||||
assert "teams" in teams_data
|
team_ids = [team["id"] for team in teams]
|
||||||
assert "total" in teams_data
|
|
||||||
team_ids = [team["id"] for team in teams_data["teams"]]
|
|
||||||
assert env["team_id"] in team_ids
|
assert env["team_id"] in team_ids
|
||||||
print("✅ Team listing successful")
|
print("✅ Team listing successful")
|
||||||
|
|
||||||
# Test 3: Get team details
|
# Test 3: Get team details
|
||||||
response = client.get(f"/api/v1/teams/{env['team_id']}")
|
response = client.get(f"/api/v1/teams/{env['team_id']}", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
team = response.json()
|
team = response.json()
|
||||||
assert team["id"] == env["team_id"]
|
assert team["id"] == env["team_id"]
|
||||||
print("✅ Team details retrieval successful")
|
print("✅ Team details retrieval successful")
|
||||||
|
|
||||||
# Test 4: List users (should see admin user)
|
# Test 4: List users (should see admin user)
|
||||||
response = client.get("/api/v1/users")
|
response = client.get("/api/v1/users", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
users_data = response.json()
|
users = response.json()
|
||||||
assert "users" in users_data
|
user_ids = [user["id"] for user in users]
|
||||||
assert "total" in users_data
|
|
||||||
user_ids = [user["id"] for user in users_data["users"]]
|
|
||||||
assert env["admin_user_id"] in user_ids
|
assert env["admin_user_id"] in user_ids
|
||||||
print("✅ User listing successful")
|
print("✅ User listing successful")
|
||||||
|
|
||||||
# Test 5: Get user details
|
# Test 5: Get user details
|
||||||
response = client.get(f"/api/v1/users/{env['admin_user_id']}")
|
response = client.get(f"/api/v1/users/{env['admin_user_id']}", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user = response.json()
|
user = response.json()
|
||||||
assert user["id"] == env["admin_user_id"]
|
assert user["id"] == env["admin_user_id"]
|
||||||
@ -192,18 +187,15 @@ class TestE2EWorkflows:
|
|||||||
# Test 6: List API keys
|
# Test 6: List API keys
|
||||||
response = client.get("/api/v1/auth/api-keys", headers=headers)
|
response = client.get("/api/v1/auth/api-keys", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
api_keys_data = response.json()
|
api_keys = response.json()
|
||||||
assert "api_keys" in api_keys_data
|
assert len(api_keys) >= 1 # Should have at least our test key
|
||||||
assert "total" in api_keys_data
|
|
||||||
assert len(api_keys_data["api_keys"]) >= 1 # Should have at least our test key
|
|
||||||
print("✅ API key listing successful")
|
print("✅ API key listing successful")
|
||||||
|
|
||||||
# Test 7: Basic image operations (placeholder test)
|
# Test 7: Basic image operations (placeholder test)
|
||||||
response = client.get("/api/v1/images", headers=headers)
|
response = client.get("/api/v1/images", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
images_data = response.json()
|
images = response.json()
|
||||||
assert "images" in images_data
|
assert "images" in images or "message" in images # Handle both implemented and placeholder responses
|
||||||
assert "total" in images_data
|
|
||||||
print("✅ Image listing endpoint accessible")
|
print("✅ Image listing endpoint accessible")
|
||||||
|
|
||||||
print("🎉 API key verification and basic workflow test passed!")
|
print("🎉 API key verification and basic workflow test passed!")
|
||||||
@ -216,7 +208,7 @@ class TestE2EWorkflows:
|
|||||||
headers = env["headers"]
|
headers = env["headers"]
|
||||||
unique_suffix = env["unique_suffix"]
|
unique_suffix = env["unique_suffix"]
|
||||||
|
|
||||||
# Test basic search endpoint (without skip parameter to avoid 500 error)
|
# Test basic search endpoint
|
||||||
response = client.get(f"/api/v1/search?q={unique_suffix}", headers=headers)
|
response = client.get(f"/api/v1/search?q={unique_suffix}", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
search_results = response.json()
|
search_results = response.json()
|
||||||
@ -228,18 +220,17 @@ class TestE2EWorkflows:
|
|||||||
assert search_results["query"] == unique_suffix
|
assert search_results["query"] == unique_suffix
|
||||||
|
|
||||||
if len(search_results["results"]) == 0:
|
if len(search_results["results"]) == 0:
|
||||||
print("⚠️ Search returned empty results (likely vector database not configured)")
|
print("⚠️ Search returned empty results (likely Pinecone not configured)")
|
||||||
print("✅ Search endpoint responding correctly (empty results)")
|
print("✅ Search endpoint responding correctly (empty results)")
|
||||||
else:
|
else:
|
||||||
print("✅ Search endpoint returning results")
|
print("✅ Search endpoint returning results")
|
||||||
# Verify result structure
|
# Verify result structure
|
||||||
for result in search_results["results"]:
|
for result in search_results["results"]:
|
||||||
assert "id" in result
|
assert "id" in result
|
||||||
# Check for either description or filename
|
|
||||||
assert "description" in result or "filename" in result
|
assert "description" in result or "filename" in result
|
||||||
|
|
||||||
# Test search with different parameters (without skip)
|
# Test search with different parameters
|
||||||
response = client.get("/api/v1/search?q=nonexistent&limit=5", headers=headers)
|
response = client.get("/api/v1/search?q=nonexistent", headers=headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
empty_results = response.json()
|
empty_results = response.json()
|
||||||
assert "results" in empty_results
|
assert "results" in empty_results
|
||||||
@ -248,7 +239,7 @@ class TestE2EWorkflows:
|
|||||||
|
|
||||||
# Test search without query (should handle gracefully)
|
# Test search without query (should handle gracefully)
|
||||||
response = client.get("/api/v1/search", headers=headers)
|
response = client.get("/api/v1/search", headers=headers)
|
||||||
assert response.status_code in [200, 400, 422] # Either works or returns validation error
|
assert response.status_code in [200, 400] # Either works or returns bad request
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
no_query_results = response.json()
|
no_query_results = response.json()
|
||||||
assert "results" in no_query_results
|
assert "results" in no_query_results
|
||||||
@ -356,14 +347,14 @@ class TestE2EWorkflows:
|
|||||||
user1_data = {
|
user1_data = {
|
||||||
"email": f"user1-{unique_suffix}@team1.com",
|
"email": f"user1-{unique_suffix}@team1.com",
|
||||||
"name": f"Team1 User {unique_suffix}",
|
"name": f"Team1 User {unique_suffix}",
|
||||||
"is_admin": False,
|
"is_admin": True,
|
||||||
"team_id": team1_id
|
"team_id": team1_id
|
||||||
}
|
}
|
||||||
|
|
||||||
user2_data = {
|
user2_data = {
|
||||||
"email": f"user2-{unique_suffix}@team2.com",
|
"email": f"user2-{unique_suffix}@team2.com",
|
||||||
"name": f"Team2 User {unique_suffix}",
|
"name": f"Team2 User {unique_suffix}",
|
||||||
"is_admin": False,
|
"is_admin": True,
|
||||||
"team_id": team2_id
|
"team_id": team2_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,30 +373,25 @@ class TestE2EWorkflows:
|
|||||||
# Create API keys for each team's user
|
# Create API keys for each team's user
|
||||||
api_key1_data = {
|
api_key1_data = {
|
||||||
"name": f"Team1 API Key {unique_suffix}",
|
"name": f"Team1 API Key {unique_suffix}",
|
||||||
"description": "API key for team 1 testing"
|
"description": "API key for team 1 testing",
|
||||||
|
"team_id": team1_id,
|
||||||
|
"user_id": user1["id"]
|
||||||
}
|
}
|
||||||
|
|
||||||
api_key2_data = {
|
api_key2_data = {
|
||||||
"name": f"Team2 API Key {unique_suffix}",
|
"name": f"Team2 API Key {unique_suffix}",
|
||||||
"description": "API key for team 2 testing"
|
"description": "API key for team 2 testing",
|
||||||
|
"team_id": team2_id,
|
||||||
|
"user_id": user2["id"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Updated to use query parameters as required by the new API structure
|
response = client.post("/api/v1/auth/api-keys", json=api_key1_data, headers=admin_headers)
|
||||||
response = client.post(
|
|
||||||
f"/api/v1/auth/api-keys?user_id={user1['id']}&team_id={team1_id}",
|
|
||||||
json=api_key1_data,
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
team1_api_key = response.json()["key"]
|
team1_api_key = response.json()["key"]
|
||||||
team1_headers = {"X-API-Key": team1_api_key}
|
team1_headers = {"X-API-Key": team1_api_key}
|
||||||
env["created_resources"]["api_keys"].append(response.json()["id"])
|
env["created_resources"]["api_keys"].append(response.json()["id"])
|
||||||
|
|
||||||
response = client.post(
|
response = client.post("/api/v1/auth/api-keys", json=api_key2_data, headers=admin_headers)
|
||||||
f"/api/v1/auth/api-keys?user_id={user2['id']}&team_id={team2_id}",
|
|
||||||
json=api_key2_data,
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
team2_api_key = response.json()["key"]
|
team2_api_key = response.json()["key"]
|
||||||
team2_headers = {"X-API-Key": team2_api_key}
|
team2_headers = {"X-API-Key": team2_api_key}
|
||||||
@ -572,8 +558,8 @@ class TestE2EWorkflows:
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
print("✅ Invalid API key properly rejected")
|
print("✅ Invalid API key properly rejected")
|
||||||
|
|
||||||
# Test missing API key on protected endpoint (images instead of teams)
|
# Test missing API key
|
||||||
response = client.get("/api/v1/images")
|
response = client.get("/api/v1/teams")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
print("✅ Missing API key properly rejected")
|
print("✅ Missing API key properly rejected")
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user