Compare commits

..

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

53 changed files with 708 additions and 1802 deletions

View File

@ -7,11 +7,11 @@ CORS_ORIGINS=["*"]
# Firestore settings
FIRESTORE_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

View File

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

View File

@ -186,11 +186,11 @@ Uses Google's Vertex AI multimodal embedding model for generating high-quality i
# Firestore settings
FIRESTORE_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

View File

@ -1,6 +1,6 @@
# Contoso Frontend Client
# SeReact Frontend Client
A modern, responsive web frontend for the Contoso AI-powered image management platform. This is a pure frontend application that communicates directly with your Contoso backend API.
A modern, responsive web frontend for the SeReact AI-powered image management platform. This is a pure frontend application that communicates directly with your SeReact backend API.
## Features
@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
{"ID":"f7ebd466-aa1e-1c15-bedf-0f9c92044463","Operation":"OperationTypeApply","Info":"","Who":"DESKTOP\\habal@Desktop","Version":"1.10.1","Created":"2025-05-26T16:46:06.4288884Z","Path":"terraform.tfstate"}

View File

@ -50,11 +50,11 @@ resource "google_firestore_database" "database" {
}
# Container Registry - no explicit resource needed, just enable the API
# 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"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ print_color() {
print_header() {
echo
print_color $CYAN "🚀 Contoso Frontend Client Manager"
print_color $CYAN "🚀 SeReact Frontend Client Manager"
echo
}

View File

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

View File

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

View File

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

View File

