diff --git a/classquiz/__init__.py b/classquiz/__init__.py index 978abea..6984834 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -32,6 +32,7 @@ from classquiz.routers import ( admin, box_controller, quiztivity, + pixabay, ) from classquiz.socket_server import sio from classquiz.helpers import meilisearch_init, telemetry_ping @@ -76,6 +77,7 @@ async def auth_middleware_wrapper(request: Request, call_next): return await rememberme_middleware(request, call_next) +app.include_router(pixabay.router, tags=["pixabay"], prefix="/api/v1/pixabay", include_in_schema=True) app.include_router(quiztivity.router, tags=["quiztivity"], prefix="/api/v1/quiztivity", include_in_schema=True) app.include_router( diff --git a/classquiz/config.py b/classquiz/config.py index 50f8cdd..853ce74 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): custom_openid_provider: CustomOpenIDProvider | None = None telemetry_enabled: bool = True free_storage_limit: int = 1074000000 + pixabay_api_key: str | None = None # storage_backend storage_backend: str | None = "local" diff --git a/classquiz/helpers/pixabay.py b/classquiz/helpers/pixabay.py new file mode 100644 index 0000000..ef693b8 --- /dev/null +++ b/classquiz/helpers/pixabay.py @@ -0,0 +1,136 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +import enum + +from aiohttp import ClientSession +from pydantic import BaseModel + + +class ImageType(str, enum.Enum): + all = "all" + photo = "photo" + illustration = "illustration" + vector = "vector" + + +class Orientation(str, enum.Enum): + all = ("all",) + horizontal = "horizontal" + vertical = "vertical" + + +class Category(str, enum.Enum): + background = "background" + fashion = "fashion" + nature = "nature" + science = "science" + education = "education" + feelings = "feelings" + health = "health" + people = "people" + religion = "religion" + places = "places" + animals = "animals" + industry = "industry" + computer = "computer" + food = "food" + sports = "sports" + transportation = "transportation" + travel = "travel" + buildings = "buildings" + business = "business" + music = "music" + + +class Colors(str, enum.Enum): + grayscale = "grayscale" + transparent = "transparent" + red = "red" + orange = "orange" + yellow = "yellow" + green = "green" + turquoise = "turquoise" + blue = "blue" + lilac = "lilac" + pink = "pink" + white = "white" + gray = "gray" + black = "black" + brown = "brown" + + +class Order(str, enum.Enum): + popular = "popular" + latest = "latest" + + +class BoolInput(str, enum.Enum): + true = "true" + false = "false" + + +class GetImagesParams(BaseModel): + q: str = "" + lang: str = "en" + id: str = "" + image_type: ImageType = ImageType.all + orientation: Orientation = Orientation.all + category: Category | str = "" + min_width: int = 0 + min_height: int = 0 + colors: Colors | str = "" + editors_choice: BoolInput = BoolInput.false + safesearch: BoolInput = BoolInput.false + order: Order = Order.popular + page: int = 1 + pretty: BoolInput = BoolInput.false + + +class Hit(BaseModel): + id: int + pageURL: str + type: str + tags: str + previewURL: str + previewWidth: int + previewHeight: int + webformatURL: str + webformatWidth: int + webformatHeight: int + largeImageURL: str + imageWidth: int + imageHeight: int + imageSize: int + views: int + downloads: int + collections: int + likes: int + comments: int + user_id: int + user: str + userImageURL: str + + +class GetImagesResponse(BaseModel): + total: int + totalHits: int + hits: list[Hit] + + +class NotFoundError(Exception): + pass + + +async def get_images(api_key: str, params: GetImagesParams) -> GetImagesResponse: + async with ClientSession() as session, session.get( + "https://pixabay.com/api/", params={"key": api_key, **params.dict()} + ) as resp: + if resp.status == 200: + return GetImagesResponse.parse_obj(await resp.json()) + else: + raise NotFoundError diff --git a/classquiz/routers/pixabay.py b/classquiz/routers/pixabay.py new file mode 100644 index 0000000..84dc32d --- /dev/null +++ b/classquiz/routers/pixabay.py @@ -0,0 +1,62 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +from datetime import datetime +from io import BytesIO +from uuid import uuid4 + +from aiohttp import ClientSession +from fastapi import APIRouter, Depends, HTTPException + +from classquiz.auth import get_current_user +from classquiz.db.models import User, StorageItem, PublicStorageItem +from classquiz.helpers.pixabay import get_images, GetImagesParams, BoolInput, GetImagesResponse, NotFoundError +from classquiz.config import settings, storage, arq + +settings = settings() +router = APIRouter() + + +@router.get("/images") +async def search_pixabay_images(query: str, page: int = 1, user: User = Depends(get_current_user)) -> GetImagesResponse: + if settings.pixabay_api_key is None: + raise HTTPException(status_code=423, detail="Pixabay not set up") + return await get_images(settings.pixabay_api_key, GetImagesParams(q=query, safesearch=BoolInput.true, page=page)) + + +@router.post("/save") +async def save_pixabay_image(id: str, user: User = Depends(get_current_user)) -> PublicStorageItem: + if settings.pixabay_api_key is None: + raise HTTPException(status_code=423, detail="Pixabay not set up") + if user.storage_used > settings.free_storage_limit: + raise HTTPException(status_code=409, detail="Storage limit reached") + try: + images = await get_images(settings.pixabay_api_key, GetImagesParams(id=id, safesearch=BoolInput.true)) + except NotFoundError: + raise HTTPException(status_code=404, detail="Pixabay file not found") + image = images.hits[0] + file_id = uuid4() + file_data = b"" + async with ClientSession() as session, session.get(image.largeImageURL) as resp: + async for i in resp.content.iter_chunked(1024): + file_data += i + content_type = resp.headers.get("Content-Type") + + if content_type is None: + content_type = "image/*" + file = BytesIO(file_data) + await storage.upload(file_name=file_id.hex, file_data=file, mime_type=content_type) + file_obj: StorageItem = StorageItem( + id=file_id, + uploaded_at=datetime.now(), + mime_type=content_type, + hash=None, + user=user, + size=0, + deleted_at=None, + alt_text=None, + imported=True, + ) + await file_obj.save() + await arq.enqueue_job("calculate_hash", file_id.hex) + return PublicStorageItem.from_db_model(file_obj) diff --git a/frontend/src/lib/editor/uploader.svelte b/frontend/src/lib/editor/uploader.svelte index 3d87e3c..adfa6d1 100644 --- a/frontend/src/lib/editor/uploader.svelte +++ b/frontend/src/lib/editor/uploader.svelte @@ -24,6 +24,7 @@ import { getLocalization } from '$lib/i18n'; import { onMount } from 'svelte'; import Library from '$lib/editor/uploader/Library.svelte'; + import Pixabay from '$lib/editor/uploader/Pixabay.svelte'; const { t } = getLocalization(); @@ -46,7 +47,9 @@ // eslint-disable-next-line no-unused-vars Video, // eslint-disable-next-line no-unused-vars - Library + Library, + // eslint-disable-next-line no-unused-vars + Pixabay } const uppy = new Uppy() @@ -165,6 +168,14 @@ {/if} +