Skip to content

Commit d68c169

Browse files
committed
Add endpoint logic
1 parent ee33ff2 commit d68c169

File tree

6 files changed

+353
-74
lines changed

6 files changed

+353
-74
lines changed

services/question-service/app/crud.py

Lines changed: 177 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import uuid
2-
import os
3-
import boto3
4-
from typing import List
5-
from endpoint_models import createQuestion
6-
from utils import get_conn, upload_to_s3, delete_from_s3
2+
from typing import List, Dict, Optional
3+
from utils import get_conn, upload_to_s3, get_from_s3, delete_from_s3
4+
from models.exceptions import QuestionNotFoundException
75

8-
9-
def list_difficulties_and_topics():
10-
"""Returns all available difficulty levels and topics."""
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+
"""
1115
with get_conn() as conn:
1216
with conn.cursor() as cur:
13-
cur.execute("SELECT * FROM difficulty_levels")
17+
cur.execute("SELECT * FROM difficulties")
1418
difficulties = [row[0] for row in cur.fetchall()]
1519

1620
cur.execute("SELECT * FROM topics")
@@ -19,25 +23,52 @@ def list_difficulties_and_topics():
1923
return {"difficulties": difficulties, "topics": topics}
2024

2125

22-
def create_question(q: createQuestion, images: list[bytes] | None = None, qid: str = None):
23-
"""Creates a new question. Returns the question ID."""
26+
def create_question(
27+
name: str,
28+
description: str,
29+
difficulty: str,
30+
topic: str,
31+
images: Optional[List[bytes]] = None,
32+
qid: Optional[str] = None
33+
) -> str:
34+
"""
35+
Creates a new question with optional images.
36+
37+
Args:
38+
name: Question name
39+
description: Question description
40+
difficulty: Difficulty level
41+
topic: Question topic
42+
images: Optional list of image files as bytes to upload to S3
43+
qid: Optional question ID. If None, a new UUID will be generated
44+
45+
Returns:
46+
str: The question ID (UUID) of the created question
47+
48+
Raises:
49+
Exception: If database insertion fails, uploaded images are cleaned up from S3
50+
"""
2451
qid = str(uuid.uuid4()) if qid is None else qid
52+
53+
# Prepare images for S3 upload
54+
files_and_keys = []
55+
if images:
56+
files_and_keys = [(img, f"questions/{qid}/{i}") for i, img in enumerate(images)]
2557

26-
# Uplaods images to S3
27-
images = [] if images is None else images
28-
files_and_keys = [(img, f"questions/{qid}/{i}") for i, img in enumerate(images)]
29-
upload_to_s3(files_and_keys)
58+
# Upload images to S3
59+
if files_and_keys:
60+
upload_to_s3(files_and_keys)
3061

3162
try:
3263
with get_conn() as conn:
3364
with conn.cursor() as cur:
3465
# Insert question into database
3566
cur.execute(
3667
"""
37-
INSERT INTO questions (id, name, description, difficulty_level, topic)
68+
INSERT INTO questions (id, name, description, difficulty, topic)
3869
VALUES (%s, %s, %s, %s, %s)
3970
""",
40-
(qid, q.name, q.description, q.difficulty_level, q.topic),
71+
(qid, name, description, difficulty, topic),
4172
)
4273

4374
# Insert images metadata into database
@@ -54,79 +85,87 @@ def create_question(q: createQuestion, images: list[bytes] | None = None, qid: s
5485
raise
5586

5687

57-
def _generate_presigned_get_urls(keys: List[str], expires_in: int | None = None) -> List[str]:
58-
if not keys:
59-
return []
60-
s3 = boto3.client(
61-
"s3",
62-
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
63-
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
64-
region_name=os.getenv("AWS_REGION"),
65-
)
66-
bucket_name = os.getenv("S3_BUCKET_NAME")
67-
expiry = int(os.getenv("S3_PRESIGN_EXPIRES", "900")) if expires_in is None else expires_in
68-
urls = []
69-
for key in keys:
70-
urls.append(
71-
s3.generate_presigned_url(
72-
"get_object",
73-
Params={"Bucket": bucket_name, "Key": key},
74-
ExpiresIn=expiry,
75-
)
76-
)
77-
return urls
78-
79-
8088
def get_question(qid: str):
81-
"""Retrieves a single question and its associated images by its ID."""
89+
"""
90+
Retrieves a single question and its associated images by its ID.
91+
92+
Args:
93+
qid (str): The unique question identifier (UUID)
94+
95+
Returns:
96+
dict: Question data including id, name, description, difficulty,
97+
topic, and images (as bytes)
98+
99+
Raises:
100+
QuestionNotFoundException: If no question exists with the given ID
101+
102+
Note:
103+
Images are returned as raw bytes data retrieved directly from S3.
104+
"""
82105
with get_conn() as conn:
83106
with conn.cursor() as cur:
84107
# Fetch the main question data
85108
cur.execute(
86-
"SELECT name, description, difficulty_level, topic FROM questions WHERE id = %s",
109+
"SELECT name, description, difficulty, topic FROM questions WHERE id = %s",
87110
(qid,),
88111
)
89112
question_data = cur.fetchone()
90113

91114
if not question_data:
92-
return None
115+
raise QuestionNotFoundException(qid)
93116

94117
# Fetch associated image keys
95118
cur.execute(
96119
"SELECT s3_key FROM question_images WHERE question_id = %s",
97120
(qid,)
98121
)
99-
# Extract keys from the fetched tuples
100122
image_keys = [row[0] for row in cur.fetchall()]
101-
image_urls = _generate_presigned_get_urls(image_keys)
123+
image_data = get_from_s3(image_keys)
102124

103125
return {
104126
"id": qid,
105127
"name": question_data[0],
106128
"description": question_data[1],
107-
"difficulty_level": question_data[2],
129+
"difficulty": question_data[2],
108130
"topic": question_data[3],
109-
"images": image_urls,
131+
"images": image_data, # Raw bytes
110132
}
111-
133+
112134

113135
def get_random_question_by_difficulty_and_topic(difficulty: str, topic: str):
114-
"""Returns a random question matching the given difficulty and topic, including presigned image URLs."""
136+
"""
137+
Returns a random question matching the given difficulty and topic, including image data.
138+
139+
Args:
140+
difficulty (str): The difficulty level to filter by (must exist in difficulties table)
141+
topic (str): The topic to filter by (must exist in topics table)
142+
143+
Returns:
144+
dict: Question data including id, name, description, difficulty,
145+
topic, and images (as bytes)
146+
147+
Raises:
148+
QuestionNotFoundException: If no question matches the given difficulty and topic
149+
150+
Note:
151+
Images are returned as raw bytes data retrieved directly from S3.
152+
Uses database RANDOM() function for selection.
153+
"""
115154
with get_conn() as conn:
116155
with conn.cursor() as cur:
117156
cur.execute(
118157
"""
119-
SELECT id, name, description, difficulty_level, topic
158+
SELECT id, name, description, difficulty, topic
120159
FROM questions
121-
WHERE difficulty_level = %s AND topic = %s
160+
WHERE difficulty = %s AND topic = %s
122161
ORDER BY RANDOM()
123162
LIMIT 1
124163
""",
125164
(difficulty, topic),
126165
)
127166
row = cur.fetchone()
128167
if not row:
129-
return None
168+
raise QuestionNotFoundException(topic=topic, difficulty=difficulty)
130169

131170
qid = row[0]
132171
# Fetch associated image keys
@@ -135,18 +174,100 @@ def get_random_question_by_difficulty_and_topic(difficulty: str, topic: str):
135174
(qid,),
136175
)
137176
image_keys = [r[0] for r in cur.fetchall()]
138-
image_urls = _generate_presigned_get_urls(image_keys)
177+
image_data = get_from_s3(image_keys)
139178

140179
return {
141180
"id": qid,
142181
"name": row[1],
143182
"description": row[2],
144-
"difficulty_level": row[3],
183+
"difficulty": row[3],
145184
"topic": row[4],
146-
"images": image_urls,
185+
"images": image_data, # Raw bytes
147186
}
148187

188+
189+
def override_question(
190+
qid: str,
191+
name: str,
192+
description: str,
193+
difficulty: str,
194+
topic: str,
195+
images: Optional[List[bytes]] = None
196+
) -> str:
197+
"""
198+
Atomically replaces an existing question with new data and images.
199+
200+
This function performs a complete replacement of a question by:
201+
1. Backing up the original question data and images
202+
2. Deleting the old question and its images
203+
3. Creating a new question with the same ID and new data
204+
4. Rolling back changes if any error occurs during the process
205+
206+
Args:
207+
qid: The unique question identifier (UUID) to override
208+
name: New question name
209+
description: New question description
210+
difficulty: New difficulty level
211+
topic: New topic
212+
images: Optional list of new image files as bytes to upload to S3
213+
214+
Returns:
215+
str: The question ID (same as input qid)
216+
217+
Raises:
218+
QuestionNotFoundException: If no question exists with the given ID
219+
Exception: If any step fails, all changes are rolled back atomically
220+
221+
Note:
222+
This operation is fully atomic - if any error occurs during the process,
223+
the original question and its images are restored to their previous state.
224+
"""
225+
# First, get the original question data for backup purposes
226+
backup_data = get_question(qid) # This will raise QuestionNotFoundException if not found
227+
228+
# Delete the old question (this is atomic and handles S3 cleanup)
229+
delete_question(qid)
230+
231+
try:
232+
# Create the new question with the same ID (this is atomic and handles S3 upload)
233+
create_question(name, description, difficulty, topic, images, qid)
234+
235+
except Exception:
236+
# If creation failed after deletion, restore the original question
237+
try:
238+
create_question(
239+
backup_data["name"],
240+
backup_data["description"],
241+
backup_data["difficulty"],
242+
backup_data["topic"],
243+
backup_data["images"], # These are already bytes
244+
qid
245+
)
246+
except Exception:
247+
# If restoration also fails, we can't do much more
248+
# The original error is more important to report
249+
pass
250+
raise
251+
252+
return qid
253+
254+
149255
def delete_question(qid: str):
256+
"""
257+
Deletes a question and its associated images by its ID.
258+
259+
Args:
260+
qid (str): The unique question identifier (UUID) to delete
261+
262+
Returns:
263+
None
264+
265+
Raises:
266+
QuestionNotFoundException: If no question exists with the given ID
267+
Exception: If S3 deletion fails, database transaction is rolled back Note:
268+
This function is atomic: if deleting images from S3 fails,
269+
the database transaction is rolled back to maintain consistency.
270+
"""
150271
with get_conn() as conn:
151272
with conn.cursor() as cur:
152273
# Fetch associated image keys
@@ -160,7 +281,7 @@ def delete_question(qid: str):
160281
cur.execute("DELETE FROM questions WHERE id = %s RETURNING id", (qid,))
161282
deleted = cur.fetchone()
162283
if not deleted:
163-
raise KeyError(f"No question found with ID '{qid}'.")
284+
raise QuestionNotFoundException(qid)
164285

165286
# Delete images from S3; rollback DB if this fails
166287
try:

services/question-service/app/endpoint_models.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)