✨ Added Pixabay image search/import
This commit is contained in:
@@ -32,6 +32,7 @@ from classquiz.routers import (
|
|||||||
admin,
|
admin,
|
||||||
box_controller,
|
box_controller,
|
||||||
quiztivity,
|
quiztivity,
|
||||||
|
pixabay,
|
||||||
)
|
)
|
||||||
from classquiz.socket_server import sio
|
from classquiz.socket_server import sio
|
||||||
from classquiz.helpers import meilisearch_init, telemetry_ping
|
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)
|
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(quiztivity.router, tags=["quiztivity"], prefix="/api/v1/quiztivity", include_in_schema=True)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
|
|||||||
custom_openid_provider: CustomOpenIDProvider | None = None
|
custom_openid_provider: CustomOpenIDProvider | None = None
|
||||||
telemetry_enabled: bool = True
|
telemetry_enabled: bool = True
|
||||||
free_storage_limit: int = 1074000000
|
free_storage_limit: int = 1074000000
|
||||||
|
pixabay_api_key: str | None = None
|
||||||
|
|
||||||
# storage_backend
|
# storage_backend
|
||||||
storage_backend: str | None = "local"
|
storage_backend: str | None = "local"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
import { getLocalization } from '$lib/i18n';
|
import { getLocalization } from '$lib/i18n';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Library from '$lib/editor/uploader/Library.svelte';
|
import Library from '$lib/editor/uploader/Library.svelte';
|
||||||
|
import Pixabay from '$lib/editor/uploader/Pixabay.svelte';
|
||||||
|
|
||||||
const { t } = getLocalization();
|
const { t } = getLocalization();
|
||||||
|
|
||||||
@@ -46,7 +47,9 @@
|
|||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
Video,
|
Video,
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
Library
|
Library,
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
Pixabay
|
||||||
}
|
}
|
||||||
|
|
||||||
const uppy = new Uppy()
|
const uppy = new Uppy()
|
||||||
@@ -165,6 +168,14 @@
|
|||||||
</BrownButton>
|
</BrownButton>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="w-full">
|
||||||
|
<BrownButton
|
||||||
|
on:click={() => {
|
||||||
|
selected_type = AvailableUploadTypes.Pixabay;
|
||||||
|
}}
|
||||||
|
>Pixabay
|
||||||
|
</BrownButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if selected_type === AvailableUploadTypes.Image}
|
{:else if selected_type === AvailableUploadTypes.Image}
|
||||||
@@ -193,6 +204,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<Library bind:data {selected_question} bind:modalOpen />
|
<Library bind:data {selected_question} bind:modalOpen />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if selected_type === AvailableUploadTypes.Pixabay}
|
||||||
|
<div>
|
||||||
|
<Pixabay bind:data {selected_question} bind:modalOpen />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!--
|
||||||
|
- 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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { EditorData } from '$lib/quiz_types';
|
||||||
|
import Spinner from '$lib/Spinner.svelte';
|
||||||
|
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||||
|
import { getLocalization } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let data: EditorData;
|
||||||
|
export let selected_question: number;
|
||||||
|
export let modalOpen: boolean;
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
let search_term = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
const { t } = getLocalization();
|
||||||
|
|
||||||
|
const set_image = async (id: string) => {
|
||||||
|
loading = true;
|
||||||
|
const res = await fetch(`/api/v1/pixabay/save?id=${id}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
const storage_id = json.id;
|
||||||
|
if (selected_question === undefined) {
|
||||||
|
data.cover_image = storage_id;
|
||||||
|
} else if (selected_question === -1) {
|
||||||
|
data.background_image = storage_id;
|
||||||
|
} else {
|
||||||
|
data.questions[selected_question].image = storage_id;
|
||||||
|
}
|
||||||
|
modalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetch_data = async () => {
|
||||||
|
const res = await fetch(`/api/v1/pixabay/images?page=${page}&query=${search_term}`);
|
||||||
|
return await res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetched_data = fetch_data();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await fetched_data}
|
||||||
|
<Spinner />
|
||||||
|
{:then data}
|
||||||
|
{#if loading}
|
||||||
|
<Spinner />
|
||||||
|
{:else}
|
||||||
|
<div class="flex w-screen p-8 h-full mt-8 mb-1">
|
||||||
|
<div
|
||||||
|
class="flex flex-col w-1/3 m-auto overflow-scroll h-full rounded p-4 gap-4 bg-white dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<div class="w-full flex gap-2">
|
||||||
|
<input
|
||||||
|
class="w-full outline-none p-1 rounded bg-gray-500"
|
||||||
|
bind:value={search_term}
|
||||||
|
/>
|
||||||
|
<div class="w-fit">
|
||||||
|
<BrownButton on:click={() => (fetched_data = fetch_data())}
|
||||||
|
>Search</BrownButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each data.hits as image}
|
||||||
|
<div class="rounded border-2 border-[#B07156] p-2 flex-col flex gap-2">
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={image.webformatURL}
|
||||||
|
loading="lazy"
|
||||||
|
alt="unavailable"
|
||||||
|
class="object-contain w-full h-full rounded max-h-[80vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<BrownButton
|
||||||
|
on:click={() => {
|
||||||
|
set_image(image.id);
|
||||||
|
}}>{$t('words.select')}</BrownButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
Reference in New Issue
Block a user