1- from fastapi import APIRouter , UploadFile , File , Form , HTTPException , Depends , Query
2- from fastapi . responses import JSONResponse
1+ import logging
2+ import os
33import random
4+ import sqlite3
45import uuid
5- import os
6- import shutil
6+ from datetime import datetime
77from pathlib import Path
88from 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
2924router = APIRouter (prefix = "/api/v1" , tags = ["api" ])
3025
3530UPLOADS_PATH = os .getenv ('UPLOADS_PATH' , 'uploads' )
3631DB_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
3939def 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 ])
6481async 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 )
139169async 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 )
272222async 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