Skip to content

Commit 5db1d18

Browse files
authored
Merge pull request #40 from CS3219-AY2526Sem1/question-service-init
Question service init
2 parents 2b3c42f + f2dd445 commit 5db1d18

19 files changed

+1143
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
**/__pycache__/
2+
.vscode/
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# dotenv files
2+
.env
3+
.env.local
4+
5+
# Mac
6+
.DS_Store
7+
8+
# editor and IDE files
9+
.vscode/
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# ======================
2+
# 1. Base Image
3+
# ======================
4+
FROM python:3.12-slim AS base
5+
6+
# Set working directory
7+
WORKDIR /app
8+
9+
# Prevent Python from writing .pyc files and enable buffered output
10+
ENV PYTHONDONTWRITEBYTECODE=1 \
11+
PYTHONUNBUFFERED=1
12+
13+
# Install system dependencies (needed for some Python packages)
14+
RUN apt-get update && apt-get install -y --no-install-recommends \
15+
build-essential gcc curl \
16+
&& rm -rf /var/lib/apt/lists/*
17+
18+
# ======================
19+
# 2. Dependencies Stage
20+
# ======================
21+
FROM base AS deps
22+
23+
# Install Python dependencies separately to leverage Docker cache
24+
COPY requirements.txt .
25+
RUN pip install --no-cache-dir --upgrade pip \
26+
&& pip install --no-cache-dir -r requirements.txt
27+
28+
# ======================
29+
# 3. Runtime Stage
30+
# ======================
31+
FROM base AS runtime
32+
33+
# Create a non-root user
34+
RUN useradd -m fastapiuser
35+
36+
# Copy installed deps from deps stage
37+
COPY --from=deps /usr/local/lib/python3.12 /usr/local/lib/python3.12
38+
COPY --from=deps /usr/local/bin /usr/local/bin
39+
40+
# Copy application code
41+
COPY . .
42+
43+
# Set ownership
44+
RUN chown -R fastapiuser:fastapiuser /app
45+
USER fastapiuser
46+
47+
# Expose FastAPI default port
48+
EXPOSE 8000
49+
50+
# Run the application with uvicorn (note: app.main:app instead of main:app)
51+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# FastAPI Question Service Application
2+
from .main import app
3+
4+
__version__ = "1.0.0"
5+
__all__ = ["app"]
File renamed without changes.
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import uuid
2+
from typing import List, Dict, Optional
3+
from app.core.utils import get_conn, upload_to_s3, get_from_s3, delete_from_s3
4+
from app.models.exceptions import QuestionNotFoundException
5+
6+
def list_difficulties_and_topics() -> Dict[str, List[str]]:
7+
"""
8+
Retrieves all available difficulty levels and topics from the database.
9+
10+
Returns:
11+
dict: A dictionary containing:
12+
- difficulties (list[str]): List of all available difficulty levels
13+
- topics (list[str]): List of all available topics
14+
"""
15+
with get_conn() as conn, conn.cursor() as cur:
16+
cur.execute("SELECT * FROM difficulties")
17+
difficulties = [row[0] for row in cur.fetchall()]
18+
19+
cur.execute("SELECT * FROM topics")
20+
topics = [row[0] for row in cur.fetchall()]
21+
22+
return {"difficulties": difficulties, "topics": topics}
23+
24+
def add_difficulty(difficulty: str):
25+
with get_conn() as conn, conn.cursor() as cur:
26+
cur.execute("INSERT INTO difficulties (name) VALUES (%s) ON CONFLICT DO NOTHING", (difficulty,))
27+
28+
def delete_difficulty(difficulty: str):
29+
with get_conn() as conn, conn.cursor() as cur:
30+
cur.execute("DELETE FROM difficulties WHERE level = %s", (difficulty,))
31+
32+
def add_topic(topic: str):
33+
with get_conn() as conn, conn.cursor() as cur:
34+
cur.execute("INSERT INTO topics (name) VALUES (%s) ON CONFLICT DO NOTHING", (topic,))
35+
36+
def delete_topic(topic: str):
37+
with get_conn() as conn, conn.cursor() as cur:
38+
cur.execute("DELETE FROM topics WHERE name = %s", (topic,))
39+
40+
def create_question(
41+
name: str,
42+
description: str,
43+
difficulty: str,
44+
topic: str,
45+
images: Optional[List[bytes]] = None,
46+
qid: Optional[str] = None
47+
) -> str:
48+
"""
49+
Creates a new question with optional images.
50+
51+
Args:
52+
name: Question name
53+
description: Question description
54+
difficulty: Difficulty level
55+
topic: Question topic
56+
images: Optional list of image files as bytes to upload to S3
57+
qid: Optional question ID. If None, a new UUID will be generated
58+
59+
Returns:
60+
str: The question ID (UUID) of the created question
61+
62+
Raises:
63+
Exception: If database insertion fails, uploaded images are cleaned up from S3
64+
"""
65+
qid = str(uuid.uuid4()) if qid is None else qid
66+
67+
# Prepare images for S3 upload
68+
files_and_keys = []
69+
if images:
70+
files_and_keys = [(img, f"questions/{qid}/{i}") for i, img in enumerate(images)]
71+
72+
# Upload images to S3
73+
if files_and_keys:
74+
upload_to_s3(files_and_keys)
75+
76+
try:
77+
with get_conn() as conn:
78+
with conn.cursor() as cur:
79+
# Insert question into database
80+
cur.execute(
81+
"""
82+
INSERT INTO questions (id, name, description, difficulty, topic)
83+
VALUES (%s, %s, %s, %s, %s)
84+
""",
85+
(qid, name, description, difficulty, topic),
86+
)
87+
88+
# Insert images metadata into database
89+
for _, key in files_and_keys:
90+
image_id = str(uuid.uuid4())
91+
cur.execute(
92+
"INSERT INTO question_images (id, question_id, s3_key) VALUES (%s, %s, %s)",
93+
(image_id, qid, key),
94+
)
95+
return qid
96+
except Exception:
97+
# If any error occurs, delete the uploaded images from S3
98+
delete_from_s3([key for _, key in files_and_keys])
99+
raise
100+
101+
102+
def get_question(qid: str):
103+
"""
104+
Retrieves a single question and its associated images by its ID.
105+
106+
Args:
107+
qid (str): The unique question identifier (UUID)
108+
109+
Returns:
110+
dict: Question data including id, name, description, difficulty,
111+
topic, and images (as bytes)
112+
113+
Raises:
114+
QuestionNotFoundException: If no question exists with the given ID
115+
116+
Note:
117+
Images are returned as raw bytes data retrieved directly from S3.
118+
"""
119+
with get_conn() as conn, conn.cursor() as cur:
120+
# Fetch the main question data
121+
cur.execute(
122+
"SELECT name, description, difficulty, topic FROM questions WHERE id = %s",
123+
(qid,),
124+
)
125+
question_data = cur.fetchone()
126+
127+
if not question_data:
128+
raise QuestionNotFoundException(qid)
129+
130+
# Fetch associated image keys
131+
cur.execute(
132+
"SELECT s3_key FROM question_images WHERE question_id = %s",
133+
(qid,)
134+
)
135+
image_keys = [row[0] for row in cur.fetchall()]
136+
image_data = get_from_s3(image_keys)
137+
138+
return {
139+
"id": qid,
140+
"name": question_data[0],
141+
"description": question_data[1],
142+
"difficulty": question_data[2],
143+
"topic": question_data[3],
144+
"images": image_data, # Raw bytes
145+
}
146+
147+
148+
def get_random_question_by_difficulty_and_topic(difficulty: str, topic: str):
149+
"""
150+
Returns a random question matching the given difficulty and topic, including image data.
151+
152+
Args:
153+
difficulty (str): The difficulty level to filter by (must exist in difficulties table)
154+
topic (str): The topic to filter by (must exist in topics table)
155+
156+
Returns:
157+
dict: Question data including id, name, description, difficulty,
158+
topic, and images (as bytes)
159+
160+
Raises:
161+
QuestionNotFoundException: If no question matches the given difficulty and topic
162+
163+
Note:
164+
Images are returned as raw bytes data retrieved directly from S3.
165+
Uses database RANDOM() function for selection.
166+
"""
167+
with get_conn() as conn, conn.cursor() as cur:
168+
cur.execute(
169+
"""
170+
SELECT id, name, description, difficulty, topic
171+
FROM questions
172+
WHERE difficulty = %s AND topic = %s
173+
ORDER BY RANDOM()
174+
LIMIT 1
175+
""",
176+
(difficulty, topic),
177+
)
178+
row = cur.fetchone()
179+
if not row:
180+
raise QuestionNotFoundException(topic=topic, difficulty=difficulty)
181+
182+
qid = row[0]
183+
# Fetch associated image keys
184+
cur.execute(
185+
"SELECT s3_key FROM question_images WHERE question_id = %s",
186+
(qid,),
187+
)
188+
image_keys = [r[0] for r in cur.fetchall()]
189+
image_data = get_from_s3(image_keys)
190+
191+
return {
192+
"id": qid,
193+
"name": row[1],
194+
"description": row[2],
195+
"difficulty": row[3],
196+
"topic": row[4],
197+
"images": image_data, # Raw bytes
198+
}
199+
200+
201+
def override_question(
202+
qid: str,
203+
name: str,
204+
description: str,
205+
difficulty: str,
206+
topic: str,
207+
images: Optional[List[bytes]] = None
208+
) -> str:
209+
"""
210+
Atomically replaces an existing question with new data and images.
211+
212+
This function performs a complete replacement of a question by:
213+
1. Backing up the original question data and images
214+
2. Deleting the old question and its images
215+
3. Creating a new question with the same ID and new data
216+
4. Rolling back changes if any error occurs during the process
217+
218+
Args:
219+
qid: The unique question identifier (UUID) to override
220+
name: New question name
221+
description: New question description
222+
difficulty: New difficulty level
223+
topic: New topic
224+
images: Optional list of new image files as bytes to upload to S3
225+
226+
Returns:
227+
str: The question ID (same as input qid)
228+
229+
Raises:
230+
QuestionNotFoundException: If no question exists with the given ID
231+
Exception: If any step fails, all changes are rolled back atomically
232+
233+
Note:
234+
This operation is fully atomic - if any error occurs during the process,
235+
the original question and its images are restored to their previous state.
236+
"""
237+
# First, get the original question data for backup purposes
238+
backup_data = get_question(qid) # This will raise QuestionNotFoundException if not found
239+
240+
# Delete the old question (this is atomic and handles S3 cleanup)
241+
delete_question(qid)
242+
243+
try:
244+
# Create the new question with the same ID (this is atomic and handles S3 upload)
245+
create_question(name, description, difficulty, topic, images, qid)
246+
247+
except Exception:
248+
# If creation failed after deletion, restore the original question
249+
try:
250+
create_question(
251+
backup_data["name"],
252+
backup_data["description"],
253+
backup_data["difficulty"],
254+
backup_data["topic"],
255+
backup_data["images"], # These are already bytes
256+
qid
257+
)
258+
except Exception:
259+
# If restoration also fails, we can't do much more
260+
# The original error is more important to report
261+
pass
262+
raise
263+
264+
return qid
265+
266+
267+
def delete_question(qid: str):
268+
"""
269+
Deletes a question and its associated images by its ID.
270+
271+
Args:
272+
qid (str): The unique question identifier (UUID) to delete
273+
274+
Returns:
275+
None
276+
277+
Raises:
278+
QuestionNotFoundException: If no question exists with the given ID
279+
Exception: If S3 deletion fails, database transaction is rolled back Note:
280+
This function is atomic: if deleting images from S3 fails,
281+
the database transaction is rolled back to maintain consistency.
282+
"""
283+
with get_conn() as conn, conn.cursor() as cur:
284+
# Fetch associated image keys
285+
cur.execute(
286+
"SELECT s3_key FROM question_images WHERE question_id = %s",
287+
(qid,)
288+
)
289+
image_keys = [row[0] for row in cur.fetchall()]
290+
291+
# Delete the question and verify it existed
292+
cur.execute("DELETE FROM questions WHERE id = %s RETURNING id", (qid,))
293+
deleted = cur.fetchone()
294+
if not deleted:
295+
raise QuestionNotFoundException(qid)
296+
297+
# Delete images from S3; rollback DB if this fails
298+
try:
299+
delete_from_s3(image_keys)
300+
except Exception:
301+
conn.rollback()
302+
raise
303+

0 commit comments

Comments
 (0)