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/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 64758fe9..9d3435b6 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -3,6 +3,8 @@ name: "Containers: Publish" on: release: types: [published] + push: + branches: [develop] permissions: packages: write @@ -12,7 +14,7 @@ jobs: name: Build and Push runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - name: Login to ghcr.io Docker registry uses: docker/login-action@v3 @@ -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 @@ -44,6 +57,7 @@ jobs: --file Dockerfile.prod \ --tag "${DOCKER_REPOSITORY}/app:latest" \ --tag "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" \ + --build-arg VERSION="${DOCKER_TAG}" \ . - name: "Push Docker container image app:latest" @@ -51,3 +65,12 @@ jobs: - 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/deploy-downstream.yml b/.github/workflows/deploy-downstream.yml deleted file mode 100644 index 2557ff17..00000000 --- a/.github/workflows/deploy-downstream.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: "Deploy: Downstream Clusters" - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: 'Image tag to deploy (e.g. 1.1.0)' - required: true - default: 'latest' - -jobs: - update-sandbox: - name: Update Sandbox Cluster - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.get_tag.outputs.TAG }} - steps: - - name: Checkout App - uses: actions/checkout@v4 - - - name: Get Release Tag - id: get_tag - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - echo "TAG=${{ inputs.tag }}" >> $GITHUB_OUTPUT - else - echo "TAG=${GITHUB_REF:11}" >> $GITHUB_OUTPUT - fi - - - name: Checkout Sandbox Cluster - uses: actions/checkout@v4 - with: - repository: CodeForPhilly/cfp-sandbox-cluster - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: sandbox - - - name: Update Sandbox Image Tag - working-directory: sandbox/balancer - run: | - curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash - ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ steps.get_tag.outputs.TAG }} - rm kustomize - - - name: Create Sandbox PR - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: sandbox - commit-message: "Deploy balancer ${{ steps.get_tag.outputs.TAG }} to sandbox" - title: "Deploy balancer ${{ steps.get_tag.outputs.TAG }}" - body: "Updates balancer image tag to ${{ steps.get_tag.outputs.TAG }}" - branch: "deploy/balancer-${{ steps.get_tag.outputs.TAG }}" - base: main - delete-branch: true - - update-live: - name: Update Live Cluster - needs: update-sandbox - runs-on: ubuntu-latest - steps: - - name: Checkout Live Cluster - uses: actions/checkout@v4 - with: - repository: CodeForPhilly/cfp-live-cluster - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: live - - - name: Update Live Image Tag - working-directory: live/balancer - run: | - curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash - ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ needs.update-sandbox.outputs.tag }} - rm kustomize - - - name: Create Live PR - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: live - commit-message: "Deploy balancer ${{ needs.update-sandbox.outputs.tag }} to live" - title: "Deploy balancer ${{ needs.update-sandbox.outputs.tag }}" - body: "Updates balancer image tag to ${{ needs.update-sandbox.outputs.tag }}" - branch: "deploy/balancer-${{ needs.update-sandbox.outputs.tag }}" - base: main - delete-branch: true 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/CLAUDE.md b/CLAUDE.md index 8562eb0d..712082e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,28 +278,6 @@ docker compose exec backend python manage.py test ### Frontend Tests No test framework currently configured. Consider adding Jest/Vitest for future testing. -## Deployment - -### Local Kubernetes (using Devbox) -```bash -# Install Devbox first: https://www.jetify.com/devbox - -# Add balancertestsite.com to /etc/hosts -sudo sh -c 'echo "127.0.0.1 balancertestsite.com" >> /etc/hosts' - -# Deploy to local k8s cluster -devbox shell -devbox create:cluster -devbox run deploy:balancer - -# Access at https://balancertestsite.com:30219/ -``` - -### Production -- Manifests: `deploy/manifests/balancer/` -- ConfigMap: `deploy/manifests/balancer/base/configmap.yml` -- Secrets: `deploy/manifests/balancer/base/secret.template.yaml` - ## Key Files Reference - `server/balancer_backend/settings.py` - Django configuration (auth, database, CORS) diff --git a/Dockerfile.prod b/Dockerfile.prod index cd1f3604..21a24ecd 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -21,6 +21,10 @@ 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 @@ -32,9 +36,11 @@ ENV PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y netcat && rm -rf /var/lib/apt/lists/* # Install Python dependencies -RUN pip install --upgrade pip +RUN pip install --upgrade pip --no-cache-dir COPY server/requirements.txt . -RUN pip install -r 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/ . diff --git a/README.md b/README.md index f1cea06b..e5a246b1 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,17 @@ 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` + - `Anthropic API` Tools used for development: 1. `Docker`: Install Docker Desktop @@ -70,40 +74,6 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` -## Local Kubernetes Deployment - -### Prereqs - -- 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: - -```bash -HOSTNAME="balancertestsite.com" -LOCAL_IP="127.0.0.1" - -# 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 -``` - -### Steps to reproduce - -Inside root dir of balancer - -```bash -devbox shell -devbox create:cluster -devbox run deploy:balancer -``` - -The website should be available in [https://balancertestsite.com:30219/](https://balancertestsite.com:30219/) - ## Architecture The Balancer website is a Postgres, Django REST, and React project. The source code layout is: 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/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 0bba34b1..4b4868e4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,6 +2,23 @@ name: balancer-prod version: "3.8" services: + 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: @@ -11,3 +28,15 @@ services: - "8000:8000" env_file: - ./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 5d2d5884..9182cdb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,50 +1,67 @@ services: db: - # Workaround for PostgreSQL crash with pgvector v0.6.1 on ARM64 - # image: pgvector/pgvector:pg15 - # volumes: - # - postgres_data:/var/lib/postgresql/data/ - # - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql - 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 - # ports: - # - "5050:80" - # networks: - # app_net: - # ipv4_address: 192.168.0.4 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] + interval: 5s + timeout: 5s + retries: 5 + + 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/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: @@ -60,10 +77,17 @@ services: - "./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 +96,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 index 57ac3fac..7f2c298e 100644 --- a/docs/DATABASE_CONNECTION.md +++ b/docs/DATABASE_CONNECTION.md @@ -74,6 +74,20 @@ 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 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/.env.production b/frontend/.env.production deleted file mode 100644 index a05a022d..00000000 --- a/frontend/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_API_BASE_URL=https://balancer.live.k8s.phl.io/ \ No newline at end of file diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 915226d6..856f78a9 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,7 +1,14 @@ 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, + endpoints, +} from "./endpoints"; + +// 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 }); @@ -31,7 +38,7 @@ const handleSubmitFeedback = async ( message: FormValues["message"], ) => { try { - const response = await publicApi.post(`/v1/api/feedback/`, { + const response = await publicApi.post(V1_API_ENDPOINTS.FEEDBACK, { feedbacktype: feedbackType, name, email, @@ -49,7 +56,7 @@ const handleSendDrugSummary = async ( guid: string, ) => { try { - const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; + const endpoint = endpoints.embeddingsAsk(guid); const response = await adminApi.post(endpoint, { message, }); @@ -63,7 +70,7 @@ const handleSendDrugSummary = async ( const handleRuleExtraction = async (guid: string) => { try { - const response = await adminApi.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) { @@ -77,7 +84,7 @@ const fetchRiskDataWithSources = async ( source: "include" | "diagnosis" | "diagnosis_depressed" = "include", ) => { try { - const response = await publicApi.post(`/v1/api/riskWithSources`, { + const response = await publicApi.post(V1_API_ENDPOINTS.RISK_WITH_SOURCES, { drug: medication, source: source, }); @@ -101,12 +108,10 @@ const handleSendDrugSummaryStream = async ( 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", @@ -206,7 +211,7 @@ const handleSendDrugSummaryStreamLegacy = async ( const fetchConversations = async (): Promise => { try { - const response = await publicApi.get(`/chatgpt/conversations/`); + const response = await publicApi.get(CONVERSATION_ENDPOINTS.CONVERSATIONS); return response.data; } catch (error) { console.error("Error(s) during getConversations: ", error); @@ -216,7 +221,7 @@ const fetchConversations = async (): Promise => { const fetchConversation = async (id: string): Promise => { try { - const response = await publicApi.get(`/chatgpt/conversations/${id}/`); + const response = await publicApi.get(endpoints.conversation(id)); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -226,7 +231,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await adminApi.post(`/chatgpt/conversations/`, { + const response = await adminApi.post(CONVERSATION_ENDPOINTS.CONVERSATIONS, { messages: [], }); return response.data; @@ -243,7 +248,7 @@ const continueConversation = async ( ): Promise<{ response: string; title: Conversation["title"] }> => { try { const response = await adminApi.post( - `/chatgpt/conversations/${id}/continue_conversation/`, + endpoints.continueConversation(id), { message, page_context, @@ -258,7 +263,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await adminApi.delete(`/chatgpt/conversations/${id}/`); + const response = await adminApi.delete(endpoints.conversation(id)); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -273,7 +278,7 @@ const updateConversationTitle = async ( { status: string; title: Conversation["title"] } | { error: string } > => { try { - const response = await adminApi.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await adminApi.patch(endpoints.updateConversationTitle(id), { title: newTitle, }); return response.data; @@ -289,7 +294,8 @@ const sendAssistantMessage = async ( previousResponseId?: string, ) => { try { - const response = await publicApi.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, }); @@ -300,6 +306,17 @@ const sendAssistantMessage = async ( } }; +export interface VersionResponse { + version: string; +} + +const fetchVersion = async (): Promise => { + const response = await publicApi.get( + V1_API_ENDPOINTS.VERSION, + ); + return response.data; +}; + export { handleSubmitFeedback, handleSendDrugSummary, @@ -314,4 +331,5 @@ export { handleSendDrugSummaryStreamLegacy, fetchRiskDataWithSources, sendAssistantMessage, + fetchVersion, }; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 00000000..3f8585f0 --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,143 @@ +/** + * 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/`, +} 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..d656f5ad 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() { @@ -108,7 +109,10 @@ function Footer() {
-

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

