This guide provides comprehensive information for developers contributing to or extending the VISTA application.
- Overview
- Getting Started
- Development Environment
- Architecture
- Backend Development
- Frontend Development
- Database
- API Development
- Testing
- Code Style
- Security
- Contributing
VISTA is a full-stack application for managing, classifying, and collaborating on visual content.
Backend:
- FastAPI (Python 3.11+) - Modern async web framework
- SQLAlchemy 2.0+ - Async ORM for PostgreSQL
- Alembic - Database migrations
- Pydantic - Data validation and serialization
- boto3 - S3/MinIO integration
- aiocache + diskcache - Caching layer
Frontend:
- React 18 - UI library with hooks
- React Router 6 - Client-side routing
- Native fetch API - HTTP requests
- CSS3 - Styling (no framework)
Infrastructure:
- PostgreSQL 15+ - Primary database
- MinIO/S3 - Object storage
- Docker & Docker Compose - Development environment
Package Management:
- uv - Python package manager
- npm - JavaScript package manager
.
├── backend/ # FastAPI backend
│ ├── main.py # Application entry point
│ ├── core/ # Core components
│ │ ├── models.py # SQLAlchemy models
│ │ ├── schemas.py # Pydantic schemas
│ │ ├── database.py # Database engine and session
│ │ ├── config.py # Configuration settings
│ │ ├── security.py # Authentication utilities
│ │ └── group_auth.py # Authorization logic
│ ├── routers/ # API endpoint definitions
│ │ ├── projects.py # Project endpoints
│ │ ├── images.py # Image endpoints
│ │ ├── comments.py # Comment endpoints
│ │ ├── image_classes.py # Classification endpoints
│ │ └── ml_analyses.py # ML analysis endpoints
│ ├── middleware/ # Request/response processing
│ │ ├── auth.py # Authentication middleware
│ │ ├── cors_debug.py # CORS configuration
│ │ └── security_headers.py # Security headers
│ ├── utils/ # Shared utilities
│ │ ├── crud.py # Database operations
│ │ ├── dependencies.py # FastAPI dependencies
│ │ ├── boto3_client.py # S3 client
│ │ └── cache_manager.py # Caching utilities
│ ├── alembic/ # Database migrations
│ └── tests/ # Backend tests
├── frontend/ # React frontend
│ ├── public/ # Static assets
│ └── src/
│ ├── App.js # Main component
│ ├── Project.js # Project view
│ ├── ImageView.js # Image detail view
│ └── components/ # Reusable components
├── docs/ # Documentation
├── scripts/ # Utility scripts
├── test/ # Test utilities
├── deployment-test/ # Kubernetes manifests
└── podman-compose.yml # Development infrastructure
- Git
- Python 3.11+
- Node.js 22+
- Docker and Docker Compose
- uv package manager:
pip install uv
-
Clone the repository:
git clone https://github.com/garland3/yet-another-image-project-app.git cd yet-another-image-project-app -
Start infrastructure:
podman compose up -d postgres minio
-
Set up environment:
cp .env.example .env # Edit .env with development settings -
Install backend dependencies:
pip install uv uv sync
-
Run database migrations:
cd backend alembic upgrade head -
Start backend:
cd backend uvicorn main:app --host 0.0.0.0 --port 8000 --reload # Or use: ./run.sh
-
Install frontend dependencies (in new terminal):
cd frontend npm install -
Start frontend dev server:
npm run dev
-
Access the application:
- Frontend: http://localhost:3000
- Backend API: http://localhost:8000
- API Docs: http://localhost:8000/docs
Recommended:
- VS Code with extensions:
- Python (Microsoft)
- Pylance (Microsoft)
- ESLint (Microsoft)
- Prettier (Prettier)
- Docker (Microsoft)
Database Management:
- pgAdmin: http://localhost:8080 (user: admin@admin.com, pass: admin)
- Or use:
psql -h localhost -p 5433 -U postgres
Storage Management:
- MinIO Console: http://localhost:9001 (user: minioadmin, pass: minioadminpassword)
Key development settings in .env:
# Development mode
DEBUG=true
SKIP_HEADER_CHECK=true # Disables auth header validation
# Mock user (when SKIP_HEADER_CHECK=true)
MOCK_USER_EMAIL=dev@example.com
MOCK_USER_GROUPS_JSON='["admin-group", "data-scientists"]'
# Database (Docker)
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/postgres
# Storage (Docker MinIO)
S3_ENDPOINT=localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadminpassword
S3_BUCKET=data-storage
S3_USE_SSL=falseBackend: FastAPI auto-reloads on code changes when using --reload flag
Frontend: React dev server auto-reloads on file changes
Database Models: After modifying models, create and apply migration:
cd backend
alembic revision --autogenerate -m "description"
alembic upgrade headUser Request → Frontend (React)
↓
REST API (FastAPI)
↓
Authentication Middleware
↓
Authorization Check
↓
Business Logic
↓
Database (PostgreSQL) + Cache (aiocache)
↓
Storage (S3/MinIO)
↓
JSON Response
Backend:
- Repository pattern (CRUD operations in
utils/crud.py) - Dependency injection (FastAPI dependencies)
- Middleware pattern (auth, CORS, security headers)
- Factory pattern (database session, S3 client)
- Caching decorator pattern
Frontend:
- Component composition
- Hooks for state management
- Custom hooks for API calls
- Controlled components for forms
Development Mode:
- Mock user from environment variables
SKIP_HEADER_CHECK=truebypasses header validation
Production Mode:
- Header-based authentication via reverse proxy
- Validates
X-User-EmailandX-Proxy-Secretheaders - Group-based access control for projects
Multi-layer caching for performance:
- Application Cache: aiocache with TTL for API responses
- Thumbnail Cache: diskcache for resized images
- Metadata Cache: Project and image metadata
Cache invalidation on mutations (create/update/delete).
backend/
├── main.py # FastAPI app factory
├── core/
│ ├── config.py # Settings (Pydantic BaseSettings)
│ ├── database.py # Async SQLAlchemy engine
│ ├── models.py # ORM models
│ ├── schemas.py # Pydantic schemas
│ ├── security.py # Password hashing, etc.
│ └── group_auth.py # Authorization helpers
├── routers/ # API endpoints by resource
├── middleware/ # Request/response middleware
├── utils/ # Shared utilities
└── tests/ # pytest tests
-
Define Pydantic schemas in
core/schemas.py:from pydantic import BaseModel from uuid import UUID from datetime import datetime class WidgetCreate(BaseModel): name: str description: str | None = None class WidgetResponse(BaseModel): id: UUID name: str description: str | None created_at: datetime class Config: from_attributes = True
-
Add database model (if needed) in
core/models.py:class Widget(Base): __tablename__ = "widgets" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(255), nullable=False) description = Column(Text) created_at = Column(DateTime(timezone=True), server_default=func.now())
-
Create migration:
cd backend alembic revision --autogenerate -m "add widget model" # Review generated migration alembic upgrade head
-
Add CRUD operations in
utils/crud.pyor router file:async def create_widget(db: AsyncSession, widget: WidgetCreate) -> Widget: db_widget = Widget(**widget.model_dump()) db.add(db_widget) await db.commit() await db.refresh(db_widget) return db_widget
-
Create router in
routers/widgets.py:from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from core.database import get_db from core.schemas import WidgetCreate, WidgetResponse from utils.dependencies import get_current_user_email router = APIRouter(prefix="/api/widgets", tags=["widgets"]) @router.post("/", response_model=WidgetResponse, status_code=201) async def create_widget_endpoint( widget: WidgetCreate, db: AsyncSession = Depends(get_db), user_email: str = Depends(get_current_user_email) ): """Create a new widget.""" db_widget = await create_widget(db, widget) return db_widget
-
Register router in
main.py:from routers import widgets api_router.include_router(widgets.router)
-
Add tests in
tests/test_widgets.py:import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_create_widget(client: AsyncClient): response = await client.post( "/api/widgets/", json={"name": "Test Widget", "description": "Test"} ) assert response.status_code == 201 data = response.json() assert data["name"] == "Test Widget"
Creating records:
from core.models import Project
from sqlalchemy.ext.asyncio import AsyncSession
async def create_project(db: AsyncSession, name: str, group_id: str):
project = Project(name=name, meta_group_id=group_id)
db.add(project)
await db.commit()
await db.refresh(project)
return projectQuerying records:
from sqlalchemy import select
async def get_project(db: AsyncSession, project_id: UUID):
result = await db.execute(
select(Project).where(Project.id == project_id)
)
return result.scalar_one_or_none()Updating records:
async def update_project(db: AsyncSession, project_id: UUID, name: str):
project = await get_project(db, project_id)
if not project:
raise ValueError("Project not found")
project.name = name
await db.commit()
await db.refresh(project)
return projectDeleting records:
async def delete_project(db: AsyncSession, project_id: UUID):
project = await get_project(db, project_id)
if project:
await db.delete(project)
await db.commit()Initialize client (in utils/boto3_client.py):
import boto3
from core.config import settings
def get_s3_client():
return boto3.client(
's3',
endpoint_url=f"http://{settings.S3_ENDPOINT}" if not settings.S3_USE_SSL else f"https://{settings.S3_ENDPOINT}",
aws_access_key_id=settings.S3_ACCESS_KEY,
aws_secret_access_key=settings.S3_SECRET_KEY
)Upload file:
from utils.boto3_client import get_s3_client
s3_client = get_s3_client()
# Upload from bytes
s3_client.put_object(
Bucket=settings.S3_BUCKET,
Key=f"projects/{project_id}/{filename}",
Body=file_content,
ContentType=content_type
)
# Upload from file
s3_client.upload_file(
"/path/to/file",
settings.S3_BUCKET,
f"projects/{project_id}/{filename}"
)Generate presigned URL:
url = s3_client.generate_presigned_url(
'get_object',
Params={
'Bucket': settings.S3_BUCKET,
'Key': object_key
},
ExpiresIn=3600 # 1 hour
)Using cache decorator:
from aiocache import cached
@cached(ttl=300, key="projects:list")
async def get_all_projects(db: AsyncSession):
result = await db.execute(select(Project))
return result.scalars().all()Manual cache operations:
from aiocache import Cache
cache = Cache()
# Set value
await cache.set("key", value, ttl=300)
# Get value
value = await cache.get("key")
# Delete value
await cache.delete("key")
# Clear all
await cache.clear()Cache invalidation pattern:
@router.post("/")
async def create_project(...):
project = await create_project_in_db(...)
# Invalidate list cache
await cache.delete(f"projects:user:{user_email}:skip:0:limit:100")
return projectfrontend/src/
├── App.js # Main component with routing
├── App.css # Global styles
├── Project.js # Project detail view
├── ImageView.js # Image detail view
├── ApiKeys.js # API key management
├── components/
│ ├── ImageGallery.js # Grid view of images
│ ├── ImageDisplay.js # Main image with overlays
│ ├── ImageClassifications.js # Classification UI
│ ├── ImageComments.js # Comment threads
│ ├── MLAnalysisPanel.js # ML analysis controls
│ ├── ClassManager.js # Class management
│ └── ...
└── __tests__/ # Jest tests
Functional component with hooks:
import React, { useState, useEffect } from 'react';
function MyComponent({ projectId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchData() {
try {
const response = await fetch(`/api/projects/${projectId}`);
if (!response.ok) throw new Error('Failed to fetch');
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchData();
}, [projectId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>{data.name}</h2>
{/* Render data */}
</div>
);
}
export default MyComponent;Custom hook for API calls:
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) throw new Error('Request failed');
const json = await response.json();
if (!cancelled) {
setData(json);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function Component() {
const { data, loading, error } = useFetch('/api/projects');
// ...
}GET request:
const response = await fetch('/api/projects');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const projects = await response.json();POST request:
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'New Project',
description: 'Description',
meta_group_id: 'group-id'
})
});
if (!response.ok) {
throw new Error('Failed to create project');
}
const project = await response.json();File upload:
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('project_id', projectId);
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});Use React hooks for state management:
import { useState, useCallback } from 'react';
function useProjects() {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(false);
const fetchProjects = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/projects');
const data = await response.json();
setProjects(data);
} finally {
setLoading(false);
}
}, []);
const createProject = useCallback(async (projectData) => {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(projectData)
});
const newProject = await response.json();
setProjects(prev => [...prev, newProject]);
return newProject;
}, []);
return { projects, loading, fetchProjects, createProject };
}The application uses pure CSS (no CSS-in-JS or frameworks):
/* Component-specific styles in App.css */
.my-component {
display: flex;
flex-direction: column;
gap: 1rem;
}
.my-component__header {
font-size: 1.5rem;
font-weight: bold;
}
/* Use BEM naming convention for clarity */All models are defined in backend/core/models.py using SQLAlchemy 2.0 async:
Key models:
User- Application usersProject- Top-level organization unitDataInstance- Images/filesImageClass- Custom classification labelsImageClassification- Links images to classesImageComment- Comments on imagesMLAnalysis- ML analysis metadataMLAnnotation- Individual ML annotationsMLArtifact- ML output artifacts
Alembic manages database schema migrations.
Creating migrations:
cd backend
alembic revision --autogenerate -m "add new field"Reviewing migrations: Always review auto-generated migrations before applying:
cat alembic/versions/<revision>_*.pyApplying migrations:
alembic upgrade headRolling back:
alembic downgrade -1 # Rollback one migration
alembic downgrade <revision> # Rollback to specific revisionMigration history:
alembic history --verbose
alembic currentAdding a column:
def upgrade():
op.add_column('projects',
sa.Column('new_field', sa.String(255), nullable=True)
)
def downgrade():
op.drop_column('projects', 'new_field')Adding an index:
def upgrade():
op.create_index('ix_projects_name', 'projects', ['name'])
def downgrade():
op.drop_index('ix_projects_name', table_name='projects')Adding a foreign key:
def upgrade():
op.add_column('images',
sa.Column('category_id', sa.UUID(), nullable=True)
)
op.create_foreign_key(
'fk_images_category',
'images', 'categories',
['category_id'], ['id']
)
def downgrade():
op.drop_constraint('fk_images_category', 'images')
op.drop_column('images', 'category_id')The API is self-documenting via OpenAPI/Swagger:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- OpenAPI JSON: http://localhost:8000/openapi.json
Development: No authentication required when SKIP_HEADER_CHECK=true
Production: All requests must include:
X-User-Email: User's emailX-Proxy-Secret: Shared secret
Success (200 OK):
{
"id": "uuid",
"name": "Project Name",
"created_at": "2024-01-01T00:00:00Z"
}Error (4xx/5xx):
{
"detail": "Error message"
}List with pagination:
{
"items": [...],
"total": 100,
"skip": 0,
"limit": 20
}-
Use Pydantic models for request/response validation
-
Return appropriate status codes:
- 200 OK - Successful GET/PUT/PATCH
- 201 Created - Successful POST
- 204 No Content - Successful DELETE
- 400 Bad Request - Invalid input
- 401 Unauthorized - Authentication required
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource doesn't exist
- 500 Internal Server Error - Server error
-
Use dependency injection for common operations (db session, current user)
-
Include docstrings for OpenAPI documentation
-
Validate inputs with Pydantic
-
Handle errors gracefully with appropriate error messages
Location: backend/tests/
Running tests:
cd backend
uv run pytest # All tests
pytest tests/test_projects.py # Specific file
pytest tests/test_projects.py::test_create_project # Specific test
pytest -v # Verbose
pytest -k "auth" # Pattern matching
pytest --cov # With coverageTest structure:
import pytest
from httpx import AsyncClient
from core.models import Project
@pytest.mark.asyncio
async def test_create_project(client: AsyncClient, db_session):
"""Test creating a new project."""
response = await client.post(
"/api/projects/",
json={
"name": "Test Project",
"description": "Test",
"meta_group_id": "test-group"
}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Project"
assert "id" in dataFixtures:
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from httpx import AsyncClient
from main import app
@pytest.fixture
async def db_session():
"""Provide a test database session."""
engine = create_async_engine("sqlite+aiosqlite:///./test.db")
# Create tables, yield session, cleanup
# ...
@pytest.fixture
async def client(db_session):
"""Provide a test client."""
async with AsyncClient(app=app, base_url="http://test") as ac:
yield acLocation: frontend/src/__tests__/
Running tests:
cd frontend
npm test # Interactive mode
npm test -- --coverage # With coverageTest structure:
import { render, screen, fireEvent } from '@testing-library/react';
import MyComponent from '../MyComponent';
test('renders component correctly', () => {
render(<MyComponent />);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
test('handles click event', () => {
render(<MyComponent />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Clicked')).toBeInTheDocument();
});Mocking API calls:
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
})
);
test('fetches data', async () => {
render(<MyComponent />);
await screen.findByText('test');
expect(fetch).toHaveBeenCalledWith('/api/endpoint');
});Test full workflows combining frontend and backend:
@pytest.mark.asyncio
async def test_upload_and_classify_image(client, db_session):
# Create project
project = await create_test_project(db_session)
# Upload image
files = {'file': ('test.jpg', image_bytes, 'image/jpeg')}
response = await client.post(
f"/api/images/upload?project_id={project.id}",
files=files
)
assert response.status_code == 201
image = response.json()
# Create class
class_response = await client.post(
f"/api/projects/{project.id}/classes",
json={"name": "Test Class"}
)
image_class = class_response.json()
# Classify image
classify_response = await client.post(
f"/api/images/{image['id']}/classifications",
json={"class_id": image_class["id"]}
)
assert classify_response.status_code == 201- Isolate tests - Each test should be independent
- Use fixtures for common setup
- Test edge cases - Empty lists, null values, errors
- Mock external services - S3, auth servers, etc.
- Use descriptive test names - What is being tested
- Assert specific values - Not just status codes
- Clean up - Reset database state between tests
- No emojis - Professional code and documentation only
- File limit - Each file should be less than 400 lines
- Clear naming - Descriptive variable and function names
- Comments - Only when necessary to explain complex logic
- Documentation - Docstrings for public APIs
# Imports grouped and sorted
import os
import sys
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
from core.models import Project
from core.schemas import ProjectCreate
# Type hints
async def get_project(
db: AsyncSession,
project_id: UUID
) -> Optional[Project]:
"""Get a project by ID.
Args:
db: Database session
project_id: UUID of the project
Returns:
Project if found, None otherwise
"""
result = await db.execute(
select(Project).where(Project.id == project_id)
)
return result.scalar_one_or_none()
# Constants in CAPS
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB// Use const/let, not var
const API_BASE = '/api';
let currentProject = null;
// Arrow functions for callbacks
const fetchProjects = async () => {
const response = await fetch(`${API_BASE}/projects`);
return response.json();
};
// Destructuring
const { name, description } = project;
// Template literals
const url = `/api/projects/${projectId}/images`;
// Async/await over promises
async function loadData() {
try {
const data = await fetchData();
processData(data);
} catch (error) {
console.error('Failed to load:', error);
}
}Python:
# Format with black
black backend/
# Sort imports
isort backend/
# Lint with flake8
flake8 backend/JavaScript:
cd frontend
npm run lint
npm run format- Validate all user inputs with Pydantic
- Use parameterized queries (SQLAlchemy handles this)
- Sanitize file uploads (check file type, size)
- Use presigned URLs for S3 access
- Never log sensitive data (passwords, tokens)
- Use HTTPS in production
- Implement rate limiting for sensitive endpoints
- Validate file extensions and MIME types
- Check group membership before granting access
- Use constant-time comparison for secrets
Always validate inputs:
from pydantic import BaseModel, validator, Field
class ProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
meta_group_id: str = Field(..., min_length=1, max_length=255)
@validator('name')
def name_must_not_be_empty(cls, v):
if not v or not v.strip():
raise ValueError('Name cannot be empty')
return v.strip()SQLAlchemy ORM protects against SQL injection. Always use:
# SAFE - parameterized
result = await db.execute(
select(Project).where(Project.id == project_id)
)
# SAFE - ORM methods
project = Project(name=name, description=description)
db.add(project)
# NEVER - raw SQL with string interpolation
# UNSAFE: await db.execute(f"SELECT * FROM projects WHERE id = '{project_id}'")Validate uploaded files:
from utils.file_security import validate_file_type
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
async def upload_image(file: UploadFile):
# Check file size
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise HTTPException(400, "File too large")
# Check file extension
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, "Invalid file type")
# Validate MIME type
if not file.content_type.startswith('image/'):
raise HTTPException(400, "Not an image file")
# Proceed with upload
# ...React automatically escapes values in JSX, preventing XSS:
// SAFE - React escapes by default
<div>{userInput}</div>
// DANGEROUS - Only use for trusted HTML
<div dangerouslySetInnerHTML={{__html: trustedHtml}} />-
Create a branch:
git checkout -b feature/my-feature
-
Make changes following code style guidelines
-
Add tests for new functionality
-
Run tests:
./test/run_tests.sh
-
Commit changes:
git add . git commit -m "Add feature: description"
-
Push to repository:
git push origin feature/my-feature
-
Create pull request with description of changes
Use clear, descriptive commit messages:
Add user profile feature
- Create profile model and schema
- Add profile API endpoints
- Implement profile UI component
- Add profile tests
Format:
- First line: Brief summary (50 chars or less)
- Blank line
- Detailed description if needed
- List of changes with bullet points
Good PR:
- Clear title and description
- References related issues
- Includes tests
- Updates documentation
- Small, focused changes
- All tests passing
PR template:
## Description
Brief description of changes
## Related Issues
Fixes #123
## Changes
- Added feature X
- Updated component Y
- Fixed bug Z
## Testing
- [ ] Added unit tests
- [ ] Added integration tests
- [ ] Manual testing completed
## Documentation
- [ ] Updated README
- [ ] Updated API docs
- [ ] Added code commentsAs author:
- Respond to all comments
- Make requested changes
- Keep discussions focused
- Be open to feedback
As reviewer:
- Be constructive and respectful
- Explain reasoning for suggestions
- Focus on code quality and correctness
- Approve when satisfied
- Main README:
/README.md - Admin Guide:
/docs/admin-guide.md - User Guide:
/docs/user-guide.md - Production Setup:
/docs/production/proxy-setup.md
- FastAPI: https://fastapi.tiangolo.com/
- React: https://react.dev/
- SQLAlchemy: https://docs.sqlalchemy.org/
- PostgreSQL: https://www.postgresql.org/docs/
- MinIO: https://min.io/docs/
- Check documentation first
- Search existing GitHub issues
- Create new issue with:
- Clear description
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, versions, etc.)