Skip to content

Commit 0fb34c7

Browse files
Merge pull request #20 from monicasmith463/backend-api
add document endpoint and related code to the API
2 parents 632f1ac + cdc66ec commit 0fb34c7

File tree

22 files changed

+1871
-13
lines changed

22 files changed

+1871
-13
lines changed

.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,8 @@ SENTRY_DSN=
4343
# Configure these with your own Docker registry images
4444
DOCKER_IMAGE_BACKEND=backend
4545
DOCKER_IMAGE_FRONTEND=frontend
46+
47+
AWS_ACCESS_KEY_ID=yourkey
48+
AWS_SECRET_ACCESS_KEY=yoursecret
49+
AWS_REGION=us-east-1
50+
S3_BUCKET=your-bucket-name

.github/workflows/playwright.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ on:
1818
jobs:
1919
changes:
2020
runs-on: ubuntu-latest
21+
env:
22+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
23+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
24+
AWS_REGION: ${{ secrets.AWS_REGION }}
25+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
26+
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
2127
# Set job outputs to values from filter step
2228
outputs:
2329
changed: ${{ steps.filter.outputs.changed }}
@@ -41,6 +47,12 @@ jobs:
4147
if: ${{ needs.changes.outputs.changed == 'true' }}
4248
timeout-minutes: 60
4349
runs-on: ubuntu-latest
50+
env:
51+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
52+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
53+
AWS_REGION: ${{ secrets.AWS_REGION }}
54+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
55+
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
4456
strategy:
4557
matrix:
4658
shardIndex: [1, 2, 3, 4]
@@ -92,6 +104,12 @@ jobs:
92104
# Merge reports after playwright-tests, even if some shards have failed
93105
if: ${{ !cancelled() && needs.changes.outputs.changed == 'true' }}
94106
runs-on: ubuntu-latest
107+
env:
108+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
109+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
110+
AWS_REGION: ${{ secrets.AWS_REGION }}
111+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
112+
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
95113
steps:
96114
- uses: actions/checkout@v5
97115
- uses: actions/setup-node@v5
@@ -123,6 +141,12 @@ jobs:
123141
needs:
124142
- test-playwright
125143
runs-on: ubuntu-latest
144+
env:
145+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
146+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
147+
AWS_REGION: ${{ secrets.AWS_REGION }}
148+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
149+
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
126150
steps:
127151
- name: Decide whether the needed jobs succeeded or failed
128152
uses: re-actors/alls-green@release/v1

.github/workflows/test-backend.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ on:
1212
jobs:
1313
test-backend:
1414
runs-on: ubuntu-latest
15+
env:
16+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
17+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
18+
AWS_REGION: ${{ secrets.AWS_REGION }}
19+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
20+
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
1521
steps:
1622
- name: Checkout
1723
uses: actions/checkout@v5
1824
- name: Set up Python
19-
uses: actions/setup-python@v6
25+
uses: actions/setup-python@v5
2026
with:
2127
python-version: "3.10"
2228
- name: Install uv

.github/workflows/test-docker-compose.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,37 @@ on:
1010
- synchronize
1111

