1
1
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
7
5
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
+ """
11
15
with get_conn () as conn :
12
16
with conn .cursor () as cur :
13
- cur .execute ("SELECT * FROM difficulty_levels " )
17
+ cur .execute ("SELECT * FROM difficulties " )
14
18
difficulties = [row [0 ] for row in cur .fetchall ()]
15
19
16
20
cur .execute ("SELECT * FROM topics" )
@@ -19,25 +23,52 @@ def list_difficulties_and_topics():
19
23
return {"difficulties" : difficulties , "topics" : topics }
20
24
21
25
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
+ """
24
51
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 )]
25
57
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 )
30
61
31
62
try :
32
63
with get_conn () as conn :
33
64
with conn .cursor () as cur :
34
65
# Insert question into database
35
66
cur .execute (
36
67
"""
37
- INSERT INTO questions (id, name, description, difficulty_level , topic)
68
+ INSERT INTO questions (id, name, description, difficulty , topic)
38
69
VALUES (%s, %s, %s, %s, %s)
39
70
""" ,
40
- (qid , q . name , q . description , q . difficulty_level , q . topic ),
71
+ (qid , name , description , difficulty , topic ),
41
72
)
42
73
43
74
# Insert images metadata into database
@@ -54,79 +85,87 @@ def create_question(q: createQuestion, images: list[bytes] | None = None, qid: s
54
85
raise
55
86
56
87
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
-
80
88
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
+ """
82
105
with get_conn () as conn :
83
106
with conn .cursor () as cur :
84
107
# Fetch the main question data
85
108
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" ,
87
110
(qid ,),
88
111
)
89
112
question_data = cur .fetchone ()
90
113
91
114
if not question_data :
92
- return None
115
+ raise QuestionNotFoundException ( qid )
93
116
94
117
# Fetch associated image keys
95
118
cur .execute (
96
119
"SELECT s3_key FROM question_images WHERE question_id = %s" ,
97
120
(qid ,)
98
121
)
99
- # Extract keys from the fetched tuples
100
122
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 )
102
124
103
125
return {
104
126
"id" : qid ,
105
127
"name" : question_data [0 ],
106
128
"description" : question_data [1 ],
107
- "difficulty_level " : question_data [2 ],
129
+ "difficulty " : question_data [2 ],
108
130
"topic" : question_data [3 ],
109
- "images" : image_urls ,
131
+ "images" : image_data , # Raw bytes
110
132
}
111
-
133
+
112
134
113
135
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
+ """
115
154
with get_conn () as conn :
116
155
with conn .cursor () as cur :
117
156
cur .execute (
118
157
"""
119
- SELECT id, name, description, difficulty_level , topic
158
+ SELECT id, name, description, difficulty , topic
120
159
FROM questions
121
- WHERE difficulty_level = %s AND topic = %s
160
+ WHERE difficulty = %s AND topic = %s
122
161
ORDER BY RANDOM()
123
162
LIMIT 1
124
163
""" ,
125
164
(difficulty , topic ),
126
165
)
127
166
row = cur .fetchone ()
128
167
if not row :
129
- return None
168
+ raise QuestionNotFoundException ( topic = topic , difficulty = difficulty )
130
169
131
170
qid = row [0 ]
132
171
# Fetch associated image keys
@@ -135,18 +174,100 @@ def get_random_question_by_difficulty_and_topic(difficulty: str, topic: str):
135
174
(qid ,),
136
175
)
137
176
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 )
139
178
140
179
return {
141
180
"id" : qid ,
142
181
"name" : row [1 ],
143
182
"description" : row [2 ],
144
- "difficulty_level " : row [3 ],
183
+ "difficulty " : row [3 ],
145
184
"topic" : row [4 ],
146
- "images" : image_urls ,
185
+ "images" : image_data , # Raw bytes
147
186
}
148
187
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
+
149
255
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
+ """
150
271
with get_conn () as conn :
151
272
with conn .cursor () as cur :
152
273
# Fetch associated image keys
@@ -160,7 +281,7 @@ def delete_question(qid: str):
160
281
cur .execute ("DELETE FROM questions WHERE id = %s RETURNING id" , (qid ,))
161
282
deleted = cur .fetchone ()
162
283
if not deleted :
163
- raise KeyError ( f"No question found with ID ' { qid } '." )
284
+ raise QuestionNotFoundException ( qid )
164
285
165
286
# Delete images from S3; rollback DB if this fails
166
287
try :
0 commit comments