@ -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")
):
"""
Create a new API key for a specific user and team
# Initialize service
auth_service = AuthService()
This endpoint creates an API key without requiring authentication.
Both user_id and team_id must be provided as query parameters.
@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):
"""
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()
Create a new API key
This endpoint no longer requires authentication - user_id and team_id must be provided
"""
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")

View File

@ -1,113 +0,0 @@
"""
Shared error handling utilities for API endpoints.
This module provides centralized error handling to ensure consistent
HTTP status codes and error responses across all API endpoints.
"""
import logging
from typing import Dict, Type
from fastapi import HTTPException, status
logger = logging.getLogger(__name__)
# HTTP Status Code Mapping for consistent error responses
HTTP_ERROR_MAP: Dict[Type[Exception], int] = {
ValueError: status.HTTP_400_BAD_REQUEST,
RuntimeError: status.HTTP_404_NOT_FOUND,
PermissionError: status.HTTP_403_FORBIDDEN,
FileNotFoundError: status.HTTP_404_NOT_FOUND,
TimeoutError: status.HTTP_408_REQUEST_TIMEOUT,
ConnectionError: status.HTTP_503_SERVICE_UNAVAILABLE,
Exception: status.HTTP_500_INTERNAL_SERVER_ERROR
}
def handle_service_error(error: Exception, operation: str) -> HTTPException:
"""
Centralized error handling for service layer exceptions
This function maps service layer exceptions to appropriate HTTP status codes
and provides consistent error logging and response formatting.
Args:
error: The exception raised by the service layer
operation: Description of the operation that failed (for logging)
Returns:
HTTPException: Properly formatted HTTP exception with appropriate status code
Examples:
>>> try:
... await some_service_method()
... except Exception as e:
... raise handle_service_error(e, "user creation")
"""
error_type = type(error)
status_code = HTTP_ERROR_MAP.get(error_type, status.HTTP_500_INTERNAL_SERVER_ERROR)
# Log errors appropriately based on severity
if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
logger.error(f"Unexpected error in {operation}: {error}", exc_info=True)
detail = "Internal server error"
elif status_code >= 500:
logger.error(f"Server error in {operation}: {error}")
detail = "Service temporarily unavailable"
else:
logger.warning(f"Client error in {operation}: {error}")
detail = str(error)
return HTTPException(status_code=status_code, detail=detail)
def handle_validation_error(error: Exception, field_name: str = None) -> HTTPException:
"""
Handle validation errors with specific formatting
Args:
error: The validation exception
field_name: Optional field name that failed validation
Returns:
HTTPException: Formatted validation error response
"""
detail = f"Validation error"
if field_name:
detail += f" for field '{field_name}'"
detail += f": {str(error)}"
logger.warning(f"Validation error: {detail}")
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
def handle_authentication_error(error: Exception) -> HTTPException:
"""
Handle authentication-related errors
Args:
error: The authentication exception
Returns:
HTTPException: Formatted authentication error response
"""
logger.warning(f"Authentication error: {error}")
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication failed",
headers={"WWW-Authenticate": "Bearer"}
)
def handle_authorization_error(error: Exception, resource: str = None) -> HTTPException:
"""
Handle authorization-related errors
Args:
error: The authorization exception
resource: Optional resource name that access was denied to
Returns:
HTTPException: Formatted authorization error response
"""
detail = "Access denied"
if resource:
detail += f" to {resource}"
logger.warning(f"Authorization error: {detail} - {error}")
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

View File

@ -1,216 +1,131 @@
import logging
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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []
@ -164,16 +160,3 @@ class FirestoreRepository(Generic[T]):
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

View File

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

View File

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

View File

@ -1,89 +0,0 @@
"""
Dependency injection module for services.
This module provides dependency injection for all services used across the API.
It follows the dependency injection pattern to improve testability and maintainability.
"""
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from src.services.auth_service import AuthService
from src.services.image_service import ImageService
from src.services.search_service import SearchService
from src.services.team_service import TeamService
from src.services.user_service import UserService
@lru_cache()
def get_auth_service() -> AuthService:
"""
Get AuthService instance.
Uses LRU cache to ensure singleton behavior for the service instance.
Returns:
AuthService: The authentication service instance
"""
return AuthService()
@lru_cache()
def get_image_service() -> ImageService:
"""
Get ImageService instance.
Uses LRU cache to ensure singleton behavior for the service instance.
Returns:
ImageService: The image service instance
"""
return ImageService()
@lru_cache()
def get_search_service() -> SearchService:
"""
Get SearchService instance.
Uses LRU cache to ensure singleton behavior for the service instance.
Returns:
SearchService: The search service instance
"""
return SearchService()
@lru_cache()
def get_team_service() -> TeamService:
"""
Get TeamService instance.
Uses LRU cache to ensure singleton behavior for the service instance.
Returns:
TeamService: The team service instance
"""
return TeamService()
@lru_cache()
def get_user_service() -> UserService:
"""
Get UserService instance.
Uses LRU cache to ensure singleton behavior for the service instance.
Returns:
UserService: The user service instance
"""
return UserService()
# Type aliases for dependency injection
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
ImageServiceDep = Annotated[ImageService, Depends(get_image_service)]
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
TeamServiceDep = Annotated[TeamService, Depends(get_team_service)]
UserServiceDep = Annotated[UserService, Depends(get_user_service)]

View File

@ -1,19 +1,16 @@
import logging
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)

View File

@ -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")
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)
# Generate embedding asynchronously (fire and forget)
try:
await self.embedding_service.generate_image_embedding(str(image.id), file_content)
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:

View File

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

View File

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

View File

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

View File

@ -1,212 +0,0 @@
"""
Centralized authorization utilities to eliminate scattered access control logic.
This module provides reusable authorization functions that can be used across
all services and API endpoints to ensure consistent access control.
"""
import logging
from typing import Optional, Any, Dict
from fastapi import HTTPException, status
from src.models.user import UserModel
logger = logging.getLogger(__name__)
class AuthorizationError(HTTPException):
"""Custom exception for authorization failures"""
def __init__(self, detail: str, status_code: int = status.HTTP_403_FORBIDDEN):
super().__init__(status_code=status_code, detail=detail)
class AuthorizationContext:
"""Context object for authorization decisions"""
def __init__(self, user: UserModel, resource_type: str, action: str, **kwargs):
self.user = user
self.resource_type = resource_type
self.action = action
self.metadata = kwargs
def to_dict(self) -> Dict[str, Any]:
"""Convert context to dictionary for logging"""
if self.user is None:
return {
"user_id": None,
"team_id": None,
"is_admin": False,
"resource_type": self.resource_type,
"action": self.action,
**self.metadata
}
return {
"user_id": str(self.user.id),
"team_id": str(self.user.team_id),
"is_admin": self.user.is_admin,
"resource_type": self.resource_type,
"action": self.action,
**self.metadata
}
def require_admin(user: UserModel, action: str = "perform admin action") -> None:
"""
Ensure user has admin privileges
Args:
user: The user to check
action: Description of the action being performed (for error messages)
Raises:
AuthorizationError: If user is not an admin
"""
if not user.is_admin:
logger.warning(f"Non-admin user {user.id} attempted to {action}")
raise AuthorizationError(f"Admin privileges required to {action}")
def require_team_access(user: UserModel, resource_team_id: str, resource_type: str, action: str = "access") -> None:
"""
Ensure user can access resources from the specified team
Args:
user: The user requesting access
resource_team_id: The team ID of the resource
resource_type: Type of resource being accessed (for error messages)
action: Action being performed (for error messages)
Raises:
AuthorizationError: If user cannot access the resource
"""
if not user.is_admin and str(user.team_id) != str(resource_team_id):
logger.warning(
f"User {user.id} from team {user.team_id} attempted to {action} "
f"{resource_type} from team {resource_team_id}"
)
raise AuthorizationError(f"Cannot {action} {resource_type} from different team")
def require_resource_owner_or_admin(user: UserModel, resource_user_id: str, resource_type: str, action: str = "access") -> None:
"""
Ensure user owns the resource or is an admin
Args:
user: The user requesting access
resource_user_id: The user ID who owns the resource
resource_type: Type of resource being accessed
action: Action being performed
Raises:
AuthorizationError: If user is not the owner and not an admin
"""
if not user.is_admin and str(user.id) != str(resource_user_id):
logger.warning(
f"User {user.id} attempted to {action} {resource_type} "
f"owned by user {resource_user_id}"
)
raise AuthorizationError(f"Cannot {action} {resource_type} owned by another user")
def can_access_team_resource(user: UserModel, resource_team_id: str) -> bool:
"""
Check if user can access a team resource (non-throwing version)
Args:
user: The user requesting access
resource_team_id: The team ID of the resource
Returns:
True if user can access the resource
"""
return user.is_admin or str(user.team_id) == str(resource_team_id)
def can_access_user_resource(user: UserModel, resource_user_id: str) -> bool:
"""
Check if user can access a user resource (non-throwing version)
Args:
user: The user requesting access
resource_user_id: The user ID who owns the resource
Returns:
True if user can access the resource
"""
return user.is_admin or str(user.id) == str(resource_user_id)
def get_team_filter(user: UserModel) -> Optional[str]:
"""
Get team filter for queries based on user permissions
Args:
user: The user making the request
Returns:
Team ID to filter by, or None if admin (can see all teams)
"""
return None if user.is_admin else str(user.team_id)
def log_authorization_context(context: AuthorizationContext, success: bool = True) -> None:
"""
Log authorization context for audit purposes
Args:
context: Authorization context
success: Whether the authorization was successful
"""
log_data = context.to_dict()
log_data["authorization_success"] = success
if success:
logger.info(f"Authorization granted for {context.action} on {context.resource_type}", extra=log_data)
else:
logger.warning(f"Authorization denied for {context.action} on {context.resource_type}", extra=log_data)
def create_auth_context(user: UserModel, resource_type: str, action: str, **kwargs) -> AuthorizationContext:
"""
Create an authorization context for logging and tracking
Args:
user: The user making the request
resource_type: Type of resource being accessed
action: Action being performed
**kwargs: Additional metadata
Returns:
AuthorizationContext object
"""
return AuthorizationContext(user, resource_type, action, **kwargs)
# Decorator for common authorization patterns
def authorize_team_resource(resource_type: str, action: str = "access"):
"""
Decorator to authorize team resource access
Args:
resource_type: Type of resource
action: Action being performed
"""
def decorator(func):
async def wrapper(*args, **kwargs):
# Extract user and resource from function arguments
# This assumes the function signature includes user and a resource with team_id
user = None
resource_team_id = None
# Find user in arguments
for arg in args:
if isinstance(arg, UserModel):
user = arg
break
# Find resource team_id in arguments or kwargs
for arg in args:
if hasattr(arg, 'team_id'):
resource_team_id = arg.team_id
break
if 'team_id' in kwargs:
resource_team_id = kwargs['team_id']
if user and resource_team_id:
require_team_access(user, resource_team_id, resource_type, action)
return await func(*args, **kwargs)
return wrapper
return decorator

View File

@ -39,7 +39,7 @@ async def test_create_collection(client: TestClient, admin_api_key: tuple, test_
"description": "A collection for testing images",
"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"]

View File

@ -1,5 +1,5 @@
"""
Global test configuration and fixtures for CONTOSO tests.
Global test configuration and fixtures for SEREACT tests.
This file provides shared fixtures and configuration for:
- Unit tests (with mocked dependencies)

View File

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

View File

@ -1,5 +1,5 @@
"""
End-to-End Tests for CONTOSO API
End-to-End Tests for SEREACT API
These tests cover the complete user workflows described in the README:
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")