Compare commits

..

No commits in common. "a866e5bddcf3a3e776d8445cff91e2b768d066f9" and "a26bd08d9c76ac391f4510dd83346254e24586fb" have entirely different histories.

53 changed files with 708 additions and 1802 deletions

View File

@ -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

View File

@ -1,4 +1,4 @@
Contoso GmbH Sereact GmbH
Assignment Assignment
Image Management API - Coding Challenge Image Management API - Coding Challenge

View File

@ -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

View File

@ -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.

View File

@ -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>

View File

@ -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>

View File

@ -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;');

View File

@ -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
}); });

View File

@ -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);

View File

@ -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.

View File

@ -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()

View File

@ -1,4 +1,4 @@
/* Custom styles for Contoso Frontend */ /* Custom styles for SeReact Frontend */
:root { :root {
--primary-color: #0d6efd; --primary-color: #0d6efd;

View File

@ -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>

View File

@ -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')

View File

@ -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

View File

@ -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"}

View File

@ -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"
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" {

View File

@ -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

View File

@ -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)

View File

@ -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"}

View File

@ -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
} }

View File

@ -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

View File

@ -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]
} }

View File

@ -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}')

View File

@ -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, @router.post("/api-keys", response_model=ApiKeyWithValueResponse, status_code=201)
auth_service: AuthServiceDep, async def create_api_key(key_data: ApiKeyCreate, request: Request, user_id: str, team_id: str):
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 Create a new API key
This endpoint creates an API key without requiring authentication. This endpoint no longer requires authentication - user_id and team_id must be provided
Both user_id and team_id must be provided as query parameters.
""" """
auth_context = create_auth_context( log_request(
user=None, # No authenticated user for this endpoint {"path": request.url.path, "method": request.method, "key_data": key_data.dict(), "user_id": user_id, "team_id": team_id}
resource_type="api_key",
action="create",
target_user_id=user_id,
target_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")

View File

@ -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)

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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:
""" """

View File

@ -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 = []
@ -163,17 +159,4 @@ class FirestoreRepository(Generic[T]):
return await self.provider.delete_document(self.collection_name, str(doc_id)) return await self.provider.delete_document(self.collection_name, str(doc_id))
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
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 raise

View File

@ -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]:
""" """

View File

@ -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()

View File

@ -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)]

View File

@ -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)

View File

@ -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:
# Generate embedding asynchronously (fire and forget) logger.warning(f"Failed to publish processing task for image {created_image.id}")
try: except Exception as e:
await self.embedding_service.generate_image_embedding(str(image.id), file_content) logger.warning(f"Failed to publish image processing task: {e}")
# Convert to response
return self._convert_to_response(created_image, request)
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:

View File

@ -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

View File

@ -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:
""" """

View File

@ -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:
""" """

View File

@ -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

View File

@ -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"]

View File

@ -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)

View File

@ -1 +1 @@
"""Integration tests package for CONTOSO database layer""" """Integration tests package for SEREACT database layer"""

View File

@ -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")