Skip to content

Commit fa41008

Browse files
authored
feat(api): ✨ upload comps via the API (#77)
1 parent 7faa398 commit fa41008

File tree

13 files changed

+675
-539
lines changed

13 files changed

+675
-539
lines changed

api/router.py

Lines changed: 207 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,25 @@
1-
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query
2-
from fastapi.responses import JSONResponse
1+
import logging
2+
import os
33
import random
4+
import sqlite3
45
import uuid
5-
import os
6-
import shutil
6+
from datetime import datetime
77
from pathlib import Path
88
from typing import List, Optional
9-
import json
10-
import sqlite3
11-
from datetime import datetime
129

13-
from .models import (
14-
ComparisonCreate,
15-
ComparisonResponse,
16-
ComparisonDetail,
17-
ImageDetail,
18-
CustomNameUpdate
19-
)
20-
from database import (
21-
create_comparison,
22-
get_comparison,
23-
store_image_position,
24-
store_image_metadata,
25-
update_image_custom_name,
26-
update_last_accessed
27-
)
10+
import aiofiles
11+
from fastapi import (APIRouter, Depends, File, Form, HTTPException, Request,
12+
UploadFile)
13+
from fastapi.responses import JSONResponse
14+
from fastapi.security import APIKeyCookie, OAuth2PasswordRequestForm
15+
16+
import auth
17+
from database import (create_comparison, delete_comparison, get_comparison,
18+
store_image_metadata, store_image_position,
19+
update_image_custom_name, update_last_accessed)
20+
21+
from .models import (ComparisonCreate, ComparisonDetail, ComparisonResponse,
22+
CustomNameUpdate)
2823

2924
router = APIRouter(prefix="/api/v1", tags=["api"])
3025

@@ -35,6 +30,11 @@
3530
UPLOADS_PATH = os.getenv('UPLOADS_PATH', 'uploads')
3631
DB_PATH = os.getenv('DB_PATH', 'comparisons.db')
3732

33+
# Configure logging
34+
logging.basicConfig(level=logging.INFO)
35+
logger = logging.getLogger(__name__)
36+
cookie_sec = APIKeyCookie(name="session")
37+
3838
# Random name generator for comparisons
3939
def generate_random_name():
4040
"""
@@ -60,6 +60,23 @@ def generate_random_name():
6060

6161
return f"{adjective} {noun}"
6262

63+
@router.post("/login")
64+
async def api_login(form_data: OAuth2PasswordRequestForm = Depends()):
65+
"""
66+
Authenticate and get an access token for API usage.
67+
- **username**: Your username
68+
- **password**: Your invitation code
69+
"""
70+
user = auth.authenticate_user(form_data.username, form_data.password)
71+
if not user:
72+
raise HTTPException(
73+
status_code=401,
74+
detail="Incorrect username or invitation code",
75+
headers={"WWW-Authenticate": "Bearer"},
76+
)
77+
access_token = auth.create_access_token(data={"sub": str(user["id"])})
78+
return {"access_token": access_token, "token_type": "bearer"}
79+
6380
@router.get("/comparisons", response_model=List[ComparisonResponse])
6481
async def list_comparisons():
6582
"""
@@ -92,7 +109,10 @@ async def list_comparisons():
92109
return comparisons
93110

94111
@router.post("/comparisons", response_model=ComparisonResponse, status_code=201)
95-
async def create_new_comparison(comparison: ComparisonCreate):
112+
async def create_new_comparison(
113+
comparison_data: ComparisonCreate,
114+
user: Optional[dict] = Depends(auth.get_optional_user)
115+
):
96116
"""
97117
Create a new comparison.
98118
@@ -104,36 +124,46 @@ async def create_new_comparison(comparison: ComparisonCreate):
104124
"""
105125
comparison_id = str(uuid.uuid4())
106126

107-
# Generate a random name if none provided
108-
if not comparison.name or comparison.name.strip() == '':
109-
comparison.name = generate_random_name()
110-
print(f"No name provided, generated random name: {comparison.name}")
127+
# Use a random name if none is provided
128+
name = comparison_data.name or generate_random_name()
111129

112-
# Prepare metadata
130+
# Get user ID if a user is authenticated
131+
user_id = user["id"] if user else None
132+
133+
# Prepare metadata, enforcing maximum rows limit
113134
metadata = {
114-
"total_columns": comparison.total_columns,
115-
"total_rows": comparison.total_rows,
135+
"total_columns": comparison_data.total_columns,
136+
"total_rows": min(comparison_data.total_rows, MAX_ROWS),
137+
"never_expire": user["never_expire_comparisons"] if user else False
116138
}
117139

118-
# Enforce maximum rows limit
119-
metadata["total_rows"] = min(metadata["total_rows"], MAX_ROWS)
120-
121140
# Create comparison directory
122141
comparison_dir = Path(UPLOADS_PATH) / comparison_id
123142
comparison_dir.mkdir(exist_ok=True, parents=True)
124143

125144
# Store in database
126145
create_comparison(
127146
comparison_id=comparison_id,
128-
name=comparison.name,
129-
show_name=comparison.show_name,
130-
tags=comparison.tags,
131-
metadata=metadata
147+
name=name,
148+
show_name=comparison_data.show_name,
149+
tags=comparison_data.tags,
150+
metadata=metadata,
151+
user_id=user_id
132152
)
133153

134-
# Get the created comparison
135-
result = get_comparison(comparison_id)
136-
return result
154+
# Return the response
155+
return ComparisonResponse(
156+
id=comparison_id,
157+
name=name,
158+
show_name=comparison_data.show_name,
159+
tags=comparison_data.tags,
160+
total_rows=metadata["total_rows"],
161+
total_columns=metadata["total_columns"],
162+
created_at=datetime.utcnow().isoformat(),
163+
last_accessed=datetime.utcnow().isoformat(),
164+
never_expire=metadata["never_expire"]
165+
)
166+
137167

138168
@router.get("/comparisons/{comparison_id}", response_model=ComparisonDetail)
139169
async def get_comparison_detail(comparison_id: str):
@@ -188,86 +218,6 @@ async def get_comparison_detail(comparison_id: str):
188218
comparison_data["images"] = images
189219
return comparison_data
190220

191-
@router.post("/comparisons/{comparison_id}/images", status_code=201)
192-
async def upload_images(
193-
comparison_id: str,
194-
files: List[UploadFile] = File(...),
195-
positions: str = Form(None)
196-
):
197-
"""
198-
Upload images to an existing comparison.
199-
200-
Upload one or more image files to a comparison and specify their positions in the grid.
201-
202-
- Files should be image files (jpg, jpeg, png, bmp, webp)
203-
- Positions can be specified as a JSON string mapping filenames to grid positions
204-
205-
Example positions JSON:
206-
```json
207-
{
208-
"image1.jpg": {"row": 0, "column": 0},
209-
"image2.jpg": {"row": 0, "column": 1}
210-
}
211-
```
212-
"""
213-
# Check if comparison exists
214-
comparison_data = get_comparison(comparison_id)
215-
if not comparison_data:
216-
raise HTTPException(status_code=404, detail="Comparison not found")
217-
218-
# Parse positions if provided
219-
positions_data = {}
220-
if positions:
221-
try:
222-
positions_data = json.loads(positions)
223-
except json.JSONDecodeError:
224-
raise HTTPException(status_code=400, detail="Invalid positions JSON format")
225-
226-
comparison_dir = Path(UPLOADS_PATH) / comparison_id
227-
if not comparison_dir.exists():
228-
comparison_dir.mkdir(exist_ok=True, parents=True)
229-
230-
uploaded_files = []
231-
232-
for file_index, file in enumerate(files):
233-
original_filename = file.filename
234-
file_ext = Path(file.filename).suffix
235-
save_path = comparison_dir / f"{uuid.uuid4()}{file_ext}"
236-
237-
if not file_ext.lower() in ['.jpg', '.jpeg', '.png', '.bmp', '.webp']:
238-
raise HTTPException(status_code=400, detail=f"Unsupported file type: {file_ext}")
239-
240-
try:
241-
with save_path.open("wb") as buffer:
242-
shutil.copyfileobj(file.file, buffer)
243-
244-
# Determine row and column position
245-
if original_filename in positions_data:
246-
row_position = positions_data[original_filename]["row"]
247-
column_index = positions_data[original_filename]["column"]
248-
else:
249-
# Default sequential positioning
250-
column_index = file_index % comparison_data["total_columns"]
251-
row_position = file_index // comparison_data["total_columns"]
252-
253-
store_image_position(comparison_id, save_path.name, row_position, column_index)
254-
255-
# Store image metadata
256-
image_size = f"{file.size} bytes"
257-
store_image_metadata(comparison_id, save_path.name, original_filename, image_size)
258-
259-
uploaded_files.append({
260-
"filename": save_path.name,
261-
"original_filename": original_filename,
262-
"row": row_position,
263-
"column": column_index
264-
})
265-
266-
except Exception as e:
267-
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
268-
269-
return {"uploaded": uploaded_files}
270-
271221
@router.put("/comparisons/{comparison_id}/images/{filename}", status_code=200)
272222
async def update_image_metadata(
273223
comparison_id: str,
@@ -302,3 +252,140 @@ async def update_image_metadata(
302252
update_image_custom_name(comparison_id, filename, update_data.custom_name)
303253

304254
return {"message": "Image metadata updated successfully"}
255+
@router.delete("/delete-comparison/{comparison_id}")
256+
async def delete_user_comparison(comparison_id: str, request: Request):
257+
"""Delete a comparison owned by the current user"""
258+
# Get current user
259+
user = await auth.get_optional_user(request)
260+
261+
# Check if user is logged in
262+
if not user:
263+
return JSONResponse(status_code=401, content={"error": "Authentication required"})
264+
265+
# Check if the comparison exists and belongs to the user
266+
conn = sqlite3.connect(DB_PATH)
267+
c = conn.cursor()
268+
c.execute('SELECT user_id FROM comparisons WHERE id = ?', (comparison_id,))
269+
result = c.fetchone()
270+
conn.close()
271+
272+
if not result or result[0] != user["id"]:
273+
return JSONResponse(
274+
status_code=403,
275+
content={"error": "You don't have permission to delete this comparison"}
276+
)
277+
278+
# Delete the comparison
279+
try:
280+
delete_comparison(comparison_id, UPLOADS_PATH)
281+
except OSError as e:
282+
logger.error("Error deleting comparison %s files: %s", comparison_id, e)
283+
return JSONResponse(
284+
status_code=500,
285+
content={"error": "Failed to delete comparison files."}
286+
)
287+
return JSONResponse(content={"message": "Comparison deleted successfully"})
288+
289+
@router.post("/comparison")
290+
async def api_create_comparison(
291+
request: Request,
292+
name: str = Form(...),
293+
show_name: Optional[str] = Form(None),
294+
expiration_type: str = Form("from_last_access"),
295+
expiration_enabled: Optional[str] = Form(None),
296+
expiration_days: int = Form(7),
297+
tags: Optional[str] = Form(None),
298+
total_rows: int = Form(1),
299+
total_columns: int = Form(2)
300+
):
301+
"""
302+
Creates a new comparison record and returns its ID.
303+
This endpoint does not accept files.
304+
"""
305+
logger.info("Received request to create a new comparison")
306+
user = await auth.get_optional_user(request)
307+
user_id = user["id"] if user else None
308+
logger.info(
309+
"Create comparison request from user: %s",
310+
user['username'] if user else 'anonymous'
311+
)
312+
313+
comparison_id = str(uuid.uuid4())
314+
315+
if not name or name.strip() == '':
316+
name = generate_random_name()
317+
logger.info("No name provided, generated random name: %s", name)
318+
319+
tag_list = []
320+
if tags and tags.strip():
321+
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
322+
323+
never_expire = None
324+
if user:
325+
if expiration_enabled == "true":
326+
never_expire = False
327+
else:
328+
never_expire = True
329+
330+
metadata = {
331+
"total_rows": total_rows,
332+
"total_columns": total_columns,
333+
"expiration_type": expiration_type,
334+
"expiration_days": expiration_days,
335+
"never_expire": never_expire
336+
}
337+
338+
create_comparison(
339+
comparison_id=comparison_id,
340+
name=name,
341+
show_name=show_name,
342+
tags=tag_list,
343+
metadata=metadata,
344+
user_id=user_id
345+
)
346+
347+
return JSONResponse(content={"comparison_id": comparison_id})
348+
349+
350+
@router.post("/comparison/{comparison_id}/image")
351+
async def api_upload_image(
352+
comparison_id: str,
353+
file: UploadFile = File(...),
354+
row: int = Form(...),
355+
column: int = Form(...),
356+
original_filename: str = Form(...),
357+
custom_name: Optional[str] = Form(None)
358+
):
359+
"""
360+
Uploads a single image to an existing comparison.
361+
"""
362+
logger.info(
363+
"Uploading image %s to comparison %s at (%s, %s)",
364+
original_filename, comparison_id, row, column
365+
)
366+
367+
comparison = get_comparison(comparison_id)
368+
if not comparison:
369+
return JSONResponse(status_code=404, content={"error": "Comparison not found"})
370+
371+
comparison_dir = Path(UPLOADS_PATH) / comparison_id
372+
comparison_dir.mkdir(parents=True, exist_ok=True)
373+
374+
file_ext = Path(file.filename).suffix
375+
unique_filename = f"{uuid.uuid4()}{file_ext}"
376+
file_path = comparison_dir / unique_filename
377+
378+
async with aiofiles.open(file_path, "wb") as buffer:
379+
await buffer.write(await file.read())
380+
381+
store_image_position(comparison_id, unique_filename, row, column)
382+
383+
image_size = os.path.getsize(file_path)
384+
store_image_metadata(comparison_id, unique_filename, original_filename, f"{image_size} bytes")
385+
386+
if custom_name:
387+
update_image_custom_name(comparison_id, unique_filename, custom_name)
388+
389+
return JSONResponse(
390+
content={"filename": unique_filename, "message": "Image uploaded successfully"}
391+
)

0 commit comments

Comments
 (0)