diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..338e2ec5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +server/.gitignore +server/Dockerfile +server/entrypoint.sh diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..45e1c1ce --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [sahilds1] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/architectural-issue.md b/.github/ISSUE_TEMPLATE/architectural-issue.md deleted file mode 100644 index 266f6868..00000000 --- a/.github/ISSUE_TEMPLATE/architectural-issue.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Architectural issue -about: For issues related to alteration of project architecture -title: '' -labels: '' -assignees: '' - ---- - -# USER STORY HERE - -## Purpose: TLDR of why we need this? -### Description -Is your feature request related to a problem? Please describe. - -## Functionality: TLDR of what will this change? -### Description -A clear and concise description of what you will happen. - -## Affects: Where will this change? - -## Pros/Cons -Describe alternatives you've considered -A clear and concise description of any alternative solutions or features you've considered. - -## Additional context -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..f2820548 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,32 @@ +--- +name: Issue template +about: Help push the project forward +title: '' +labels: '' +assignees: '' + +--- + +## Title + + +## Background + + +## Existing Behavior + + +## Acceptance Criteria +- [] + +## Approach + + +## References + + +## Risks and Rollback + + +## Screenshots / Recordings + \ No newline at end of file diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 9cd5fcce..9d3435b6 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -1,8 +1,10 @@ name: "Containers: Publish" on: + release: + types: [published] push: - tags: ["v*"] + branches: [develop] permissions: packages: write @@ -12,10 +14,10 @@ jobs: name: Build and Push runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Login to ghcr.io Docker registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -24,7 +26,18 @@ jobs: - name: Compute Docker container image addresses run: | DOCKER_REPOSITORY="ghcr.io/${GITHUB_REPOSITORY,,}" - DOCKER_TAG="${GITHUB_REF:11}" + git fetch --tags --force + + if [[ "${{ github.event_name }}" == "release" ]]; then + TAG="${GITHUB_REF#refs/tags/}" + DOCKER_TAG="${TAG#v}" + else + # Pre-release for develop + BASE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + VERSION="${BASE_TAG#v}" + TIMESTAMP=$(date +%Y%m%d%H%M%S) + DOCKER_TAG="${VERSION}-dev.${TIMESTAMP}" + fi echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_ENV echo "DOCKER_TAG=${DOCKER_TAG}" >> $GITHUB_ENV @@ -34,36 +47,30 @@ jobs: # - name: 'Pull previous Docker container image: :latest' # run: docker pull "${DOCKER_REPOSITORY}:latest" || true - - name: "Pull previous Docker container image: frontend-static:latest" - run: docker pull "${DOCKER_REPOSITORY}/frontend-static:latest" || true + - name: "Pull previous Docker container image: app:latest" + run: docker pull "${DOCKER_REPOSITORY}/app:latest" || true - - name: "Build Docker container image: frontend-static:latest" + - name: "Build Docker container image: app:latest" run: | docker build \ - --cache-from "${DOCKER_REPOSITORY}/frontend-static:latest" \ - --file frontend/Dockerfile.demo \ - --build-arg SERVER_NAME=localhost \ - --tag "${DOCKER_REPOSITORY}/frontend-static:latest" \ - --tag "${DOCKER_REPOSITORY}/frontend-static:${DOCKER_TAG}" \ - frontend - - name: "Push Docker container image frontend-static:latest" - run: docker push "${DOCKER_REPOSITORY}/frontend-static:latest" + --cache-from "${DOCKER_REPOSITORY}/app:latest" \ + --file Dockerfile.prod \ + --tag "${DOCKER_REPOSITORY}/app:latest" \ + --tag "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" \ + --build-arg VERSION="${DOCKER_TAG}" \ + . - - name: "Push Docker container image frontend-static:v*" - run: docker push "${DOCKER_REPOSITORY}/frontend-static:${DOCKER_TAG}" -# -# -# - name: 'Build Docker container image: backend:latest' -# run: | -# cd backend && \ -# make && \ -# docker image tag "${DOCKER_REPOSITORY}/backend/local:latest" "${DOCKER_REPOSITORY}/backend:latest" -# -# - name: Push Docker container image backend:latest -# run: docker push "${DOCKER_REPOSITORY}/backend:latest" -# -# - name: Push Docker container image backend:v* -# run: docker push "${DOCKER_REPOSITORY}/backend:${DOCKER_TAG}" + - name: "Push Docker container image app:latest" + run: docker push "${DOCKER_REPOSITORY}/app:latest" -# - name: Push Docker container image :v*" -# run: docker push "${DOCKER_REPOSITORY}:${DOCKER_TAG}" + - name: "Push Docker container image app:v*" + run: docker push "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" + + - name: Save Docker Tag + run: echo "${DOCKER_TAG}" > docker_tag.txt + + - name: Upload Docker Tag + uses: actions/upload-artifact@v4 + with: + name: docker-tag + path: docker_tag.txt diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 00000000..4427c9f5 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,35 @@ +name: "Frontend: Lint and Build" + +on: + push: + branches: [develop] + pull_request: + branches: [develop] + +jobs: + frontend: + name: Lint and Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Lint + run: npm run lint + continue-on-error: true + + - name: Build + run: npm run build + continue-on-error: true diff --git a/.gitignore b/.gitignore index bf4f8dfe..984178dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -config/* +config/env/* +!config/env/*.example .idea/ -env* \ No newline at end of file +.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d7b1fd77 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Conditional PDF Access Based on Authentication** (2025-01-XX) + - Logged-in users see "View PDF" button that opens PDF viewer in new tab + - Non-logged-in users see "Download PDF" button that directly downloads the file + - Backend: Added `upload_file_guid` field to risk/source API responses + - Frontend: Conditional rendering based on Redux authentication state + - Fallback GUID extraction from URL if backend field is missing + + **Backend Changes:** + + *File: `server/api/views/risk/views_riskWithSources.py`* + ```python + # Added to source_info dictionary in 3 locations (lines ~138, ~252, ~359): + source_info = { + 'filename': filename, + 'title': getattr(embedding, 'title', None), + 'publication': getattr(embedding, 'publication', ''), + 'text': getattr(embedding, 'text', ''), + 'rule_type': medrule.rule_type, + 'history_type': medrule.history_type, + 'upload_fileid': getattr(embedding, 'upload_file_id', None), + 'page': getattr(embedding, 'page_num', None), + 'link_url': self._build_pdf_link(embedding), + 'upload_file_guid': str(embedding.upload_file.guid) if embedding.upload_file else None # NEW + } + ``` + + **Frontend Changes:** + + *File: `frontend/src/pages/PatientManager/PatientManager.tsx`* + ```typescript + // Added imports: + import { useSelector } from "react-redux"; + import { RootState } from "../../services/actions/types"; + + // Added hook to get auth state: + const { isAuthenticated } = useSelector((state: RootState) => state.auth); + + // Passed to PatientSummary: + + ``` + + *File: `frontend/src/pages/PatientManager/PatientSummary.tsx`* + ```typescript + // Updated interface: + interface PatientSummaryProps { + // ... existing props + isAuthenticated?: boolean; // NEW + } + + // Updated SourceItem type: + type SourceItem = { + // ... existing fields + upload_file_guid?: string | null; // NEW + }; + + // Added helper function: + const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } + }; + + // Updated component: + const PatientSummary = ({ + // ... existing props + isAuthenticated = false, // NEW + }: PatientSummaryProps) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || ''; // NEW + + // Updated MedicationItem props: + const MedicationItem = ({ + // ... existing props + isAuthenticated, // NEW + baseURL, // NEW + }: { + // ... existing types + isAuthenticated: boolean; // NEW + baseURL: string; // NEW + }) => { + + // Updated MedicationTier props: + const MedicationTier = ({ + // ... existing props + isAuthenticated, // NEW + baseURL, // NEW + }: { + // ... existing types + isAuthenticated: boolean; // NEW + baseURL: string; // NEW + }) => ( + // ... passes to MedicationItem + + ); + + // Conditional button rendering: + {s.link_url && (() => { + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + return isAuthenticated ? ( + + View PDF + + ) : ( + + Download PDF + + ); + })()} + + // Updated all MedicationTier calls to pass new props: + + ``` + +### Fixed +- **URL Route Case Consistency** (2025-01-XX) + - Fixed case mismatch between backend URL generation (`/drugsummary`) and frontend route (`/drugSummary`) + - Updated all references to use consistent camelCase `/drugSummary` route + - Affected files: `views_riskWithSources.py`, `Layout_V2_Sidebar.tsx`, `Layout_V2_Header.tsx`, `FileRow.tsx` + +- **Protected Route Authentication Flow** (2025-01-XX) + - Fixed blank page issue when opening protected routes in new tab + - `ProtectedRoute` now waits for authentication check to complete before redirecting + - Added `useAuth()` hook to `Layout_V2_Main` to trigger auth verification + +### Changed +- **PatientSummary Component** (2025-01-XX) + - Now receives `isAuthenticated` prop from Redux state + - Props passed through component hierarchy: `PatientManager` → `PatientSummary` → `MedicationTier` → `MedicationItem` + - Added `baseURL` constant for API endpoint construction + +## [Previous versions would go here] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b9f417e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,306 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Balancer is a web application designed to help prescribers choose suitable medications for patients with bipolar disorder. It's a Code for Philly project built with a PostgreSQL + Django REST Framework + React stack, running on Docker. + +Live site: https://balancertestsite.com + +## Development Setup + +### Prerequisites +- Docker Desktop +- Node.js and npm +- API keys for OpenAI and Anthropic (request from team) + +### Initial Setup +```bash +# Clone the repository +git clone + +# Install frontend dependencies +cd frontend +npm install +cd .. + +# Configure environment variables +# Copy config/env/dev.env.example and fill in API keys: +# - OPENAI_API_KEY +# - ANTHROPIC_API_KEY +# - PINECONE_API_KEY (if needed) + +# Start all services +docker compose up --build +``` + +### Services +- **Frontend**: React + Vite dev server at http://localhost:3000 +- **Backend**: Django REST Framework at http://localhost:8000 +- **Database**: PostgreSQL at localhost:5433 +- **pgAdmin**: Commented out by default (port 5050) + +## Common Development Commands + +### Docker Operations +```bash +# Start all services +docker compose up --build + +# Start in detached mode +docker compose up -d + +# View logs +docker compose logs -f [service_name] + +# Stop all services +docker compose down + +# Rebuild a specific service +docker compose build [frontend|backend|db] + +# Access Django shell in backend container +docker compose exec backend python manage.py shell + +# Run Django migrations +docker compose exec backend python manage.py makemigrations +docker compose exec backend python manage.py migrate +``` + +### Frontend Development +```bash +cd frontend + +# Start dev server (outside Docker) +npm run dev + +# Build for production +npm run build + +# Lint TypeScript/TSX files +npm run lint + +# Preview production build +npm run preview +``` + +### Backend Development +```bash +cd server + +# Create Django superuser (credentials in api/management/commands/createsu.py) +docker compose exec backend python manage.py createsuperuser + +# Access Django admin +# Navigate to http://localhost:8000/admin + +# Run database migrations +docker compose exec backend python manage.py makemigrations +docker compose exec backend python manage.py migrate + +# Django shell +docker compose exec backend python manage.py shell +``` + +### Git Workflow +- Main development branch: `develop` +- Production branch: `listOfMed` (used for PRs) +- Create feature branches from `develop` +- PRs should target `listOfMed` branch + +## Architecture + +### Backend Architecture (Django REST Framework) + +#### URL Routing Pattern +Django uses **dynamic URL importing** (see `server/balancer_backend/urls.py`). API endpoints are organized by feature modules in `server/api/views/`: +- `conversations/` - Patient conversation management +- `feedback/` - User feedback +- `listMeds/` - Medication catalog +- `risk/` - Risk assessment endpoints +- `uploadFile/` - PDF document uploads +- `ai_promptStorage/` - AI prompt templates +- `ai_settings/` - AI configuration +- `embeddings/` - Vector embeddings for RAG +- `medRules/` - Medication rules management +- `text_extraction/` - PDF text extraction +- `assistant/` - AI assistant endpoints + +Each module contains: +- `views.py` or `views_*.py` - API endpoints +- `models.py` - Django ORM models +- `urls.py` - URL patterns +- `serializers.py` - DRF serializers (if present) + +#### Authentication +- Uses **JWT authentication** with `rest_framework_simplejwt` +- Default: All endpoints require authentication (`IsAuthenticated`) +- To make an endpoint public, add to the view class: + ```python + from rest_framework.permissions import AllowAny + + class MyView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] # Optional: disable auth entirely + ``` +- Auth endpoints via Djoser: `/auth/` +- JWT token lifetime: 60 minutes (access), 1 day (refresh) + +#### API Documentation +- Auto-generated using **drf-spectacular** (OpenAPI 3.0) +- **Swagger UI**: `http://localhost:8000/api/docs/` — interactive API explorer +- **ReDoc**: `http://localhost:8000/api/redoc/` — readable reference docs +- **Raw schema**: `http://localhost:8000/api/schema/` +- Configuration in `SPECTACULAR_SETTINGS` in `settings.py` +- Views use `@extend_schema` decorators and `serializer_class` attributes for schema generation +- JWT auth is configured in the schema — use `JWT ` (not `Bearer`) in Swagger UI's Authorize dialog +- To document a new endpoint: add `serializer_class` to the view if it has one, or add `@extend_schema` with `inline_serializer` for views returning raw dicts + +#### Key Data Models +- **Medication** (`api.views.listMeds.models`) - Medication catalog with benefits/risks +- **MedRule** (`api.models.model_medRule`) - Include/Exclude rules for medications based on patient history +- **MedRuleSource** - Junction table linking MedRules → Embeddings → Medications +- **Embeddings** (`api.models.model_embeddings`) - Vector embeddings from uploaded PDFs for RAG +- **UploadFile** (`api.views.uploadFile.models`) - Uploaded PDF documents with GUID references + +#### RAG (Retrieval Augmented Generation) System +The application uses embeddings from medical literature PDFs to provide evidence-based medication recommendations: +1. PDFs uploaded via `uploadFile` → text extracted → chunked → embedded (OpenAI/Pinecone) +2. MedRules created linking medications to specific evidence (embeddings) +3. API endpoints return recommendations with source citations (filename, page number, text excerpt) + +### Frontend Architecture (React + TypeScript) + +#### Project Structure +- **`src/components/`** - Reusable React components (Header, forms, etc.) +- **`src/pages/`** - Page-level components +- **`src/routes/routes.tsx`** - React Router configuration +- **`src/services/`** - Redux store, actions, reducers, API clients +- **`src/contexts/`** - React Context providers (GlobalContext for app state) +- **`src/api/`** - API client functions using Axios +- **`src/utils/`** - Utility functions + +#### State Management +- **Redux** for auth state and global application data + - Store: `src/services/store.tsx` + - Actions: `src/services/actions/` + - Reducers: `src/services/reducers/` +- **React Context** (`GlobalContext`) for UI state: + - `showSummary` - Display medication summary + - `enterNewPatient` - New patient form state + - `isEditing` - Form edit mode + - `showMetaPanel` - Metadata panel visibility + +#### Routing +Routes defined in `src/routes/routes.tsx`: +- `/` - Medication Suggester (main tool) +- `/medications` - Medication List +- `/about` - About page +- `/help` - Help documentation +- `/feedback` - Feedback form +- `/logout` - Logout handler +- Admin routes (superuser only): + - `/rulesmanager` - Manage medication rules + - `/ManageMeds` - Manage medication database + +#### Styling +- **Tailwind CSS** for utility-first styling +- **PostCSS** with nesting support +- Custom CSS in component directories (e.g., `Header/header.css`) +- Fonts: Quicksand (branding), Satoshi (body text) + +### Database Schema Notes +- **pgvector extension** enabled for vector similarity search +- Custom Dockerfile for PostgreSQL (`db/Dockerfile`) - workaround for ARM64 compatibility +- Database connection: + - Host: `db` (Docker internal) or `localhost:5433` (external) + - Credentials: `balancer/balancer` (dev environment) + - Database: `balancer_dev` + +### Environment Configuration +- **Development**: `config/env/dev.env` (used by Docker Compose) +- **Frontend Production**: `frontend/.env.production` + - Contains `VITE_API_BASE_URL` for production API endpoint +- **Never commit** actual API keys - use `.env.example` as template +- Django `SECRET_KEY` should be a long random string in production (not "foo") + +## Important Development Patterns + +### Adding a New API Endpoint +1. Create view in appropriate `server/api/views/{module}/views.py` +2. Add URL pattern to `server/api/views/{module}/urls.py` +3. If new module, add to `urls` list in `server/balancer_backend/urls.py` +4. Consider authentication requirements (add `permission_classes` if needed) + +### Working with MedRules +MedRules use a many-to-many relationship with medications and embeddings: +- `rule_type`: "INCLUDE" (beneficial) or "EXCLUDE" (contraindicated) +- `history_type`: Patient diagnosis state (e.g., "DIAGNOSIS_DEPRESSED", "DIAGNOSIS_MANIC") +- Access sources via `MedRuleSource` intermediate model +- API returns benefits/risks with source citations (filename, page, text, **upload_file_guid**) + +### PDF Access and Authentication +**Feature**: Conditional PDF viewing/downloading based on authentication state + +**Behavior**: +- **Logged-in users**: See "View PDF" button (blue) that opens `/drugSummary` page in new tab +- **Non-logged-in users**: See "Download PDF" button (green) that directly downloads via `/v1/api/uploadFile/` endpoint + +**Implementation Details**: +- Backend: `upload_file_guid` field added to source_info in `views_riskWithSources.py` (3 locations) +- Frontend: `isAuthenticated` prop passed through component hierarchy: + - `PatientManager` (gets from Redux) → `PatientSummary` → `MedicationTier` → `MedicationItem` +- Download endpoint: `/v1/api/uploadFile/` is **public** (AllowAny permission) +- Fallback: If `upload_file_guid` missing from API, GUID is extracted from `link_url` query parameter +- Route: `/drugSummary` (camelCase) - fixed from inconsistent `/drugsummary` usage + +**Files Modified**: +- Backend: `server/api/views/risk/views_riskWithSources.py` +- Frontend: `frontend/src/pages/PatientManager/PatientManager.tsx`, `PatientSummary.tsx` +- Routes: Multiple files updated for consistent `/drugSummary` casing +- Auth: `ProtectedRoute.tsx` and `Layout_V2_Main.tsx` fixed for proper auth checking + +### Frontend API Calls +- API client functions in `src/api/` +- Use Axios with base URL from environment +- JWT tokens managed by Redux auth state +- Error handling should check for 401 (unauthorized) and redirect to login + +### Docker Networking +Services use a custom network (192.168.0.0/24): +- db: 192.168.0.2 +- backend: 192.168.0.3 +- frontend: 192.168.0.5 +- Services communicate using service names (e.g., `http://backend:8000`) + +## Testing + +### Backend Tests +Limited test coverage currently. Example test: +- `server/api/views/uploadFile/test_title.py` + +To run tests: +```bash +docker compose exec backend python manage.py test +``` + +### Frontend Tests +No test framework currently configured. Consider adding Jest/Vitest for future testing. + +## Key Files Reference + +- `server/balancer_backend/settings.py` - Django configuration (auth, database, CORS) +- `server/balancer_backend/urls.py` - Root URL configuration with dynamic imports +- `frontend/src/routes/routes.tsx` - React Router configuration +- `frontend/src/services/store.tsx` - Redux store setup +- `docker-compose.yml` - Local development environment +- `config/env/dev.env.example` - Environment variables template + +## Project Conventions + +- Python: Follow Django conventions, use class-based views (APIView) +- TypeScript: Use functional components with hooks, avoid default exports except for pages +- CSS: Prefer Tailwind utilities, use custom CSS only when necessary +- Git: Feature branches from `develop`, PRs to `listOfMed` +- Code formatting: Prettier for frontend (with Tailwind plugin) diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000..21a24ecd --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,58 @@ +# Multi-stage Dockerfile for Balancer Application +# Produces a single image with Django backend serving the React frontend + +# Stage 1: Build Frontend +FROM node:18 AS frontend-builder + +WORKDIR /frontend + +# Copy frontend package files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend - outputs to dist/ as configured in vite.config.ts +RUN npm run build + +# Stage 2: Build Backend +FROM python:3.11.4-slim-bullseye + +# Receive version argument from build command +ARG VERSION +ENV VERSION=${VERSION} + +# Set work directory +WORKDIR /usr/src/app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies +RUN apt-get update && apt-get install -y netcat && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +RUN pip install --upgrade pip --no-cache-dir +COPY server/requirements.txt . +# Install CPU-only torch to save space (avoids ~4GB of CUDA libs) +RUN pip install torch --index-url https://download.pytorch.org/whl/cpu --no-cache-dir +RUN pip install -r requirements.txt --no-cache-dir + +# Copy backend application code +COPY server/ . + +# Copy frontend build from frontend-builder stage to where Django expects it +COPY --from=frontend-builder /frontend/dist ./build + +# Expose port +EXPOSE 8000 + +# Run entrypoint +ENTRYPOINT ["./entrypoint.prod.sh"] + +# Start gunicorn +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--noreload"] diff --git a/README.md b/README.md index 0b48973e..fe765910 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage -You can view the current build of the website here: [https://balancertestsite.com](https://balancertestsite.com/) +You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) + +You can view the website in a sandbox here: [https://sandbox.balancerproject.org/](https://sandbox.balancerproject.org/) ## Contributing @@ -21,17 +23,19 @@ The project kanban board is [on GitHub here](https://github.com/orgs/CodeForPhil The Code for Philly Code of Conduct is [here](https://codeforphilly.org/pages/code_of_conduct/) -### Setting up a development environment +### Setting up a development environment Get the code using git by either forking or cloning `CodeForPhilly/balancer-main` -Tools used to run Balancer: -1. `OpenAI API`: Ask for an API key and add it to `config/env/env.dev` -2. `Anthropic API`: Ask for an API key and add it to `config/env/env.dev` +1. Copy the example environment file: + ```bash + cp config/env/dev.env.example config/env/dev.env + ``` +2. (Optional) Add your API keys to `config/env/dev.env`: + - `OpenAI API` Tools used for development: 1. `Docker`: Install Docker Desktop -2. `Postman`: Ask to get invited to the Balancer Postman team `balancer_dev` 3. `npm`: In the terminal run 1) 'cd frontend' 2) 'npm install' 3) 'cd ..' ### Running Balancer for development @@ -39,7 +43,17 @@ Tools used for development: Start the Postgres, Django REST, and React services by starting Docker Desktop and running `docker compose up --build` #### Postgres -- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) + +The application supports connecting to PostgreSQL databases via: + +1. **CloudNativePG** - Kubernetes-managed PostgreSQL cluster (for production/sandbox) +2. **AWS RDS** - External PostgreSQL database (AWS managed) +3. **Local Docker Compose** - For local development + +See [Database Connection Documentation](./docs/DATABASE_CONNECTION.md) for detailed configuration. + +**Local Development:** +- Download a sample of papers to upload from [https://balancerproject.org/](https://balancerproject.org/) - The email and password of `pgAdmin` are specified in `balancer-main/docker-compose.yml` - The first time you use `pgAdmin` after building the Docker containers you will need to register the server. - The `Host name/address` is the Postgres server service name in the Docker Compose file @@ -59,40 +73,36 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` +- Backend tests can be run using `pytest` by running the below command inside the running backend container: -## Local Kubernetes Deployment +``` +docker compose exec backend pytest api/ -v +``` -### Prereqs +## API Documentation -- Fill the configmap with the [env vars](./deploy/manifests/balancer/base/configmap.yml) -- Install [Devbox](https://www.jetify.com/devbox) -- Run the following script with admin privileges: +Interactive API docs are auto-generated using [drf-spectacular](https://drf-spectacular.readthedocs.io/) and available at: -```bash -HOSTNAME="balancertestsite.com" -LOCAL_IP="127.0.0.1" +- **Swagger UI**: [http://localhost:8000/api/docs/](http://localhost:8000/api/docs/) — interactive explorer with "Try it out" functionality +- **ReDoc**: [http://localhost:8000/api/redoc/](http://localhost:8000/api/redoc/) — clean, readable reference docs +- **Raw schema**: [http://localhost:8000/api/schema/](http://localhost:8000/api/schema/) — OpenAPI 3.0 JSON/YAML -# Check if the correct line already exists -if grep -q "^$LOCAL_IP[[:space:]]\+$HOSTNAME" /etc/hosts; then - echo "Entry for $HOSTNAME with IP $LOCAL_IP already exists in /etc/hosts" -else - echo "Updating /etc/hosts for $HOSTNAME" - sudo sed -i "/[[:space:]]$HOSTNAME/d" /etc/hosts - echo "$LOCAL_IP $HOSTNAME" | sudo tee -a /etc/hosts -fi -``` +### Testing authenticated endpoints -### Steps to reproduce +Most endpoints require JWT authentication. To test them in Swagger UI: -Inside root dir of balancer +1. **Get a token**: Find the `POST /auth/jwt/create/` endpoint in Swagger UI, click **Try it out**, enter an authorized `email` and `password`, and click **Execute**. Copy the `access` token from the response. +2. **Authorize**: Click the **Authorize** button (lock icon) at the top of the page. Enter `JWT ` in the value field. The prefix must be `JWT`, not `Bearer`. +3. **Test endpoints**: All subsequent requests will include your token. Use **Try it out** on any protected endpoint. +4. **Token refresh**: Access tokens expire after 60 minutes. Use `POST /auth/jwt/refresh/` with your `refresh` token, or repeat step 1. -```bash -devbox shell -devbox create:cluster -devbox run deploy:balancer -``` +### Deployment -The website should be available in [https://balancertestsite.com:30219/](https://balancertestsite.com:30219/) +1. Merging your PR into develop automatically triggers a GitHub Release +2. The release triggers a container build workflow that builds and pushes the Docker image +3. [Go to GitHub Packages](https://github.com/CodeForPhilly/balancer-main/pkgs/container/balancer-main%2Fapp) to find the new image tag +4. Update newTag in kustomization.yaml [in the cluster repo](https://github.com/CodeForPhilly/cfp-live-cluster/blob/main/balancer/kustomization.yaml) +5. Open a PR to [cfp-sandbox-cluster](https://github.com/CodeForPhilly/cfp-sandbox-cluster) (or [cfp-live-cluster](https://github.com/CodeForPhilly/cfp-live-cluster)) ## Architecture diff --git a/config/env/.env.prod.db b/config/env/.env.prod.db deleted file mode 100644 index 3c73c247..00000000 --- a/config/env/.env.prod.db +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=set_me -POSTGRES_PASSWORD=set_me -POSTGRES_DB=balancer_prod \ No newline at end of file diff --git a/config/env/dev.env.example b/config/env/dev.env.example new file mode 100644 index 00000000..b8e195cf --- /dev/null +++ b/config/env/dev.env.example @@ -0,0 +1,41 @@ +DEBUG=True +SECRET_KEY=foo + +# Database Configuration +# Supports both CloudNativePG (Kubernetes service) and AWS RDS (external host) +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer_dev +SQL_USER=balancer +SQL_PASSWORD=balancer + +# Connection Type Examples: +# +# CloudNativePG (Kubernetes service within cluster): +# SQL_HOST=balancer-postgres-rw +# SQL_HOST=balancer-postgres-rw.balancer.svc.cluster.local +# (SSL typically not required within cluster) +# +# AWS RDS (External database): +# SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com +# (SSL typically required - set SQL_SSL_MODE if needed) +# +# Local development: +# SQL_HOST=localhost +# SQL_HOST=db # Docker Compose service name +SQL_HOST=db +SQL_PORT=5432 + +# Optional: SSL mode for PostgreSQL connections +# Options: disable, allow, prefer, require, verify-ca, verify-full +# Default: require for external hosts (AWS RDS), disabled for CloudNativePG +# SQL_SSL_MODE=require + +LOGIN_REDIRECT_URL= +CORS_ALLOWED_ORIGINS=http://localhost:3000 +# Domain used by Djoser for activation and password reset email links (should be the frontend URL) +FRONTEND_DOMAIN=localhost:3000 +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +PINECONE_API_KEY= +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= diff --git a/config/env/env.dev b/config/env/env.dev deleted file mode 100644 index 22e70e5d..00000000 --- a/config/env/env.dev +++ /dev/null @@ -1,16 +0,0 @@ -DEBUG=True -SECRET_KEY=foo -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] -SQL_ENGINE=django.db.backends.postgresql -SQL_DATABASE=balancer_dev -SQL_USER=balancer -SQL_PASSWORD=balancer -SQL_HOST=db -SQL_PORT=5432 -DATABASE=postgres -LOGIN_REDIRECT_URL= -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -PINECONE_API_KEY= -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= \ No newline at end of file diff --git a/config/env/env.prod b/config/env/env.prod deleted file mode 100644 index 12b3491a..00000000 --- a/config/env/env.prod +++ /dev/null @@ -1,14 +0,0 @@ -DEBUG=0 -SECRET_KEY=change_this -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] -SQL_ENGINE=django.db.backends.postgresql -SQL_DATABASE=balancer_prod -SQL_USER=set_me -SQL_PASSWORD=set_me -SQL_HOST=db -SQL_PORT=5432 -DATABASE=postgres -LOGIN_REDIRECT_URL= -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= \ No newline at end of file diff --git a/config/env/env.prod.db b/config/env/env.prod.db deleted file mode 100644 index 3c73c247..00000000 --- a/config/env/env.prod.db +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=set_me -POSTGRES_PASSWORD=set_me -POSTGRES_DB=balancer_prod \ No newline at end of file diff --git a/db/Dockerfile b/db/Dockerfile deleted file mode 100644 index 71264cbd..00000000 --- a/db/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Use the official PostgreSQL 15 image as a parent image -FROM postgres:15 - -# Install build dependencies and update CA certificates -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - git \ - build-essential \ - postgresql-server-dev-15 \ - && update-ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -# Clone, build and install pgvector -RUN cd /tmp \ - && git clone --branch v0.6.1 https://github.com/pgvector/pgvector.git \ - && cd pgvector \ - && make \ - && make install - -# Clean up unnecessary packages and files -RUN apt-get purge -y --auto-remove git build-essential postgresql-server-dev-15 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/pgvector - -COPY init-vector-extension.sql /docker-entrypoint-initdb.d/ diff --git a/deploy/kind-config.yml b/deploy/kind-config.yaml similarity index 100% rename from deploy/kind-config.yml rename to deploy/kind-config.yaml diff --git a/deploy/manifests/balancer/base/balancer.env b/deploy/manifests/balancer/base/balancer.env new file mode 100644 index 00000000..e69de29b diff --git a/deploy/manifests/balancer/base/configmap.yml b/deploy/manifests/balancer/base/configmap.yml deleted file mode 100644 index c513fd61..00000000 --- a/deploy/manifests/balancer/base/configmap.yml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -data: - DEBUG: "1" - SECRET_KEY: "foo" - DJANGO_ALLOWED_HOSTS: "localhost 127.0.0.1 [::1] balancertestsite.com" - SQL_ENGINE: "django.db.backends.postgresql" - SQL_DATABASE: "balancer_dev" - SQL_USER: "balancer" - SQL_PASSWORD: "" - SQL_HOST: "" - SQL_PORT: "5432" - DATABASE: "postgres" - LOGIN_REDIRECT_URL: "" - OPENAI_API_KEY: "" - PINECONE_API_KEY: "" - REACT_APP_API_BASE_URL: "https://balancertestsite.com/" -kind: ConfigMap -metadata: - name: balancer-config diff --git a/deploy/manifests/balancer/base/deployment.yml b/deploy/manifests/balancer/base/deployment.yaml similarity index 76% rename from deploy/manifests/balancer/base/deployment.yml rename to deploy/manifests/balancer/base/deployment.yaml index 919d10c9..10bcfc93 100644 --- a/deploy/manifests/balancer/base/deployment.yml +++ b/deploy/manifests/balancer/base/deployment.yaml @@ -3,7 +3,6 @@ kind: Deployment metadata: labels: app: balancer - service: backend name: balancer spec: replicas: 1 @@ -17,16 +16,18 @@ spec: app: balancer spec: containers: - - image: ghcr.io/codeforphilly/balancer-main/backend - name: balancer + - image: ghcr.io/codeforphilly/balancer-main/app + name: app envFrom: + - secretRef: + name: balancer-config - configMapRef: name: balancer-config ports: - containerPort: 8000 readinessProbe: httpGet: - path: / + path: /admin/ port: 8000 initialDelaySeconds: 30 periodSeconds: 10 diff --git a/deploy/manifests/balancer/base/gateway-listeners.yaml b/deploy/manifests/balancer/base/gateway-listeners.yaml new file mode 100644 index 00000000..4085d38a --- /dev/null +++ b/deploy/manifests/balancer/base/gateway-listeners.yaml @@ -0,0 +1,27 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: ListenerSet +metadata: + name: balancer-listeners + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hostname: HOSTNAME_PLACEHOLDER +spec: + parentRef: + name: main-gateway + namespace: envoy-gateway-system + group: gateway.networking.k8s.io + kind: Gateway + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: HOSTNAME_PLACEHOLDER + - name: https + protocol: HTTPS + port: 443 + hostname: HOSTNAME_PLACEHOLDER + tls: + mode: Terminate + certificateRefs: + - name: balancer-tls + kind: Secret diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml new file mode 100644 index 00000000..8877b798 --- /dev/null +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -0,0 +1,23 @@ + +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: balancer + annotations: + hostname: HOSTNAME_PLACEHOLDER +spec: + parentRefs: + - name: balancer-listeners + kind: ListenerSet + group: gateway.networking.k8s.io + sectionName: https + hostnames: + - HOSTNAME_PLACEHOLDER + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: balancer + port: 80 diff --git a/deploy/manifests/balancer/base/ingress.yml b/deploy/manifests/balancer/base/ingress.yml deleted file mode 100644 index d931fd5f..00000000 --- a/deploy/manifests/balancer/base/ingress.yml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: balancer - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-staging" -spec: - ingressClassName: nginx - tls: - - hosts: - - balancertestsite.com - secretName: balancer-tls - rules: - - host: balancertestsite.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: balancer - port: - number: 80 diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml new file mode 100644 index 00000000..04d76f2a --- /dev/null +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -0,0 +1,24 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - deployment.yaml + - service.yaml + - gateway-listeners.yaml + - httproute.yaml + +labels: + - pairs: + app.kubernetes.io/name: balancer + app.kubernetes.io/part-of: balancer + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/component: web + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + +configMapGenerator: + - name: balancer-config + envs: + - balancer.env diff --git a/deploy/manifests/balancer/base/kustomization.yml b/deploy/manifests/balancer/base/kustomization.yml deleted file mode 100644 index d9e5dd29..00000000 --- a/deploy/manifests/balancer/base/kustomization.yml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - deployment.yml - - service.yml - - configmap.yml - - ingress.yml diff --git a/deploy/manifests/balancer/base/namespace.yaml b/deploy/manifests/balancer/base/namespace.yaml new file mode 100644 index 00000000..11df30ee --- /dev/null +++ b/deploy/manifests/balancer/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: balancer diff --git a/deploy/manifests/balancer/base/secret.template.yaml b/deploy/manifests/balancer/base/secret.template.yaml new file mode 100644 index 00000000..e003a6ce --- /dev/null +++ b/deploy/manifests/balancer/base/secret.template.yaml @@ -0,0 +1,27 @@ +# Secret Template for Balancer Application +# +# This file documents the required secret keys for the balancer application. +# +# IMPORTANT: This file is a TEMPLATE only. Do NOT create a Secret manifest in this +# repository. Secrets should be created in each target cluster using cluster-specific +# tools (e.g., SealedSecrets in the cfp-sandbox-cluster). +# +apiVersion: v1 +kind: Secret +metadata: + name: balancer-config + namespace: balancer +type: Opaque +stringData: + DEBUG: '1' + LOGIN_REDIRECT_URL: '' + OPENAI_API_KEY: openapi_key_here + PINECONE_API_KEY: pinecone_key_here + REACT_APP_API_BASE_URL: https://balancer.sandbox.k8s.phl.io/ + SECRET_KEY: randomly_generated_key_ere + SQL_ENGINE: django.db.backends.postgresql + SQL_HOST: sql_host_here + SQL_PORT: '5432' + SQL_DATABASE: balancer_dev + SQL_USER: balancer + SQL_PASSWORD: sql_password_here diff --git a/deploy/manifests/balancer/base/service.yml b/deploy/manifests/balancer/base/service.yaml similarity index 100% rename from deploy/manifests/balancer/base/service.yml rename to deploy/manifests/balancer/base/service.yaml diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yml b/deploy/manifests/balancer/overlays/dev/kustomization.yml deleted file mode 100644 index 92a6001b..00000000 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - "../../base" - -images: - - name: ghcr.io/codeforphilly/balancer-main/backend - newTag: "1.0.2" - -namespace: balancer diff --git a/deploy/manifests/balancer/overlays/production/balancer.env b/deploy/manifests/balancer/overlays/production/balancer.env new file mode 100644 index 00000000..1fa5802e --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/balancer.env @@ -0,0 +1 @@ +CORS_ALLOWED_ORIGINS=https://balancerproject.org diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml new file mode 100644 index 00000000..c664e118 --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -0,0 +1,44 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: 1.1.5 + +configMapGenerator: + - name: balancer-config + behavior: merge + literals: + - HOSTNAME=balancerproject.org + envs: + - balancer.env + +labels: + - includeSelectors: true + pairs: + environment: production + app.kubernetes.io/instance: balancer-production + +patches: + - target: + kind: ListenerSet + name: balancer-listeners + patch: | + - op: replace + path: /spec/listeners/0/hostname + value: balancerproject.org + - op: replace + path: /spec/listeners/1/hostname + value: balancerproject.org + - target: + kind: HTTPRoute + name: balancer + patch: | + - op: replace + path: /spec/hostnames/0 + value: balancerproject.org \ No newline at end of file diff --git a/deploy/manifests/balancer/overlays/sandbox/balancer.env b/deploy/manifests/balancer/overlays/sandbox/balancer.env new file mode 100644 index 00000000..55d82ecc --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/balancer.env @@ -0,0 +1 @@ +CORS_ALLOWED_ORIGINS=https://sandbox.balancerproject.org diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml new file mode 100644 index 00000000..8ba92568 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-db-config +data: + SQL_HOST: shared-cluster-rw.cloudnative-pg.svc.cluster.local + SQL_PORT: "5432" + SQL_DATABASE: balancer + SQL_USER: balancer + SQL_ENGINE: django.db.backends.postgresql diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..89c27bd0 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,62 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + - configmap.yaml + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: 1.1.5 + +configMapGenerator: + - name: balancer-config + behavior: merge + literals: + - HOSTNAME=sandbox.balancerproject.org + envs: + - balancer.env + +labels: + - includeSelectors: true + pairs: + environment: sandbox + app.kubernetes.io/instance: balancer-sandbox + +patches: + - target: + kind: ListenerSet + name: balancer-listeners + patch: | + - op: replace + path: /spec/listeners/0/hostname + value: sandbox.balancerproject.org + - op: replace + path: /spec/listeners/1/hostname + value: sandbox.balancerproject.org + - target: + kind: HTTPRoute + name: balancer + patch: | + - op: replace + path: /spec/hostnames/0 + value: sandbox.balancerproject.org + - target: + kind: Deployment + name: balancer + patch: | + - op: add + path: /spec/template/spec/containers/0/envFrom/- + value: + configMapRef: + name: balancer-db-config + - op: add + path: /spec/template/spec/containers/0/env + value: + - name: SQL_PASSWORD + valueFrom: + secretKeyRef: + name: balancer-db-credentials + key: password \ No newline at end of file diff --git a/deploy/manifests/cert-manager/clusterissuer.yml b/deploy/manifests/cert-manager/clusterissuer.yaml similarity index 100% rename from deploy/manifests/cert-manager/clusterissuer.yml rename to deploy/manifests/cert-manager/clusterissuer.yaml diff --git a/deployment.sh b/deployment.sh deleted file mode 100644 index 491ed457..00000000 --- a/deployment.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -cd frontend - -## Frontend - -npm run i && npm run build - -cd .. - -docker compose -f docker-compose.prod.yml up --build # This is generating balancer-backend:latest image - -aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com - -echo "$CR_PAT" | docker login ghcr.io -u TineoC --password-stdin - -docker tag balancer-backend:latest ghcr.io/codeforphilly/balancer-main/backend:latest . - -docker tag balancer-backend:latest chrissst/balancer:latest - -docker push chrissst/balancer:latest \ No newline at end of file diff --git a/devbox.json b/devbox.json deleted file mode 100644 index 87e91159..00000000 --- a/devbox.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.2/.schema/devbox.schema.json", - "packages": [ - "kubectl@latest", - "argocd@latest", - "kubernetes-helm@latest", - "kind@latest", - "k9s@latest", - "kustomize@latest", - "jq@latest" - ], - "shell": { - "init_hook": [ - "echo 'Welcome to devbox!' > /dev/null" - ], - "scripts": { - "create:cluster": [ - "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yml", - "kubectl cluster-info" - ], - "deploy:balancer": [ - "devbox run install:prereqs", - "devbox run install:balancer" - ], - "install:prereqs": [ - "devbox run install:cert-manager", - "devbox run install:ingress-nginx" - ], - "install:balancer": [ - "kubectl create namespace balancer || true", - "kubectl apply -k ./deploy/manifests/balancer/overlays/dev", - "echo 'Balancer deployed successfully!'", - "echo 'You can access the balancer site at:'", - "echo \"HTTPS: https://balancertestsite.com:$(kubectl get svc -n ingress-nginx -o json ingress-nginx-controller | jq .spec.ports[1].nodePort)\"" - ], - "install:cert-manager": [ - "helm repo add jetstack https://charts.jetstack.io || true", - "helm repo update jetstack", - "helm upgrade --install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --set crds.enabled=true", - "kubectl apply -f ./deploy/manifests/cert-manager" - ], - "install:ingress-nginx": [ - "helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx || true", - "helm repo update ingress-nginx", - "helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx --create-namespace --set controller.service.nodePorts.http=31880 --set controller.service.nodePorts.https=30219", - "kubectl wait --namespace ingress-nginx --for=condition=Available deployment/ingress-nginx-controller --timeout=120s" - ] - } - } -} \ No newline at end of file diff --git a/devbox.lock b/devbox.lock deleted file mode 100644 index a47830e5..00000000 --- a/devbox.lock +++ /dev/null @@ -1,449 +0,0 @@ -{ - "lockfile_version": "1", - "packages": { - "argocd@latest": { - "last_modified": "2025-05-16T20:19:48Z", - "resolved": "github:NixOS/nixpkgs/12a55407652e04dcf2309436eb06fef0d3713ef3#argocd", - "source": "devbox-search", - "version": "2.14.11", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/yw33qpp6rg4r176yvdmvp4zwswynrmsl-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/yw33qpp6rg4r176yvdmvp4zwswynrmsl-argocd-2.14.11" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/qi3z0kl0w9cscw76g6x34927n1dfbjjh-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/qi3z0kl0w9cscw76g6x34927n1dfbjjh-argocd-2.14.11" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/s4cf6hh4qpmyywfkdm9z75i5yxx72qq7-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/s4cf6hh4qpmyywfkdm9z75i5yxx72qq7-argocd-2.14.11" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/c1cx9j19132wr5rbhldwvkvnc1xh0hgi-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/c1cx9j19132wr5rbhldwvkvnc1xh0hgi-argocd-2.14.11" - } - } - }, - "github:NixOS/nixpkgs/nixpkgs-unstable": { - "last_modified": "2025-06-20T02:24:11Z", - "resolved": "github:NixOS/nixpkgs/076e8c6678d8c54204abcb4b1b14c366835a58bb?lastModified=1750386251&narHash=sha256-1ovgdmuDYVo5OUC5NzdF%2BV4zx2uT8RtsgZahxidBTyw%3D" - }, - "jq@latest": { - "last_modified": "2025-06-25T15:38:15Z", - "resolved": "github:NixOS/nixpkgs/61c0f513911459945e2cb8bf333dc849f1b976ff#jq", - "source": "devbox-search", - "version": "1.8.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/04gj0cpc6mv0pkyz114p23fq65zx8mbx-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/7zdrvbyc5pgq9by1wzpn0q28iqsd0lx7-jq-1.8.0-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/glkhwajjprqny359z1awxll8vnsa66lf-jq-1.8.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/yygyqari7g4kz9j0yyyl2lq6v2bg3dw2-jq-1.8.0-doc" - }, - { - "name": "out", - "path": "/nix/store/78wqqi0zdlrgadz3nmd909axh5182k7v-jq-1.8.0" - } - ], - "store_path": "/nix/store/04gj0cpc6mv0pkyz114p23fq65zx8mbx-jq-1.8.0-bin" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/k9mybm2b3yr0v9fsm8vi0319diai4flj-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/v8lgx3i8v7kjqzgs8x75v0ysrlylfhg1-jq-1.8.0-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/rzzhwmzryil6g7pl5i7jb4fs54nkkrm4-jq-1.8.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/xjcyd1pjjzja918407x5hvsa6sa3k4mj-jq-1.8.0-doc" - }, - { - "name": "out", - "path": "/nix/store/8p4cdklsb5kn1w4ycq9na07ja19j6d87-jq-1.8.0" - } - ], - "store_path": "/nix/store/k9mybm2b3yr0v9fsm8vi0319diai4flj-jq-1.8.0-bin" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/4d5y298s33gi9vcvviq8xah06203395s-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/drgz0ky78p3c6raccn7xsb5m9f91ba3x-jq-1.8.0-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/0122gf5v7922213mkjp3vlij53fkqvir-jq-1.8.0-doc" - }, - { - "name": "out", - "path": "/nix/store/akq414spg0yr5rdba7mbbvz8s945gmya-jq-1.8.0" - }, - { - "name": "dev", - "path": "/nix/store/zsmngm14i76pv54z4n8sj7dcwy6x10kn-jq-1.8.0-dev" - } - ], - "store_path": "/nix/store/4d5y298s33gi9vcvviq8xah06203395s-jq-1.8.0-bin" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/2n9hfcfqdszxgsmi4qyqq6rv947dwwg9-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/njrgxwqnifcyh3x0v18v83ig179zccx0-jq-1.8.0-man", - "default": true - }, - { - "name": "out", - "path": "/nix/store/qqx05qwhhmbrviw3iskgaigjxhczqhvx-jq-1.8.0" - }, - { - "name": "dev", - "path": "/nix/store/dvy119mx8ab0yjxblaaippb2js6nbzkn-jq-1.8.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/5qly4lwxrq5r3x472g2w35rz50b54a6n-jq-1.8.0-doc" - } - ], - "store_path": "/nix/store/2n9hfcfqdszxgsmi4qyqq6rv947dwwg9-jq-1.8.0-bin" - } - } - }, - "k9s@latest": { - "last_modified": "2025-06-01T15:36:18Z", - "resolved": "github:NixOS/nixpkgs/5929de975bcf4c7c8d8b5ca65c8cd9ef9e44523e#k9s", - "source": "devbox-search", - "version": "0.50.6", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/0kjbnz4vyqv50xmidkf3a9fd9xkv7qnx-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/0kjbnz4vyqv50xmidkf3a9fd9xkv7qnx-k9s-0.50.6" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/cy9v8qdf8y1g45774rm9jzw03pf0866d-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/cy9v8qdf8y1g45774rm9jzw03pf0866d-k9s-0.50.6" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/33wpwmnd235m388diiky223sm2g1gf9g-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/33wpwmnd235m388diiky223sm2g1gf9g-k9s-0.50.6" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/ym871cb8337ph62j517586skc6ya7znp-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/ym871cb8337ph62j517586skc6ya7znp-k9s-0.50.6" - } - } - }, - "kind@latest": { - "last_modified": "2025-06-12T07:29:08Z", - "resolved": "github:NixOS/nixpkgs/d202f48f1249f013aa2660c6733e251c85712cbe#kind", - "source": "devbox-search", - "version": "0.29.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/81jc2zsdv4zhdniyyggpxm56lpl88cxb-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/81jc2zsdv4zhdniyyggpxm56lpl88cxb-kind-0.29.0" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/dwzvvmcignd20dg6kgizzn71vkj9la91-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/dwzvvmcignd20dg6kgizzn71vkj9la91-kind-0.29.0" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/shydfb0h27gbdrmwhjbfg354xc22vxg2-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/shydfb0h27gbdrmwhjbfg354xc22vxg2-kind-0.29.0" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/52vfnn1wcqn3d5jzrqvcd6yzp3i1gw2m-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/52vfnn1wcqn3d5jzrqvcd6yzp3i1gw2m-kind-0.29.0" - } - } - }, - "kubectl@latest": { - "last_modified": "2025-05-24T21:46:02Z", - "resolved": "github:NixOS/nixpkgs/edb3633f9100d9277d1c9af245a4e9337a980c07#kubectl", - "source": "devbox-search", - "version": "1.33.1", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/vcq5gsn9rp26xbz14b5b2fd8map8qnvj-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/20v8bx884m4i34zdkksdq5qpkm966m65-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/cjm9i86w7is18g3cpsgfc0c3jmsnp0s8-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/vcq5gsn9rp26xbz14b5b2fd8map8qnvj-kubectl-1.33.1" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/m8406nxn25y7a80jxq6mdk70p1xl8xrc-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/gy8hdpwiqcy35zp0a9imbv4fqqy3cwn8-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/kh7b55lvpwfrdfbq3qrzcj9qjanfqn7c-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/m8406nxn25y7a80jxq6mdk70p1xl8xrc-kubectl-1.33.1" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/g8r4y54jpdyrvnrbhqyg60sr1wpqx0ff-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/0n7ik9w8sjrhanv7yb1ijhwyawx7xcz2-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/fdpw2205wf6qq7h271nzbhxdmx561vq0-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/g8r4y54jpdyrvnrbhqyg60sr1wpqx0ff-kubectl-1.33.1" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/lrfm3r4z5iqyn5fqf085bdyp7b5ghhdr-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/hhank6pxbzwzm6b6gphpc1rj2jjdpmmk-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/yqlm8fmchxsxzica482r16sfm8x84hck-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/lrfm3r4z5iqyn5fqf085bdyp7b5ghhdr-kubectl-1.33.1" - } - } - }, - "kubernetes-helm@latest": { - "last_modified": "2025-06-12T07:29:08Z", - "resolved": "github:NixOS/nixpkgs/d202f48f1249f013aa2660c6733e251c85712cbe#kubernetes-helm", - "source": "devbox-search", - "version": "3.18.2", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/jlp184pfj4sr13bynvhh2xdr2kcqki6s-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/jlp184pfj4sr13bynvhh2xdr2kcqki6s-kubernetes-helm-3.18.2" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/iyc7rs8vwp0dgjsjbkln1aa32gfls80l-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/iyc7rs8vwp0dgjsjbkln1aa32gfls80l-kubernetes-helm-3.18.2" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/hxwfq2n2shcwvg0mz967d12clys1i2hd-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/hxwfq2n2shcwvg0mz967d12clys1i2hd-kubernetes-helm-3.18.2" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/i7ak9gjj38s29k5lxjnak735713caf6f-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/i7ak9gjj38s29k5lxjnak735713caf6f-kubernetes-helm-3.18.2" - } - } - }, - "kustomize@latest": { - "last_modified": "2025-06-20T02:24:11Z", - "resolved": "github:NixOS/nixpkgs/076e8c6678d8c54204abcb4b1b14c366835a58bb#kustomize", - "source": "devbox-search", - "version": "5.6.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/j3fhq0sjgibzg128f55sa7yyxs26qiik-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/j3fhq0sjgibzg128f55sa7yyxs26qiik-kustomize-5.6.0" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/li5cccrjxgig3jqaycrrbzs7n6xwvpqp-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/li5cccrjxgig3jqaycrrbzs7n6xwvpqp-kustomize-5.6.0" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/3sa5673n6ah9fry8yzz94fscqjk8xxb4-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/3sa5673n6ah9fry8yzz94fscqjk8xxb4-kustomize-5.6.0" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/vkaya31s09dj8xyy9xyrjqwgaixjq160-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/vkaya31s09dj8xyy9xyrjqwgaixjq160-kustomize-5.6.0" - } - } - } - } -} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f6772b9c..4b4868e4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,12 +1,42 @@ name: balancer-prod version: "3.8" + services: - backend: - image: balancer-backend + db: + image: pgvector/pgvector:pg15 + volumes: + - postgres_data_prod:/var/lib/postgresql/data/ + - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + networks: + - app_net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] + interval: 5s + timeout: 5s + retries: 5 + + app: + image: balancer-app build: - context: server + context: . dockerfile: Dockerfile.prod ports: - "8000:8000" env_file: - - ./config/env/env.dev + - ./config/env/prod.env + depends_on: + db: + condition: service_healthy + networks: + - app_net + +volumes: + postgres_data_prod: + +networks: + app_net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index aea1993b..7a6e7fe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,49 +1,62 @@ services: db: - build: - context: ./db - dockerfile: Dockerfile + image: pgvector/pgvector:pg15 volumes: - postgres_data:/var/lib/postgresql/data/ + - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql environment: - POSTGRES_USER=balancer - POSTGRES_PASSWORD=balancer - POSTGRES_DB=balancer_dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] + interval: 10s + timeout: 5s + retries: 5 ports: - "5433:5432" networks: app_net: ipv4_address: 192.168.0.2 - # pgadmin: - # container_name: pgadmin4 - # image: dpage/pgadmin4 - # environment: - # PGADMIN_DEFAULT_EMAIL: balancer-noreply@codeforphilly.org - # PGADMIN_DEFAULT_PASSWORD: balancer - # # PGADMIN_LISTEN_PORT = 80 - # # volumes: - # # - ./pgadmin-data:/var/lib/pgadmin - # # # PGADMIN_LISTEN_PORT = 80 - # ports: - # - "5050:80" - # networks: - # app_net: - # ipv4_address: 192.168.0.4 + + pgadmin: + image: dpage/pgadmin4 + environment: + - PGADMIN_DEFAULT_EMAIL=balancer-noreply@codeforphilly.org + - PGADMIN_DEFAULT_PASSWORD=balancer + ports: + - "5050:80" + depends_on: + db: + condition: service_healthy + networks: + app_net: + ipv4_address: 192.168.0.4 + backend: image: balancer-backend build: ./server command: python manage.py runserver 0.0.0.0:8000 + restart: on-failure ports: - "8000:8000" env_file: - - ./config/env/env.dev + - ./config/env/dev.env depends_on: - - db + db: + condition: service_healthy volumes: - ./server:/usr/src/server networks: app_net: ipv4_address: 192.168.0.3 + healthcheck: + test: ["CMD-SHELL", "python3 -c 'import http.client;conn=http.client.HTTPConnection(\"localhost:8000\");conn.request(\"GET\",\"/admin/login/\");res=conn.getresponse();exit(0 if res.status in [200,301,302,401] else 1)'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + frontend: image: balancer-frontend build: @@ -55,15 +68,21 @@ services: - "3000:3000" environment: - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - "./frontend:/usr/src/app:delegated" - "/usr/src/app/node_modules/" depends_on: - - backend + backend: + condition: service_healthy networks: app_net: ipv4_address: 192.168.0.5 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + volumes: postgres_data: networks: @@ -72,4 +91,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 + gateway: 192.168.0.1 \ No newline at end of file diff --git a/docs/DATABASE_CONNECTION.md b/docs/DATABASE_CONNECTION.md new file mode 100644 index 00000000..7f2c298e --- /dev/null +++ b/docs/DATABASE_CONNECTION.md @@ -0,0 +1,188 @@ +# Database Connection Configuration + +The balancer application supports connecting to PostgreSQL databases via two methods: + +1. **CloudNativePG** - Kubernetes-managed PostgreSQL cluster (within cluster) +2. **AWS RDS** - External PostgreSQL database (AWS managed) + +The application automatically detects the connection type based on the `SQL_HOST` environment variable format. + +## Connection Type Detection + +The application determines the connection type by analyzing the `SQL_HOST` value: + +- **CloudNativePG**: + - Contains `.svc.cluster.local` (Kubernetes service DNS) + - Short service name (e.g., `balancer-postgres-rw`) + - Typically no SSL required within cluster + +- **AWS RDS**: + - Full domain name (e.g., `balancer-db.xxxxx.us-east-1.rds.amazonaws.com`) + - External IP address + - Typically requires SSL + +## Configuration + +### Environment Variables + +All database configuration is done via environment variables: + +- `SQL_ENGINE`: Database engine (default: `django.db.backends.postgresql`) +- `SQL_DATABASE`: Database name +- `SQL_USER`: Database username +- `SQL_PASSWORD`: Database password +- `SQL_HOST`: Database host (see examples below) +- `SQL_PORT`: Database port (default: `5432`) +- `SQL_SSL_MODE`: Optional SSL mode (see SSL Configuration below) + +### CloudNativePG Configuration + +When using CloudNativePG, the application connects to the Kubernetes service created by the operator. + +**Example Configuration:** +```bash +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer +SQL_USER=balancer +SQL_PASSWORD= +SQL_HOST=balancer-postgres-rw +SQL_PORT=5432 +``` + +**Service Names:** +- `{cluster-name}-rw`: Read-write service (primary instance) +- `{cluster-name}-r`: Read service (replicas) +- `{cluster-name}-ro`: Read-only service + +**Full DNS Name:** +```bash +SQL_HOST=balancer-postgres-rw.balancer.svc.cluster.local +``` + +### AWS RDS Configuration + +When using AWS RDS, the application connects to the external RDS endpoint. + +**Example Configuration:** +```bash +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer +SQL_USER=balancer +SQL_PASSWORD= +SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com +SQL_PORT=5432 +SQL_SSL_MODE=require +``` + +### Local Docker Compose Configuration + +When using Docker Compose for local development, the application connects to the `db` service container. + +**Example Configuration:** +```bash +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer_dev +SQL_USER=balancer +SQL_PASSWORD=balancer +SQL_HOST=db +SQL_PORT=5432 +``` + +## SSL Configuration + +### CloudNativePG + +SSL is typically **not required** for connections within the Kubernetes cluster. The application will not use SSL by default for CloudNativePG connections. + +### AWS RDS + +SSL is typically **required** for AWS RDS connections. The application defaults to `require` mode for external hosts, but you can override this: + +**SSL Mode Options:** +- `disable`: No SSL +- `allow`: Try non-SSL first, then SSL +- `prefer`: Try SSL first, then non-SSL (default for external) +- `require`: Require SSL +- `verify-ca`: Require SSL and verify CA +- `verify-full`: Require SSL and verify CA and hostname + +**Example:** +```bash +SQL_SSL_MODE=require +``` + +## Migration Guide + +### From AWS RDS to CloudNativePG + +1. Update the `SQL_HOST` environment variable in your SealedSecret: + ```bash + # Old (AWS RDS) + SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com + + # New (CloudNativePG) + SQL_HOST=balancer-postgres-rw + ``` + +2. Update database credentials to match CloudNativePG secret + +3. Remove or set `SQL_SSL_MODE` to `disable` (optional, as it's auto-detected) + +4. Restart the application pods + +### From CloudNativePG to AWS RDS + +1. Update the `SQL_HOST` environment variable: + ```bash + # Old (CloudNativePG) + SQL_HOST=balancer-postgres-rw + + # New (AWS RDS) + SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com + ``` + +2. Update database credentials to match RDS credentials + +3. Set `SQL_SSL_MODE=require` (or appropriate mode) + +4. Ensure network connectivity (VPC peering, security groups, etc.) + +5. Restart the application pods + +## Troubleshooting + +### Connection Issues + +1. **Verify host format**: Check that `SQL_HOST` matches the expected format for your connection type + +2. **Check network connectivity**: + - CloudNativePG: Ensure pods are in the same namespace + - AWS RDS: Verify VPC peering, security groups, and network ACLs + +3. **Verify credentials**: Ensure username, password, and database name are correct + +4. **Check SSL configuration**: For AWS RDS, ensure SSL is properly configured + +### Common Errors + +**"Connection refused"** +- Verify the host and port are correct +- Check if the database service is running +- Verify network connectivity + +**"SSL required"** +- Add `SQL_SSL_MODE=require` for AWS RDS connections +- Verify SSL certificates are available + +**"Authentication failed"** +- Verify username and password +- Check database user permissions +- Ensure the database exists + +## References + +- [Django Database Configuration](https://docs.djangoproject.com/en/4.2/ref/settings/#databases) +- [CloudNativePG Documentation](https://cloudnative-pg.io/) +- [AWS RDS PostgreSQL](https://docs.aws.amazon.com/rds/latest/userguide/CHAP_PostgreSQL.html) +- [PostgreSQL SSL Configuration](https://www.postgresql.org/docs/current/libpq-ssl.html) + diff --git a/docs/MIGRATION_PDF_AUTH.md b/docs/MIGRATION_PDF_AUTH.md new file mode 100644 index 00000000..d5f7df26 --- /dev/null +++ b/docs/MIGRATION_PDF_AUTH.md @@ -0,0 +1,307 @@ +# Migration Guide: Conditional PDF Access Feature + +**Date**: January 2025 +**Feature**: Authentication-based PDF viewing and downloading +**PR/Issue**: [Link to PR if applicable] + +## Overview + +This migration adds conditional behavior to PDF source buttons based on user authentication status: +- **Authenticated users**: "View PDF" button opens PDF viewer in new tab +- **Unauthenticated users**: "Download PDF" button triggers direct file download + +## How It Works + +### Button Logic Flow + +The button checks the user's authentication state and uses the `upload_file_guid` to determine behavior: + +``` +User clicks medication → Expands to show sources + ↓ + Check: isAuthenticated? + ↓ + ┌─────────────────┴─────────────────┐ + ↓ ↓ + YES (Logged In) NO (Not Logged In) + ↓ ↓ + "View PDF" (Blue Button) "Download PDF" (Green Button) + ↓ ↓ + Opens /drugSummary page Direct download via + with PDF viewer /v1/api/uploadFile/ + (target="_blank") (download attribute) +``` + +### When User is NOT Authenticated: + +```typescript + + Download PDF + +``` + +- Uses `upload_file_guid` to construct download URL: `/v1/api/uploadFile/` +- The `download` attribute forces browser to download instead of opening +- Endpoint is **public** (AllowAny permission) - no authentication required +- File downloads directly with original filename from database + +### When User IS Authenticated: + +```typescript + + View PDF + +``` + +- Uses `link_url` which points to `/drugSummary` page +- Opens in new tab with `target="_blank"` +- The drugSummary page renders a PDF viewer with navigation controls +- User can navigate between pages, zoom, etc. + +### Key Points: + +1. ✅ **Both auth types can access PDFs** - the download endpoint (`/v1/api/uploadFile/`) is public +2. ✅ The difference is **presentation**: + - **Authenticated**: Rich PDF viewer experience with navigation + - **Unauthenticated**: Simple direct download to local machine +3. ✅ The `upload_file_guid` is the primary identifier for fetching files from the database +4. ✅ **Fallback mechanism**: If `upload_file_guid` is missing from API response, it's extracted from the `link_url` query parameter + +### Code Location: + +The conditional logic is in `frontend/src/pages/PatientManager/PatientSummary.tsx` around line 165-180: + +```typescript +{s.link_url && (() => { + // Get GUID from API or extract from URL as fallback + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + // Render different button based on authentication + return isAuthenticated ? ( + // Blue "View PDF" button for authenticated users + View PDF + ) : ( + // Green "Download PDF" button for unauthenticated users + Download PDF + ); +})()} +``` + +## Breaking Changes + +⚠️ **None** - This is a backward-compatible enhancement + +## Database Changes + +✅ **None** - No migrations required + +## API Changes + +### Backend: `POST /v1/api/riskWithSources` + +**Response Schema Update**: +```python +# New field added to each item in sources array: +{ + "sources": [ + { + "filename": "example.pdf", + "title": "Example Document", + "publication": "Journal Name", + "text": "...", + "rule_type": "INCLUDE", + "history_type": "DIAGNOSIS_MANIC", + "upload_fileid": 123, + "page": 5, + "link_url": "/drugSummary?guid=xxx&page=5", + "upload_file_guid": "xxx-xxx-xxx" // NEW FIELD + } + ] +} +``` + +**File**: `server/api/views/risk/views_riskWithSources.py` +**Lines Modified**: ~138-149, ~252-263, ~359-370 + +## Frontend Changes + +### 1. Component Prop Changes + +**PatientManager** now retrieves and passes authentication state: +```typescript +// Added imports +import { useSelector } from "react-redux"; +import { RootState } from "../../services/actions/types"; + +// New hook call +const { isAuthenticated } = useSelector((state: RootState) => state.auth); + +// New prop passed + +``` + +**PatientSummary** interface updated: +```typescript +interface PatientSummaryProps { + // ... existing props + isAuthenticated?: boolean; // NEW +} + +type SourceItem = { + // ... existing fields + upload_file_guid?: string | null; // NEW +} +``` + +### 2. New Helper Function + +```typescript +/** + * Fallback to extract GUID from URL if API doesn't provide upload_file_guid + */ +const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } +}; +``` + +### 3. Component Hierarchy Updates + +Props now flow through: `PatientManager` → `PatientSummary` → `MedicationTier` → `MedicationItem` + +Each intermediate component needs `isAuthenticated` and `baseURL` props added. + +## Route Changes + +### URL Consistency Fix + +**Old (inconsistent)**: +- Backend: `/drugsummary` (lowercase) +- Frontend route: `/drugSummary` (camelCase) + +**New (consistent)**: +- All references now use: `/drugSummary` (camelCase) + +**Files Updated**: +- `server/api/views/risk/views_riskWithSources.py` +- `frontend/src/pages/Layout/Layout_V2_Sidebar.tsx` +- `frontend/src/pages/Layout/Layout_V2_Header.tsx` +- `frontend/src/pages/Files/FileRow.tsx` + +## Authentication Flow Fixes + +### ProtectedRoute Component + +**Problem**: Opening protected routes in new tab caused immediate redirect to login + +**Solution**: Wait for auth check to complete +```typescript +if (isAuthenticated === null) { + return null; // Wait for auth verification +} +``` + +### Layout_V2_Main Component + +**Added**: `useAuth()` hook to trigger authentication check on mount + +## Testing Checklist + +### Manual Testing Steps + +1. **As unauthenticated user**: + - [ ] Navigate to medication suggester + - [ ] Submit patient information + - [ ] Expand medication to view sources + - [ ] Verify "Download PDF" button appears (green) + - [ ] Click button and verify file downloads + - [ ] Verify no redirect to login occurs + +2. **As authenticated user**: + - [ ] Log in to application + - [ ] Navigate to medication suggester + - [ ] Submit patient information + - [ ] Expand medication to view sources + - [ ] Verify "View PDF" button appears (blue) + - [ ] Click button and verify PDF viewer opens in new tab + - [ ] Verify new tab doesn't redirect to login + +3. **Edge cases**: + - [ ] Test with sources that have no link_url + - [ ] Test with sources that have link_url but no upload_file_guid + - [ ] Test opening protected route directly in new tab + - [ ] Test authentication state persistence across tabs + +### Automated Tests + +**TODO**: Add integration tests for: +- PDF button conditional rendering +- GUID extraction fallback +- Protected route authentication flow + +## Deployment Notes + +### Backend Deployment + +1. Deploy updated Django code +2. **No database migrations required** +3. Restart Django application server +4. Verify API response includes `upload_file_guid` field + +### Frontend Deployment + +1. Build frontend with updated code: `npm run build` +2. Deploy built assets +3. Clear CDN/browser cache if applicable +4. Verify button behavior for both auth states + +### Rollback Plan + +If issues occur: +1. Revert backend to previous version (API still compatible) +2. Frontend will use fallback GUID extraction from URL +3. Feature will degrade gracefully - button may show for all users but behavior remains functional + +## Environment Variables + +No new environment variables required. Uses existing: +- `VITE_API_BASE_URL` - Frontend API base URL + +## Known Issues / Limitations + +1. **GUID Fallback**: If both `upload_file_guid` and `link_url` are missing/invalid, no button appears +2. **Download Naming**: Downloaded files use server-provided filename, not customizable per-user +3. **Public Access**: Download endpoint is public - PDFs accessible to anyone with GUID + +## Future Enhancements + +- [ ] Add loading spinner while PDF downloads +- [ ] Add analytics tracking for PDF views/downloads +- [ ] Implement PDF access permissions/restrictions +- [ ] Add rate limiting to download endpoint + +## Support + +For questions or issues: +- GitHub Issues: [Repository Issues Link] +- Team Contact: balancerteam@codeforphilly.org + +## References + +- CHANGELOG.md - High-level changes +- CLAUDE.md - Updated project documentation +- Code comments in PatientSummary.tsx diff --git a/frontend/.env b/frontend/.env index 2bfce617..b6cfc3de 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1,2 @@ -# VITE_API_BASE_URL=https://balancertestsite.com/ -VITE_API_BASE_URL=http://localhost:8000 \ No newline at end of file +# Optional: add VITE_* vars here if needed. None required for docker-compose; +# the app uses relative API URLs and vite.config.ts proxies /api to the backend. \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 6e10711f..d67a573c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,8 +12,6 @@ dist dist-ssr *.local -config/env/env.dev - # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod deleted file mode 100644 index c0648913..00000000 --- a/frontend/Dockerfile.prod +++ /dev/null @@ -1,19 +0,0 @@ -FROM node:18 as builder - -WORKDIR /usr/src/app - -COPY package*.json ./ - -RUN npm ci --legacy-peer-deps - -COPY . . - -RUN npm run build - -FROM nginx:latest - -COPY nginx.conf /etc/nginx/conf.d/default.conf - -COPY --from=builder /usr/src/app/build /usr/share/nginx/html - -EXPOSE 80 \ No newline at end of file diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 26a6ab8a..545ce5d4 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,9 +1,19 @@ import axios from "axios"; import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; -const baseURL = import.meta.env.VITE_API_BASE_URL; +import { + V1_API_ENDPOINTS, + CONVERSATION_ENDPOINTS, + AUTH_ENDPOINTS, + endpoints, +} from "./endpoints"; -export const api = axios.create({ +// Empty baseURL so API calls are relative to current origin; one image works for both sandbox and production. +const baseURL = ""; + +export const publicApi = axios.create({ baseURL }); + +export const adminApi = axios.create({ baseURL, headers: { Authorization: `JWT ${localStorage.getItem("access")}`, @@ -11,7 +21,7 @@ export const api = axios.create({ }); // Request interceptor to set the Authorization header -api.interceptors.request.use( +adminApi.interceptors.request.use( (configuration) => { const token = localStorage.getItem("access"); if (token) { @@ -22,6 +32,70 @@ api.interceptors.request.use( (error) => Promise.reject(error), ); +// Response interceptor to handle token refresh on 401 +let isRefreshing = false; +let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }[] = []; + +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +adminApi.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `JWT ${token}`; + return adminApi(originalRequest); + }).catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem("refresh"); + + if (!refreshToken) { + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(error); + } + + try { + const response = await axios.post(AUTH_ENDPOINTS.JWT_REFRESH, { refresh: refreshToken }); + const newAccessToken = response.data.access; + localStorage.setItem("access", newAccessToken); + processQueue(null, newAccessToken); + originalRequest.headers.Authorization = `JWT ${newAccessToken}`; + return adminApi(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + }, +); + const handleSubmitFeedback = async ( feedbackType: FormValues["feedbackType"], name: FormValues["name"], @@ -29,7 +103,7 @@ const handleSubmitFeedback = async ( message: FormValues["message"], ) => { try { - const response = await api.post(`/v1/api/feedback/`, { + const response = await publicApi.post(V1_API_ENDPOINTS.FEEDBACK, { feedbacktype: feedbackType, name, email, @@ -42,10 +116,13 @@ const handleSubmitFeedback = async ( } }; -const handleSendDrugSummary = async (message: FormValues["message"], guid: string) => { +const handleSendDrugSummary = async ( + message: FormValues["message"], + guid: string, +) => { try { - const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; - const response = await api.post(endpoint, { + const endpoint = endpoints.embeddingsAsk(guid); + const response = await adminApi.post(endpoint, { message, }); console.log("Response data:", JSON.stringify(response.data, null, 2)); @@ -58,7 +135,7 @@ const handleSendDrugSummary = async (message: FormValues["message"], guid: strin const handleRuleExtraction = async (guid: string) => { try { - const response = await api.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await adminApi.get(endpoints.ruleExtraction(guid)); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -67,9 +144,12 @@ const handleRuleExtraction = async (guid: string) => { } }; -const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" | "diagnosis_depressed" = "include") => { +const fetchRiskDataWithSources = async ( + medication: string, + source: "include" | "diagnosis" | "diagnosis_depressed" = "include", +) => { try { - const response = await api.post(`/v1/api/riskWithSources`, { + const response = await publicApi.post(V1_API_ENDPOINTS.RISK_WITH_SOURCES, { drug: medication, source: source, }); @@ -90,15 +170,13 @@ interface StreamCallbacks { const handleSendDrugSummaryStream = async ( message: string, guid: string, - callbacks: StreamCallbacks + callbacks: StreamCallbacks, ): Promise => { const token = localStorage.getItem("access"); - const endpoint = `/v1/api/embeddings/ask_embeddings?stream=true${ - guid ? `&guid=${guid}` : "" - }`; + const endpoint = endpoints.embeddingsAskStream(guid); try { - const response = await fetch(baseURL + endpoint, { + const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", @@ -165,12 +243,18 @@ const handleSendDrugSummaryStream = async ( } } } catch (parseError) { - console.error("Failed to parse SSE data:", parseError, "Raw line:", line); + console.error( + "Failed to parse SSE data:", + parseError, + "Raw line:", + line, + ); } } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; console.error("Error in stream:", errorMessage); callbacks.onError?.(errorMessage); throw error; @@ -186,13 +270,13 @@ const handleSendDrugSummaryStreamLegacy = async ( return handleSendDrugSummaryStream(message, guid, { onContent: onChunk, onError: (error) => console.error("Stream error:", error), - onComplete: () => console.log("Stream completed") + onComplete: () => console.log("Stream completed"), }); }; const fetchConversations = async (): Promise => { try { - const response = await api.get(`/chatgpt/conversations/`); + const response = await publicApi.get(CONVERSATION_ENDPOINTS.CONVERSATIONS); return response.data; } catch (error) { console.error("Error(s) during getConversations: ", error); @@ -202,7 +286,7 @@ const fetchConversations = async (): Promise => { const fetchConversation = async (id: string): Promise => { try { - const response = await api.get(`/chatgpt/conversations/${id}/`); + const response = await publicApi.get(endpoints.conversation(id)); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -212,7 +296,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await api.post(`/chatgpt/conversations/`, { + const response = await adminApi.post(CONVERSATION_ENDPOINTS.CONVERSATIONS, { messages: [], }); return response.data; @@ -228,8 +312,8 @@ const continueConversation = async ( page_context?: string, ): Promise<{ response: string; title: Conversation["title"] }> => { try { - const response = await api.post( - `/chatgpt/conversations/${id}/continue_conversation/`, + const response = await adminApi.post( + endpoints.continueConversation(id), { message, page_context, @@ -244,7 +328,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await api.delete(`/chatgpt/conversations/${id}/`); + const response = await adminApi.delete(endpoints.conversation(id)); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -255,9 +339,11 @@ const deleteConversation = async (id: string) => { const updateConversationTitle = async ( id: Conversation["id"], newTitle: Conversation["title"], -): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { +): Promise< + { status: string; title: Conversation["title"] } | { error: string } +> => { try { - const response = await api.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await adminApi.patch(endpoints.updateConversationTitle(id), { title: newTitle, }); return response.data; @@ -268,9 +354,13 @@ const updateConversationTitle = async ( }; // Assistant API functions -const sendAssistantMessage = async (message: string, previousResponseId?: string) => { +const sendAssistantMessage = async ( + message: string, + previousResponseId?: string, +) => { try { - const response = await api.post(`/v1/api/assistant`, { + const api = localStorage.getItem("access") ? adminApi : publicApi; + const response = await api.post(V1_API_ENDPOINTS.ASSISTANT, { message, previous_response_id: previousResponseId, }); @@ -281,6 +371,17 @@ const sendAssistantMessage = async (message: string, previousResponseId?: string } }; +export interface VersionResponse { + version: string; +} + +const fetchVersion = async (): Promise => { + const response = await publicApi.get( + V1_API_ENDPOINTS.VERSION, + ); + return response.data; +}; + export { handleSubmitFeedback, handleSendDrugSummary, @@ -294,5 +395,6 @@ export { handleSendDrugSummaryStream, handleSendDrugSummaryStreamLegacy, fetchRiskDataWithSources, - sendAssistantMessage -}; \ No newline at end of file + sendAssistantMessage, + fetchVersion, +}; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 00000000..8e43a239 --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,147 @@ +/** + * Centralized API endpoints configuration + * + * This file contains all API endpoint paths used throughout the application. + * Update endpoints here to change them across the entire frontend. + */ + +const API_BASE = '/api'; + +/** Base path for v1 API (avoids repeating /api/v1/api in every endpoint) */ +const V1_API_BASE = `${API_BASE}/v1/api`; + +/** + * Authentication endpoints + */ +export const AUTH_ENDPOINTS = { + JWT_VERIFY: `${API_BASE}/auth/jwt/verify/`, + JWT_CREATE: `${API_BASE}/auth/jwt/create/`, + USER_ME: `${API_BASE}/auth/users/me/`, + RESET_PASSWORD: `${API_BASE}/auth/users/reset_password/`, + RESET_PASSWORD_CONFIRM: `${API_BASE}/auth/users/reset_password_confirm/`, + USERS_CREATE: `${API_BASE}/auth/users/`, + USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, + USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, + JWT_REFRESH: `${API_BASE}/auth/jwt/refresh/`, +} as const; + +/** + * V1 API endpoints + */ +export const V1_API_ENDPOINTS = { + // Feedback + FEEDBACK: `${V1_API_BASE}/feedback/`, + + // Embeddings + EMBEDDINGS_ASK: `${V1_API_BASE}/embeddings/ask_embeddings`, + RULE_EXTRACTION: `${V1_API_BASE}/rule_extraction_openai`, + + // Risk + RISK_WITH_SOURCES: `${V1_API_BASE}/riskWithSources`, + + // Assistant + ASSISTANT: `${V1_API_BASE}/assistant`, + + // File Management + UPLOAD_FILE: `${V1_API_BASE}/uploadFile`, + EDIT_METADATA: `${V1_API_BASE}/editmetadata`, + + // Medications + GET_FULL_LIST_MED: `${V1_API_BASE}/get_full_list_med`, + GET_MED_RECOMMEND: `${V1_API_BASE}/get_med_recommend`, + ADD_MEDICATION: `${V1_API_BASE}/add_medication`, + DELETE_MED: `${V1_API_BASE}/delete_med`, + + // Medication Rules + MED_RULES: `${V1_API_BASE}/medRules`, + + // Version (build/deploy info) + VERSION: `${V1_API_BASE}/version`, +} as const; + +/** + * ChatGPT/Conversations endpoints + */ +export const CONVERSATION_ENDPOINTS = { + CONVERSATIONS: `${API_BASE}/chatgpt/conversations/`, + EXTRACT_TEXT: `${API_BASE}/chatgpt/extract_text/`, +} as const; + +/** + * AI Settings endpoints + */ +export const AI_SETTINGS_ENDPOINTS = { + SETTINGS: `${API_BASE}/ai_settings/settings/`, +} as const; + +/** + * Helper functions for dynamic endpoints + */ +export const endpoints = { + /** + * Get embeddings endpoint with optional GUID + */ + embeddingsAsk: (guid?: string): string => { + const base = V1_API_ENDPOINTS.EMBEDDINGS_ASK; + return guid ? `${base}?guid=${guid}` : base; + }, + + /** + * Get embeddings streaming endpoint + */ + embeddingsAskStream: (guid?: string): string => { + const base = `${V1_API_ENDPOINTS.EMBEDDINGS_ASK}?stream=true`; + return guid ? `${base}&guid=${guid}` : base; + }, + + /** + * Get rule extraction endpoint with GUID + */ + ruleExtraction: (guid: string): string => { + return `${V1_API_ENDPOINTS.RULE_EXTRACTION}?guid=${guid}`; + }, + + /** + * Get conversation by ID + */ + conversation: (id: string): string => { + return `${CONVERSATION_ENDPOINTS.CONVERSATIONS}${id}/`; + }, + + /** + * Continue conversation endpoint + */ + continueConversation: (id: string): string => { + return `${CONVERSATION_ENDPOINTS.CONVERSATIONS}${id}/continue_conversation/`; + }, + + /** + * Update conversation title endpoint + */ + updateConversationTitle: (id: string): string => { + return `${CONVERSATION_ENDPOINTS.CONVERSATIONS}${id}/update_title/`; + }, + + /** + * Get upload file endpoint with GUID + */ + uploadFile: (guid: string): string => { + return `${V1_API_ENDPOINTS.UPLOAD_FILE}/${guid}`; + }, + + /** + * Edit metadata endpoint with GUID + */ + editMetadata: (guid: string): string => { + return `${V1_API_ENDPOINTS.EDIT_METADATA}/${guid}`; + }, +} as const; + +/** + * Type-safe endpoint values + */ +export type AuthEndpoint = typeof AUTH_ENDPOINTS[keyof typeof AUTH_ENDPOINTS]; +export type V1ApiEndpoint = typeof V1_API_ENDPOINTS[keyof typeof V1_API_ENDPOINTS]; +export type ConversationEndpoint = typeof CONVERSATION_ENDPOINTS[keyof typeof CONVERSATION_ENDPOINTS]; +export type AiSettingsEndpoint = typeof AI_SETTINGS_ENDPOINTS[keyof typeof AI_SETTINGS_ENDPOINTS]; + diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 68a22263..977c59d4 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -2,6 +2,7 @@ import { useState, useRef, KeyboardEvent } from "react"; import { Link } from "react-router-dom"; +import Version from "../Version/Version"; import "../../App.css"; // Import the common Tailwind CSS styles function Footer() { @@ -61,11 +62,11 @@ function Footer() { > Leave feedback - - Donate + Support Development
-