1212
jobs:
13-
1413
test-docker-compose:
1514
runs-on: ubuntu-latest
15+
env:
16+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
17+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
18+
AWS_REGION: ${{ secrets.AWS_REGION }}
19+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
20+
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
21+
1622
steps:
1723
- name: Checkout
1824
uses: actions/checkout@v5
25+
- name: Create .env for Docker Compose
26+
run: |
27+
echo "POSTGRES_USER=$POSTGRES_USER" >> backend/.env
28+
echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> backend/.env
29+
echo "POSTGRES_DB=$POSTGRES_DB" >> backend/.env
30+
echo "POSTGRES_PORT=$POSTGRES_PORT" >> backend/.env
31+
echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> backend/.env
32+
echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> backend/.env
33+
echo "AWS_REGION=$AWS_REGION" >> backend/.env
34+
echo "S3_BUCKET=$S3_BUCKET" >> backend/.env
35+
echo "STACK_NAME=study-assistant" >> backend/.env
36+
1937
- run: docker compose build
2038
- run: docker compose down -v --remove-orphans
21-
- run: docker compose up -d --wait backend frontend adminer
39+
- run: docker compose up -d --wait backend adminer frontend
40+
2241
- name: Test backend is up
2342
run: curl http://localhost:8000/api/v1/utils/health-check
43+
2444
- name: Test frontend is up
2545
run: curl http://localhost:5173
2646
- run: docker compose down -v --remove-orphans
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Add Document model to the DB
2+
3+
Revision ID: db14556d2858
4+
Revises: 1a31ce608336
5+
Create Date: 2025-10-07 13:48:49.351591
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'db14556d2858'
15+
down_revision = '1a31ce608336'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.create_table('document',
23+
sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
24+
sa.Column('s3_url', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
25+
sa.Column('s3_key', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=True),
26+
sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
27+
sa.Column('size', sa.Integer(), nullable=True),
28+
sa.Column('id', sa.Uuid(), nullable=False),
29+
sa.Column('owner_id', sa.Uuid(), nullable=False),
30+
sa.Column('extracted_text', sa.Text(), nullable=True),
31+
sa.Column('chunk_count', sa.Integer(), nullable=False),
32+
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
33+
sa.PrimaryKeyConstraint('id')
34+
)
35+
op.create_table('documentchunk',
36+
sa.Column('text', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
37+
sa.Column('id', sa.Uuid(), nullable=False),
38+
sa.Column('document_id', sa.Uuid(), nullable=False),
39+
sa.Column('size', sa.Integer(), nullable=False),
40+
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
41+
sa.ForeignKeyConstraint(['document_id'], ['document.id'], ondelete='CASCADE'),
42+
sa.PrimaryKeyConstraint('id')
43+
)
44+
# ### end Alembic commands ###
45+
46+
47+
def downgrade():
48+
# ### commands auto generated by Alembic - please adjust! ###
49+
op.drop_table('documentchunk')
50+
op.drop_table('document')
51+
# ### end Alembic commands ###

backend/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import items, login, private, users, utils
3+
from app.api.routes import documents, items, login, private, users, utils
44
from app.core.config import settings
55

66
api_router = APIRouter()
7+
api_router.include_router(documents.router)
78
api_router.include_router(login.router)
89
api_router.include_router(users.router)
910
api_router.include_router(utils.router)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import uuid
2+
from typing import Any
3+
4+
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
5+
from sqlmodel import func, select
6+
7+
from app.api.deps import CurrentUser, SessionDep
8+
from app.core.extractors import extract_text_and_save_to_db
9+
from app.core.s3 import generate_s3_url, upload_file_to_s3
10+
from app.models import (
11+
Document,
12+
DocumentCreate,
13+
DocumentPublic,
14+
DocumentsPublic,
15+
DocumentUpdate,
16+
Message,
17+
)
18+
19+
router = APIRouter(prefix="/documents", tags=["documents"])
20+
21+
22+
@router.post("/", response_model=DocumentPublic)
23+
def create_document(
24+
*,
25+
session: SessionDep,
26+
current_user: CurrentUser,
27+
background_tasks: BackgroundTasks, # noqa: ARG001
28+
file: UploadFile = File(...),
29+
) -> Any:
30+
key = None
31+
try:
32+
key = upload_file_to_s3(file, str(current_user.id))
33+
except Exception as e:
34+
raise HTTPException(500, f"Failed to upload file. Error: {str(e)}")
35+
36+
try:
37+
url = generate_s3_url(key)
38+
except Exception:
39+
raise HTTPException(500, f"Could not generate URL for file key: {key}")
40+
41+
document_in = DocumentCreate(
42+
filename=file.filename,
43+
content_type=file.content_type,
44+
size=file.size,
45+
s3_url=url,
46+
s3_key=key,
47+
)
48+
49+
document = Document.model_validate(
50+
document_in, update={"owner_id": current_user.id}
51+
)
52+
53+
session.add(document)
54+
session.commit()
55+
session.refresh(document)
56+
57+
# 3. Kick off background job
58+
background_tasks.add_task(extract_text_and_save_to_db, key, str(document.id))
59+
return document
60+
61+
62+
@router.get("/{id}", response_model=DocumentPublic)
63+
def read_document(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
64+
"""
65+
Get document by ID.
66+
"""
67+
document = session.get(Document, id)
68+
if not document:
69+
raise HTTPException(status_code=404, detail="Document not found")
70+
if not current_user.is_superuser and (document.owner_id != current_user.id):
71+
raise HTTPException(status_code=400, detail="Not enough permissions")
72+
return document
73+
74+
75+
@router.get("/", response_model=DocumentsPublic)
76+
def read_documents(
77+
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
78+
) -> Any:
79+
"""
80+
Retrieve documents.
81+
"""
82+
83+
if current_user.is_superuser:
84+
count_statement = select(func.count()).select_from(Document)
85+
count = session.exec(count_statement).one()
86+
statement = select(Document).offset(skip).limit(limit)
87+
documents = session.exec(statement).all()
88+
else:
89+
count_statement = (
90+
select(func.count())
91+
.select_from(Document)
92+
.where(Document.owner_id == current_user.id)
93+
)
94+
count = session.exec(count_statement).one()
95+
statement = (
96+
select(Document)
97+
.where(Document.owner_id == current_user.id)
98+
.offset(skip)
99+
.limit(limit)
100+
)
101+
documents = session.exec(statement).all()
102+
103+
return DocumentsPublic(data=documents, count=count)
104+
105+
106+
@router.put("/{id}", response_model=DocumentPublic)
107+
def update_document(
108+
*,
109+
session: SessionDep,
110+
current_user: CurrentUser,
111+
id: uuid.UUID,
112+
document_in: DocumentUpdate,
113+
) -> Any:
114+
"""
115+
Update an document.
116+
"""
117+
document = session.get(Document, id)
118+
if not document:
119+
raise HTTPException(status_code=404, detail="Document not found")
120+
if not current_user.is_superuser and (document.owner_id != current_user.id):
121+
raise HTTPException(status_code=400, detail="Not enough permissions")
122+
update_dict = document_in.model_dump(exclude_unset=True)
123+
document.sqlmodel_update(update_dict)
124+
session.add(document)
125+
session.commit()
126+
session.refresh(document)
127+
return document
128+
129+
130+
@router.delete("/{id}")
131+
def delete_document(
132+
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
133+
) -> Message:
134+
"""
135+
Delete an document.
136+
"""
137+
document = session.get(Document, id)
138+
if not document:
139+
raise HTTPException(status_code=404, detail="Document not found")
140+
if not current_user.is_superuser and (document.owner_id != current_user.id):
141+
raise HTTPException(status_code=400, detail="Not enough permissions")
142+
session.delete(document)
143+
session.commit()
144+
return Message(message="Document deleted successfully")

backend/app/core/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ def all_cors_origins(self) -> list[str]:
5555
POSTGRES_USER: str
5656
POSTGRES_PASSWORD: str = ""
5757
POSTGRES_DB: str = ""
58+
AWS_ACCESS_KEY_ID: str = ""
59+
AWS_SECRET_ACCESS_KEY: str = ""
60+
AWS_REGION: str = "us-east-1"
61+
S3_BUCKET: str = "test-bucket"
62+
63+
OPENAI_API_KEY: str = ""
5864

5965
@computed_field # type: ignore[prop-decorator]
6066
@property

0 commit comments

Comments
 (0)