|
|
from fastapi import FastAPI, File, UploadFile, Depends, BackgroundTasks
|
|
|
from fastapi.responses import JSONResponse
|
|
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
|
from fastapi.middleware.cors import CORSMiddleware # CORS
|
|
|
|
|
|
from secrets import compare_digest
|
|
|
from datetime import datetime
|
|
|
from uuid import uuid4
|
|
|
import sqlite3
|
|
|
import zipfile
|
|
|
|
|
|
# Global settings of this program
|
|
|
# ./config.py
|
|
|
from config import DB_LOCATION, DATA_LOCATION
|
|
|
|
|
|
app = FastAPI()
|
|
|
security = HTTPBasic()
|
|
|
con = sqlite3.connect(DB_LOCATION)
|
|
|
con.row_factory = sqlite3.Row
|
|
|
cur = con.cursor() # NB! single is enough for now, we might require multiple later
|
|
|
|
|
|
origins = [ # CORS
|
|
|
"*",
|
|
|
]
|
|
|
|
|
|
app.add_middleware( # CORS
|
|
|
CORSMiddleware,
|
|
|
allow_origins=origins,
|
|
|
allow_credentials=True,
|
|
|
allow_methods=["*"],
|
|
|
allow_headers=["*"],
|
|
|
)
|
|
|
|
|
|
|
|
|
@app.get("/new_session", responses={503: {"description": "Unable to initiate session"}})
|
|
|
async def new_session():
|
|
|
"""Start a new session"""
|
|
|
# add session to the database
|
|
|
time = datetime.utcnow().replace(microsecond=0).isoformat()
|
|
|
tries = 3 # something is very wrong with our random, if we miss 3 times
|
|
|
for i in range(tries):
|
|
|
try:
|
|
|
# generate a cookie
|
|
|
cookie = uuid4().hex
|
|
|
cur.execute(
|
|
|
"""INSERT INTO sessions(cookie, time)
|
|
|
VALUES(:cookie, :time)
|
|
|
""",
|
|
|
{"cookie": cookie, "time": time},
|
|
|
)
|
|
|
con.commit()
|
|
|
except sqlite3.IntegrityError as e:
|
|
|
if i < tries - 1 and str(e) == "UNIQUE constraint failed: sessions.cookie":
|
|
|
continue
|
|
|
elif str(e) == "UNIQUE constraint failed: sessions.cookie":
|
|
|
return JSONResponse(status_code=503)
|
|
|
else:
|
|
|
raise
|
|
|
break
|
|
|
|
|
|
# return new session cookie
|
|
|
return {"cookie": cookie}
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
"/next_picture/{cookie}",
|
|
|
responses={
|
|
|
204: {"description": "All available images have been appraised"},
|
|
|
409: {"description": "Uninitiated session"},
|
|
|
},
|
|
|
)
|
|
|
async def next_picture(cookie: str):
|
|
|
"""Request new picture to rate."""
|
|
|
# check if the cookie is valid
|
|
|
cur.execute(
|
|
|
"""SELECT sessionid
|
|
|
FROM sessions
|
|
|
WHERE cookie = :cookie
|
|
|
LIMIT 1""",
|
|
|
{"cookie": cookie},
|
|
|
)
|
|
|
sessionid = cur.fetchone()
|
|
|
if sessionid is None:
|
|
|
return JSONResponse(status_code=409)
|
|
|
|
|
|
# take not rated picture from the database
|
|
|
# do not insert anything in the database yet
|
|
|
# return this picture
|
|
|
|
|
|
# SELECT all images EXCEPT images with marks from the current session ->
|
|
|
# -> SELECT paths for these images
|
|
|
# FIXME[0]: can this be done better?
|
|
|
cur.execute(
|
|
|
"""SELECT imgid, resizedpath
|
|
|
FROM images
|
|
|
WHERE imgid IN (SELECT imgid
|
|
|
FROM images
|
|
|
EXCEPT
|
|
|
SELECT imgid
|
|
|
FROM marks
|
|
|
WHERE sessionid = :sessionid)
|
|
|
LIMIT 1
|
|
|
""",
|
|
|
{"sessionid": sessionid["sessionid"]},
|
|
|
)
|
|
|
r = cur.fetchone()
|
|
|
if r is not None:
|
|
|
return {"picture_id": r["imgid"], "picture_uri": r["resizedpath"]}
|
|
|
else:
|
|
|
# All available pics have been voted for by this sessionid
|
|
|
return JSONResponse(status_code=204)
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
"/rate_picture/{cookie}/{picture_id}/{mark}",
|
|
|
responses={
|
|
|
406: {"description": "Already appraised"},
|
|
|
409: {"description": "Uninitiated session"},
|
|
|
},
|
|
|
)
|
|
|
async def rate_picture(cookie: str, picture_id: int, mark: int):
|
|
|
"""Submit a rating for the picture"""
|
|
|
# check if the cookie is valid
|
|
|
cur.execute(
|
|
|
"""SELECT sessionid
|
|
|
FROM sessions
|
|
|
WHERE cookie = :cookie
|
|
|
LIMIT 1""",
|
|
|
{"cookie": cookie},
|
|
|
)
|
|
|
sessionid = cur.fetchone()
|
|
|
if sessionid is None:
|
|
|
return JSONResponse(status_code=409)
|
|
|
|
|
|
# add new mark to the session table
|
|
|
try:
|
|
|
cur.execute(
|
|
|
"""INSERT INTO marks(imgid, sessionid, mark)
|
|
|
VALUES(:imgid,:sessionid,:mark)
|
|
|
""",
|
|
|
{"imgid": picture_id, "sessionid": sessionid["sessionid"], "mark": mark},
|
|
|
)
|
|
|
con.commit()
|
|
|
except sqlite3.IntegrityError as e:
|
|
|
if str(e) == "UNIQUE constraint failed: marks.imgid, marks.sessionid":
|
|
|
return JSONResponse(status_code=406)
|
|
|
|
|
|
return {"status": "success"}
|
|
|
|
|
|
|
|
|
@app.get("/photo_points")
|
|
|
async def photo_points():
|
|
|
"""Get points with the url of a photo and the rate"""
|
|
|
# assume we always have at least some photos
|
|
|
# fetch them all
|
|
|
cur.execute(
|
|
|
"""SELECT images.imgid, resizedpath, GPSLatitude, GPSLongitude,
|
|
|
100*SUM(marks.mark)/COUNT(marks.mark)/MAX(marks.mark)
|
|
|
FROM images
|
|
|
LEFT JOIN marks ON images.imgid = marks.imgid
|
|
|
GROUP BY images.imgid;
|
|
|
""", # 100 * SUM(marks.mark)/COUNT(marks.mark)/MAX(marks.mark)
|
|
|
# is an ad-hoc percentage of likes without know how front end defined like/dislike
|
|
|
# returns None with no marks (sqlite handles division by 0 gracefully)
|
|
|
)
|
|
|
points = cur.fetchall()
|
|
|
return [
|
|
|
{
|
|
|
"id": point["imgid"],
|
|
|
"url": point["resizedpath"],
|
|
|
"lon": point["GPSLongitude"],
|
|
|
"lat": point["GPSLatitude"],
|
|
|
"rate": point["100*SUM(marks.mark)/COUNT(marks.mark)/MAX(marks.mark)"],
|
|
|
}
|
|
|
for point in points
|
|
|
]
|
|
|
@app.post(
|
|
|
"/upload_pictures/",
|
|
|
responses={
|
|
|
202: {"description": "Archive accepted into processing"},
|
|
|
401: {"description": "Authentication is required to access this resource"},
|
|
|
415: {"description": "Cannot process uploaded archive"},
|
|
|
},
|
|
|
)
|
|
|
async def upload_pictures(
|
|
|
background_tasks: BackgroundTasks,
|
|
|
credentials: HTTPBasicCredentials = Depends(security),
|
|
|
file: UploadFile = File(...),
|
|
|
):
|
|
|
"""Интерфейс для загрузки фотографий"""
|
|
|
"""Условно кладём в браузер zip с фотографиями и он их потихоньку ест.
|
|
|
Доступ к этому интерфейсу, наверное, лучше ограничить паролем или как-нибудь ещё.
|
|
|
Пока исходим из предположения, что только я буду загружать фотографии.
|
|
|
"""
|
|
|
# check authenticity
|
|
|
correct_username = compare_digest(credentials.username, "1")
|
|
|
correct_password = compare_digest(credentials.password, "1")
|
|
|
if not (correct_username and correct_password):
|
|
|
return JSONResponse(status_code=401)
|
|
|
# slurp the zip
|
|
|
if not zipfile.is_zipfile(file.file):
|
|
|
return JSONResponse(status_code=415)
|
|
|
# detach from the interface
|
|
|
# unpack zip
|
|
|
tasks = BackgroundTasks()
|
|
|
tasks.add_task(
|
|
|
unpack_pictures_zip,
|
|
|
file=file,
|
|
|
time=datetime.utcnow().replace(microsecond=0).isoformat(),
|
|
|
)
|
|
|
|
|
|
# feed the pictures to util/import_photos.py
|
|
|
return JSONResponse("Accepted", background=tasks)
|
|
|
|
|
|
|
|
|
def unpack_pictures_zip(file: UploadFile, time):
|
|
|
"""
|
|
|
Unpack and process zip archived photo
|
|
|
Extract pictures in the DATA_LOCATION/processing
|
|
|
#TODO: and feed them to util/import_photos.py
|
|
|
#TODO: Walk the nested DATA_LOCATION/processing ourselves
|
|
|
Uses: DATA_LOCATION
|
|
|
"""
|
|
|
# we only call this function sporadically, so import here
|
|
|
import os
|
|
|
|
|
|
print(f"Accepted {file.filename} at {time} into processing")
|
|
|
os.chdir(DATA_LOCATION)
|
|
|
os.makedirs("processing", exist_ok=True)
|
|
|
os.chdir("processing")
|
|
|
|
|
|
# using private ._file field is a dirty hack, but
|
|
|
# SpooledTemporaryFile does not implement seekable
|
|
|
# required by zipfile 'r' mode
|
|
|
# https://bugs.python.org/issue26175
|
|
|
with zipfile.ZipFile(file.file._file) as photo_zip:
|
|
|
problem_files = photo_zip.testzip()
|
|
|
if problem_files is not None:
|
|
|
print(f"Errors in {file.filename} accepted at {time}: {problem_files}")
|
|
|
photo_zip.extractall()
|
|
|
photo_zip.close()
|
|
|
print(f"Succesfully processed {file.filename} accepted at {time}")
|