© 2025 Balancer. All rights reserved. V1 2-04-2025

+

+ © 2025 Balancer. All rights reserved. + +

diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index c6315068..a6258865 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -310,9 +310,9 @@ const Chat: React.FC = ({ showChat, setShowChat }) => {

Hi there, I'm {CHATBOT_NAME}!

- You can ask me questions about your uploaded documents. - I'll search through them to provide accurate, cited - answers. + You can ask me questions about bipolar medications. + I'll search through our database of verified medical + journal articles to provide accurate, cited answers.

Learn more about my sources. diff --git a/frontend/src/components/Header/FeatureMenuDropDown.tsx b/frontend/src/components/Header/FeatureMenuDropDown.tsx index b1bbf03e..36d72792 100644 --- a/frontend/src/components/Header/FeatureMenuDropDown.tsx +++ b/frontend/src/components/Header/FeatureMenuDropDown.tsx @@ -4,13 +4,13 @@ export const FeatureMenuDropDown = () => { const location = useLocation(); const currentPath = location.pathname; return ( -
-
+
+
    Manage files -
    +
    Manage and chat with files
@@ -19,7 +19,7 @@ export const FeatureMenuDropDown = () => {
    Manage rules -
    +
    Manage list of rules
@@ -28,7 +28,7 @@ export const FeatureMenuDropDown = () => {
    Manage meds -
    +
    Manage list of meds
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 49798c83..488920d8 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,20 +1,16 @@ -import { useState, useRef, useEffect, Fragment } from "react"; -// import { useState, Fragment } from "react"; -import accountLogo from "../../assets/account.svg"; +import { useState, useRef, useEffect } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; -import LoginMenuDropDown from "./LoginMenuDropDown"; import "../../components/Header/header.css"; import Chat from "./Chat"; import { FeatureMenuDropDown } from "./FeatureMenuDropDown"; import MdNavBar from "./MdNavBar"; -import { connect, useDispatch } from "react-redux"; +import { connect } from "react-redux"; import { RootState } from "../../services/actions/types"; -import { logout, AppDispatch } from "../../services/actions/auth"; -import { HiChevronDown } from "react-icons/hi"; +import { FaChevronDown, FaSignOutAlt } from "react-icons/fa"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; isSuperuser: boolean; } @@ -24,44 +20,19 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { const dropdownRef = useRef(null); let delayTimeout: number | null = null; const [showChat, setShowChat] = useState(false); - const [showLoginMenu, setShowLoginMenu] = useState(false); - const [redirect, setRedirect] = useState(false); const { setShowSummary, setEnterNewPatient, triggerFormReset, setIsEditing } = useGlobalContext(); - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - setRedirect(false); - }; - - const guestLinks = () => ( - - ); - const authLinks = () => ( - + + Sign Out + + ); - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - const handleMouseEnter = () => { if (delayTimeout !== null) { clearTimeout(delayTimeout); @@ -102,22 +73,29 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => {

- Welcome to Balancer’s first release! Found a bug or have feedback? Let us know {" "} + Welcome to Balancer’s first release! Found a bug or have feedback? Let + us know{" "} - here {" "} + here{" "} - or email {" "} - + or email{" "} + balancerteam@codeforphilly.org .

- App is in beta; report issues to {" "} - + App is in beta; report issues to{" "} + balancerteam@codeforphilly.org . @@ -133,7 +111,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Balancer -

diff --git a/frontend/src/components/Header/LoginMenuDropDown.tsx b/frontend/src/components/Header/LoginMenuDropDown.tsx deleted file mode 100644 index 427fdf07..00000000 --- a/frontend/src/components/Header/LoginMenuDropDown.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -import { classNames } from "../../utils/classNames"; - -interface LoginMenuDropDownProps { - showLoginMenu: boolean; - handleLoginMenu: () => void; -} - -const LoginMenuDropDown: React.FC = ({ - showLoginMenu, -}) => { - return ( - <> - - -
- - Balancer - - - -

- Balancer is an interactive and user-friendly research tool for bipolar - medications, powered by Code for Philly volunteers. -

-

- We built Balancer{" "} - - to improve the health and well-being of people with bipolar - disorder. - -

-

- Balancer is currently still being developed, so do not take any - information on the test site as actual medical advice. -

- - {/*

- You can log in or sign up for a Balancer account using your email, - gmail or Facebook account. -

*/} - - - - - {/* - - */} -
- - ); -}; - -const LoginMenu = ({ show }: { show: boolean }) => { - if (!show) return null; - - return
; -}; - -export default LoginMenuDropDown; diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 926794cf..550b74d2 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -5,11 +5,9 @@ import Chat from "./Chat"; // import logo from "../../assets/balancer.png"; import closeLogo from "../../assets/close.svg"; import hamburgerLogo from "../../assets/hamburger.svg"; -import {useDispatch} from "react-redux"; -import {logout, AppDispatch} from "../../services/actions/auth"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; handleForm: () => void; } @@ -22,13 +20,6 @@ const MdNavBar = (props: LoginFormProps) => { setNav(!nav); }; - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - }; - - return (
{
  • - - Donate + Support Development
  • - {isAuthenticated && -
  • - - Sign Out - -
  • - } + {isAuthenticated ? ( +
  • + + Sign Out + +
  • + ) : ( +
  • + + Log In + +
  • + )}
    - {isAuthenticated && ( - - )} +
    ); }; diff --git a/frontend/src/components/Header/header.css b/frontend/src/components/Header/header.css index 4b0f4a2c..c7e807b9 100644 --- a/frontend/src/components/Header/header.css +++ b/frontend/src/components/Header/header.css @@ -23,7 +23,7 @@ } .header-nav-item { - @apply text-black border-transparent border-b-2 hover:border-blue-600 hover:text-blue-600 hover:border-b-2 hover:border-blue-600; + @apply text-black border-transparent border-b-2 hover:cursor-pointer hover:border-blue-600 hover:text-blue-600 hover:border-b-2 hover:border-blue-600; } .header-nav-item.header-nav-item-selected { @@ -31,7 +31,7 @@ } .subheader-nav-item { - @apply cursor-pointer rounded-lg p-3 transition duration-300 hover:bg-gray-100; + @apply cursor-pointer p-3 transition duration-300 hover:bg-gray-200 border-b border-gray-200; } .subheader-nav-item.subheader-nav-item-selected { diff --git a/frontend/src/components/ProtectedRoute/AdminRoute.tsx b/frontend/src/components/ProtectedRoute/AdminRoute.tsx new file mode 100644 index 00000000..61195cb8 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/AdminRoute.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; + +interface AdminRouteProps { + children: ReactNode; +} + +const AdminRoute = ({ children }: AdminRouteProps) => { + const location = useLocation(); + const dispatch = useDispatch(); + const { isAuthenticated, isSuperuser } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + + if (isAuthenticated === null) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (!isSuperuser) { + return ; + } + + return children; +}; + +export default AdminRoute; diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 00000000..66333556 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,40 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const location = useLocation(); + const dispatch = useDispatch(); + const { isAuthenticated } = useSelector((state: RootState) => state.auth); + + // Check authentication status when component mounts + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + + // Wait for auth check to complete (null means not checked yet) + // TODO: Consider adding error handling for auth check failures + if (isAuthenticated === null) { + // TODO: Consider adding accessibility attributes (role="status", aria-live="polite", aria-label) + // TODO: Consider preventing Loading State Flash by adding delay before showing spinner + return ; + } + + // If not authenticated, redirect to login and include the original location + if (!isAuthenticated) { + return ; + } + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/components/Version/Version.tsx b/frontend/src/components/Version/Version.tsx new file mode 100644 index 00000000..ba54f64c --- /dev/null +++ b/frontend/src/components/Version/Version.tsx @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; +import { fetchVersion } from "../../api/apiClient"; + +type VersionProps = { + /** Text before the version number (e.g. "Version " or " Version ") */ + prefix?: string; + /** Rendered when version is loading or failed (e.g. " —") */ + fallback?: React.ReactNode; + /** Optional class name for the wrapper element */ + className?: string; + /** Wrapper element (span for inline, p for block) */ + as?: "span" | "p"; +}; + +function Version({ + prefix = "Version ", + fallback = null, + className, + as: Wrapper = "span", +}: VersionProps) { + const [version, setVersion] = useState(null); + + useEffect(() => { + fetchVersion() + .then((data) => setVersion(data.version)) + .catch(() => setVersion(null)); + }, []); + + const content = version != null ? prefix + version : fallback; + if (content === null || content === undefined) { + return null; + } + + return {content}; +} + +export default Version; diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index b8170333..9481c74d 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -1,5 +1,6 @@ //import Welcome from "../../components/Welcome/Welcome.tsx"; import Layout from "../Layout/Layout"; +import Version from "../../components/Version/Version"; // import image from "./OIP.jpeg"; import image from "./OIP2.png"; @@ -76,9 +77,9 @@ function About() {
    - + @@ -88,6 +89,10 @@ function About() {
    +
    diff --git a/frontend/src/pages/Activate/Activate.tsx b/frontend/src/pages/Activate/Activate.tsx new file mode 100644 index 00000000..391ec04b --- /dev/null +++ b/frontend/src/pages/Activate/Activate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { verify, AppDispatch } from "../../services/actions/auth"; +import Layout from "../Layout/Layout"; +import Spinner from "../../components/LoadingSpinner/LoadingSpinner"; + +const Activate = () => { + const { uid, token } = useParams<{ uid: string; token: string }>(); + const dispatch = useDispatch(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + if (!uid || !token) { + setStatus("error"); + return; + } + + (async () => { + try { + await dispatch(verify(uid, token)); + setStatus("success"); + } catch { + setStatus("error"); + } + })(); + }, [dispatch, uid, token]); + + if (status === "loading") { + return ( + + + + ); + } + + if (status === "error") { + return ( + +
    +
    +

    + Activation failed +

    +

    + This activation link is invalid or has already been used. Please register again or request a new activation email. +

    + + Back to register + +
    +
    +
    + ); + } + + return ( + +
    +
    +

    + Email verified +

    +

    + Your account has been activated. You can now log in. +

    + + Continue to log in + +
    +
    +
    + ); +}; + +export default Activate; diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index 35c4b84f..32b727e8 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from "react"; -import axios from "axios"; +import { adminApi } from "../../api/apiClient"; import TypingAnimation from "../../components/Header/components/TypingAnimation.tsx"; import Layout from "../Layout/Layout.tsx"; @@ -22,16 +22,9 @@ const UploadFile: React.FC = () => { formData.append("file", file); try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.post( - `${baseUrl}/v1/api/uploadFile`, + const response = await adminApi.post( + `/api/v1/api/uploadFile`, formData, - { - headers: { - "Content-Type": "multipart/form-data", - Authorization: `JWT ${localStorage.getItem("access")}`, // Assuming JWT is used for auth - }, - } ); console.log("File uploaded successfully", response.data); } catch (error) { diff --git a/frontend/src/pages/DrugSummary/PDFViewer.tsx b/frontend/src/pages/DrugSummary/PDFViewer.tsx index 39ddfbfc..e4aae111 100644 --- a/frontend/src/pages/DrugSummary/PDFViewer.tsx +++ b/frontend/src/pages/DrugSummary/PDFViewer.tsx @@ -10,6 +10,7 @@ import { import { Document, Page, pdfjs } from "react-pdf"; import { useLocation, useNavigate } from "react-router-dom"; import axios from "axios"; +import { endpoints } from "../../api/endpoints"; import "react-pdf/dist/esm/Page/AnnotationLayer.css"; import "react-pdf/dist/esm/Page/TextLayer.css"; import ZoomMenu from "./ZoomMenu"; @@ -50,11 +51,10 @@ const PDFViewer = () => { const params = new URLSearchParams(location.search); const guid = params.get("guid"); const pageParam = params.get("page"); - const baseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; const pdfUrl = useMemo(() => { - return guid && baseURL ? `${baseURL}/v1/api/uploadFile/${guid}` : null; - }, [guid, baseURL]); + return guid ? endpoints.uploadFile(guid) : null; + }, [guid]); useEffect(() => setUiScalePct(Math.round(scale * 100)), [scale]); diff --git a/frontend/src/pages/Files/FileRow.tsx b/frontend/src/pages/Files/FileRow.tsx index 19665855..57ed66bf 100644 --- a/frontend/src/pages/Files/FileRow.tsx +++ b/frontend/src/pages/Files/FileRow.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; +import { endpoints } from "../../api/endpoints"; interface File { id: number; @@ -42,8 +43,7 @@ const FileRow: React.FC = ({ const handleSave = async () => { setLoading(true); try { - const baseUrl = import.meta.env.VITE_API_BASE_URL as string; - await fetch(`${baseUrl}/v1/api/editmetadata/${file.guid}`, { + await fetch(endpoints.editMetadata(file.guid), { method: "PATCH", headers: { "Content-Type": "application/json", diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index b53874bf..37bd459a 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; import Layout from "../Layout/Layout"; import FileRow from "./FileRow"; import Table from "../../components/Table/Table"; @@ -30,14 +30,12 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const [downloading, setDownloading] = useState(null); const [opening, setOpening] = useState(null); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - useEffect(() => { const fetchFiles = async () => { try { - const url = `${baseUrl}/v1/api/uploadFile`; + const url = `/api/v1/api/uploadFile`; - const { data } = await api.get(url); + const { data } = await publicApi.get(url); if (Array.isArray(data)) { setFiles(data); @@ -50,7 +48,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ }; fetchFiles(); - }, [baseUrl]); + }, []); const updateFileName = (guid: string, updatedFile: Partial) => { setFiles((prevFiles) => @@ -63,7 +61,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await publicApi.get(`/api/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); @@ -84,7 +82,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await publicApi.get(`/api/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index 3c12358b..02274b78 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -1,48 +1,22 @@ // Layout.tsx -import {ReactNode, useState, useEffect} from "react"; +import {ReactNode} from "react"; import Header from "../../components/Header/Header"; import Footer from "../../components/Footer/Footer"; -import LoginMenuDropDown from "../../components/Header/LoginMenuDropDown"; import {connect} from "react-redux"; import {useAuth} from "./authHooks.ts"; import {RootState} from "../../services/actions/types"; -import {useLocation} from "react-router-dom"; interface LayoutProps { children: ReactNode; } interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } export const Layout = ({ - children, - isAuthenticated, - }: LayoutProps & LoginFormProps): JSX.Element => { - const [showLoginMenu, setShowLoginMenu] = useState(false); - const location = useLocation(); - - - useEffect(() => { - if (!isAuthenticated) { - if ( - location.pathname === "/login" || - location.pathname === "/resetpassword" || - location.pathname.includes("password") || - location.pathname.includes("reset") - ) { - setShowLoginMenu(false); - } else { - setShowLoginMenu(true); - } - } - }, [isAuthenticated, location.pathname]); - - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - + children +}: LayoutProps & LoginFormProps): JSX.Element => { useAuth(); return (
    @@ -50,12 +24,6 @@ export const Layout = ({
    - {!isAuthenticated && showLoginMenu && ( - - )}
    {children}
    diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index b510c62d..c896b7b1 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -1,38 +1,17 @@ -import { useState, useEffect } from "react"; import { Link, useLocation } from "react-router-dom"; -import LoginMenuDropDown from "../../components/Header/LoginMenuDropDown.tsx"; import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } -const Header: React.FC = ({ isAuthenticated }) => { - const [showLoginMenu, setShowLoginMenu] = useState(false); +const Header: React.FC = () => { const location = useLocation(); const { setShowMetaPanel } = useGlobalContext(); const isOnDrugSummaryPage = location.pathname.includes("/drugsummary"); - useEffect(() => { - // only show the login menu on non‑auth pages - if (!isAuthenticated) { - const path = location.pathname; - const isAuthPage = - path === "/login" || - path === "/resetpassword" || - path.includes("password") || - path.includes("reset"); - - setShowLoginMenu(!isAuthPage); - } - }, [isAuthenticated, location.pathname]); - - const handleLoginMenu = () => { - setShowLoginMenu((prev) => !prev); - }; - useAuth(); return ( @@ -65,14 +44,6 @@ const Header: React.FC = ({ isAuthenticated }) => { )}
    - {!isAuthenticated && showLoginMenu && ( -
    - -
    - )} ); }; diff --git a/frontend/src/pages/Layout/Layout_V2_Main.tsx b/frontend/src/pages/Layout/Layout_V2_Main.tsx index 132482b6..2ebad75c 100644 --- a/frontend/src/pages/Layout/Layout_V2_Main.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Main.tsx @@ -7,7 +7,7 @@ import Sidebar from "./Layout_V2_Sidebar"; interface LayoutProps { children: ReactNode; - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const Layout: React.FC = ({ children, isAuthenticated }) => { diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index 19163290..b947c2d6 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -24,12 +24,7 @@ const Sidebar: React.FC = () => { useEffect(() => { const fetchFiles = async () => { try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - }); + const response = await axios.get(`/api/v1/api/uploadFile`); if (Array.isArray(response.data)) { setFiles(response.data); } diff --git a/frontend/src/pages/ListMeds/useMedications.tsx b/frontend/src/pages/ListMeds/useMedications.tsx index e15cc758..d78702db 100644 --- a/frontend/src/pages/ListMeds/useMedications.tsx +++ b/frontend/src/pages/ListMeds/useMedications.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; export interface MedData { name: string; @@ -11,14 +11,12 @@ export function useMedications() { const [medications, setMedications] = useState([]); const [errors, setErrors] = useState([]); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - useEffect(() => { const fetchMedications = async () => { try { - const url = `${baseUrl}/v1/api/get_full_list_med`; + const url = `/api/v1/api/get_full_list_med`; - const { data } = await api.get(url); + const { data } = await publicApi.get(url); data.sort((a: MedData, b: MedData) => { const nameA = a.name.toUpperCase(); @@ -44,7 +42,7 @@ export function useMedications() { }; fetchMedications(); - }, [baseUrl]); + }, []); console.log(medications); diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d4579ead..1d27aac5 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -1,6 +1,5 @@ import { useFormik } from "formik"; -// import { Link, useNavigate } from "react-router-dom"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { login, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; @@ -9,7 +8,7 @@ import ErrorMessage from "../../components/ErrorMessage"; import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; loginError?: string | null; // Align this with the mapped state } @@ -59,10 +58,9 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { onSubmit={handleSubmit} className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12" > -
    - {/* {errorMessage &&
    {errorMessage}
    } */} +

    - Welcome + Log in

    @@ -100,27 +98,21 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
    - {/* - - */}
    +
    + + Don't have an account? Sign up + + + Forgot password? + +
    - { loading && } - - {/*

    - Don't have an account?{" "} - - {" "} - Register here - - . -

    */} + { loading && } ); } diff --git a/frontend/src/pages/Login/ResetPassword.tsx b/frontend/src/pages/Login/ResetPassword.tsx index ba57f601..34ffc44b 100644 --- a/frontend/src/pages/Login/ResetPassword.tsx +++ b/frontend/src/pages/Login/ResetPassword.tsx @@ -1,19 +1,23 @@ import { useFormik } from "formik"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import { reset_password, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; import { useEffect, useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; import Layout from "../Layout/Layout"; interface ResetPasswordProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } function ResetPassword(props: ResetPasswordProps) { const { isAuthenticated } = props; const dispatch = useDispatch(); const [requestSent, setRequestSent] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); const navigate = useNavigate(); @@ -29,49 +33,86 @@ function ResetPassword(props: ResetPasswordProps) { }, onSubmit: (values) => { dispatch(reset_password(values.email)); + setSubmittedEmail(values.email); setRequestSent(true); }, }); + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.RESET_PASSWORD, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + if (requestSent) { - navigate("/"); - } - return ( - <> + return ( -
    -

    - Reset Password -

    -
    -
    - - -
    -
    -
    -
    +
    - + ); + } + + return ( + +
    +
    +

    + Reset password +

    +
    + + +
    + +
    + + Back to log in + +
    +
    +
    +
    ); } @@ -79,8 +120,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant const ConnectedResetPassword = connect(mapStateToProps)(ResetPassword); - -// Export the named constant export default ConnectedResetPassword; diff --git a/frontend/src/pages/Login/ResetPasswordConfirm.tsx b/frontend/src/pages/Login/ResetPasswordConfirm.tsx index 8f497817..80f36a63 100644 --- a/frontend/src/pages/Login/ResetPasswordConfirm.tsx +++ b/frontend/src/pages/Login/ResetPasswordConfirm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, Link } from "react-router-dom"; import { reset_password_confirm, AppDispatch, @@ -10,14 +10,15 @@ import { useEffect, useState } from "react"; import Layout from "../Layout/Layout"; interface ResetPasswordConfirmProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const ResetPasswordConfirm: React.FC = ({ isAuthenticated, }) => { const dispatch = useDispatch(); - const [requestSent, setRequestSent] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); const { uid, token } = useParams<{ uid: string; token: string }>(); const navigate = useNavigate(); @@ -33,66 +34,94 @@ const ResetPasswordConfirm: React.FC = ({ new_password: "", re_new_password: "", }, - onSubmit: (values) => { - dispatch( - reset_password_confirm( - uid!, - token!, - values.new_password, - values.re_new_password - ) - ); - setRequestSent(true); + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch( + reset_password_confirm( + uid!, + token!, + values.new_password, + values.re_new_password + ) + ); + setSuccess(true); + } catch { + setError("This reset link is invalid or has expired. Please request a new one."); + } finally { + setSubmitting(false); + } }, }); - if (requestSent) { - navigate("/"); - } - return ( - <> + if (success) { + return ( -
    -

    - Reset Password -

    -
    -
    - - - -
    -
    - -
    -
    +
    +
    +

    + Password updated +

    +

    + Your password has been reset. You can now log in with your new password. +

    + + Log in now + +
    - + ); + } + + return ( + +
    +
    +

    + Set new password +

    + {error &&

    {error}

    } +
    + + +
    +
    + + +
    + +
    +
    +
    ); }; @@ -100,9 +129,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant -const ConnectedResetPasswordConfirm = - connect(mapStateToProps)(ResetPasswordConfirm); - -// Export the named constant +const ConnectedResetPasswordConfirm = connect(mapStateToProps)(ResetPasswordConfirm); export default ConnectedResetPasswordConfirm; diff --git a/frontend/src/pages/Logout/Logout.tsx b/frontend/src/pages/Logout/Logout.tsx new file mode 100644 index 00000000..b09f0ca3 --- /dev/null +++ b/frontend/src/pages/Logout/Logout.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch } from "react-redux"; +import { logout, AppDispatch } from "../../services/actions/auth"; + +const LogoutPage = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(logout()); + + const timer = setTimeout(() => { + navigate('/'); + }, 3000); // Redirect after 3 seconds + + // Cleanup the timer on component unmount + return () => clearTimeout(timer); + }, [dispatch, navigate]); + + return ( +
    +
    +

    You’ve been logged out

    +
    +
    +
    +

    + Thank you for using Balancer. You'll be redirected to the homepage shortly. +

    + +
    +
    + ); +}; + +export default LogoutPage; diff --git a/frontend/src/pages/ManageMeds/ManageMeds.tsx b/frontend/src/pages/ManageMeds/ManageMeds.tsx index 071a2690..c2372b9e 100644 --- a/frontend/src/pages/ManageMeds/ManageMeds.tsx +++ b/frontend/src/pages/ManageMeds/ManageMeds.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import Layout from "../Layout/Layout"; import Welcome from "../../components/Welcome/Welcome"; import ErrorMessage from "../../components/ErrorMessage"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; function ManageMedications() { interface MedData { id: string; @@ -18,12 +18,11 @@ function ManageMedications() { const [newMedRisks, setNewMedRisks] = useState(""); const [showAddMed, setShowAddMed] = useState(false); const [hoveredMed, setHoveredMed] = useState(null); - const baseUrl = import.meta.env.VITE_API_BASE_URL; // Fetch Medications const fetchMedications = async () => { try { - const url = `${baseUrl}/v1/api/get_full_list_med`; - const { data } = await api.get(url); + const url = `/api/v1/api/get_full_list_med`; + const { data } = await adminApi.get(url); data.sort((a: MedData, b: MedData) => a.name.localeCompare(b.name)); setMedications(data); } catch (e: unknown) { @@ -36,7 +35,7 @@ function ManageMedications() { // Handle Delete Medication const handleDelete = async (name: string) => { try { - await api.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); + await adminApi.delete(`/api/v1/api/delete_med`, { data: { name } }); setMedications((prev) => prev.filter((med) => med.name !== name)); setConfirmDelete(null); } catch (e: unknown) { @@ -56,7 +55,7 @@ function ManageMedications() { return; } try { - await api.post(`${baseUrl}/v1/api/add_medication`, { + await adminApi.post(`/api/v1/api/add_medication`, { name: newMedName, benefits: newMedBenefits, risks: newMedRisks, diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 232ed296..144fe684 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -4,7 +4,7 @@ import { PatientInfo, Diagnosis } from "./PatientTypes"; import { useMedications } from "../ListMeds/useMedications"; import ChipsInput from "../../components/ChipsInput/ChipsInput"; import Tooltip from "../../components/Tooltip"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; // import ErrorMessage from "../../components/ErrorMessage"; @@ -113,14 +113,14 @@ const NewPatientForm = ({ }; useEffect(() => { - const patientInfoFromLocalStorage = JSON.parse( + const patientInfoFromSessionStorage = JSON.parse( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - localStorage.getItem("patientInfos") + sessionStorage.getItem("patientInfos") ); - if (patientInfoFromLocalStorage) { - setAllPatientInfo(patientInfoFromLocalStorage); + if (patientInfoFromSessionStorage) { + setAllPatientInfo(patientInfoFromSessionStorage); } }, []); @@ -152,10 +152,9 @@ const NewPatientForm = ({ setIsLoading(true); // Start loading try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/v1/api/get_med_recommend`; + const url = `/api/v1/api/get_med_recommend`; - const { data } = await api.post(url, payload); + const { data } = await publicApi.post(url, payload); const categorizedMedications = { first: data.first ?? [], @@ -190,11 +189,11 @@ const NewPatientForm = ({ updatedAllPatientInfo = [updatedPatientInfo, ...allPatientInfo]; } - // Update state and localStorage + // Update state and sessionStorage setPatientInfo(updatedPatientInfo); setAllPatientInfo(updatedAllPatientInfo); setShowSummary(true); - localStorage.setItem( + sessionStorage.setItem( "patientInfos", JSON.stringify(updatedAllPatientInfo) ); @@ -311,7 +310,7 @@ const NewPatientForm = ({ >

    - Click To Enter New Patient + Enter New Patient

    patient.ID !== patientIDToDelete ); - localStorage.setItem("patientInfos", JSON.stringify(updatedPatientInfo)); + sessionStorage.setItem("patientInfos", JSON.stringify(updatedPatientInfo)); setAllPatientInfo(updatedPatientInfo); onPatientDeleted(patientIDToDelete); diff --git a/frontend/src/pages/PatientManager/PatientManager.tsx b/frontend/src/pages/PatientManager/PatientManager.tsx index f49dfa48..00b94050 100644 --- a/frontend/src/pages/PatientManager/PatientManager.tsx +++ b/frontend/src/pages/PatientManager/PatientManager.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { RootState } from "../../services/actions/types"; import NewPatientForm from "./NewPatientForm.tsx"; import PatientHistory from "./PatientHistory.tsx"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -11,6 +13,7 @@ import Welcome from "../../components/Welcome/Welcome.tsx"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; const PatientManager = () => { + const { isAuthenticated } = useSelector((state: RootState) => state.auth); const [patientInfo, setPatientInfo] = useState({ ID: "", Diagnosis: Diagnosis.Manic, @@ -116,6 +119,7 @@ const PatientManager = () => { patientInfo={patientInfo} isPatientDeleted={isPatientDeleted} setPatientInfo={setPatientInfo} + isAuthenticated={isAuthenticated} /> >; + isAuthenticated: boolean | null; } type SourceItem = { @@ -27,6 +28,7 @@ type SourceItem = { guid?: string | null; page?: number | null; link_url?: string | null; + upload_file_guid?: string | null; }; type RiskData = { benefits: string[]; @@ -43,12 +45,28 @@ type MedicationWithSource = { const truncate = (s = "", n = 220) => s.length > n ? s.slice(0, n).trim() + "…" : s; +/** + * Extracts the GUID from a drugSummary URL query parameter + * Used as fallback when upload_file_guid is not provided by the API + * @param url - URL string like "/drugSummary?guid=xxx&page=1" + * @returns GUID string or null if not found + */ +const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } +}; + const MedicationItem = ({ medication, isClicked, riskData, loading, onTierClick, + isAuthenticated, }: { medication: string; source: string; @@ -56,6 +74,7 @@ const MedicationItem = ({ riskData: RiskData | null; loading: boolean; onTierClick: () => void; + isAuthenticated: boolean | null; }) => { if (medication === "None") { return ( @@ -141,16 +160,35 @@ const MedicationItem = ({
    {s.title || "Untitled source"} - {s.link_url && ( - - View PDF - - )} + {/* + Conditional PDF Button: + - Logged in: "View PDF" (blue) → Opens /drugSummary in new tab + - Not logged in: "Download PDF" (green) → Direct download via /v1/api/uploadFile/ + - Fallback: Extracts GUID from link_url if upload_file_guid is missing + */} + {s.link_url && (() => { + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + return isAuthenticated ? ( + + View PDF + + ) : ( + + Download PDF + + ); + })()}
    {s.publication && ( @@ -192,6 +230,7 @@ const MedicationTier = ({ riskData, loading, onTierClick, + isAuthenticated, }: { title: string; tier: string; @@ -200,6 +239,7 @@ const MedicationTier = ({ riskData: RiskData | null; loading: boolean; onTierClick: (medication: MedicationWithSource) => void; + isAuthenticated: boolean | null; }) => ( <>
    @@ -216,6 +256,7 @@ const MedicationTier = ({ riskData={riskData} loading={loading} onTierClick={() => onTierClick(medicationObj)} + isAuthenticated={isAuthenticated} /> ))} @@ -232,7 +273,9 @@ const PatientSummary = ({ setIsEditing, patientInfo, isPatientDeleted, + isAuthenticated = false, }: PatientSummaryProps) => { + // Using relative URLs - no baseURL needed const [loading, setLoading] = useState(false); const [riskData, setRiskData] = useState(null); const [clickedMedication, setClickedMedication] = useState( @@ -374,6 +417,7 @@ const PatientSummary = ({ riskData={riskData} loading={loading} onTierClick={handleTierClick} + isAuthenticated={isAuthenticated} />
    @@ -395,6 +440,7 @@ const PatientSummary = ({ riskData={riskData} loading={loading} onTierClick={handleTierClick} + isAuthenticated={isAuthenticated} />
    diff --git a/frontend/src/pages/Register/RegistrationForm.tsx b/frontend/src/pages/Register/RegistrationForm.tsx index c1745b3d..8134c521 100644 --- a/frontend/src/pages/Register/RegistrationForm.tsx +++ b/frontend/src/pages/Register/RegistrationForm.tsx @@ -1,71 +1,211 @@ import { useFormik } from "formik"; +import * as Yup from "yup"; import { Link } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { signup, AppDispatch } from "../../services/actions/auth"; +import { RootState } from "../../services/actions/types"; +import { useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; + +const validationSchema = Yup.object({ + first_name: Yup.string().required("First name is required"), + last_name: Yup.string().required("Last name is required"), + email: Yup.string().email("Enter a valid email").required("Email is required"), + password: Yup.string() + .min(8, "Password must be at least 8 characters") + .required("Password is required"), + re_password: Yup.string() + .oneOf([Yup.ref("password")], "Passwords must match") + .required("Please confirm your password"), +}); + +const RegistrationForm = () => { + const dispatch = useDispatch(); + const signupError = useSelector((state: RootState) => state.auth.error); + const [submitted, setSubmitted] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); + + const { handleSubmit, handleChange, handleBlur, values, errors, touched, isSubmitting } = + useFormik({ + initialValues: { + first_name: "", + last_name: "", + email: "", + password: "", + re_password: "", + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch(signup(values.first_name, values.last_name, values.email, values.password, values.re_password)); + setSubmittedEmail(values.email); + setSubmitted(true); + } catch { + // error is stored in Redux state and displayed via signupError + } finally { + setSubmitting(false); + } + }, + }); + + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.USERS_RESEND_ACTIVATION, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + + if (submitted) { + return ( +
    +
    +

    + Check your email +

    +

    + We sent an activation link to {submittedEmail}. Click the link to activate your account. +

    +
    + + Go to log in + + +
    +
    +
    + ); + } -const LoginForm = () => { - const { handleSubmit, handleChange, values } = useFormik({ - initialValues: { - email: "", - password: "", - }, - onSubmit: (values) => { - console.log("values", values); - // make registration post request here. - }, - }); return ( - <> -
    -

    - Register +
    +
    +

    + Create account

    - -
    - - -
    -
    - - -
    - -
    -
    -

    + {signupError && ( +

    {signupError}

    + )} + +
    + + + {touched.first_name && errors.first_name && ( +

    {errors.first_name}

    + )} +
    + +
    + + + {touched.last_name && errors.last_name && ( +

    {errors.last_name}

    + )} +
    + +
    + + + {touched.email && errors.email && ( +

    {errors.email}

    + )} +
    + +
    + + + {touched.password && errors.password && ( +

    {errors.password}

    + )} +
    + +
    + + + {touched.re_password && errors.re_password && ( +

    {errors.re_password}

    + )} +
    + + + +

    Already have an account?{" "} - {" "} - Login here. + Log in

    - +

    ); }; -export default LoginForm; +export default RegistrationForm; diff --git a/frontend/src/pages/RulesManager/RulesManager.tsx b/frontend/src/pages/RulesManager/RulesManager.tsx index be4980d4..e77b39cd 100644 --- a/frontend/src/pages/RulesManager/RulesManager.tsx +++ b/frontend/src/pages/RulesManager/RulesManager.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import Layout from "../Layout/Layout"; import Welcome from "../../components/Welcome/Welcome"; import ErrorMessage from "../../components/ErrorMessage"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; import { ChevronDown, ChevronUp } from "lucide-react"; interface Medication { @@ -63,13 +63,11 @@ function RulesManager() { const [isLoading, setIsLoading] = useState(true); const [expandedMeds, setExpandedMeds] = useState>(new Set()); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - useEffect(() => { const fetchMedRules = async () => { try { - const url = `${baseUrl}/v1/api/medRules`; - const { data } = await api.get(url); + const url = `/api/v1/api/medRules`; + const { data } = await adminApi.get(url); if (!data || !Array.isArray(data.results)) { throw new Error("Invalid response format"); @@ -86,7 +84,7 @@ function RulesManager() { }; fetchMedRules(); - }, [baseUrl]); + }, []); const toggleMedication = (ruleId: number, medName: string) => { const medKey = `${ruleId}-${medName}`; diff --git a/frontend/src/pages/Settings/SettingsManager.tsx b/frontend/src/pages/Settings/SettingsManager.tsx index c16ded96..3854298c 100644 --- a/frontend/src/pages/Settings/SettingsManager.tsx +++ b/frontend/src/pages/Settings/SettingsManager.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import axios from "axios"; +import { AI_SETTINGS_ENDPOINTS } from "../../api/endpoints"; // Define an interface for the setting items interface SettingItem { @@ -36,10 +37,8 @@ const SettingsManager: React.FC = () => { }, }; - // Use an environment variable for the base URL or directly insert the URL if not available - const baseUrl = - import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; - const url = `${baseUrl}/ai_settings/settings/`; + // Use centralized endpoint + const url = AI_SETTINGS_ENDPOINTS.SETTINGS; try { const response = await axios.get(url, config); setSettings(response.data); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 2e6273d4..b94cb64f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -1,6 +1,7 @@ import App from "../App"; import RouteError from "../pages/404/404.tsx"; import LoginForm from "../pages/Login/Login.tsx"; +import Logout from "../pages/Logout/Logout.tsx"; import AdminPortal from "../pages/AdminPortal/AdminPortal.tsx"; import ResetPassword from "../pages/Login/ResetPassword.tsx"; import ResetPasswordConfirm from "../pages/Login/ResetPasswordConfirm.tsx"; @@ -17,6 +18,9 @@ import UploadFile from "../pages/DocumentManager/UploadFile.tsx"; import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; +import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; +import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; +import Activate from "../pages/Activate/Activate.tsx"; const routes = [ { @@ -26,30 +30,38 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", - element: , + element: , }, { path: "register", element: , }, + { + path: "activate/:uid/:token", + element: , + }, { path: "login", element: , }, + { + path: "logout", + element: , + }, { path: "resetPassword", element: , @@ -80,11 +92,11 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -92,7 +104,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 2573c223..43c95fd7 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -20,6 +20,7 @@ import { FACEBOOK_AUTH_FAIL, LOGOUT, } from "./types"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; import { ThunkAction } from "redux-thunk"; import { RootState } from "../reducers"; @@ -75,8 +76,7 @@ export const checkAuthenticated = () => async (dispatch: AppDispatch) => { }; const body = JSON.stringify({ token: localStorage.getItem("access") }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/jwt/verify/`; + const url = AUTH_ENDPOINTS.JWT_VERIFY; try { const res = await axios.post(url, body, config); @@ -112,8 +112,7 @@ export const load_user = (): ThunkType => async (dispatch: AppDispatch) => { Accept: "application/json", }, }; - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/users/me/`; + const url = AUTH_ENDPOINTS.USER_ME; try { const res = await axios.get(url, config); @@ -143,11 +142,14 @@ export const login = }; const body = JSON.stringify({ email, password }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/jwt/create/`; + const url = AUTH_ENDPOINTS.JWT_CREATE; try { const res = await axios.post(url, body, config); + // Clear session data from previous unauthenticated session + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); + dispatch({ type: LOGIN_SUCCESS, payload: res.data, @@ -169,9 +171,10 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { - // Clear chat conversation data on logout for security + // Clear session data on logout for privacy sessionStorage.removeItem('currentConversation'); - + sessionStorage.removeItem('patientInfos'); + dispatch({ type: LOGOUT, }); @@ -187,8 +190,7 @@ export const reset_password = }; console.log("yes"); const body = JSON.stringify({ email }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/users/reset_password/`; + const url = AUTH_ENDPOINTS.RESET_PASSWORD; try { await axios.post(url, body, config); @@ -217,8 +219,7 @@ export const reset_password_confirm = }; const body = JSON.stringify({ uid, token, new_password, re_new_password }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/users/reset_password_confirm/`; + const url = AUTH_ENDPOINTS.RESET_PASSWORD_CONFIRM; try { const response = await axios.post(url, body, config); dispatch({ @@ -232,64 +233,58 @@ export const reset_password_confirm = } }; -// export const signup = -// (first_name, last_name, email, password, re_password) => -// async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; - -// const body = JSON.stringify({ -// first_name, -// last_name, -// email, -// password, -// re_password, -// }); - -// try { -// const res = await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/`, -// body, -// config -// ); +export const signup = + (first_name: string, last_name: string, email: string, password: string, re_password: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// dispatch({ -// type: SIGNUP_SUCCESS, -// payload: res.data, -// }); -// } catch (err) { -// dispatch({ -// type: SIGNUP_FAIL, -// }); -// } -// }; + const body = JSON.stringify({ first_name, last_name, email, password, re_password }); -// export const verify = -// (uid, token) => async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; + try { + const res = await axios.post(AUTH_ENDPOINTS.USERS_CREATE, body, config); + dispatch({ + type: SIGNUP_SUCCESS, + payload: res.data, + }); + } catch (err) { + let errorMessage = "Registration failed"; + if (isAxiosError(err) && err.response) { + const messages = Object.values(err.response.data as Record).flat(); + if (messages.length > 0) errorMessage = messages.join(" "); + } + dispatch({ + type: SIGNUP_FAIL, + payload: errorMessage, + }); + throw err; + } + }; -// const body = JSON.stringify({ uid, token }); +export const verify = + (uid: string, token: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// try { -// await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/activation/`, -// body, -// config -// ); + const body = JSON.stringify({ uid, token }); -// dispatch({ -// type: ACTIVATION_SUCCESS, -// }); -// } catch (err) { -// dispatch({ -// type: ACTIVATION_FAIL, -// }); -// } -// }; + try { + await axios.post(AUTH_ENDPOINTS.USERS_ACTIVATION, body, config); + dispatch({ + type: ACTIVATION_SUCCESS, + payload: "", + }); + } catch (err) { + dispatch({ + type: ACTIVATION_FAIL, + }); + throw err; + } + }; diff --git a/frontend/src/services/actions/types.tsx b/frontend/src/services/actions/types.tsx index add0dad9..c7f73b94 100644 --- a/frontend/src/services/actions/types.tsx +++ b/frontend/src/services/actions/types.tsx @@ -21,7 +21,8 @@ export const LOGOUT = "LOGOUT"; export interface RootState { auth: { error: any; - isAuthenticated: boolean; + // Catch any code that doesn't handle the null case by matching the actual reducer state defined in auth.ts + isAuthenticated: boolean | null; isSuperuser: boolean; }; } diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..9cc5d278 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -68,12 +68,15 @@ const initialState: StateType = { export default function authReducer(state = initialState, action: ActionType): StateType { switch(action.type) { - case AUTHENTICATED_SUCCESS: + case AUTHENTICATED_SUCCESS: { + const token = localStorage.getItem('access'); + const decoded: TokenClaims = token ? jwtDecode(token) : { is_superuser: false }; return { ...state, isAuthenticated: true, - isSuperuser: true + isSuperuser: decoded.is_superuser } + } case LOGIN_SUCCESS: case GOOGLE_AUTH_SUCCESS: case FACEBOOK_AUTH_SUCCESS:{ diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index bcc1e693..4161a741 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -10,8 +10,15 @@ export default { lora: "'Lora', serif", 'quicksand': ['Quicksand', 'sans-serif'] }, + keyframes: { + 'loading': { + '0%': { left: '-40%' }, + '100%': { left: '100%' }, + }, + }, animation: { - 'pulse-bounce': 'pulse-bounce 2s infinite', // Adjust duration and iteration as needed + 'pulse-bounce': 'pulse-bounce 2s infinite', + 'loading': 'loading 3s infinite', }, plugins: [], }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index cdf82274..1f02c51f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,7 +4,7 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ build: { - outDir: '../server/build', // Custom output directory + outDir: 'dist', // Output to local dist directory assetsDir: 'static', }, plugins: [react()], @@ -15,5 +15,11 @@ export default defineConfig({ host: "0.0.0.0", strictPort: true, port: 3000, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, }, }); \ No newline at end of file diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 00000000..ede07e70 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,26 @@ +## Description + + + +## Related Issue + + + +## Manual Tests + + + +## Automated Tests + + + +## Documentation + + + +## Reviewers + + + +## Notes + \ No newline at end of file diff --git a/server/Dockerfile.prod b/server/Dockerfile.prod deleted file mode 100644 index 97b2c142..00000000 --- a/server/Dockerfile.prod +++ /dev/null @@ -1,30 +0,0 @@ -# pull official base image -FROM python:3.11.4-slim-bullseye - - -# set work directory -WORKDIR /usr/src/app - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 - -# install system dependencies -RUN apt-get update && apt-get install -y netcat - -# install dependencies -RUN pip install --upgrade pip -COPY ./requirements.txt . -RUN pip install -r requirements.txt - -# copy project -COPY . /usr/src/app - -# Correct line endings in entrypoint.sh and make it executable -RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh - -# run entrypoint.sh -ENTRYPOINT ["./entrypoint.prod.sh"] - -# Default command to run on container start -CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--noreload"] diff --git a/server/Dockerfile.prodBackup b/server/Dockerfile.prodBackup deleted file mode 100644 index 9c5244c6..00000000 --- a/server/Dockerfile.prodBackup +++ /dev/null @@ -1,71 +0,0 @@ -########### -# BUILDER # -########### - -# pull official base image -FROM python:3.11.4-slim-buster as builder - -# set work directory -WORKDIR /usr/src/app - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 - -# install system dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends gcc - -# lint -# RUN pip install --upgrade pip -# RUN pip install flake8==6.0.0 -# COPY . /usr/src/app/ -# RUN flake8 --ignore=E501,F401 . - -# install python dependencies -COPY ./requirements.txt . -RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt - - -######### -# FINAL # -######### - -# pull official base image -FROM python:3.11.4-slim-buster - -# create directory for the app user -RUN mkdir -p /home/app - -# create the app user -RUN addgroup --system app && adduser --system --group app - -# create the appropriate directories -ENV HOME=/home/app -ENV APP_HOME=/home/app/web -RUN mkdir $APP_HOME -WORKDIR $APP_HOME - -# install dependencies -RUN apt-get update && apt-get install -y --no-install-recommends netcat -COPY --from=builder /usr/src/app/wheels /wheels -COPY --from=builder /usr/src/app/requirements.txt . -RUN pip install --upgrade pip -RUN pip install --no-cache /wheels/* - -# copy entrypoint.prod.sh -COPY ./entrypoint.prod.sh . -RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.prod.sh -RUN chmod +x $APP_HOME/entrypoint.prod.sh - -# copy project -COPY . $APP_HOME - -# chown all the files to the app user -RUN chown -R app:app $APP_HOME - -# change to the app user -USER app - -# run entrypoint.prod.sh -ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"] \ No newline at end of file diff --git a/server/api/apps.py b/server/api/apps.py index 66656fd2..13977850 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -4,3 +4,38 @@ class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' + + def ready(self): + + try: + import os + import sys + + # ready() runs in every Django process: migrate, test, shell, runserver, etc. + # Only preload the model when we're actually going to serve requests. + # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. + # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. + # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing + # runserver — the guard below correctly skips model loading for those commands too. + if sys.argv[1:2] != ['runserver']: + return + + # Dev's autoreloader spawns two processes: a parent file-watcher and a child + # server. ready() runs in both, but only the child (RUN_MAIN=true) serves + # requests. Skip the parent to avoid loading the model twice on each file change. + # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. + if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: + return + + # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first + # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. + # That cache is ephemeral — every container rebuild re-downloads the model unless + # a volume is mounted at that path. + from .services.sentencetTransformer_model import TransformerModel + TransformerModel.get_instance() + except Exception: + # TransformerModel._instance stays None on failure, so the first actual request + # that calls get_instance() will attempt to load the model again. + import logging + logger = logging.getLogger(__name__) + logger.exception("Failed to preload the embedding model at startup") diff --git a/server/api/migrations/0015_semanticsearchusage.py b/server/api/migrations/0015_semanticsearchusage.py new file mode 100644 index 00000000..0475b71f --- /dev/null +++ b/server/api/migrations/0015_semanticsearchusage.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.3 on 2025-11-26 21:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0014_alter_medrule_rule_type'), + ] + + operations = [ + migrations.CreateModel( + name='SemanticSearchUsage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('guid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('query_text', models.TextField(blank=True, help_text='The search query text', null=True)), + ('document_name', models.TextField(blank=True, help_text='Document name filter if used', null=True)), + ('document_guid', models.UUIDField(blank=True, help_text='Document GUID filter if used', null=True)), + ('num_results_requested', models.IntegerField(default=10, help_text='Number of results requested')), + ('encoding_time', models.FloatField(help_text='Time to encode query in seconds')), + ('db_query_time', models.FloatField(help_text='Time for database query in seconds')), + ('num_results_returned', models.IntegerField(help_text='Number of results returned')), + ('min_distance', models.FloatField(blank=True, help_text='Minimum L2 distance (null if no results)', null=True)), + ('max_distance', models.FloatField(blank=True, help_text='Maximum L2 distance (null if no results)', null=True)), + ('median_distance', models.FloatField(blank=True, help_text='Median L2 distance (null if no results)', null=True)), + ('user', models.ForeignKey(blank=True, help_text='User who performed the search (null for unauthenticated users)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='semantic_searches', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['-timestamp'], name='api_semanti_timesta_0b5730_idx'), models.Index(fields=['user', '-timestamp'], name='api_semanti_user_id_e11ecb_idx')], + }, + ), + ] diff --git a/server/api/models/model_search_usage.py b/server/api/models/model_search_usage.py new file mode 100644 index 00000000..cdc3dee6 --- /dev/null +++ b/server/api/models/model_search_usage.py @@ -0,0 +1,42 @@ +import uuid + +from django.db import models +from django.conf import settings + +class SemanticSearchUsage(models.Model): + """ + Tracks performance metrics and usage data for embedding searches. + """ + guid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) + timestamp = models.DateTimeField(auto_now_add=True) + query_text = models.TextField(blank=True, null=True, help_text="The search query text") + document_name = models.TextField(blank=True, null=True, help_text="Document name filter if used") + document_guid = models.UUIDField(blank=True, null=True, help_text="Document GUID filter if used") + num_results_requested = models.IntegerField(default=10, help_text="Number of results requested") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='semantic_searches', + null=True, + blank=True, + help_text="User who performed the search (null for unauthenticated users)" + ) + encoding_time = models.FloatField(help_text="Time to encode query in seconds") + db_query_time = models.FloatField(help_text="Time for database query in seconds") + num_results_returned = models.IntegerField(help_text="Number of results returned") + min_distance = models.FloatField(null=True, blank=True, help_text="Minimum L2 distance (null if no results)") + max_distance = models.FloatField(null=True, blank=True, help_text="Maximum L2 distance (null if no results)") + median_distance = models.FloatField(null=True, blank=True, help_text="Median L2 distance (null if no results)") + + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['-timestamp']), + models.Index(fields=['user', '-timestamp']), + ] + + def __str__(self): + total_time = self.encoding_time + self.db_query_time + user_display = self.user.email if self.user else "Anonymous" + return f"Search by {user_display} at {self.timestamp} ({total_time:.3f}s)" diff --git a/server/api/permissions.py b/server/api/permissions.py new file mode 100644 index 00000000..0dbe0597 --- /dev/null +++ b/server/api/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsSuperUser(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_superuser) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 6fd34d35..213519e5 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,25 +1,28 @@ -# services/embedding_services.py +import time +import logging +from statistics import median +# Use Q objects to express OR conditions in Django queries +from django.db.models import Q from pgvector.django import L2Distance from .sentencetTransformer_model import TransformerModel - -# Adjust import path as needed from ..models.model_embeddings import Embeddings +from ..models.model_search_usage import SemanticSearchUsage +logger = logging.getLogger(__name__) -def get_closest_embeddings( - user, message_data, document_name=None, guid=None, num_results=10 -): + +def build_query(user, embedding_vector, document_name=None, guid=None, num_results=10): """ - Find the closest embeddings to a given message for a specific user. + Build an unevaluated QuerySet for the closest embeddings. Parameters ---------- user : User The user whose uploaded documents will be searched - message_data : str - The input message to find similar embeddings for + embedding_vector : array-like + Pre-computed embedding vector to compare against document_name : str, optional Filter results to a specific document name guid : str, optional @@ -29,41 +32,52 @@ def get_closest_embeddings( Returns ------- - list[dict] - List of dictionaries containing embedding results with keys: - - name: document name - - text: embedded text content - - page_number: page number in source document - - chunk_number: chunk number within the document - - distance: L2 distance from query embedding - - file_id: GUID of the source file + QuerySet + Unevaluated Django QuerySet ordered by L2 distance, sliced to num_results """ - - # - transformerModel = TransformerModel.get_instance().model - embedding_message = transformerModel.encode(message_data) - # Start building the query based on the message's embedding - closest_embeddings_query = ( - Embeddings.objects.filter(upload_file__uploaded_by=user) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) + # Django QuerySets are lazily evaluated + if user.is_authenticated: + # User sees their own files + files uploaded by superusers + queryset = Embeddings.objects.filter( + Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) ) + else: + # Unauthenticated users only see superuser-uploaded files + queryset = Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) + + queryset = ( + queryset + .annotate(distance=L2Distance("embedding_sentence_transformers", embedding_vector)) .order_by("distance") ) - # Filter by GUID if provided, otherwise filter by document name if provided + # Filtering to a document GUID takes precedence over a document name if guid: - closest_embeddings_query = closest_embeddings_query.filter( - upload_file__guid=guid - ) + queryset = queryset.filter(upload_file__guid=guid) elif document_name: - closest_embeddings_query = closest_embeddings_query.filter(name=document_name) + queryset = queryset.filter(name=document_name) - # Slice the results to limit to num_results - closest_embeddings_query = closest_embeddings_query[:num_results] + # Slicing is equivalent to SQL's LIMIT clause + return queryset[:num_results] - # Format the results to be returned - results = [ + +def evaluate_query(queryset): + """ + Evaluate a QuerySet and return a list of result dicts. + + Parameters + ---------- + queryset : iterable + Iterable of Embeddings objects (or any objects with the expected attributes) + + Returns + ------- + list[dict] + List of dicts with keys: name, text, page_number, chunk_number, distance, file_id + """ + # Iterating evaluates the QuerySet and hits the database + # TODO: Research improving the query evaluation performance + return [ { "name": obj.name, "text": obj.text, @@ -72,7 +86,116 @@ def get_closest_embeddings( "distance": obj.distance, "file_id": obj.upload_file.guid if obj.upload_file else None, } - for obj in closest_embeddings_query + for obj in queryset ] + +def log_usage( + results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time +): + """ + Create a SemanticSearchUsage record. Swallows exceptions so search isn't interrupted. + + Parameters + ---------- + results : list[dict] + The search results, each containing a "distance" key + message_data : str + The original search query text + user : User + The user who performed the search + guid : str or None + Document GUID filter used in the search + document_name : str or None + Document name filter used in the search + num_results : int + Number of results requested + encoding_time : float + Time in seconds to encode the query + db_query_time : float + Time in seconds for the database query + """ + try: + if results: + distances = [r["distance"] for r in results] + SemanticSearchUsage.objects.create( + query_text=message_data, + user=user if (user and user.is_authenticated) else None, + document_guid=guid, + document_name=document_name, + num_results_requested=num_results, + encoding_time=encoding_time, + db_query_time=db_query_time, + num_results_returned=len(results), + max_distance=max(distances), + median_distance=median(distances), + min_distance=min(distances), + ) + else: + logger.warning("Semantic search returned no results") + SemanticSearchUsage.objects.create( + query_text=message_data, + user=user if (user and user.is_authenticated) else None, + document_guid=guid, + document_name=document_name, + num_results_requested=num_results, + encoding_time=encoding_time, + db_query_time=db_query_time, + num_results_returned=0, + max_distance=None, + median_distance=None, + min_distance=None, + ) + except Exception: + logger.exception("Failed to create semantic search usage database record") + + +def get_closest_embeddings( + user, message_data, document_name=None, guid=None, num_results=10 +): + """ + Find the closest embeddings to a given message for a specific user. + + Parameters + ---------- + user : User + The user whose uploaded documents will be searched + message_data : str + The input message to find similar embeddings for + document_name : str, optional + Filter results to a specific document name + guid : str, optional + Filter results to a specific document GUID (takes precedence over document_name) + num_results : int, default 10 + Maximum number of results to return + + Returns + ------- + list[dict] + List of dictionaries containing embedding results with keys: + - name: document name + - text: embedded text content + - page_number: page number in source document + - chunk_number: chunk number within the document + - distance: L2 distance from query embedding + - file_id: GUID of the source file + + Notes + ----- + Creates a SemanticSearchUsage record. Swallows exceptions so search isn't interrupted. + """ + encoding_start = time.time() + model = TransformerModel.get_instance().model + embedding_vector = model.encode(message_data) + encoding_time = time.time() - encoding_start + + db_query_start = time.time() + queryset = build_query(user, embedding_vector, document_name, guid, num_results) + results = evaluate_query(queryset) + db_query_time = time.time() - db_query_start + + log_usage( + results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time + ) + return results diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py new file mode 100644 index 00000000..e43c0d74 --- /dev/null +++ b/server/api/services/test_embedding_services.py @@ -0,0 +1,400 @@ +from unittest.mock import MagicMock, patch + +from django.db.models import Q +from pgvector.django import L2Distance + +from api.services.embedding_services import ( + build_query, + evaluate_query, + get_closest_embeddings, + log_usage, +) + +# Each function is tested one responsibility at a time. One test for the whole +# function collapses all responsibilities into a single assertion block — when +# it fails you know something is broken but not which responsibility. You have +# to debug to find out. + +# --------------------------------------------------------------------------- +# build_query tests +# +# build_query is responsible for access control, annotate/order, document filter +# and slicing and only constructs a lazy Django QuerySet without evaluating it +# +# We can test build_query by patching Embeddings.objects and inspecting which +# methods and arguments were called on Embeddings.objects +# --------------------------------------------------------------------------- + +# Only forwarded to L2Distance +EMBEDDING_VECTOR = [0.1, 0.2, 0.3] + +# Test authenticated/unauthenticated user access control + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_authenticated_uses_or_filter(mock_objects): + # An authenticated user should see their own files OR files uploaded by a + # superuser. The initial filter must use an OR-connected Q expression. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR) + + # Q objects support equality comparison in pure Python — no DB needed. + expected_q = Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) + actual_q = mock_objects.filter.call_args.args[0] + assert actual_q == expected_q + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): + # An unauthenticated user may only see files uploaded by superusers. + # The source uses a plain kwarg here (not a positional Q object), so the + # value lives in call_args.kwargs, not call_args.args. + user = MagicMock(is_authenticated=False) + + build_query(user, EMBEDDING_VECTOR) + + assert mock_objects.filter.call_args.kwargs == {"upload_file__uploaded_by__is_superuser": True} + +# Test application of annotate and order_by + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_annotates_and_orders_by_distance(mock_objects): + # Regardless of other arguments, annotate(distance=L2Distance(...)) and + # order_by("distance") must always be applied to the queryset. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR) + + # Retrieve the mock chain that .filter() returned, then check its methods. + filtered_qs = mock_objects.filter.return_value + filtered_qs.annotate.assert_called_once() + filtered_qs.annotate.return_value.order_by.assert_called_once_with("distance") + + # L2Distance is a Django Func subclass, which implements __eq__ by comparing + # class and source expressions — so we can assert the exact field name and + # vector without patching L2Distance itself. + actual_distance_expr = filtered_qs.annotate.call_args.kwargs["distance"] + assert actual_distance_expr == L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR) + +# Test guid-over-document precedence logic + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_no_document_filter_when_both_none(mock_objects): + # When neither guid nor document_name is provided, only the access-control + # filter should fire — no secondary filter call for a document. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, document_name=None, guid=None) + + # Exactly one filter call: the auth/access-control filter. + assert mock_objects.filter.call_count == 1 + + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_guid_takes_precedence_over_document_name(mock_objects): + # When both guid and document_name are provided, the guid branch runs and + # the document_name branch is skipped entirely. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="abc-123", document_name="study.pdf") + + # The auth filter fires on mock_objects.filter (call_count == 1). + # The document filter fires on the chained ordered_qs.filter — a different + # mock object — so mock_objects.filter.call_count stays at 1. + assert mock_objects.filter.call_count == 1 + + # The document filter must use upload_file__guid, not name, and must be + # called exactly once (confirming document_name branch was skipped). + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(upload_file__guid="abc-123") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_guid_filter_applied(mock_objects): + # When only guid is given, a second filter on upload_file__guid is applied. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="doc-guid-456") + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(upload_file__guid="doc-guid-456") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_document_name_filter_applied(mock_objects): + # When only document_name is given (guid is None), a second filter on + # name is applied instead of upload_file__guid. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, document_name="study.pdf", guid=None) + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(name="study.pdf") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_empty_string_guid_falls_back_to_document_name(mock_objects): + # An empty-string guid is falsy in Python, so it should not trigger the + # guid branch. The document_name filter should fire instead. This guards + # against callers passing guid="" from an unset form field. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="", document_name="fallback.pdf") + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(name="fallback.pdf") + +# Cover LIMIT slicing + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_respects_num_results(mock_objects): + # num_results controls the SQL LIMIT via queryset slicing. Verify that a + # non-default value propagates correctly to the __getitem__ call. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, num_results=5) + + # Django translates qs[:5] into qs.__getitem__(slice(None, 5, None)). + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.__getitem__.assert_called_once_with(slice(None, 5, None)) + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_returns_unevaluated_queryset(mock_objects): + # build_query must NOT evaluate the queryset (no list(), no iteration). + # The return value should be the mock produced by the final __getitem__ call. + user = MagicMock(is_authenticated=True) + + result = build_query(user, EMBEDDING_VECTOR) + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + assert result is ordered_qs.__getitem__.return_value + assert not isinstance(result, list) + + +# --------------------------------------------------------------------------- +# evaluate_query tests +# +# evaluate_query is responsible for iterating the queryset and mapping each +# Embeddings object's attributes to a result dict, including the rename +# page_num -> page_number and the None-safe file_id lookup +# +# We can test evaluate_query by passing plain MagicMock objects directly as +# the iterable and asserting on the shape and values of the returned list +# --------------------------------------------------------------------------- + +def test_evaluate_query_empty_queryset(): + # An empty iterable should return an empty list, not raise an exception. + assert evaluate_query([]) == [] + + +def test_evaluate_query_maps_fields(): + # Verify that each Embeddings model attribute is mapped to the correct + # output dict key. Note the rename: obj.page_num -> result["page_number"]. + obj = MagicMock() + obj.name = "doc.pdf" + obj.text = "some text" + obj.page_num = 3 + obj.chunk_number = 1 + obj.distance = 0.42 + obj.upload_file.guid = "abc-123" + + results = evaluate_query([obj]) + + assert results == [ + { + "name": "doc.pdf", + "text": "some text", + "page_number": 3, + "chunk_number": 1, + "distance": 0.42, + "file_id": "abc-123", + } + ] + + +def test_evaluate_query_none_upload_file(): + # When upload_file is None, file_id must be None rather than raising + # an AttributeError on None.guid. + obj = MagicMock() + obj.name = "doc.pdf" + obj.text = "some text" + obj.page_num = 1 + obj.chunk_number = 0 + obj.distance = 1.0 + obj.upload_file = None + + results = evaluate_query([obj]) + + assert results[0]["file_id"] is None + +# --------------------------------------------------------------------------- +# log_usage tests +# +# log_usage is responsible for computing distance stats, storing the correct +# user (None for unauthenticated), handling empty results, and swallowing +# exceptions so search is never interrupted +# +# We can test log_usage by patching SemanticSearchUsage.objects.create and +# inspecting the keyword arguments it was called with +# --------------------------------------------------------------------------- + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_empty_results(mock_create): + # Empty results hits the else branch. The record should still be created + # with num_results_returned=0 and all distance fields set to None. + user = MagicMock(is_authenticated=True) + + log_usage( + [], + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + mock_create.assert_called_once() + kwargs = mock_create.call_args.kwargs + assert kwargs["num_results_returned"] == 0 + assert kwargs["max_distance"] is None + assert kwargs["median_distance"] is None + assert kwargs["min_distance"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_unauthenticated_user_stored_as_none(mock_create): + # An unauthenticated user should be stored as None in the DB record, not as + # the user object itself, so the FK constraint is not violated. + user = MagicMock(is_authenticated=False) + + log_usage( + [{"distance": 1.0}], + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + kwargs = mock_create.call_args.kwargs + assert kwargs["user"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_none_user_stored_as_none(mock_create): + # Passing user=None directly (e.g. from an anonymous request) should also + # store None — the expression `user if (user and user.is_authenticated)` + # short-circuits on the falsy None before accessing .is_authenticated. + log_usage( + [{"distance": 1.0}], + message_data="test query", + user=None, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + kwargs = mock_create.call_args.kwargs + assert kwargs["user"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_computes_distance_stats(mock_create): + # Verify min, max, and median are computed correctly from the distance + # values in the results list and forwarded to the DB record. + results = [{"distance": 1.0}, {"distance": 3.0}, {"distance": 2.0}] + user = MagicMock(is_authenticated=True) + + log_usage( + results, + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + mock_create.assert_called_once() + kwargs = mock_create.call_args.kwargs + assert kwargs["min_distance"] == 1.0 + assert kwargs["max_distance"] == 3.0 + assert kwargs["median_distance"] == 2.0 + assert kwargs["num_results_returned"] == 3 + + +@patch( + "api.services.embedding_services.SemanticSearchUsage.objects.create", + side_effect=Exception("DB error"), +) +def test_log_usage_swallows_exceptions(mock_create): + # log_usage must not propagate exceptions — a logging failure should never + # interrupt the caller's search flow. + # pytest fails the test if it catches unhandled Exception + results = [{"distance": 1.0}] + user = MagicMock(is_authenticated=True) + + log_usage( + results, + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + +# --------------------------------------------------------------------------- +# get_closest_embeddings tests +# +# get_closest_embeddings is responsible for wiring together encode, +# build_query, evaluate_query, and log_usage and returning the results +# +# We can test get_closest_embeddings by patching all four collaborators and +# asserting that each is called with the correct arguments in the correct order +# --------------------------------------------------------------------------- + +@patch("api.services.embedding_services.log_usage") +@patch("api.services.embedding_services.evaluate_query") +@patch("api.services.embedding_services.build_query") +@patch("api.services.embedding_services.TransformerModel") +def test_get_closest_embeddings_wiring(mock_transformer, mock_build, mock_evaluate, mock_log): + # Smoke test verifying that get_closest_embeddings correctly wires together + # encode → build_query → evaluate_query → log_usage and returns the results. + user = MagicMock(is_authenticated=True) + + # Simulate the model encoding the message to a vector. + fake_vector = [0.1, 0.2, 0.3] + mock_transformer.get_instance.return_value.model.encode.return_value = fake_vector + + # build_query returns a queryset; evaluate_query turns it into a results list. + fake_queryset = MagicMock() + mock_build.return_value = fake_queryset + fake_results = [{"name": "doc.pdf", "distance": 0.5}] + mock_evaluate.return_value = fake_results + + result = get_closest_embeddings(user, "some query", document_name="doc.pdf", guid=None, num_results=5) + + # The encoded vector must be forwarded to build_query. + mock_build.assert_called_once_with(user, fake_vector, "doc.pdf", None, 5) + + # evaluate_query must receive the queryset that build_query returned. + mock_evaluate.assert_called_once_with(fake_queryset) + + # log_usage must be called with the results and original parameters. + mock_log.assert_called_once() + log_kwargs = mock_log.call_args.args + assert log_kwargs[0] is fake_results + + # The function must return evaluate_query's result unchanged. + assert result is fake_results diff --git a/server/api/views/ai_promptStorage/views.py b/server/api/views/ai_promptStorage/views.py index 7354feb3..cc50f22e 100644 --- a/server/api/views/ai_promptStorage/views.py +++ b/server/api/views/ai_promptStorage/views.py @@ -1,10 +1,12 @@ from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from .models import AI_PromptStorage from .serializers import AI_PromptStorageSerializer +@extend_schema(request=AI_PromptStorageSerializer, responses={201: AI_PromptStorageSerializer}) @api_view(['POST']) # @permission_classes([IsAuthenticated]) def store_prompt(request): @@ -21,6 +23,7 @@ def store_prompt(request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@extend_schema(responses={200: AI_PromptStorageSerializer(many=True)}) @api_view(['GET']) def get_all_prompts(request): """ diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 349b9fd9..7f453200 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -1,13 +1,15 @@ from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from .models import AI_Settings from .serializers import AISettingsSerializer +@extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsSuperUser]) def settings_view(request): if request.method == 'GET': settings = AI_Settings.objects.all() diff --git a/server/api/views/assistant/sanitizer.py b/server/api/views/assistant/sanitizer.py new file mode 100644 index 00000000..fd851df6 --- /dev/null +++ b/server/api/views/assistant/sanitizer.py @@ -0,0 +1,76 @@ +import re +import logging + +logger = logging.getLogger(__name__) +def sanitize_input(user_input:str) -> str: + """ + Sanitize user input to prevent injection attacks and remove unwanted characters. + + Args: + user_input (str): The raw input string from the user. + + Returns: + str: The sanitized input string. + """ + try: + sanitized = user_input + + # Remove any style tags + sanitized = re.sub(r'.*?', '', sanitized, flags=re.IGNORECASE) + + # Remove any HTML/script tags + sanitized = re.sub(r'<.*?>', '', sanitized) + + # Remove Phone Numbers + sanitized = re.sub(r'\+?\d[\d -]{8,}\d', '[Phone Number]', sanitized) + + # Remove Email Addresses + sanitized = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[Email Address]', sanitized) + + # Remove Medical Record Numbers (simple pattern) + sanitized = re.sub(r'\bMRN[:\s]*\d+\b', '[Medical Record Number]', sanitized, flags=re.IGNORECASE) + + # Normalize pronouns + sanitized = normalize_pronouns(sanitized) + + # Escape special characters + sanitized = re.sub(r'\s+', '', sanitized) + + # Limit length to prevent buffer overflow attacks + max_length = 5000 + if len(sanitized) > max_length: + sanitized = sanitized[:max_length] + + return sanitized.strip() + except Exception as e: + logger.error(f"Error sanitizing input: {e}") + return "" + +def normalize_pronouns(text:str) -> str: + """ + Normalize first and second person pronouns to third person clinical language. + + Converts patient centric pronouns to a more neutral form. + Args: + text (str): The input text containing pronouns. + Returns: + str: The text with normalized pronouns. + """ + # Normalize first person possessives: I, me, my, mine -> the patient + text = re.sub(r'\bMy\b', 'The patient\'s', text) + text = re.sub(r'\bmy\b', 'the patient\'s', text) + + # First person subject: I -> the patient + text = re.sub(r'\bI\b', 'the patient', text) + + # First person object: me -> the patient + text = re.sub(r'\bme\b', 'the patient', text) + + # First person reflexive: myself -> the patient + text = re.sub(r'\bmyself\b', 'the patient', text) + + # Second person: you, your -> the clinician + text = re.sub(r'\bYour\b', 'the clinician', text) + return text + + diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 32089c58..e3e8d6f7 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -7,9 +7,11 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers from openai import OpenAI @@ -111,8 +113,23 @@ def invoke_functions_from_response( @method_decorator(csrf_exempt, name="dispatch") class Assistant(APIView): - permission_classes = [IsAuthenticated] - + permission_classes = [AllowAny] + + @extend_schema( + request=inline_serializer(name='AssistantRequest', fields={ + 'message': drf_serializers.CharField(help_text='User message to send to the assistant'), + 'previous_response_id': drf_serializers.CharField(required=False, allow_null=True, help_text='ID of previous response for conversation continuity'), + }), + responses={ + 200: inline_serializer(name='AssistantResponse', fields={ + 'response_output_text': drf_serializers.CharField(), + 'final_response_id': drf_serializers.CharField(), + }), + 500: inline_serializer(name='AssistantError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): try: user = request.user @@ -196,28 +213,42 @@ def search_documents(query: str, user=user) -> str: return f"Error searching documents: {str(e)}. Please try again if the issue persists." INSTRUCTIONS = """ - You are an AI assistant that helps users find and understand information about bipolar disorder - from their uploaded bipolar disorder research documents using semantic search. - + You are an AI assistant that helps users find and understand information about bipolar disorder + from your internal library of bipolar disorder research sources using semantic search. + + IMPORTANT CONTEXT: + - You have access to a library of sources that the user CANNOT see + - The user did not upload these sources and doesn't know about them + - You must explain what information exists in your sources and provide clear references + + TOPIC RESTRICTIONS: + When a prompt is received that is unrelated to bipolar disorder, mental health treatment, + or psychiatric medications, respond by saying you are limited to bipolar-specific conversations. + SEMANTIC SEARCH STRATEGY: - Always perform semantic search using the search_documents function when users ask questions - Use conceptually related terms and synonyms, not just exact keyword matches - Search for the meaning and context of the user's question, not just literal words - Consider medical terminology, lay terms, and related conditions when searching - + FUNCTION USAGE: - - When a user asks about information that might be in their documents ALWAYS use the search_documents function first + - When a user asks about information that might be in your source library, ALWAYS use the search_documents function first - Perform semantic searches using concepts, symptoms, treatments, and related terms from the user's question - - Only provide answers based on information found through document searches - + - Only provide answers based on information found through your source searches + RESPONSE FORMAT: After gathering information through semantic searches, provide responses that: 1. Answer the user's question directly using only the found information 2. Structure responses with clear sections and paragraphs - 3. Include citations using this exact format: ***[Name {name}, Page {page_number}]*** - 4. Only cite information that directly supports your statements - - If no relevant information is found in the documents, clearly state that the information is not available in the uploaded documents. + 3. Explain what information you found in your sources and provide context + 4. Include citations using this exact format: [Name {name}, Page {page_number}] + 5. Only cite information that directly supports your statements + + If no relevant information is found in your source library, clearly state that the information + is not available in your current sources. + + REMEMBER: You are working with an internal library of bipolar disorder sources that the user + cannot see. Always search these sources first, explain what you found, and provide proper citations. """ MODEL_DEFAULTS = { diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index d5921eaf..de927cf1 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -1,7 +1,7 @@ from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from rest_framework.exceptions import APIException from django.http import JsonResponse from bs4 import BeautifulSoup @@ -16,6 +16,8 @@ from .models import Conversation, Message from .serializers import ConversationSerializer from ...services.tools.tools import tools, execute_tool +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers @csrf_exempt @@ -81,7 +83,7 @@ def __init__(self, detail=None, code=None): class ConversationViewSet(viewsets.ModelViewSet): serializer_class = ConversationSerializer - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get_queryset(self): return Conversation.objects.filter(user=self.request.user) @@ -95,6 +97,21 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema( + request=inline_serializer(name='ContinueConversationRequest', fields={ + 'message': drf_serializers.CharField(help_text='User message to continue the conversation'), + 'page_context': drf_serializers.CharField(required=False, help_text='Optional page context'), + }), + responses={ + 200: inline_serializer(name='ContinueConversationResponse', fields={ + 'response': drf_serializers.CharField(), + 'title': drf_serializers.CharField(), + }), + 400: inline_serializer(name='ContinueConversationBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) @action(detail=True, methods=['post']) def continue_conversation(self, request, pk=None): conversation = self.get_object() @@ -123,6 +140,20 @@ def continue_conversation(self, request, pk=None): return Response({"response": chatgpt_response, "title": conversation.title}) + @extend_schema( + request=inline_serializer(name='UpdateTitleRequest', fields={ + 'title': drf_serializers.CharField(help_text='New conversation title'), + }), + responses={ + 200: inline_serializer(name='UpdateTitleResponse', fields={ + 'status': drf_serializers.CharField(), + 'title': drf_serializers.CharField(), + }), + 400: inline_serializer(name='UpdateTitleBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) @action(detail=True, methods=['patch']) def update_title(self, request, pk=None): conversation = self.get_object() diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index d0bdd8ca..ebcf0774 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,8 +1,9 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from django.http import StreamingHttpResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter from ...services.embedding_services import get_closest_embeddings from ...services.conversions_services import convert_uuids from ...services.openai_services import openAIServices @@ -15,6 +16,26 @@ class AskEmbeddingsAPIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=False, description='Optional file GUID to filter embeddings'), + OpenApiParameter(name='stream', type=bool, location=OpenApiParameter.QUERY, required=False, description='Enable streaming response'), + ], + request=inline_serializer(name='AskEmbeddingsRequest', fields={ + 'message': drf_serializers.CharField(help_text='Question to ask against embedded documents'), + }), + responses={ + 200: inline_serializer(name='AskEmbeddingsResponse', fields={ + 'question': drf_serializers.CharField(), + 'llm_response': drf_serializers.CharField(), + 'embeddings_info': drf_serializers.CharField(), + 'sent_to_llm': drf_serializers.CharField(), + }), + 400: inline_serializer(name='AskEmbeddingsBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request, *args, **kwargs): try: user = request.user diff --git a/server/api/views/feedback/views.py b/server/api/views/feedback/views.py index dcbef992..424e0758 100644 --- a/server/api/views/feedback/views.py +++ b/server/api/views/feedback/views.py @@ -1,4 +1,4 @@ - +from rest_framework.permissions import AllowAny from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -8,6 +8,9 @@ class FeedbackView(APIView): + permission_classes = [AllowAny] + serializer_class = FeedbackSerializer + def post(self, request, *args, **kwargs): serializer = FeedbackSerializer(data=request.data) if serializer.is_valid(): diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 1976458e..4321615d 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,6 +1,9 @@ -from rest_framework import status +from rest_framework import status, serializers as drf_serializers +from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, inline_serializer from .models import Diagnosis, Medication, Suggestion from .serializers import MedicationSerializer @@ -21,6 +24,35 @@ class GetMedication(APIView): + permission_classes = [AllowAny] + + @extend_schema( + request=inline_serializer( + name='GetMedicationRequest', + fields={ + 'state': drf_serializers.CharField(help_text='Diagnosis state, e.g. "depressed", "manic"'), + 'suicideHistory': drf_serializers.BooleanField(default=False), + 'kidneyHistory': drf_serializers.BooleanField(default=False), + 'liverHistory': drf_serializers.BooleanField(default=False), + 'bloodPressureHistory': drf_serializers.BooleanField(default=False), + 'weightGainConcern': drf_serializers.BooleanField(default=False), + 'priorMedications': drf_serializers.CharField(required=False, default='', help_text='Comma-separated medication names'), + } + ), + responses={ + 200: inline_serializer( + name='GetMedicationResponse', + fields={ + 'first': drf_serializers.ListField(child=drf_serializers.DictField()), + 'second': drf_serializers.ListField(child=drf_serializers.DictField()), + 'third': drf_serializers.ListField(child=drf_serializers.DictField()), + } + ), + 404: inline_serializer(name='GetMedicationNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): data = request.data state_query = data.get('state', '') @@ -71,6 +103,9 @@ def post(self, request): class ListOrDetailMedication(APIView): + permission_classes = [AllowAny] + serializer_class = MedicationSerializer + def get(self, request): name_query = request.query_params.get('name', None) if name_query: @@ -93,6 +128,8 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + permission_classes = [IsSuperUser] + serializer_class = MedicationSerializer def post(self, request): data = request.data @@ -123,7 +160,24 @@ class DeleteMedication(APIView): """ API endpoint to delete medication if medication in database. """ - + permission_classes = [IsSuperUser] + + @extend_schema( + request=inline_serializer(name='DeleteMedicationRequest', fields={ + 'name': drf_serializers.CharField(), + }), + responses={ + 200: inline_serializer(name='DeleteMedicationSuccess', fields={ + 'success': drf_serializers.CharField(), + }), + 400: inline_serializer(name='DeleteMedicationBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='DeleteMedicationNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def delete(self, request): data = request.data name = data.get('name', '').strip() diff --git a/server/api/views/medRules/serializers.py b/server/api/views/medRules/serializers.py index df5e3663..e0d7d3f3 100644 --- a/server/api/views/medRules/serializers.py +++ b/server/api/views/medRules/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field from ...models.model_medRule import MedRule, MedRuleSource from ..listMeds.serializers import MedicationSerializer from ...models.model_embeddings import Embeddings @@ -30,6 +31,7 @@ class Meta: "medication_sources", ] + @extend_schema_field(MedicationWithSourcesSerializer(many=True)) def get_medication_sources(self, obj): medrule_sources = MedRuleSource.objects.filter(medrule=obj).select_related( "medication", "embedding" diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2fae140b..7e4ecae5 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,9 +1,10 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers +from api.permissions import IsSuperUser from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema, inline_serializer from ...models.model_medRule import MedRule from .serializers import MedRuleSerializer # You'll need to create this from ..listMeds.models import Medication @@ -12,7 +13,8 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] + serializer_class = MedRuleSerializer def get(self, request, format=None): # Get all med rules @@ -29,6 +31,27 @@ def get(self, request, format=None): return Response(data, status=status.HTTP_200_OK) + @extend_schema( + request=inline_serializer(name='MedRuleCreateRequest', fields={ + 'rule_type': drf_serializers.CharField(help_text='INCLUDE or EXCLUDE'), + 'history_type': drf_serializers.CharField(help_text='e.g. DIAGNOSIS_DEPRESSED, DIAGNOSIS_MANIC'), + 'reason': drf_serializers.CharField(), + 'label': drf_serializers.CharField(), + 'explanation': drf_serializers.CharField(), + 'medication_names': drf_serializers.ListField(child=drf_serializers.CharField()), + 'chunk_ids': drf_serializers.ListField(child=drf_serializers.IntegerField()), + 'file_guid': drf_serializers.CharField(), + }), + responses={ + 201: MedRuleSerializer, + 400: inline_serializer(name='MedRuleCreateBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='MedRuleCreateNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): data = request.data diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index 0be43dbb..26cad9f8 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -1,6 +1,8 @@ from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers +from rest_framework.permissions import AllowAny +from drf_spectacular.utils import extend_schema, inline_serializer from api.views.listMeds.models import Medication from api.models.model_medRule import MedRule, MedRuleSource import openai @@ -8,6 +10,30 @@ class RiskWithSourcesView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + request=inline_serializer(name='RiskWithSourcesRequest', fields={ + 'drug': drf_serializers.CharField(help_text='Medication name'), + 'source': drf_serializers.CharField(required=False, help_text='One of: include, diagnosis, diagnosis_depressed, diagnosis_manic, diagnosis_hypomanic, diagnosis_euthymic'), + }), + responses={ + 200: inline_serializer(name='RiskWithSourcesResponse', fields={ + 'benefits': drf_serializers.ListField(child=drf_serializers.CharField()), + 'risks': drf_serializers.ListField(child=drf_serializers.CharField()), + 'sources': drf_serializers.ListField(child=drf_serializers.DictField()), + 'medrules_found': drf_serializers.IntegerField(required=False), + 'source_type': drf_serializers.CharField(required=False), + 'note': drf_serializers.CharField(required=False), + }), + 400: inline_serializer(name='RiskWithSourcesBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='RiskWithSourcesNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): openai.api_key = os.environ.get("OPENAI_API_KEY") diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index e4122851..35abe976 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,12 +3,14 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt import anthropic +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter +from rest_framework import serializers as drf_serializers from ...services.openai_services import openAIServices from api.models.model_embeddings import Embeddings @@ -95,8 +97,22 @@ def anthropic_citations(client: anthropic.Client, user_prompt: str, content_chun @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=True, description='File GUID to extract rules from'), + ], + responses={ + 200: inline_serializer(name='RuleExtractionResponse', fields={ + 'texts': drf_serializers.CharField(), + 'cited_texts': drf_serializers.CharField(), + }), + 500: inline_serializer(name='RuleExtractionError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def get(self, request): try: @@ -139,8 +155,21 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=True, description='File GUID to extract rules from'), + ], + responses={ + 200: inline_serializer(name='RuleExtractionOpenAIResponse', fields={ + 'rules': drf_serializers.ListField(child=drf_serializers.DictField()), + }), + 500: inline_serializer(name='RuleExtractionOpenAIError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def get(self, request): try: user_prompt = """ diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 69979620..ef694e14 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -4,6 +4,39 @@ from . import title +def make_page_dict(blocks): + """Helper to build a get_text("dict") return value from a simple list of blocks. + Each block is a list of (text, font_size) tuples representing spans. + """ + dict_blocks = [] + for spans in blocks: + dict_blocks.append({ + "type": 0, + "lines": [{ + "spans": [{"text": text, "size": size} for text, size in spans] + }] + }) + return {"blocks": dict_blocks} + + +def make_mock_doc(pages_data, metadata=None): + """Build a mock fitz.Document. + pages_data: list of block lists, one per page. Each block is a list of (text, size) tuples. + """ + doc = MagicMock() + doc.metadata = metadata or {"title": None} + doc.__len__ = lambda self: len(pages_data) + + mock_pages = [] + for page_blocks in pages_data: + page = MagicMock() + page.get_text.return_value = make_page_dict(page_blocks) + mock_pages.append(page) + + doc.__getitem__ = lambda self, idx: mock_pages[idx] + return doc + + class TestGenerateTitle(unittest.TestCase): def test_prefers_metadata_title_if_valid(self): doc = MagicMock() @@ -11,59 +44,112 @@ def test_prefers_metadata_title_if_valid(self): self.assertEqual( "A Study Regarding The Efficacy of Drugs", title.generate_title(doc)) - def test_falls_back_to_first_page_text_if_metadata_title_is_empty(self): - doc = MagicMock() - doc.metadata = {"title": ""} - doc[0].get_text = MagicMock() - - foo_block = [None] * 7 - foo_block[4] = "foo" - foo_block[6] = 0 - - title_block = [None] * 7 - title_block[4] = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" - title_block[6] = 0 - - bar_block = [None] * 7 - bar_block[4] = "bar" - bar_block[6] = 0 - doc[0].get_text.return_value = [foo_block, title_block, bar_block] - + def test_falls_back_to_font_size_if_metadata_title_is_empty(self): + doc = make_mock_doc( + pages_data=[[ + [("foo", 10.0)], + [("Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia", 18.0)], + [("bar", 10.0)], + ]], + metadata={"title": ""}, + ) expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) - def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(self): - doc = MagicMock() - doc.metadata = {"title": "abcd1234"} - doc[0].get_text = MagicMock() - - foo_block = [None] * 7 - foo_block[4] = "foo" - foo_block[6] = 0 - - title_block = [None] * 7 - title_block[4] = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" - title_block[6] = 0 - - bar_block = [None] * 7 - bar_block[4] = "bar" - bar_block[6] = 0 - doc[0].get_text.return_value = [foo_block, title_block, bar_block] - + def test_falls_back_to_font_size_if_metadata_title_does_not_match_regex(self): + doc = make_mock_doc( + pages_data=[[ + [("foo", 10.0)], + [("Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia", 18.0)], + [("bar", 10.0)], + ]], + metadata={"title": "abcd1234"}, + ) expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc = make_mock_doc( + pages_data=[[]] # no blocks at all + ) - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = "A Study Regarding The Efficacy of Drugs" - mock_openAI.return_value = mock_response + mock_openAI.return_value = "A Study Regarding The Efficacy of Drugs" - title.generate_title(doc) + result = title.generate_title(doc) self.assertTrue(mock_openAI.called) + self.assertEqual(result, "A Study Regarding The Efficacy of Drugs") + + @patch("api.views.uploadFile.title.openAIServices.openAI") + def test_strips_quotes_from_openai_title(self, mock_openAI): + doc = make_mock_doc(pages_data=[[]]) + + mock_openAI.return_value = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' + + result = title.generate_title(doc) + + self.assertEqual(result, "Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder") + + @patch("api.views.uploadFile.title.openAIServices.openAI") + def test_truncates_long_openai_title(self, mock_openAI): + doc = make_mock_doc(pages_data=[[]]) + + mock_openAI.return_value = "A" * 300 + + result = title.generate_title(doc) + + # Ensure the title is truncated to fit the UploadFile model's title field (max_length=255), since OpenAI responses may exceed this limit + self.assertLessEqual(len(result), 255) + + def test_font_size_joins_adjacent_spans_in_same_block(self): + """A title split across multiple spans in the same block should be joined.""" + doc = make_mock_doc( + pages_data=[[ + [("Author Name", 10.0)], + [("Advances in Mood Disorder", 18.0), ("Pharmacotherapy", 18.0)], + [("Some journal info", 10.0)], + ]], + ) + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") + + def test_font_size_ignores_short_spans(self): + """Superscript markers and other tiny spans should be filtered out.""" + doc = make_mock_doc( + pages_data=[[ + [("Advances in Mood Disorder Pharmacotherapy", 18.0), ("*", 18.0)], + [("Author Name et al.", 10.0)], + ]], + ) + # The "*" span is < 2 chars, so it should be ignored; title is just the real text + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") + + def test_font_size_returns_none_when_no_regex_match(self): + """If the largest-font text doesn't match the title regex, return None.""" + doc = make_mock_doc( + pages_data=[[ + # Only 2 words — regex requires at least 3 + [("Psychiatry Research", 18.0)], + [("Author Name et al.", 10.0)], + ]], + ) + result = title.extract_title_by_font_size(doc) + self.assertIsNone(result) + + def test_font_size_finds_title_on_later_page(self): + """Title on page 2 should still be found if it has the largest font.""" + doc = make_mock_doc( + pages_data=[ + [ # page 1: cover page with smaller text + [("Some preamble text here", 12.0)], + ], + [ # page 2: actual title in larger font + [("Advances in Mood Disorder Pharmacotherapy", 18.0)], + [("Author Name et al.", 10.0)], + ], + ], + ) + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 06e0ce0c..38dcd5d5 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -6,44 +6,89 @@ # regular expression to match common research white paper titles. Created by Chat-gpt -# requires at least 3 words, no dates, no version numbers. +# requires at least 3 words, no version numbers. title_regex = re.compile( - r'^(?=(?:\b\w+\b[\s:,\-\(\)]*){3,})(?!.*\b(?:19|20)\d{2}\b)(?!.*\bv\d+\b)[A-Za-z0-9][\w\s:,\-\(\)]*[A-Za-z\)]$', re.IGNORECASE) + r"^(?=(?:\b\w+\b[^A-Za-z0-9]*){3,})(?!.*\bv\d+\b)[A-Za-z0-9].+[A-Za-z\)?!]$", re.IGNORECASE) def generate_title(pdf: fitz.Document) -> str | None: document_metadata_title = pdf.metadata["title"] if document_metadata_title is not None and document_metadata_title != "": if title_regex.match(document_metadata_title): - print("suitable title was found in metadata") return document_metadata_title.strip() - else: - print("metadata title did not match regex") - print("Looking for title in first page text") - first_page = pdf[0] - first_page_blocks = first_page.get_text("blocks") - text_blocks = [ - block[4].strip().replace("\n", " ") - for block in first_page_blocks - if block[6] == 0 # only include text blocks. - ] - - # For some reason, extracted PDF text has extra spaces. Collapse them here. - regex = r"\s{2,}" - text_blocks = [re.sub(regex, " ", text) for text in text_blocks] - - if len(text_blocks) != 0: - for text in text_blocks: - if title_regex.match(text): - return text - - print( - "no suitable title found in first page text. Using GPT-4 to summarize the PDF") + font_title = extract_title_by_font_size(pdf) + if font_title: + return font_title + gpt_title = summarize_pdf(pdf) return gpt_title or None +def extract_title_by_font_size(pdf: fitz.Document, max_pages: int = 3) -> str | None: + """ + Extract the title by finding the largest font size across the first few pages + and collecting contiguous runs of text at that size. + """ + pages_to_scan = min(max_pages, len(pdf)) + + # First pass: collect all spans with their font size, and find the max font size. + all_spans = [] + max_font_size = 0.0 + + for page_idx in range(pages_to_scan): + page_dict = pdf[page_idx].get_text("dict") + for block in page_dict["blocks"]: + if block.get("type") != 0: + continue + for line in block["lines"]: + for span in line["spans"]: + text = span["text"].strip() + size = span["size"] + if len(text) < 2 or size < 6.0: + continue + all_spans.append({"text": text, "size": size}) + if size > max_font_size: + max_font_size = size + + if max_font_size == 0.0: + return None + + # Second pass: gather contiguous runs of spans at the max font size. + # Runs continue across block boundaries so multi-block titles (e.g., + # "BIPOLAR DISORDER IN PRIMARY CARE:" in one block and "DIAGNOSIS AND + # MANAGEMENT" in the next) are joined into a single candidate. + # A run only ends when a non-max-size span interrupts it. + candidates = [] + current_run = [] + + for span in all_spans: + if span["size"] == max_font_size: + current_run.append(span["text"]) + else: + if current_run: + candidates.append(" ".join(current_run)) + current_run = [] + + if current_run: + candidates.append(" ".join(current_run)) + + # Collapse extra whitespace, validate against title regex, and pick the longest match. + # Longest wins because real titles are typically longer than section headers + # (e.g., "About the Author") that may share the same max font size. + best = None + for candidate in candidates: + cleaned = re.sub(r"\s{2,}", " ", candidate).strip() + if title_regex.match(cleaned): + if best is None or len(cleaned) > len(best): + best = cleaned + + if best: + return best[:255] + + return None + + def summarize_pdf(pdf: fitz.Document) -> str: """ Summarize a PDF document using OpenAI's GPT-4 model. @@ -58,4 +103,6 @@ def summarize_pdf(pdf: fitz.Document) -> str: prompt = "Please provide a title for this document. The title should be less than 256 characters and will be displayed on a webpage." response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) - return response.choices[0].message.content + title = response.strip().strip('"').strip("'") + # Truncate to fit UploadFile model's max_length=255 title field as a final safeguard + return title[:255] diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 6904e061..6da092ce 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,8 +1,10 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse import pdfplumber from .models import UploadFile # Import your UploadFile model from .serializers import UploadFileSerializer @@ -12,23 +14,44 @@ import fitz from django.db import transaction from .title import generate_title +import logging + +logger = logging.getLogger(__name__) class UploadFileView(APIView): + serializer_class = UploadFileSerializer + + def get_permissions(self): + if self.request.method == 'GET': + return [AllowAny()] # Public access + return [IsSuperUser()] # Superuser required for write methods def get(self, request, format=None): print("UploadFileView, get list") - # Get the authenticated user - user = request.user - - # Filter the files uploaded by the authenticated user - files = UploadFile.objects.filter(uploaded_by=user.id).defer( - 'file').order_by('-date_of_upload') + files = UploadFile.objects.all().defer('file').order_by('-date_of_upload') serializer = UploadFileSerializer(files, many=True) return Response(serializer.data) + @extend_schema( + request={'multipart/form-data': inline_serializer( + name='UploadFileRequest', + fields={ + 'file': drf_serializers.FileField(help_text='PDF file to upload'), + } + )}, + responses={ + 201: inline_serializer(name='UploadFileSuccess', fields={ + 'message': drf_serializers.CharField(), + 'file_id': drf_serializers.IntegerField(), + }), + 400: inline_serializer(name='UploadFileBadRequest', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def post(self, request, format=None): print(request.auth) print(f"UploadFileView post called. Path: {request.path}") @@ -125,9 +148,26 @@ def post(self, request, format=None): ) except Exception as e: # Handle potential errors + logger.exception("File upload failed for '%s': %s", uploaded_file.name, e) return Response({"message": f"Error processing file and embeddings: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + @extend_schema( + request=inline_serializer(name='DeleteFileRequest', fields={ + 'guid': drf_serializers.CharField(help_text='GUID of file to delete'), + }), + responses={ + 200: inline_serializer(name='DeleteFileSuccess', fields={ + 'message': drf_serializers.CharField(), + }), + 403: inline_serializer(name='DeleteFileForbidden', fields={ + 'message': drf_serializers.CharField(), + }), + 404: inline_serializer(name='DeleteFileNotFound', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def delete(self, request, format=None): guid = request.data.get('guid') if not guid: @@ -156,12 +196,19 @@ def delete(self, request, format=None): class RetrieveUploadFileView(APIView): - permission_classes = [IsAuthenticated] - + permission_classes = [AllowAny] + + @extend_schema( + responses={ + (200, 'application/pdf'): OpenApiResponse(description='PDF file binary content'), + 404: inline_serializer(name='RetrieveFileNotFound', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def get(self, request, guid, format=None): try: - file = UploadFile.objects.get( - guid=guid, uploaded_by=request.user.id) + file = UploadFile.objects.get(guid=guid) response = HttpResponse(file.file, content_type='application/pdf') # print(file.file[:100]) response['Content-Disposition'] = f'attachment; filename="{file.file_name}"' @@ -171,7 +218,7 @@ def get(self, request, guid, format=None): class EditFileMetadataView(UpdateAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = UploadFileSerializer lookup_field = 'guid' diff --git a/server/api/views/version/urls.py b/server/api/views/version/urls.py new file mode 100644 index 00000000..6fb34919 --- /dev/null +++ b/server/api/views/version/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import VersionView + +urlpatterns = [ + path("v1/api/version", VersionView.as_view(), name="version"), +] diff --git a/server/api/views/version/views.py b/server/api/views/version/views.py new file mode 100644 index 00000000..af59e9e0 --- /dev/null +++ b/server/api/views/version/views.py @@ -0,0 +1,20 @@ +import os + +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import serializers as drf_serializers +from drf_spectacular.utils import extend_schema, inline_serializer + + +class VersionView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + responses={200: inline_serializer(name='VersionResponse', fields={ + 'version': drf_serializers.CharField(), + })} + ) + def get(self, request, *args, **kwargs): + version = os.environ.get("VERSION") or "dev" + return Response({"version": version}) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index df62d198..7c2c9e67 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,7 +29,7 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. @@ -51,6 +51,7 @@ "corsheaders", "rest_framework", "djoser", + 'drf_spectacular', ] MIDDLEWARE = [ @@ -66,7 +67,10 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +# CORS configuration +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") +# Ensure no empty strings if input was empty or trailing comma +CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] TEMPLATES = [ { @@ -94,23 +98,59 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases -DATABASES = { - "default": { +# Detect connection type based on SQL_HOST +# CloudNativePG: Kubernetes service names (e.g., "balancer-postgres-rw" or contains ".svc.cluster.local") +# AWS RDS: External hostnames (e.g., "balancer-db.xxxxx.us-east-1.rds.amazonaws.com") +SQL_HOST = os.environ.get("SQL_HOST", "localhost") +is_cloudnativepg = ( + ".svc.cluster.local" in SQL_HOST + or not ("." in SQL_HOST and len(SQL_HOST.split(".")) > 2) + or SQL_HOST.count(".") <= 1 +) + +# Build database configuration +db_config = { "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), "USER": os.environ.get("SQL_USER", "user"), "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), - "HOST": os.environ.get("SQL_HOST", "localhost"), + "HOST": SQL_HOST, "PORT": os.environ.get("SQL_PORT", "5432"), } + +# Configure SSL/TLS based on connection type +# CloudNativePG within cluster typically doesn't require SSL +# AWS RDS typically requires SSL +if db_config["ENGINE"] == "django.db.backends.postgresql": + # Check if SSL is explicitly configured + ssl_mode = os.environ.get("SQL_SSL_MODE", None) + + if ssl_mode: + # Use explicit SSL configuration + db_config["OPTIONS"] = { + "sslmode": ssl_mode, + } + elif not is_cloudnativepg: + # For external databases (AWS RDS), default to require SSL + # This can be overridden by setting SQL_SSL_MODE + db_config["OPTIONS"] = { + "sslmode": "require", + } + # For CloudNativePG (within cluster), no SSL by default + +DATABASES = { + "default": db_config, } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" -EMAIL_PORT = 587 -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = True +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +else: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = "smtp.gmail.com" + EMAIL_PORT = 587 + EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") + EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") + EMAIL_USE_TLS = True # Password validation @@ -162,8 +202,19 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } +SPECTACULAR_SETTINGS = { + 'TITLE': 'Balancer API', + 'DESCRIPTION': 'API for the Balancer medication decision support tool', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'SECURITY': [{'jwtAuth': []}], + 'SWAGGER_UI_SETTINGS': { + 'persistAuthorization': True, + }, +} SIMPLE_JWT = { "AUTH_HEADER_TYPES": ("JWT",), @@ -173,6 +224,12 @@ "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } +# Domain used by Djoser to build activation and password reset links in emails. +# Should point to the frontend, not the backend, since the frontend handles these routes. +# Override in production via environment variable. +DOMAIN = os.environ.get("FRONTEND_DOMAIN", "localhost:3000") +SITE_NAME = "Balancer" + DJOSER = { "LOGIN_FIELD": "email", "USER_CREATE_PASSWORD_RETYPE": True, diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 56f307e4..55bd2032 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -6,23 +6,22 @@ # Import TemplateView for rendering templates from django.views.generic import TemplateView import importlib # Import the importlib module for dynamic module importing +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + + # Define a list of URL patterns for the application +# Keep admin outside /api/ prefix urlpatterns = [ # Map 'admin/' URL to the Django admin interface path("admin/", admin.site.urls), - # Include Djoser's URL patterns under 'auth/' for basic auth - path("auth/", include("djoser.urls")), - # Include Djoser's JWT auth URL patterns under 'auth/' - path("auth/", include("djoser.urls.jwt")), - # Include Djoser's social auth URL patterns under 'auth/' - path("auth/", include("djoser.social.urls")), ] # List of application names for which URL patterns will be dynamically added urls = [ "conversations", "feedback", + "version", "listMeds", "risk", "uploadFile", @@ -34,15 +33,46 @@ "assistant", ] +# Build API URL patterns to be included under /api/ prefix +api_urlpatterns = [ + # Include Djoser's URL patterns under 'auth/' for basic auth + path("auth/", include("djoser.urls")), + # Include Djoser's JWT auth URL patterns under 'auth/' + path("auth/", include("djoser.urls.jwt")), + # Include Djoser's social auth URL patterns under 'auth/' + path("auth/", include("djoser.social.urls")), +] + # Loop through each application name and dynamically import and add its URL patterns for url in urls: # Dynamically import the URL module for each app url_module = importlib.import_module(f"api.views.{url}.urls") # Append the URL patterns from each imported module - urlpatterns += getattr(url_module, "urlpatterns", []) + api_urlpatterns += getattr(url_module, "urlpatterns", []) + +# Wrap all API routes under /api/ prefix +urlpatterns += [ + path("api/", include(api_urlpatterns)), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), +] + +import os +from django.conf import settings +from django.http import HttpResponseNotFound + + +def spa_fallback(request): + """Serve index.html for SPA routing when build is present; otherwise 404.""" + index_path = os.path.join(settings.BASE_DIR, "build", "index.html") + if os.path.exists(index_path): + return TemplateView.as_view(template_name="index.html")(request) + return HttpResponseNotFound() + -# Add a catch-all URL pattern for handling SPA (Single Page Application) routing -# Serve 'index.html' for any unmatched URL +# Always register SPA catch-all so production serves the frontend regardless of +# URL config load order. At request time we serve index.html if build exists, else 404. urlpatterns += [ - re_path(r"^.*$", TemplateView.as_view(template_name="index.html")), + re_path(r"^(?!api|admin|static).*$", spa_fallback), ] diff --git a/server/entrypoint.prod.sh b/server/entrypoint.prod.sh index 9506422f..3ab59e2e 100755 --- a/server/entrypoint.prod.sh +++ b/server/entrypoint.prod.sh @@ -1,10 +1,10 @@ #!/bin/sh -if [ "$DATABASE" = "postgres" ] +if [ "$SQL_ENGINE" = "django.db.backends.postgresql" ] then echo "Waiting for postgres..." - while ! nc -z $SQL_HOST $SQL_PORT; do + while ! nc -z "$SQL_HOST" "$SQL_PORT"; do sleep 0.1 done @@ -18,4 +18,5 @@ python manage.py migrate python manage.py createsu # populate the database on start up python manage.py populatedb + exec "$@" diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 2d2c872f..f81a0b8f 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/sh -if [ "$DATABASE" = "postgres" ] +if [ "$SQL_ENGINE" = "django.db.backends.postgresql" ] then echo "Waiting for postgres..." diff --git a/server/pytest.ini b/server/pytest.ini new file mode 100644 index 00000000..235b9752 --- /dev/null +++ b/server/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = balancer_backend.settings +pythonpath = . diff --git a/server/requirements.txt b/server/requirements.txt index bbaf7bc9..f952b200 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -18,4 +18,7 @@ sentence_transformers PyMuPDF==1.24.0 Pillow pytesseract -anthropic \ No newline at end of file +anthropic +pytest +pytest-django +drf-spectacular