Skip to content

Commit 53864c9

Browse files
Merge pull request #21 from monicasmith463/backend-api-2
api part 2: port over remaining endpoints for exam, exam attempts
2 parents 0fb34c7 + 0939264 commit 53864c9

25 files changed

+2622
-165
lines changed

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ AWS_ACCESS_KEY_ID=yourkey
4848
AWS_SECRET_ACCESS_KEY=yoursecret
4949
AWS_REGION=us-east-1
5050
S3_BUCKET=your-bucket-name
51+
52+
OPENAI_API_KEY=yourkey

.github/workflows/playwright.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
2424
AWS_REGION: ${{ secrets.AWS_REGION }}
2525
S3_BUCKET: ${{ secrets.S3_BUCKET }}
26-
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
26+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
2727
# Set job outputs to values from filter step
2828
outputs:
2929
changed: ${{ steps.filter.outputs.changed }}
@@ -52,7 +52,7 @@ jobs:
5252
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
5353
AWS_REGION: ${{ secrets.AWS_REGION }}
5454
S3_BUCKET: ${{ secrets.S3_BUCKET }}
55-
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
55+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
5656
strategy:
5757
matrix:
5858
shardIndex: [1, 2, 3, 4]
@@ -109,7 +109,7 @@ jobs:
109109
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
110110
AWS_REGION: ${{ secrets.AWS_REGION }}
111111
S3_BUCKET: ${{ secrets.S3_BUCKET }}
112-
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
112+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
113113
steps:
114114
- uses: actions/checkout@v5
115115
- uses: actions/setup-node@v5
@@ -146,7 +146,7 @@ jobs:
146146
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
147147
AWS_REGION: ${{ secrets.AWS_REGION }}
148148
S3_BUCKET: ${{ secrets.S3_BUCKET }}
149-
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
149+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
150150
steps:
151151
- name: Decide whether the needed jobs succeeded or failed
152152
uses: re-actors/alls-green@release/v1

.github/workflows/test-backend.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1818
AWS_REGION: ${{ secrets.AWS_REGION }}
1919
S3_BUCKET: ${{ secrets.S3_BUCKET }}
20-
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
20+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
2121
steps:
2222
- name: Checkout
2323
uses: actions/checkout@v5

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1818
AWS_REGION: ${{ secrets.AWS_REGION }}
1919
S3_BUCKET: ${{ secrets.S3_BUCKET }}
20-
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
20+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
2121

