summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorWolfgang Müller2024-11-29 16:02:17 +0100
committerWolfgang Müller2025-01-19 16:10:05 +0100
commit261ceaa057742fc70c52885021221d7a89c28af7 (patch)
tree6256a4968a98320c999f41c7c4eeed5a9ed03802
parent9dea5cbc01e2d447d77d5fb205acb1d17fe9c55f (diff)
downloadhircine-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__.py2
-rw-r--r--src/hircine/api/query/resolvers.py29
-rw-r--r--src/hircine/api/responses.py30
-rw-r--r--src/hircine/db/ops.py8
-rw-r--r--tests/api/test_statistics.py106
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)