+

+ © 2025 Balancer. All rights reserved. + +

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..c50f6705 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"; @@ -88,6 +89,10 @@ function About() {

+ diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index f3d0f477..2ee7b5db 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -22,9 +22,8 @@ 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`, + `/api/v1/api/uploadFile`, formData, { headers: { 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 efed19e5..b6fff4ee 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -30,12 +30,10 @@ 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 publicApi.get(url); @@ -50,7 +48,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ }; fetchFiles(); - }, [baseUrl]); + }, []); const updateFileName = (guid: string, updatedFile: Partial) => { setFiles((prevFiles) => diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index bec32d50..b947c2d6 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -24,8 +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`); + 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 022eb07a..d78702db 100644 --- a/frontend/src/pages/ListMeds/useMedications.tsx +++ b/frontend/src/pages/ListMeds/useMedications.tsx @@ -11,12 +11,10 @@ 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 publicApi.get(url); @@ -44,7 +42,7 @@ export function useMedications() { }; fetchMedications(); - }, [baseUrl]); + }, []); console.log(medications); diff --git a/frontend/src/pages/ManageMeds/ManageMeds.tsx b/frontend/src/pages/ManageMeds/ManageMeds.tsx index 23493f7e..c2372b9e 100644 --- a/frontend/src/pages/ManageMeds/ManageMeds.tsx +++ b/frontend/src/pages/ManageMeds/ManageMeds.tsx @@ -18,11 +18,10 @@ 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 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); @@ -36,7 +35,7 @@ function ManageMedications() { // Handle Delete Medication const handleDelete = async (name: string) => { try { - await adminApi.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 adminApi.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 b2ff2e01..94c718de 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -152,8 +152,7 @@ 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 publicApi.post(url, payload); diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 9b8c462c..faab5e6a 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -67,7 +67,6 @@ const MedicationItem = ({ loading, onTierClick, isAuthenticated, - baseURL, }: { medication: string; source: string; @@ -76,7 +75,6 @@ const MedicationItem = ({ loading: boolean; onTierClick: () => void; isAuthenticated: boolean | null; - baseURL: string; }) => { if (medication === "None") { return ( @@ -183,7 +181,7 @@ const MedicationItem = ({ ) : ( @@ -233,7 +231,6 @@ const MedicationTier = ({ loading, onTierClick, isAuthenticated, - baseURL, }: { title: string; tier: string; @@ -243,7 +240,6 @@ const MedicationTier = ({ loading: boolean; onTierClick: (medication: MedicationWithSource) => void; isAuthenticated: boolean | null; - baseURL: string; }) => ( <>
@@ -261,7 +257,6 @@ const MedicationTier = ({ loading={loading} onTierClick={() => onTierClick(medicationObj)} isAuthenticated={isAuthenticated} - baseURL={baseURL} /> ))} @@ -280,7 +275,7 @@ const PatientSummary = ({ isPatientDeleted, isAuthenticated = false, }: PatientSummaryProps) => { - const baseURL = import.meta.env.VITE_API_BASE_URL || ''; + // Using relative URLs - no baseURL needed const [loading, setLoading] = useState(false); const [riskData, setRiskData] = useState(null); const [clickedMedication, setClickedMedication] = useState( @@ -423,7 +418,6 @@ const PatientSummary = ({ loading={loading} onTierClick={handleTierClick} isAuthenticated={isAuthenticated} - baseURL={baseURL} />
@@ -448,7 +441,6 @@ const PatientSummary = ({ loading={loading} onTierClick={handleTierClick} isAuthenticated={isAuthenticated} - baseURL={baseURL} />
diff --git a/frontend/src/pages/RulesManager/RulesManager.tsx b/frontend/src/pages/RulesManager/RulesManager.tsx index 0268a4c8..e77b39cd 100644 --- a/frontend/src/pages/RulesManager/RulesManager.tsx +++ b/frontend/src/pages/RulesManager/RulesManager.tsx @@ -63,12 +63,10 @@ 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 url = `/api/v1/api/medRules`; const { data } = await adminApi.get(url); if (!data || !Array.isArray(data.results)) { @@ -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/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 3dcfcac5..a6a30ff3 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,9 +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; - console.log(baseUrl); - const url = `${baseUrl}/auth/jwt/verify/`; + const url = AUTH_ENDPOINTS.JWT_VERIFY; try { const res = await axios.post(url, body, config); @@ -113,9 +112,7 @@ export const load_user = (): ThunkType => async (dispatch: AppDispatch) => { Accept: "application/json", }, }; - const baseUrl = import.meta.env.VITE_API_BASE_URL; - console.log(baseUrl); - const url = `${baseUrl}/auth/users/me/`; + const url = AUTH_ENDPOINTS.USER_ME; try { const res = await axios.get(url, config); @@ -145,9 +142,7 @@ export const login = }; const body = JSON.stringify({ email, password }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - console.log(baseUrl); - const url = `${baseUrl}/auth/jwt/create/`; + const url = AUTH_ENDPOINTS.JWT_CREATE; try { const res = await axios.post(url, body, config); @@ -195,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); @@ -225,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({ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1d907506..1f02c51f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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/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/services/embedding_services.py b/server/api/services/embedding_services.py index b50dd750..e35f7965 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,11 +1,15 @@ +import time +import logging +from statistics import median + 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 @@ -38,9 +42,14 @@ def get_closest_embeddings( - file_id: GUID of the source file """ + encoding_start = time.time() transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) + encoding_time = time.time() - encoding_start + db_query_start = time.time() + + # Django QuerySets are lazily evaluated if user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( @@ -62,7 +71,7 @@ def get_closest_embeddings( .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 @@ -70,10 +79,11 @@ def get_closest_embeddings( elif document_name: closest_embeddings_query = closest_embeddings_query.filter(name=document_name) - # Slice the results to limit to num_results + # Slicing is equivalent to SQL's LIMIT clause closest_embeddings_query = closest_embeddings_query[:num_results] - # Format the results to be returned + # Iterating evaluates the QuerySet and hits the database + # TODO: Research improving the query evaluation performance results = [ { "name": obj.name, @@ -86,4 +96,42 @@ def get_closest_embeddings( for obj in closest_embeddings_query ] + db_query_time = time.time() - db_query_start + + try: + # Handle user having no uploaded docs or doc filtering returning no matches + 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 as e: + logger.error(f"Failed to create semantic search usage database record: {e}") + return results diff --git a/server/api/views/assistant/sanitizer.py b/server/api/views/assistant/sanitizer.py new file mode 100644 index 00000000..bdbbc77f --- /dev/null +++ b/server/api/views/assistant/sanitizer.py @@ -0,0 +1,26 @@ +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: + # Remove any script tags + sanitized = re.sub(r'.*?', '', user_input, flags=re.IGNORECASE) + # Remove any HTML tags + sanitized = re.sub(r'<.*?>', '', sanitized) + # Escape special characters + sanitized = re.sub(r'["\'\\]', '', sanitized) + # Limit length to prevent buffer overflow attacks + max_length = 1000 + if len(sanitized) > max_length: + sanitized = sanitized[:max_length] + return sanitized.strip() + except Exception as e: + logger.error(f"Error sanitizing input: {e}") + return "" \ No newline at end of file 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..b79d6577 --- /dev/null +++ b/server/api/views/version/views.py @@ -0,0 +1,13 @@ +import os + +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView +from rest_framework.response import Response + + +class VersionView(APIView): + permission_classes = [AllowAny] + + 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 58148617..9f917a94 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -106,13 +106,13 @@ # 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"), + "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": SQL_HOST, - "PORT": os.environ.get("SQL_PORT", "5432"), -} + "PORT": os.environ.get("SQL_PORT", "5432"), + } # Configure SSL/TLS based on connection type # CloudNativePG within cluster typically doesn't require SSL diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 56f307e4..c8bd290d 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -8,21 +8,17 @@ import importlib # Import the importlib module for dynamic module importing # 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 +30,43 @@ "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)), +] + +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), ]