diff --git a/.env b/.env
index 1d44286e25..c7ec92fb9c 100644
--- a/.env
+++ b/.env
@@ -43,3 +43,8 @@ SENTRY_DSN=
# Configure these with your own Docker registry images
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend
+
+AWS_ACCESS_KEY_ID=fake-access-key-changethis
+AWS_SECRET_ACCESS_KEY=fake-secret-key-changethis
+AWS_REGION=us-east-1
+AWS_S3_ATTACHMENTS_BUCKET=patient-attachments
diff --git a/.gitignore b/.gitignore
index a6dd346572..877086b91a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
+.aider*
diff --git a/README.md b/README.md
index afe124f3fb..f9fca03765 100644
--- a/README.md
+++ b/README.md
@@ -1,239 +1,95 @@
-# Full Stack FastAPI Template
+# MCG Take-Home Assessment - Patient Care Management System
-
-
+## Design
-## Technology Stack and Features
+See the system design in [Excalidraw here](https://excalidraw.com/#json=4cLkV3RAlGQ95xFiVW1fY,QTeiqBvSWKEk77UItun2aA)
-- โก [**FastAPI**](https://fastapi.tiangolo.com) for the Python backend API.
- - ๐งฐ [SQLModel](https://sqlmodel.tiangolo.com) for the Python SQL database interactions (ORM).
- - ๐ [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
- - ๐พ [PostgreSQL](https://www.postgresql.org) as the SQL database.
-- ๐ [React](https://react.dev) for the frontend.
- - ๐ Using TypeScript, hooks, Vite, and other parts of a modern frontend stack.
- - ๐จ [Chakra UI](https://chakra-ui.com) for the frontend components.
- - ๐ค An automatically generated frontend client.
- - ๐งช [Playwright](https://playwright.dev) for End-to-End testing.
- - ๐ฆ Dark mode support.
-- ๐ [Docker Compose](https://www.docker.com) for development and production.
-- ๐ Secure password hashing by default.
-- ๐ JWT (JSON Web Token) authentication.
-- ๐ซ Email based password recovery.
-- โ
Tests with [Pytest](https://pytest.org).
-- ๐ [Traefik](https://traefik.io) as a reverse proxy / load balancer.
-- ๐ข Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates.
-- ๐ญ CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
+## Implementation
-### Dashboard Login
+This is based on the [FastAPI full stack template](https://fastapi.tiangolo.com/project-generation/), started as a fork of that project.
-[](https://github.com/fastapi/full-stack-fastapi-template)
+It includes an API server based on Python and FastAPI, with an SQLModel ORM,
+Pydantic type checking, connects to a PostgreSQL database with
+migrations managed by Alembic, and creates AWS presigned URLs for attachment
+uploads and downloads using `boto3`.
-### Dashboard - Admin
+- Added SQLModel classes and associated Alembic migrations for Patient and Attachment tables
+- Added configuration variables for setting AWS credentials for use by `boto3`
+- Implemented `/patients/...` and `/attachments/...` API routes as specified in the "REST API Sketch" section of the [Design Document](https://excalidraw.com/#json=4cLkV3RAlGQ95xFiVW1fY,QTeiqBvSWKEk77UItun2aA)
+- Implemented automated tests for all API routes based on `pytest` and the FastAPI TestClient.
+- All patient and attachment APIs require a valid JWT authentication token present in the request headers.
-[](https://github.com/fastapi/full-stack-fastapi-template)
+AI coding assistants used in development: [GitHub Copilot](https://github.com/features/copilot) and [Aider](https://aider.chat) connected to Claude 3.5-sonnet. Aider proved quite adept at writing tests.
-### Dashboard - Create User
+### Design Notes
-[](https://github.com/fastapi/full-stack-fastapi-template)
+Publicly visible patient and attachment IDs are non-sequential UUIDs, which is a security best practice.
-### Dashboard - Items
+File uploads and downloads go directly to and from AWS S3 via presigned URLs, bypassing the application server, which greatly reduces load on the application server and improves upload and download performance.
-[](https://github.com/fastapi/full-stack-fastapi-template)
+### APIs Implemented
-### Dashboard - User Settings
+#### Authentication and User Management
-[](https://github.com/fastapi/full-stack-fastapi-template)
+Fully functional user management and authentication APIs are
+included in the base project, including password reset emails.
+See the template documentation for details.
-### Dashboard - Dark Mode
+#### POST `/api/v1/patients`
-[](https://github.com/fastapi/full-stack-fastapi-template)
+Creates a new patient record.
-### Interactive API Documentation
+ headers: user's JWT token
+ response: JSON representation of new patient record
-[](https://github.com/fastapi/full-stack-fastapi-template)
+#### GET `/api/v1/patients/`
-## How To Use It
+Gets the details of a single patient.
-You can **just fork or clone** this repository and use it as is.
+ request headers: user's JWT token
+ response: JSON representation of patient
-โจ It just works. โจ
+#### GET `/api/v1/patients?`
-### How to Use a Private Repository
+Retrieves patient records.
-If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks.
+ request headers: user's JWT token
+ query parameters (optional):
+ - name_text
+ - name_exact
+ - history_text
+ - has_attachment_mime_type
+ - skip, limit (for paging)
+ response:
+ JSON array of matching patient records,
+ filtered according to query params.
-But you can do the following:
+#### POST `/api/v1/attachments`
-- Create a new GitHub repo, for example `my-full-stack`.
-- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`:
+Creates a new attachment.
-```bash
-git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack
-```
+ request headers:
+ user's JWT token
+ - Content-Type:
+ body:
+ - file_name
+ response:
+ - id
+ - upload_url: presigned upload URL
-- Enter into the new directory:
+Note: after creating the attachment, the client should upload the file
+itself to the provided presigned AWS S3 URL.
-```bash
-cd my-full-stack
-```
+#### GET `/api/v1/attachments//content`
-- Set the new origin to your new repository, copy it from the GitHub interface, for example:
+Retrieves attachment content
-```bash
-git remote set-url origin git@github.com:octocat/my-full-stack.git
-```
+ request headers: user's JWT token
+ response:
+ 302 redirect to presigned blob storage URL
-- Add this repo as another "remote" to allow you to get updates later:
-
-```bash
-git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git
-```
-
-- Push the code to your new repository:
-
-```bash
-git push -u origin master
-```
-
-### Update From the Original Template
-
-After cloning the repository, and after doing changes, you might want to get the latest changes from this original template.
-
-- Make sure you added the original repository as a remote, you can check it with:
-
-```bash
-git remote -v
-
-origin git@github.com:octocat/my-full-stack.git (fetch)
-origin git@github.com:octocat/my-full-stack.git (push)
-upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch)
-upstream git@github.com:fastapi/full-stack-fastapi-template.git (push)
-```
-
-- Pull the latest changes without merging:
-
-```bash
-git pull --no-commit upstream master
-```
-
-This will download the latest changes from this template without committing them, that way you can check everything is right before committing.
-
-- If there are conflicts, solve them in your editor.
-
-- Once you are done, commit the changes:
-
-```bash
-git merge --continue
-```
-
-### Configure
-
-You can then update configs in the `.env` files to customize your configurations.
-
-Before deploying it, make sure you change at least the values for:
-
-- `SECRET_KEY`
-- `FIRST_SUPERUSER_PASSWORD`
-- `POSTGRES_PASSWORD`
-
-You can (and should) pass these as environment variables from secrets.
-
-Read the [deployment.md](./deployment.md) docs for more details.
-
-### Generate Secret Keys
-
-Some environment variables in the `.env` file have a default value of `changethis`.
-
-You have to change them with a secret key, to generate secret keys you can run the following command:
-
-```bash
-python -c "import secrets; print(secrets.token_urlsafe(32))"
-```
-
-Copy the content and use that as password / secret key. And run that again to generate another secure key.
-
-## How To Use It - Alternative With Copier
-
-This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
-
-It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
-
-### Install Copier
-
-You can install Copier with:
-
-```bash
-pip install copier
-```
-
-Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
-
-```bash
-pipx install copier
-```
-
-**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
-
-### Generate a Project With Copier
-
-Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
-
-Go to the directory that will be the parent of your project, and run the command with your project's name:
-
-```bash
-copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
-```
-
-If you have `pipx` and you didn't install `copier`, you can run it directly:
-
-```bash
-pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
-```
-
-**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
-
-### Input Variables
-
-Copier will ask you for some data, you might want to have at hand before generating the project.
-
-But don't worry, you can just update any of that in the `.env` files afterwards.
-
-The input variables, with their default values (some auto generated) are:
-
-- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
-- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env).
-- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
-- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
-- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
-- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
-- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
-- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env.
-- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
-- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
-- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
-
-## Backend Development
-
-Backend docs: [backend/README.md](./backend/README.md).
-
-## Frontend Development
-
-Frontend docs: [frontend/README.md](./frontend/README.md).
-
-## Deployment
-
-Deployment docs: [deployment.md](./deployment.md).
-
-## Development
-
-General development docs: [development.md](./development.md).
-
-This includes using Docker Compose, custom local domains, `.env` configurations, etc.
-
-## Release Notes
-
-Check the file [release-notes.md](./release-notes.md).
+NOTE: AWS presigned URL generation supports setting the Content-Disposition header (and thus the downloaded file name) that will be provided to the client, which is what is implemented here.
## License
-The Full Stack FastAPI Template is licensed under the terms of the MIT license.
+This is a fork of the [FastAPI full stack template](https://fastapi.tiangolo.com/project-generation/) and is licensed under the terms of the MIT license.
diff --git a/backend/app/alembic/versions/5c21bc8e2271_add_patient_and_attachment_models.py b/backend/app/alembic/versions/5c21bc8e2271_add_patient_and_attachment_models.py
new file mode 100644
index 0000000000..adbe5f46ab
--- /dev/null
+++ b/backend/app/alembic/versions/5c21bc8e2271_add_patient_and_attachment_models.py
@@ -0,0 +1,47 @@
+"""Add Patient and Attachment models
+
+Revision ID: 5c21bc8e2271
+Revises: 1a31ce608336
+Create Date: 2025-02-21 20:56:29.918096
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+
+
+# revision identifiers, used by Alembic.
+revision = '5c21bc8e2271'
+down_revision = '1a31ce608336'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('patient',
+ sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('dob', sa.DateTime(), nullable=True),
+ sa.Column('contact_info', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('medical_history', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('attachment',
+ sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('dob', sa.DateTime(), nullable=True),
+ sa.Column('contact_info', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('medical_history', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('patient_id', sa.Uuid(), nullable=False),
+ sa.ForeignKeyConstraint(['patient_id'], ['patient.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('attachment')
+ op.drop_table('patient')
+ # ### end Alembic commands ###
diff --git a/backend/app/alembic/versions/60300545fecf_add_fulltext_index_for_patient_medical_.py b/backend/app/alembic/versions/60300545fecf_add_fulltext_index_for_patient_medical_.py
new file mode 100644
index 0000000000..2a8b41d18d
--- /dev/null
+++ b/backend/app/alembic/versions/60300545fecf_add_fulltext_index_for_patient_medical_.py
@@ -0,0 +1,33 @@
+"""add fulltext index for patient.medical_history
+
+Revision ID: 60300545fecf
+Revises: 9e47d43a4b8a
+Create Date: 2025-02-23 19:56:05.494933
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+
+
+# revision identifiers, used by Alembic.
+revision = '60300545fecf'
+down_revision = '9e47d43a4b8a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
+ op.create_index(
+ 'ix_patient_medical_history_gin',
+ 'patient',
+ ['medical_history'],
+ unique=False,
+ postgresql_using='gin',
+ postgresql_ops={'medical_history': 'gin_trgm_ops'}
+ )
+
+def downgrade() -> None:
+ op.drop_index('ix_patient_medical_history_gin', table_name='patient')
+
diff --git a/backend/app/alembic/versions/9e47d43a4b8a_correct_attachment_model.py b/backend/app/alembic/versions/9e47d43a4b8a_correct_attachment_model.py
new file mode 100644
index 0000000000..04cf369909
--- /dev/null
+++ b/backend/app/alembic/versions/9e47d43a4b8a_correct_attachment_model.py
@@ -0,0 +1,39 @@
+"""correct attachment model
+
+Revision ID: 9e47d43a4b8a
+Revises: 5c21bc8e2271
+Create Date: 2025-02-21 21:22:02.495692
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '9e47d43a4b8a'
+down_revision = '5c21bc8e2271'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('attachment', sa.Column('file_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
+ op.add_column('attachment', sa.Column('mime_type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True))
+ op.drop_column('attachment', 'medical_history')
+ op.drop_column('attachment', 'contact_info')
+ op.drop_column('attachment', 'dob')
+ op.drop_column('attachment', 'name')
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('attachment', sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False))
+ op.add_column('attachment', sa.Column('dob', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))
+ op.add_column('attachment', sa.Column('contact_info', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
+ op.add_column('attachment', sa.Column('medical_history', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
+ op.drop_column('attachment', 'mime_type')
+ op.drop_column('attachment', 'file_name')
+ # ### end Alembic commands ###
diff --git a/backend/app/alembic/versions/da61fe01b001_drop_storage_path_column.py b/backend/app/alembic/versions/da61fe01b001_drop_storage_path_column.py
new file mode 100644
index 0000000000..b25f04f524
--- /dev/null
+++ b/backend/app/alembic/versions/da61fe01b001_drop_storage_path_column.py
@@ -0,0 +1,29 @@
+"""drop storage_path column
+
+Revision ID: da61fe01b001
+Revises: e62dc93fe967
+Create Date: 2025-02-23 20:49:29.015113
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+
+
+# revision identifiers, used by Alembic.
+revision = 'da61fe01b001'
+down_revision = 'e62dc93fe967'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('attachment', 'storage_path')
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('attachment', sa.Column('storage_path', sa.VARCHAR(length=1024), autoincrement=False, nullable=False))
+ # ### end Alembic commands ###
diff --git a/backend/app/alembic/versions/e62dc93fe967_add_storage_path_to_attachment_model.py b/backend/app/alembic/versions/e62dc93fe967_add_storage_path_to_attachment_model.py
new file mode 100644
index 0000000000..177c030e86
--- /dev/null
+++ b/backend/app/alembic/versions/e62dc93fe967_add_storage_path_to_attachment_model.py
@@ -0,0 +1,27 @@
+"""Add storage_path to Attachment model
+
+Revision ID: e62dc93fe967
+Revises: 60300545fecf
+Create Date: 2025-02-23 19:56:35.403358
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+
+
+# revision identifiers, used by Alembic.
+revision = 'e62dc93fe967'
+down_revision = '60300545fecf'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add storage_path column
+ op.add_column('attachment', sa.Column('storage_path', sa.String(length=1024), nullable=False))
+
+
+def downgrade() -> None:
+ # Remove storage_path column
+ op.drop_column('attachment', 'storage_path')
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index c2b83c841d..b27ccd8c18 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -1,6 +1,7 @@
from collections.abc import Generator
from typing import Annotated
+import boto3
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -55,3 +56,11 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user
+
+
+# shared AWS SDK boto3 session
+s3_client = boto3.Session(
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ region_name=settings.AWS_REGION,
+).client('s3')
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index eac18c8e8f..72143ffb7d 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from app.api.routes import items, login, private, users, utils
+from app.api.routes import items, login, private, users, utils, patients, attachments
from app.core.config import settings
api_router = APIRouter()
@@ -8,6 +8,8 @@
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
+api_router.include_router(patients.router)
+api_router.include_router(attachments.router)
if settings.ENVIRONMENT == "local":
diff --git a/backend/app/api/routes/attachments.py b/backend/app/api/routes/attachments.py
new file mode 100644
index 0000000000..52b7368d4d
--- /dev/null
+++ b/backend/app/api/routes/attachments.py
@@ -0,0 +1,108 @@
+import logging
+from typing import Any
+import uuid
+
+import boto3
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import RedirectResponse
+from sqlmodel import func, select
+
+from app.api.deps import CurrentUser, SessionDep, s3_client
+from app.core.config import settings
+from app.models.attachments import (
+ Attachment,
+ AttachmentCreate,
+ AttachmentCreatePublic,
+ AttachmentPublic,
+ AttachmentsPublic,
+ AttachmentUpdate,
+)
+from app.models import Message
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/attachments", tags=["attachments"])
+
+
+@router.get("/", response_model=AttachmentsPublic)
+def read_attachments(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve list of all attachments.
+ """
+ count_statement = select(func.count()).select_from(Attachment)
+ count = session.exec(count_statement).one()
+ statement = select(Attachment).offset(skip).limit(limit)
+ attachments = session.exec(statement).all()
+
+ return AttachmentsPublic(data=attachments, count=count)
+
+@router.get("/{id}/content")
+def read_attachment_content(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get attachment content by ID.
+ """
+ attachment = session.get(Attachment, id)
+ if not attachment:
+ raise HTTPException(status_code=404, detail="Attachment not found")
+
+ try:
+ presigned_url = s3_client.generate_presigned_url(
+ "get_object",
+ Params={
+ "Bucket": settings.AWS_S3_ATTACHMENTS_BUCKET,
+ "Key": attachment.storage_path,
+ "ContentDisposition": f"attachment; filename={attachment.file_name}",
+ },
+ ExpiresIn=3600,
+ )
+ except Exception as e:
+ logger.exception(e)
+ raise HTTPException(status_code=500, detail="Could not generate presigned URL")
+
+ return RedirectResponse(status_code=302, url=presigned_url)
+
+
+@router.get("/{id}", response_model=AttachmentPublic)
+def read_attachment(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get attachment details by ID.
+ """
+ attachment = session.get(Attachment, id)
+ if not attachment:
+ raise HTTPException(status_code=404, detail="Attachment not found")
+ return attachment
+
+
+@router.post("/", response_model=AttachmentCreatePublic)
+def create_attachment(
+ *, session: SessionDep, current_user: CurrentUser, attachment_in: AttachmentCreate
+) -> Any:
+ """
+ Create a new attachment.
+ """
+ attachment = Attachment.model_validate(attachment_in)
+ session.add(attachment)
+ session.commit()
+ session.refresh(attachment)
+
+ try:
+ presigned_upload_url = s3_client.generate_presigned_url(
+ "put_object",
+ Params={
+ "Bucket": settings.AWS_S3_ATTACHMENTS_BUCKET,
+ "Key": attachment.storage_path,
+ "ContentDisposition": f"attachment; filename={attachment.file_name}",
+ },
+ ExpiresIn=3600,
+ )
+ except Exception as e:
+ logger.exception(e)
+ raise HTTPException(status_code=500, detail="Could not generate presigned URL")
+
+ resmodel = AttachmentCreatePublic.model_validate(
+ attachment, update={"upload_url": presigned_upload_url}
+ )
+
+ return resmodel
diff --git a/backend/app/api/routes/patients.py b/backend/app/api/routes/patients.py
new file mode 100644
index 0000000000..c93fa26321
--- /dev/null
+++ b/backend/app/api/routes/patients.py
@@ -0,0 +1,115 @@
+from typing import Any
+import uuid
+
+from fastapi import APIRouter, HTTPException
+from sqlalchemy.dialects.postgresql.ext import to_tsquery
+from sqlmodel import func, select
+
+from app.api.deps import CurrentUser, SessionDep
+from app.models.patients import (
+ Patient,
+ PatientCreate,
+ PatientPublic,
+ PatientsPublic,
+ PatientUpdate,
+)
+from app.models.attachments import Attachment
+from app.models import Message
+
+router = APIRouter(prefix="/patients", tags=["patients"])
+
+
+@router.get("/", response_model=PatientsPublic)
+def read_patients(
+ session: SessionDep,
+ current_user: CurrentUser,
+ skip: int = 0,
+ limit: int = 100,
+ history_text: str = None, # full text search for medical history
+ name_exact: str = None, # exact match for name
+ name_text: str = None, # full text search for name
+ has_attachment_mime_type: str = None, # filter by attachment MIME type
+) -> Any:
+ """
+ Retrieve patients.
+ """
+
+ # assemble filters array from query parameters
+ filters = []
+ if history_text:
+ filters.append(Patient.medical_history.op("@@")(to_tsquery(history_text)))
+ if name_exact:
+ filters.append(Patient.name == name_exact)
+ if name_text:
+ filters.append(Patient.name.op("@@")(to_tsquery(name_text)))
+ if has_attachment_mime_type:
+ filters.append(Patient.attachments.any(Attachment.mime_type == has_attachment_mime_type))
+
+ count_statement = select(func.count()).select_from(Patient).filter(*filters)
+ count = session.exec(count_statement).one()
+ statement = select(Patient).filter(*filters).offset(skip).limit(limit)
+ patients = session.exec(statement).all()
+
+ return PatientsPublic(data=patients, count=count)
+
+
+@router.get("/{id}", response_model=PatientPublic)
+def read_patient(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get patient by ID.
+ """
+ patient = session.get(Patient, id)
+ if not patient:
+ raise HTTPException(status_code=404, detail="Patient not found")
+ return patient
+
+
+@router.post("/", response_model=PatientPublic)
+def create_patient(
+ *, session: SessionDep, current_user: CurrentUser, patient_in: PatientCreate
+) -> Any:
+ """
+ Create new patient.
+ """
+ patient = Patient.model_validate(patient_in)
+ session.add(patient)
+ session.commit()
+ session.refresh(patient)
+ return patient
+
+
+@router.put("/{id}", response_model=PatientPublic)
+def update_patient(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ id: uuid.UUID,
+ patient_in: PatientUpdate,
+) -> Any:
+ """
+ Update a patient.
+ """
+ patient = session.get(Patient, id)
+ if not patient:
+ raise HTTPException(status_code=404, detail="Patient not found")
+ update_dict = patient_in.model_dump(exclude_unset=True)
+ patient.sqlmodel_update(update_dict)
+ session.add(patient)
+ session.commit()
+ session.refresh(patient)
+ return patient
+
+
+@router.delete("/{id}")
+def delete_patient(
+ session: SessionDep, current_user: CurrentUser, id: uuid.UUID
+) -> Message:
+ """
+ Delete a patient.
+ """
+ patient = session.get(Patient, id)
+ if not patient:
+ raise HTTPException(status_code=404, detail="Patient not found")
+ session.delete(patient)
+ session.commit()
+ return Message(message="Patient deleted successfully")
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index d58e03c87d..6c90825365 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -95,6 +95,12 @@ def emails_enabled(self) -> bool:
FIRST_SUPERUSER: EmailStr
FIRST_SUPERUSER_PASSWORD: str
+ # configuration for AWS client
+ AWS_ACCESS_KEY_ID: str
+ AWS_SECRET_ACCESS_KEY: str
+ AWS_REGION: str
+ AWS_S3_ATTACHMENTS_BUCKET: str
+
def _check_default_secret(self, var_name: str, value: str | None) -> None:
if value == "changethis":
message = (
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000000..6b35605273
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,7 @@
+
+from .users import *
+from .items import *
+from .patients import *
+from .attachments import *
+
+
diff --git a/backend/app/models/attachments.py b/backend/app/models/attachments.py
new file mode 100644
index 0000000000..3806d38fe2
--- /dev/null
+++ b/backend/app/models/attachments.py
@@ -0,0 +1,57 @@
+import re
+import uuid
+
+from pydantic import field_validator
+from sqlmodel import Field, Relationship, SQLModel
+
+from .patients import Patient
+
+mime_type_pattern = re.compile(r'^[a-zA-Z0-9]+/[a-zA-Z0-9\-.+]+$')
+
+# Shared properties
+class AttachmentBase(SQLModel):
+ file_name: str = Field(min_length=1, max_length=None)
+ mime_type: str | None = Field(default=None, max_length=255)
+
+ @field_validator("mime_type")
+ @classmethod
+ def validate_mime_type(cls, v):
+ if v is None:
+ return v
+ if not mime_type_pattern.match(v):
+ raise ValueError("Invalid MIME type format")
+ return v
+
+# Properties to receive on attachment creation
+class AttachmentCreate(AttachmentBase):
+ patient_id: uuid.UUID = Field(nullable=False)
+
+# Properties to receive on attachment update
+class AttachmentUpdate(AttachmentBase):
+ pass
+
+# Database model, database table inferred from class name
+class Attachment(AttachmentBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ patient_id: uuid.UUID = Field(
+ foreign_key="patient.id", nullable=False, ondelete="CASCADE"
+ )
+ patient: Patient | None = Relationship(back_populates="attachments")
+
+ # get the blob storage path, which is assembled from the patient
+ # id and the attachment ID
+ @property
+ def storage_path(self):
+ return f"patients/{self.patient_id}/attachments/{self.id}"
+
+# Properties to return via API, id is always required
+class AttachmentPublic(AttachmentBase):
+ id: uuid.UUID
+ patient_id: uuid.UUID = Field(nullable=False)
+
+class AttachmentCreatePublic(AttachmentPublic):
+ upload_url: str = Field(nullable=False) # presigned URL for uploading to blob storage
+
+class AttachmentsPublic(SQLModel):
+ data: list[AttachmentPublic]
+ count: int
diff --git a/backend/app/models/items.py b/backend/app/models/items.py
new file mode 100644
index 0000000000..0c6d827802
--- /dev/null
+++ b/backend/app/models/items.py
@@ -0,0 +1,36 @@
+import uuid
+
+from sqlmodel import Field, Relationship, SQLModel
+
+from .users import User
+
+# Shared properties
+class ItemBase(SQLModel):
+ title: str = Field(min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=255)
+
+# Properties to receive on item creation
+class ItemCreate(ItemBase):
+ pass
+
+# Properties to receive on item update
+class ItemUpdate(ItemBase):
+ title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
+
+# Database model, database table inferred from class name
+class Item(ItemBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ title: str = Field(max_length=255)
+ owner_id: uuid.UUID = Field(
+ foreign_key="user.id", nullable=False, ondelete="CASCADE"
+ )
+ owner: User | None = Relationship(back_populates="items")
+
+# Properties to return via API, id is always required
+class ItemPublic(ItemBase):
+ id: uuid.UUID
+ owner_id: uuid.UUID
+
+class ItemsPublic(SQLModel):
+ data: list[ItemPublic]
+ count: int
diff --git a/backend/app/models/patients.py b/backend/app/models/patients.py
new file mode 100644
index 0000000000..cc00f7bc6e
--- /dev/null
+++ b/backend/app/models/patients.py
@@ -0,0 +1,47 @@
+from typing import TYPE_CHECKING
+from datetime import datetime
+
+from sqlmodel import Field, Relationship, SQLModel, create_engine, Session, select
+from sqlalchemy import Index
+import uuid
+
+if TYPE_CHECKING:
+ from .attachments import Attachment
+
+# Shared properties
+class PatientBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ dob: datetime | None = Field(default=None)
+ contact_info: str | None = Field(default=None, max_length=255)
+ medical_history: str | None = Field(default=None, max_length=None)
+
+# Properties to receive on patient creation
+class PatientCreate(PatientBase):
+ pass
+
+# Properties to receive on patient update
+class PatientUpdate(PatientBase):
+ name: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
+
+# Database model, database table inferred from class name
+class Patient(PatientBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ attachments: list["Attachment"] = Relationship(back_populates="patient", cascade_delete=True)
+
+ __table_args__ = (
+ # define a fulltext index on medical_history
+ Index(
+ 'ix_patient_medical_history_gin',
+ 'medical_history',
+ postgresql_using='gin',
+ postgresql_ops={'medical_history': 'gin_trgm_ops'}
+ ),
+ )
+
+# Properties to return via API, id is always required
+class PatientPublic(PatientBase):
+ id: uuid.UUID
+
+class PatientsPublic(SQLModel):
+ data: list[PatientPublic]
+ count: int
diff --git a/backend/app/models.py b/backend/app/models/users.py
similarity index 67%
rename from backend/app/models.py
rename to backend/app/models/users.py
index 90ef5559e3..0b4689447e 100644
--- a/backend/app/models.py
+++ b/backend/app/models/users.py
@@ -1,8 +1,11 @@
import uuid
+from typing import TYPE_CHECKING
from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
+if TYPE_CHECKING:
+ from .items import Item
# Shared properties
class UserBase(SQLModel):
@@ -55,44 +58,6 @@ class UsersPublic(SQLModel):
data: list[UserPublic]
count: int
-
-# Shared properties
-class ItemBase(SQLModel):
- title: str = Field(min_length=1, max_length=255)
- description: str | None = Field(default=None, max_length=255)
-
-
-# Properties to receive on item creation
-class ItemCreate(ItemBase):
- pass
-
-
-# Properties to receive on item update
-class ItemUpdate(ItemBase):
- title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
-
-
-# Database model, database table inferred from class name
-class Item(ItemBase, table=True):
- id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
- title: str = Field(max_length=255)
- owner_id: uuid.UUID = Field(
- foreign_key="user.id", nullable=False, ondelete="CASCADE"
- )
- owner: User | None = Relationship(back_populates="items")
-
-
-# Properties to return via API, id is always required
-class ItemPublic(ItemBase):
- id: uuid.UUID
- owner_id: uuid.UUID
-
-
-class ItemsPublic(SQLModel):
- data: list[ItemPublic]
- count: int
-
-
# Generic message
class Message(SQLModel):
message: str
diff --git a/backend/app/tests/api/routes/test_attachments.py b/backend/app/tests/api/routes/test_attachments.py
new file mode 100644
index 0000000000..c5a2af94d4
--- /dev/null
+++ b/backend/app/tests/api/routes/test_attachments.py
@@ -0,0 +1,160 @@
+from datetime import datetime
+import uuid
+from typing import Dict
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.models.attachments import Attachment
+from app.models.patients import Patient
+
+def test_create_attachment(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ # First create a patient to attach to
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ data = {
+ "file_name": "test.pdf",
+ "mime_type": "application/pdf",
+ "description": "Test attachment",
+ "patient_id": str(patient.id)
+ }
+ response = client.post(
+ "/api/v1/attachments/",
+ headers=superuser_token_headers,
+ json=data,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["file_name"] == data["file_name"]
+ assert content["mime_type"] == data["mime_type"]
+ assert content["upload_url"].startswith("https://")
+ assert "id" in content
+
+def test_read_attachment_details(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ # Create test patient
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ # Create test attachment
+ attachment = Attachment(
+ file_name="test.pdf",
+ mime_type="application/pdf",
+ description="Test attachment",
+ patient_id=patient.id
+ )
+ db.add(attachment)
+ db.commit()
+ db.refresh(attachment)
+
+ response = client.get(
+ f"/api/v1/attachments/{attachment.id}",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["file_name"] == attachment.file_name
+ assert content["id"] == str(attachment.id)
+ assert content["patient_id"] == str(attachment.patient_id)
+
+
+def test_list_attachments(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ # Create test patient
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ # Create test attachments
+ attachment1 = Attachment(
+ file_name="test1.pdf",
+ mime_type="application/pdf",
+ patient_id=patient.id
+ )
+ attachment2 = Attachment(
+ file_name="test2.pdf",
+ mime_type="application/pdf",
+ patient_id=patient.id
+ )
+ db.add(attachment1)
+ db.add(attachment2)
+ db.commit()
+
+ response = client.get(
+ "/api/v1/attachments/",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] >= 2
+ assert len(content["data"]) >= 2
+
+
+def test_read_attachment_content(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session,
+) -> None:
+ # Create test patient
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ # Create test attachment
+ attachment = Attachment(
+ file_name="test.pdf",
+ mime_type="application/pdf",
+ storage_path="test/path/test.pdf",
+ patient_id=patient.id
+ )
+ db.add(attachment)
+ db.commit()
+ db.refresh(attachment)
+
+ # Mock the S3 presigned URL generation
+ mock_url = "https://example.com/presigned-url"
+ with patch("app.api.deps.s3_client.generate_presigned_url", return_value=mock_url):
+ response = client.get(
+ f"/api/v1/attachments/{attachment.id}/content",
+ headers=superuser_token_headers,
+ follow_redirects=False
+ )
+
+ assert response.status_code == 302 # Redirect status code
+ assert response.headers["location"] == mock_url
+
+def test_attachment_not_found(
+ client: TestClient, superuser_token_headers: Dict[str, str]
+) -> None:
+ response = client.get(
+ f"/api/v1/attachments/{uuid.uuid4()}",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 404
diff --git a/backend/app/tests/api/routes/test_patients.py b/backend/app/tests/api/routes/test_patients.py
new file mode 100644
index 0000000000..f8a3e2fbe9
--- /dev/null
+++ b/backend/app/tests/api/routes/test_patients.py
@@ -0,0 +1,289 @@
+from datetime import datetime
+import uuid
+from typing import Dict
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.models.patients import Patient
+
+def test_create_patient(
+ client: TestClient, superuser_token_headers: Dict[str, str]
+) -> None:
+ data = {
+ "name": "Test Patient",
+ "dob": "2000-01-01T00:00:00",
+ "contact_info": "test@example.com",
+ "medical_history": "No significant history"
+ }
+ response = client.post(
+ "/api/v1/patients/",
+ headers=superuser_token_headers,
+ json=data,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["name"] == data["name"]
+ assert content["contact_info"] == data["contact_info"]
+ assert "id" in content
+
+def test_read_patient(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com",
+ medical_history="No significant history"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ response = client.get(
+ f"/api/v1/patients/{patient.id}",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["name"] == patient.name
+ assert content["id"] == str(patient.id)
+
+def test_read_patients(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ patient1 = Patient(
+ name="Test Patient 1",
+ dob=datetime(2000, 1, 1),
+ contact_info="test1@example.com"
+ )
+ patient2 = Patient(
+ name="Test Patient 2",
+ dob=datetime(2000, 1, 2),
+ contact_info="test2@example.com"
+ )
+ db.add(patient1)
+ db.add(patient2)
+ db.commit()
+
+ response = client.get(
+ "/api/v1/patients/",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] >= 2
+ assert len(content["data"]) >= 2
+
+def test_read_patients_history_search(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ # Create test patients with different medical histories
+ patient1 = Patient(
+ name="Test Patient 1",
+ dob=datetime(2000, 1, 1),
+ contact_info="test1@example.com",
+ medical_history="Patient has a fulltext history of asthma"
+ )
+ patient2 = Patient(
+ name="Test Patient 2",
+ dob=datetime(2000, 1, 2),
+ contact_info="test2@example.com",
+ medical_history="Patient has a fulltext history of diabetes"
+ )
+ patient3 = Patient(
+ name="Test Patient 3",
+ dob=datetime(2000, 1, 3),
+ contact_info="test3@example.com",
+ medical_history="No significant fulltext medical history"
+ )
+ db.add(patient1)
+ db.add(patient2)
+ db.add(patient3)
+ db.commit()
+
+ # Test searching for patients with asthma
+ response = client.get(
+ "/api/v1/patients/?history_text=asthma",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] == 1
+ assert content["data"][0]["medical_history"] == patient1.medical_history
+
+ # Test searching for patients with any history
+ response = client.get(
+ "/api/v1/patients/?history_text=fulltext",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] == 3
+
+def test_read_patients_name_exact(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ # Create test patients with different names
+ patient1 = Patient(
+ name="Bob Smith",
+ dob=datetime(2000, 1, 1),
+ contact_info="bob@example.com"
+ )
+ patient2 = Patient(
+ name="Bob Doe",
+ dob=datetime(2000, 1, 2),
+ contact_info="doe@example.com"
+ )
+ db.add(patient1)
+ db.add(patient2)
+ db.commit()
+
+ # Test exact name match
+ response = client.get(
+ "/api/v1/patients/?name_exact=Bob Smith",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] == 1
+ assert content["data"][0]["name"] == "Bob Smith"
+
+def test_read_patients_name_text(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ # Create test patients with different names
+ patient1 = Patient(
+ name="John Smith",
+ dob=datetime(2000, 1, 1),
+ contact_info="john@example.com"
+ )
+ patient2 = Patient(
+ name="John Doe",
+ dob=datetime(2000, 1, 2),
+ contact_info="doe@example.com"
+ )
+ patient3 = Patient(
+ name="Jane Wilson",
+ dob=datetime(2000, 1, 3),
+ contact_info="jane@example.com"
+ )
+ db.add(patient1)
+ db.add(patient2)
+ db.add(patient3)
+ db.commit()
+
+ # Test full text search on name
+ response = client.get(
+ "/api/v1/patients/?name_text=John",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] == 2
+ assert all("John" in patient["name"] for patient in content["data"])
+
+def test_read_patients_attachment_filter(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ from app.models.attachments import Attachment
+
+ # Create test patients with different attachments
+ patient1 = Patient(
+ name="Test Patient 1",
+ dob=datetime(2000, 1, 1),
+ contact_info="test1@example.com"
+ )
+ patient2 = Patient(
+ name="Test Patient 2",
+ dob=datetime(2000, 1, 2),
+ contact_info="test2@example.com"
+ )
+ db.add(patient1)
+ db.add(patient2)
+ db.commit()
+ db.refresh(patient1)
+ db.refresh(patient2)
+
+ # Add different types of attachments
+ attachment1 = Attachment(
+ file_name="test1.bam",
+ mime_type="application/x-bam",
+ patient_id=patient1.id
+ )
+ attachment2 = Attachment(
+ file_name="test.cram",
+ mime_type="application/x-cram",
+ patient_id=patient2.id
+ )
+ db.add(attachment1)
+ db.add(attachment2)
+ db.commit()
+
+ # Test filtering by attachment MIME type
+ response = client.get(
+ "/api/v1/patients/?has_attachment_mime_type=application/x-bam",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["count"] == 1
+ assert content["data"][0]["id"] == str(patient1.id)
+
+def test_update_patient(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ data = {"name": "Updated Patient Name"}
+ response = client.put(
+ f"/api/v1/patients/{patient.id}",
+ headers=superuser_token_headers,
+ json=data,
+ )
+ assert response.status_code == 200
+ content = response.json()
+ assert content["name"] == data["name"]
+ assert content["id"] == str(patient.id)
+
+def test_delete_patient(
+ client: TestClient, superuser_token_headers: Dict[str, str], db: Session
+) -> None:
+ patient = Patient(
+ name="Test Patient",
+ dob=datetime(2000, 1, 1),
+ contact_info="test@example.com"
+ )
+ db.add(patient)
+ db.commit()
+ db.refresh(patient)
+
+ response = client.delete(
+ f"/api/v1/patients/{patient.id}",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 200
+
+ # Verify patient was deleted
+ response = client.get(
+ f"/api/v1/patients/{patient.id}",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 404
+
+def test_patient_not_found(
+ client: TestClient, superuser_token_headers: Dict[str, str]
+) -> None:
+ response = client.get(
+ f"/api/v1/patients/{uuid.uuid4()}",
+ headers=superuser_token_headers,
+ )
+ assert response.status_code == 404
diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py
index 90ab39a357..20d7591c7c 100644
--- a/backend/app/tests/conftest.py
+++ b/backend/app/tests/conftest.py
@@ -7,7 +7,7 @@
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
-from app.models import Item, User
+from app.models import Item, User, Patient, Attachment
from app.tests.utils.user import authentication_token_from_email
from app.tests.utils.utils import get_superuser_token_headers
@@ -21,6 +21,10 @@ def db() -> Generator[Session, None, None]:
session.execute(statement)
statement = delete(User)
session.execute(statement)
+ statement = delete(Patient)
+ session.execute(statement)
+ statement = delete(Attachment)
+ session.execute(statement)
session.commit()
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 1c77b83ded..5f3f32b621 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -21,6 +21,7 @@ dependencies = [
"pydantic-settings<3.0.0,>=2.2.1",
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
"pyjwt<3.0.0,>=2.8.0",
+ "boto3>=1.36.26",
]
[tool.uv]
diff --git a/backend/uv.lock b/backend/uv.lock
index cfc200d3c3..d1b4ac6913 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -1,4 +1,5 @@
version = 1
+revision = 1
requires-python = ">=3.10, <4.0"
resolution-markers = [
"python_full_version < '3.13'",
@@ -50,6 +51,7 @@ source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "bcrypt" },
+ { name = "boto3" },
{ name = "email-validator" },
{ name = "emails" },
{ name = "fastapi", extra = ["standard"] },
@@ -80,6 +82,7 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = ">=1.12.1,<2.0.0" },
{ name = "bcrypt", specifier = "==4.0.1" },
+ { name = "boto3", specifier = ">=1.36.26" },
{ name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" },
{ name = "emails", specifier = ">=0.6,<1.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" },
@@ -125,6 +128,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930 },
]
+[[package]]
+name = "boto3"
+version = "1.36.26"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/af/2082fde2cbd81f8b60fd46e3ac07a0f841abfdb9818b818d560e42b5c444/boto3-1.36.26.tar.gz", hash = "sha256:523b69457eee55ac15aa707c0e768b2a45ca1521f95b2442931090633ec72458", size = 111027 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/a7/9081049e432f5130c6bd4d86f4db7a7729f812ebceda59baff69a06b19a5/boto3-1.36.26-py3-none-any.whl", hash = "sha256:f67d014a7c5a3cd540606d64d7cb9eec3600cf42acab1ac0518df9751ae115e2", size = 139178 },
+]
+
+[[package]]
+name = "botocore"
+version = "1.36.26"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/db/caa8778cf98ecbe0ad0efd7fbf673e2d036373386582e15dffff80bf16e1/botocore-1.36.26.tar.gz", hash = "sha256:4a63bcef7ecf6146fd3a61dc4f9b33b7473b49bdaf1770e9aaca6eee0c9eab62", size = 13574958 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/0c/a3eeca35b22ac8f441d412881582a5f3b8665de0269baf9fdeb8e86d7f1c/botocore-1.36.26-py3-none-any.whl", hash = "sha256:4e3f19913887a58502e71ef8d696fe7eaa54de7813ff73390cd5883f837dfa6e", size = 13360675 },
+]
+
[[package]]
name = "cachetools"
version = "5.5.0"
@@ -220,7 +251,7 @@ name = "click"
version = "8.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "platform_system == 'Windows'" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
wheels = [
@@ -581,6 +612,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
]
+[[package]]
+name = "jmespath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
+]
+
[[package]]
name = "lxml"
version = "5.3.0"
@@ -1188,6 +1228,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/a8/4abb5a9f58f51e4b1ea386be5ab2e547035bc1ee57200d1eca2f8909a33e/ruff-0.6.7-py3-none-win_arm64.whl", hash = "sha256:b28f0d5e2f771c1fe3c7a45d3f53916fc74a480698c4b5731f0bea61e52137c8", size = 8618044 },
]
+[[package]]
+name = "s3transfer"
+version = "0.11.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/45/2323b5928f86fd29f9afdcef4659f68fa73eaa5356912b774227f5cf46b5/s3transfer-0.11.2.tar.gz", hash = "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", size = 147885 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/ac/e7dc469e49048dc57f62e0c555d2ee3117fa30813d2a1a2962cce3a2a82a/s3transfer-0.11.2-py3-none-any.whl", hash = "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc", size = 84151 },
+]
+
[[package]]
name = "sentry-sdk"
version = "1.45.1"
diff --git a/docker-compose.yml b/docker-compose.yml
index c92d5d4451..295fddd1f8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -107,6 +107,10 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID?Variable not set}
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY?Variable not set}
+ - AWS_REGION=${AWS_REGION?Variable not set}
+ - AWS_S3_ATTACHMENTS_BUCKET=${AWS_S3_ATTACHMENTS_BUCKET?Variable not set}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]