Skip to content

Commit ee33ff2

Browse files
committed
Add CRUD logic
1 parent 9bac503 commit ee33ff2

File tree

4 files changed

+223
-1
lines changed

4 files changed

+223
-1
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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
7+
8+
9+
def list_difficulties_and_topics():
10+
"""Returns all available difficulty levels and topics."""
11+
with get_conn() as conn:
12+
with conn.cursor() as cur:
13+
cur.execute("SELECT * FROM difficulty_levels")
14+
difficulties = [row[0] for row in cur.fetchall()]
15+
16+
cur.execute("SELECT * FROM topics")
17+
topics = [row[0] for row in cur.fetchall()]
18+
19+
return {"difficulties": difficulties, "topics": topics}
20+
21+
22+
def create_question(q: createQuestion, images: list[bytes] | None = None, qid: str = None):
23+
"""Creates a new question. Returns the question ID."""
24+
qid = str(uuid.uuid4()) if qid is None else qid
25+
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)
30+
31+
try:
32+
with get_conn() as conn:
33+
with conn.cursor() as cur:
34+
# Insert question into database
35+
cur.execute(
36+
"""
37+
INSERT INTO questions (id, name, description, difficulty_level, topic)
38+
VALUES (%s, %s, %s, %s, %s)
39+
""",
40+
(qid, q.name, q.description, q.difficulty_level, q.topic),
41+
)
42+
43+
# Insert images metadata into database
44+
for _, key in files_and_keys:
45+
image_id = str(uuid.uuid4())
46+
cur.execute(
47+
"INSERT INTO question_images (id, question_id, s3_key) VALUES (%s, %s, %s)",
48+
(image_id, qid, key),
49+
)
50+
return qid
51+
except Exception:
52+
# If any error occurs, delete the uploaded images from S3
53+
delete_from_s3([key for _, key in files_and_keys])
54+
raise
55+
56+
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+
def get_question(qid: str):
81+
"""Retrieves a single question and its associated images by its ID."""
82+
with get_conn() as conn:
83+
with conn.cursor() as cur:
84+
# Fetch the main question data
85+
cur.execute(
86+
"SELECT name, description, difficulty_level, topic FROM questions WHERE id = %s",
87+
(qid,),
88+
)
89+
question_data = cur.fetchone()
90+
91+
if not question_data:
92+
return None
93+
94+
# Fetch associated image keys
95+
cur.execute(
96+
"SELECT s3_key FROM question_images WHERE question_id = %s",
97+
(qid,)
98+
)
99+
# Extract keys from the fetched tuples
100+
image_keys = [row[0] for row in cur.fetchall()]
101+
image_urls = _generate_presigned_get_urls(image_keys)
102+
103+
return {
104+
"id": qid,
105+
"name": question_data[0],
106+
"description": question_data[1],
107+
"difficulty_level": question_data[2],
108+
"topic": question_data[3],
109+
"images": image_urls,
110+
}
111+
112+
113+
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."""
115+
with get_conn() as conn:
116+
with conn.cursor() as cur:
117+
cur.execute(
118+
"""
119+
SELECT id, name, description, difficulty_level, topic
120+
FROM questions
121+
WHERE difficulty_level = %s AND topic = %s
122+
ORDER BY RANDOM()
123+
LIMIT 1
124+
""",
125+
(difficulty, topic),
126+
)
127+
row = cur.fetchone()
128+
if not row:
129+
return None
130+
131+
qid = row[0]
132+
# Fetch associated image keys
133+
cur.execute(
134+
"SELECT s3_key FROM question_images WHERE question_id = %s",
135+
(qid,),
136+
)
137+
image_keys = [r[0] for r in cur.fetchall()]
138+
image_urls = _generate_presigned_get_urls(image_keys)
139+
140+
return {
141+
"id": qid,
142+
"name": row[1],
143+
"description": row[2],
144+
"difficulty_level": row[3],
145+
"topic": row[4],
146+
"images": image_urls,
147+
}
148+
149+
def delete_question(qid: str):
150+
with get_conn() as conn:
151+
with conn.cursor() as cur:
152+
# Fetch associated image keys
153+
cur.execute(
154+
"SELECT s3_key FROM question_images WHERE question_id = %s",
155+
(qid,)
156+
)
157+
image_keys = [row[0] for row in cur.fetchall()]
158+
159+
# Delete the question and verify it existed
160+
cur.execute("DELETE FROM questions WHERE id = %s RETURNING id", (qid,))
161+
deleted = cur.fetchone()
162+
if not deleted:
163+
raise KeyError(f"No question found with ID '{qid}'.")
164+
165+
# Delete images from S3; rollback DB if this fails
166+
try:
167+
delete_from_s3(image_keys)
168+
except Exception:
169+
conn.rollback()
170+
raise
171+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pydantic import BaseModel
2+
from typing import Optional
3+
4+
class createQuestion(BaseModel):
5+
name: str
6+
description: str
7+
difficulty_level: str
8+
topic: str
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import os
2+
import psycopg
3+
import boto3
4+
from typing import List, Tuple
5+
6+
def get_conn():
7+
"""Establishes and returns a new postgres database connection"""
8+
return psycopg.connect(
9+
dbname=os.getenv("DB_NAME"),
10+
user=os.getenv("DB_USER"),
11+
password=os.getenv("DB_PASSWORD"),
12+
host=os.getenv("DB_HOST"),
13+
port=os.getenv("DB_PORT"),
14+
)
15+
16+
17+
def upload_to_s3(files_and_keys: List[Tuple[bytes, str]]):
18+
"""Uploads files to S3 with the given keys."""
19+
if not files_and_keys:
20+
return
21+
s3 = boto3.client(
22+
"s3",
23+
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
24+
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
25+
)
26+
bucket_name = os.getenv("S3_BUCKET_NAME")
27+
for file, key in files_and_keys:
28+
s3.put_object(Bucket=bucket_name, Key=key, Body=file)
29+
30+
31+
def delete_from_s3(keys: List[str]):
32+
"""Deletes files from S3 with the given keys."""
33+
if not keys:
34+
return
35+
s3 = boto3.client(
36+
"s3",
37+
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
38+
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
39+
)
40+
bucket_name = os.getenv("S3_BUCKET_NAME")
41+
objects_to_delete = [{"Key": key} for key in keys]
42+
s3.delete_objects(Bucket=bucket_name, Delete={"Objects": objects_to_delete})
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
fastapi[standard]
2-
psycopg[binary]
2+
psycopg[binary]
3+
boto3

0 commit comments

Comments
 (0)