diff options
author | Wolfgang Müller | 2024-11-29 16:02:17 +0100 |
---|---|---|
committer | Wolfgang Müller | 2025-01-19 16:10:05 +0100 |
commit | 261ceaa057742fc70c52885021221d7a89c28af7 (patch) | |
tree | 6256a4968a98320c999f41c7c4eeed5a9ed03802 | |
parent | 9dea5cbc01e2d447d77d5fb205acb1d17fe9c55f (diff) | |
download | hircine-261ceaa057742fc70c52885021221d7a89c28af7.tar.gz |
backend: Add basic statistics query endpoint
For now we simply collect totals for all scrapers, models, and comic
associations. These should be sufficient to compile some basic but still
interesting statistics.
-rw-r--r-- | src/hircine/api/query/__init__.py | 2 | ||||
-rw-r--r-- | src/hircine/api/query/resolvers.py | 29 | ||||
-rw-r--r-- | src/hircine/api/responses.py | 30 | ||||
-rw-r--r-- | src/hircine/db/ops.py | 8 | ||||
-rw-r--r-- | tests/api/test_statistics.py | 106 |
5 files changed, 174 insertions, 1 deletions
diff --git a/src/hircine/api/query/__init__.py b/src/hircine/api/query/__init__.py index 2e07c71..7bc4b7d 100644 --- a/src/hircine/api/query/__init__.py +++ b/src/hircine/api/query/__init__.py @@ -24,6 +24,7 @@ from .resolvers import ( every, scrape_comic, single, + statistics, ) @@ -52,3 +53,4 @@ class Query: world: rp.WorldResponse = query(single(models.World)) worlds: FilterResult[World] = query(every(models.World)) scrape_comic: rp.ScrapeComicResponse = query(scrape_comic) + statistics: rp.Statistics = query(statistics) diff --git a/src/hircine/api/query/resolvers.py b/src/hircine/api/query/resolvers.py index 6609cc1..389a200 100644 --- a/src/hircine/api/query/resolvers.py +++ b/src/hircine/api/query/resolvers.py @@ -10,10 +10,13 @@ import hircine.plugins as plugins from hircine.api.filters import Input as FilterInput from hircine.api.inputs import Pagination from hircine.api.responses import ( + ComicTotals, IDNotFoundError, ScraperError, ScraperNotAvailableError, ScraperNotFoundError, + Statistics, + Totals, ) from hircine.api.sort import Input as SortInput from hircine.api.types import ( @@ -144,3 +147,29 @@ async def scrape_comic(id: int, scraper: str): ) except ScrapeError as e: return ScraperError(error=str(e)) + + +async def statistics(): + async with db.session() as s: + total = Totals( + archives=await ops.count(s, models.Archive), + artists=await ops.count(s, models.Artist), + characters=await ops.count(s, models.Character), + circles=await ops.count(s, models.Circle), + comic=ComicTotals( + artists=await ops.count(s, models.ComicArtist), + characters=await ops.count(s, models.ComicCharacter), + circles=await ops.count(s, models.ComicCircle), + tags=await ops.count(s, models.ComicTag), + worlds=await ops.count(s, models.ComicWorld), + ), + comics=await ops.count(s, models.Comic), + images=await ops.count(s, models.Image), + namespaces=await ops.count(s, models.Namespace), + pages=await ops.count(s, models.Page), + scrapers=len(plugins.get_scrapers()), + tags=await ops.count(s, models.Tag), + worlds=await ops.count(s, models.World), + ) + + return Statistics(total=total) diff --git a/src/hircine/api/responses.py b/src/hircine/api/responses.py index 99d5113..883705b 100644 --- a/src/hircine/api/responses.py +++ b/src/hircine/api/responses.py @@ -147,6 +147,36 @@ class ScraperNotAvailableError(Error): return f"Scraper {self.scraper} not available for comic ID {self.comic_id}" +@strawberry.type +class ComicTotals: + artists: int + characters: int + circles: int + tags: int + worlds: int + + +@strawberry.type +class Totals: + archives: int + artists: int + characters: int + circles: int + comics: int + comic: ComicTotals + images: int + namespaces: int + pages: int + scrapers: int + tags: int + worlds: int + + +@strawberry.type +class Statistics: + total: Totals + + AddComicResponse = Annotated[ Union[ AddComicSuccess, diff --git a/src/hircine/db/ops.py b/src/hircine/db/ops.py index 91c830d..8cc5ddc 100644 --- a/src/hircine/db/ops.py +++ b/src/hircine/db/ops.py @@ -1,7 +1,7 @@ import random from collections import defaultdict -from sqlalchemy import delete, func, null, select, text, tuple_ +from sqlalchemy import delete, func, literal_column, null, select, text, tuple_ from sqlalchemy.orm import contains_eager, undefer from sqlalchemy.orm.util import identity_key from strawberry import UNSET @@ -204,3 +204,9 @@ async def delete_all(session, model, ids): result = await session.execute(delete(model).where(model.id.in_(ids))) return result.rowcount + + +async def count(session, model): + sql = select(func.count(literal_column("1"))).select_from(model) + + return (await session.execute(sql)).scalar_one() diff --git a/tests/api/test_statistics.py b/tests/api/test_statistics.py new file mode 100644 index 0000000..98c8dc7 --- /dev/null +++ b/tests/api/test_statistics.py @@ -0,0 +1,106 @@ +import pytest +from conftest import DB, Response + +import hircine.plugins +from hircine.db.models import ( + Artist, + Character, + Circle, + Page, + Tag, + World, +) +from hircine.scraper import Scraper + +totals_fragment = """ + fragment Totals on Statistics { + total { + archives + artists + characters + circles + comics + namespaces + scrapers + tags + worlds + images + pages + comic { + artists + characters + circles + tags + worlds + } + } + } +""" + + +@pytest.fixture +def query_statistics(execute): + query = """ + query statistics { + statistics { + __typename + ... Totals + } + } + """ + + return execute(totals_fragment + query) + + +@pytest.mark.anyio +async def test_statistics_returns_totals( + gen_comic, gen_image, query_statistics, empty_plugins +): + comic = next(gen_comic) + await DB.add(comic) + await DB.add(Artist(name="foo")) + await DB.add(Character(name="foo")) + await DB.add(Circle(name="foo")) + await DB.add(World(name="foo")) + await DB.add(Tag(name="foo")) + + image = next(gen_image) + await DB.add(image) + await DB.add( + Page(id=100, index=100, path="100.png", image=image, archive=comic.archive) + ) + await DB.add( + Page(id=101, index=101, path="101.png", image=image, archive=comic.archive) + ) + + namespaces = set() + for tag in comic.tags: + namespaces.add(tag.namespace.id) + + class MockScraper(Scraper): + name = "Scraper" + + def scrape(self): + yield None + + hircine.plugins.register_scraper("mock", MockScraper) + + response = Response(await query_statistics()) + + response.assert_is("Statistics") + assert response.total["comics"] == 1 + assert response.total["archives"] == 1 + assert response.total["artists"] == len(comic.artists) + 1 + assert response.total["characters"] == len(comic.characters) + 1 + assert response.total["circles"] == len(comic.circles) + 1 + assert response.total["worlds"] == len(comic.worlds) + 1 + assert response.total["tags"] == len(comic.tags) + 1 + assert response.total["namespaces"] == len(namespaces) + assert response.total["images"] == len(comic.pages) + 1 + assert response.total["pages"] == len(comic.pages) + 2 + assert response.total["scrapers"] == 1 + assert response.total["comic"]["artists"] == len(comic.artists) + assert response.total["comic"]["characters"] == len(comic.characters) + assert response.total["comic"]["circles"] == len(comic.circles) + assert response.total["comic"]["tags"] == len(comic.tags) + assert response.total["comic"]["worlds"] == len(comic.worlds) |