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