2222
steps:
2323
- name: Checkout
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Add Question, Answer, Exam, ExamAttempt to the db
2+
3+
Revision ID: d29f99ac75e5
4+
Revises: db14556d2858
5+
Create Date: 2025-10-08 11:21:23.657380
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 = 'd29f99ac75e5'
15+
down_revision = 'db14556d2858'
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('exam',
23+
sa.Column('title', sa.Text(), nullable=False),
24+
sa.Column('description', sa.Text(), nullable=True),
25+
sa.Column('duration_minutes', sa.Integer(), nullable=True),
26+
sa.Column('is_published', sa.Boolean(), nullable=False),
27+
sa.Column('id', sa.Uuid(), nullable=False),
28+
sa.Column('owner_id', sa.Uuid(), nullable=False),
29+
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
30+
sa.PrimaryKeyConstraint('id')
31+
)
32+
op.create_table('examattempt',
33+
sa.Column('score', sa.Float(), nullable=True),
34+
sa.Column('is_complete', sa.Boolean(), nullable=False),
35+
sa.Column('id', sa.Uuid(), nullable=False),
36+
sa.Column('exam_id', sa.Uuid(), nullable=False),
37+
sa.Column('owner_id', sa.Uuid(), nullable=False),
38+
sa.Column('completed_at', sa.DateTime(), nullable=True),
39+
sa.Column('created_at', sa.DateTime(), nullable=False),
40+
sa.Column('updated_at', sa.DateTime(), nullable=False),
41+
sa.ForeignKeyConstraint(['exam_id'], ['exam.id'], ondelete='CASCADE'),
42+
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
43+
sa.PrimaryKeyConstraint('id')
44+
)
45+
op.create_table('question',
46+
sa.Column('question', sa.Text(), nullable=False),
47+
sa.Column('answer', sa.Text(), nullable=True),
48+
sa.Column('id', sa.Uuid(), nullable=False),
49+
sa.Column('type', sa.Enum('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', name='questiontype'), nullable=False),
50+
sa.Column('options', sa.JSON(), nullable=True),
51+
sa.Column('exam_id', sa.Uuid(), nullable=False),
52+
sa.ForeignKeyConstraint(['exam_id'], ['exam.id'], ),
53+
sa.PrimaryKeyConstraint('id')
54+
)
55+
op.create_table('answer',
56+
sa.Column('response', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
57+
sa.Column('is_correct', sa.Boolean(), nullable=True),
58+
sa.Column('explanation', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
59+
sa.Column('id', sa.Uuid(), nullable=False),
60+
sa.Column('attempt_id', sa.Uuid(), nullable=False),
61+
sa.Column('question_id', sa.Uuid(), nullable=False),
62+
sa.Column('created_at', sa.DateTime(), nullable=False),
63+
sa.Column('updated_at', sa.DateTime(), nullable=False),
64+
sa.ForeignKeyConstraint(['attempt_id'], ['examattempt.id'], ),
65+
sa.ForeignKeyConstraint(['question_id'], ['question.id'], ),
66+
sa.PrimaryKeyConstraint('id')
67+
)
68+
# ### end Alembic commands ###
69+
70+
71+
def downgrade():
72+
# ### commands auto generated by Alembic - please adjust! ###
73+
op.drop_table('answer')
74+
op.drop_table('question')
75+
op.drop_table('examattempt')
76+
op.drop_table('exam')
77+
# ### end Alembic commands ###

backend/app/api/main.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import documents, items, login, private, users, utils
3+
from app.api.routes import (
4+
documents,
5+
exam_attempts,
6+
exams,
7+
items,
8+
login,
9+
private,
10+
users,
11+
utils,
12+
)
413
from app.core.config import settings
514

615
api_router = APIRouter()
@@ -9,6 +18,8 @@
918
api_router.include_router(users.router)
1019
api_router.include_router(utils.router)
1120
api_router.include_router(items.router)
21+
api_router.include_router(exams.router)
22+
api_router.include_router(exam_attempts.router)
1223

1324

1425
if settings.ENVIRONMENT == "local":
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import uuid
2+
from typing import Any
3+
4+
from fastapi import APIRouter, HTTPException
5+
6+
from app import crud
7+
from app.api.deps import CurrentUser, SessionDep
8+
from app.models import (
9+
Exam,
10+
ExamAttempt,
11+
ExamAttemptCreate,
12+
ExamAttemptPublic,
13+
ExamAttemptUpdate,
14+
)
15+
16+
router = APIRouter(prefix="/exam-attempts", tags=["exam-attempts"])
17+
18+
19+
def get_exam_by_id(session: SessionDep, exam_in: ExamAttemptCreate) -> Exam | None:
20+
exam = session.get(Exam, exam_in.exam_id)
21+
return exam
22+
23+
24+
@router.post("/", response_model=ExamAttemptPublic)
25+
def create_exam_attempt(
26+
session: SessionDep, current_user: CurrentUser, exam_in: ExamAttemptCreate
27+
) -> Any:
28+
"""
29+
Create a new exam attempt for a specific exam.
30+
"""
31+
exam = get_exam_by_id(session, exam_in)
32+
if not exam:
33+
raise HTTPException(status_code=404, detail="Exam not found")
34+
35+
if not current_user.is_superuser and exam.owner_id != current_user.id:
36+
raise HTTPException(status_code=403, detail="Not enough permissions")
37+
38+
exam_attempt = crud.create_exam_attempt(
39+
session=session, user_id=current_user.id, exam_in=exam_in
40+
)
41+
return exam_attempt
42+
43+
44+
@router.get("/{id}", response_model=ExamAttemptPublic)
45+
def read_exam_attempt(
46+
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
47+
) -> Any:
48+
"""
49+
Get ExamAttempt by ID.
50+
"""
51+
exam_attempt = session.get(ExamAttempt, id)
52+
if not exam_attempt:
53+
raise HTTPException(status_code=404, detail="Exam Attempt not found")
54+
if not current_user.is_superuser and (exam_attempt.owner_id != current_user.id):
55+
raise HTTPException(status_code=403, detail="Not enough permissions")
56+
57+
return exam_attempt
58+
59+
60+
@router.patch("/{attempt_id}", response_model=ExamAttemptPublic)
61+
def update_exam_attempt(
62+
*,
63+
attempt_id: uuid.UUID,
64+
session: SessionDep,
65+
exam_attempt_in: ExamAttemptUpdate,
66+
current_user: CurrentUser,
67+
) -> Any:
68+
"""
69+
Update an exam attempt with answers.
70+
If `is_complete=True`, compute the score.
71+
"""
72+
exam_attempt = session.get(ExamAttempt, attempt_id)
73+
if not exam_attempt:
74+
raise HTTPException(status_code=404, detail="Exam attempt not found")
75+
76+
if not current_user.is_superuser and exam_attempt.owner_id != current_user.id:
77+
raise HTTPException(status_code=403, detail="Not allowed")
78+
79+
if exam_attempt.is_complete:
80+
raise HTTPException(status_code=409, detail="Exam attempt is already completed")
81+
82+
exam_attempt = crud.update_exam_attempt(
83+
session=session, db_exam_attempt=exam_attempt, exam_attempt_in=exam_attempt_in
84+
)
85+
return exam_attempt

backend/app/api/routes/exams.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import uuid
2+
from typing import Any
3+
4+
from fastapi import APIRouter, HTTPException
5+
from sqlmodel import func, select
6+
7+
from app import crud
8+
from app.api.deps import CurrentUser, SessionDep
9+
from app.core.ai.openai import generate_questions_from_documents
10+
from app.models import (
11+
Exam,
12+
ExamCreate,
13+
ExamPublic,
14+
ExamsPublic,
15+
ExamUpdate,
16+
GenerateQuestionsRequest,
17+
Message,
18+
QuestionCreate,
19+
)
20+
21+
router = APIRouter(prefix="/exams", tags=["exams"])
22+
23+
24+
@router.post("/generate", response_model=ExamPublic)
25+
async def generate_exam(
26+
*,
27+
session: SessionDep,
28+
payload: GenerateQuestionsRequest,
29+
current_user: CurrentUser,
30+
) -> ExamPublic:
31+
# TODO: fix the hardcoding here
32+
exam_in = ExamCreate(
33+
title="Midterm Exam",
34+
description="generated exam",
35+
duration_minutes=30,
36+
is_published=False,
37+
)
38+
db_exam = crud.create_db_exam(
39+
session=session, exam_in=exam_in, owner_id=current_user.id
40+
)
41+
42+
# 2. Generate questions
43+
generated_questions: list[QuestionCreate] = await generate_questions_from_documents(
44+
session, payload.document_ids
45+
)
46+
47+
return crud.create_exam(
48+
session=session,
49+
db_exam=db_exam,
50+
questions=generated_questions,
51+
)
52+
53+
54+
@router.get("/{id}", response_model=ExamPublic)
55+
def read_exam(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
56+
"""
57+
Get exam by ID.
58+
"""
59+
exam = session.get(Exam, id)
60+
if not exam:
61+
raise HTTPException(status_code=404, detail="Exam not found")
62+
if not current_user.is_superuser and (exam.owner_id != current_user.id):
63+
raise HTTPException(status_code=400, detail="Not enough permissions")
64+
return exam
65+
66+
67+
@router.get("/", response_model=ExamsPublic)
68+
def read_exams(
69+
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
70+
) -> Any:
71+
"""
72+
Retrieve exams.
73+
"""
74+
75+
if current_user.is_superuser:
76+
count_statement = select(func.count()).select_from(Exam)
77+
count = session.exec(count_statement).one()
78+
statement = select(Exam).offset(skip).limit(limit)
79+
exams = session.exec(statement).all()
80+
else:
81+
count_statement = (
82+
select(func.count())
83+
.select_from(Exam)
84+
.where(Exam.owner_id == current_user.id)
85+
)
86+
count = session.exec(count_statement).one()
87+
statement = (
88+
select(Exam)
89+
.where(Exam.owner_id == current_user.id)
90+
.offset(skip)
91+
.limit(limit)
92+
)
93+
exams = session.exec(statement).all()
94+
95+
return ExamsPublic(data=exams, count=count)
96+
97+
98+
@router.put("/{id}", response_model=ExamPublic)
99+
def update_exam(
100+
*,
101+
session: SessionDep,
102+
current_user: CurrentUser,
103+
id: uuid.UUID,
104+
exam_in: ExamUpdate,
105+
) -> Any:
106+
"""
107+
Update an exam.
108+
"""
109+
exam = session.get(Exam, id)
110+
if not exam:
111+
raise HTTPException(status_code=404, detail="Exam not found")
112+
if not current_user.is_superuser and (exam.owner_id != current_user.id):
113+
raise HTTPException(status_code=403, detail="Not enough permissions")
114+
update_dict = exam_in.model_dump(exclude_unset=True)
115+
exam.sqlmodel_update(update_dict)
116+
session.add(exam)
117+
session.commit()
118+
session.refresh(exam)
119+
return exam
120+
121+
122+
@router.delete("/{id}")
123+
def delete_exam(
124+
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
125+
) -> Message:
126+
"""
127+
Delete an exam.
128+
"""
129+
exam = session.get(Exam, id)
130+
if not exam:
131+
raise HTTPException(status_code=404, detail="Exam not found")
132+
if not current_user.is_superuser and (exam.owner_id != current_user.id):
133+
raise HTTPException(status_code=403, detail="Not enough permissions")
134+
session.delete(exam)
135+
session.commit()
136+
return Message(message="Exam deleted successfully")

0 commit comments

Comments
 (0)