|
| 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