summaryrefslogtreecommitdiffstatshomepage
path: root/src/hircine/api/inputs.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/hircine/api/inputs.py')
-rw-r--r--src/hircine/api/inputs.py578
1 files changed, 578 insertions, 0 deletions
diff --git a/src/hircine/api/inputs.py b/src/hircine/api/inputs.py
new file mode 100644
index 0000000..c88bcce
--- /dev/null
+++ b/src/hircine/api/inputs.py
@@ -0,0 +1,578 @@
+import datetime
+from abc import ABC, abstractmethod
+from typing import List, Optional, Type
+
+import strawberry
+from sqlalchemy.orm.util import identity_key
+from strawberry import UNSET
+
+import hircine.db
+import hircine.db.ops as ops
+from hircine.api import APIException, MutationContext
+from hircine.api.responses import (
+ IDNotFoundError,
+ InvalidParameterError,
+ PageClaimedError,
+ PageRemoteError,
+)
+from hircine.db.models import Archive, Base, Comic, ComicTag, Namespace, Tag
+from hircine.enums import (
+ Category,
+ Censorship,
+ Direction,
+ Language,
+ Layout,
+ OnMissing,
+ Rating,
+ UpdateMode,
+)
+
+
+def add_input_cls(modelcls):
+ return globals().get(f"Add{modelcls.__name__}Input")
+
+
+def update_input_cls(modelcls):
+ return globals().get(f"Update{modelcls.__name__}Input")
+
+
+def upsert_input_cls(modelcls):
+ return globals().get(f"Upsert{modelcls.__name__}Input")
+
+
+class Fetchable(ABC):
+ """
+ When mutating a model's associations, the API requires the user to pass
+ referential IDs. These may be referencing new items to add to a list of
+ associations, or items that should be removed.
+
+ For example, the updateTags mutation requires as its input for the
+ "namespaces" field a list of numerical IDs:
+
+ mutation updateTags {
+ updateTags(ids: 1, input: {namespaces: {ids: [1, 2]}}) { [...] }
+ }
+
+ Mutations make heavy use of SQLAlchemy's ORM features to reconcile changes
+ between related objects (like Tags and Namespaces). In the example above,
+ to reconcile the changes made to a Tag's valid Namespaces, SQLAlchemy needs
+ to know about three objects: the Tag that is being modified and the two
+ Namespaces being added to it.
+
+ This way SQLAlchemy can figure out whether it needs to add those Namespaces
+ to the Tag (or whether they're already there and can be skipped) and will,
+ upon commit, update the relevant tables automatically without us having to
+ emit custom SQL.
+
+ SQLAlchemy cannot know about an object's relationships by ID alone, so it
+ needs to be fetched from the database first. The Fetchable class
+ facilitates this. It provides an abstract "fetch" method that, given a
+ MutationContext, will return any relevant objects from the database.
+
+ Additionally, fetched items can be "constrained" to enforce API rules.
+ """
+
+ _model: Type[Base]
+
+ @abstractmethod
+ async def fetch(self, ctx: MutationContext):
+ pass
+
+ def update_mode(self):
+ try:
+ return self.options.mode
+ except AttributeError:
+ return UpdateMode.REPLACE
+
+ @classmethod
+ async def constrain_item(cls, item, ctx: MutationContext):
+ pass
+
+
+class FetchableID(Fetchable):
+ """
+ A Fetchable for numerical IDs. Database queries are batched to avoid an
+ excess amount of SQL queries.
+ """
+
+ @classmethod
+ async def get_from_id(cls, id, ctx: MutationContext):
+ item, *_ = await cls.get_from_ids([id], ctx)
+
+ return item
+
+ @classmethod
+ async def get_from_ids(cls, ids, ctx: MutationContext):
+ items, missing = await ops.get_all(
+ ctx.session, cls._model, ids, use_identity_map=True
+ )
+
+ if missing:
+ raise APIException(IDNotFoundError(cls._model, missing.pop()))
+
+ for item in items:
+ await cls.constrain_item(item, ctx)
+
+ return items
+
+
+class FetchableName(Fetchable):
+ """
+ A Fetchable for textual IDs (used only for Tags). As with FetchableID,
+ queries are batched.
+ """
+
+ @classmethod
+ async def get_from_names(cls, names, ctx: MutationContext, on_missing: OnMissing):
+ for name in names:
+ if not name:
+ raise APIException(
+ InvalidParameterError(
+ parameter=f"{cls._model.__name__}.name", text="cannot be empty"
+ )
+ )
+
+ items, missing = await ops.get_all_names(ctx.session, cls._model, names)
+
+ if on_missing == OnMissing.CREATE:
+ for m in missing:
+ items.append(cls._model(name=m))
+
+ return items
+
+
+@strawberry.input
+class Input(FetchableID):
+ id: int
+
+ async def fetch(self, ctx: MutationContext):
+ return await self.get_from_id(self.id, ctx)
+
+
+@strawberry.input
+class InputList(FetchableID):
+ ids: List[int]
+
+ async def fetch(self, ctx: MutationContext):
+ if not self.ids:
+ return []
+
+ return await self.get_from_ids(self.ids, ctx)
+
+
+@strawberry.input
+class UpdateOptions:
+ mode: UpdateMode = UpdateMode.REPLACE
+
+
+@strawberry.input
+class UpdateInputList(InputList):
+ options: Optional[UpdateOptions] = UNSET
+
+
+@strawberry.input
+class Pagination:
+ page: int = 1
+ items: int = 40
+
+
+@hircine.db.model("Archive")
+@strawberry.input
+class ArchiveInput(Input):
+ pass
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class UniquePagesInput(InputList):
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if page.comic_id:
+ raise APIException(PageClaimedError(id=page.id, comic_id=page.comic_id))
+
+ if page.archive_id != ctx.input.archive.id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class UniquePagesUpdateInput(UpdateInputList):
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if page.comic_id and page.comic_id != ctx.root.id:
+ raise APIException(PageClaimedError(id=page.id, comic_id=page.comic_id))
+
+ if page.archive_id != ctx.root.archive_id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Namespace")
+@strawberry.input
+class NamespacesInput(InputList):
+ pass
+
+
+@hircine.db.model("Namespace")
+@strawberry.input
+class NamespacesUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class CoverInput(Input):
+ async def fetch(self, ctx: MutationContext):
+ page = await self.get_from_id(self.id, ctx)
+ return page.image
+
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if page.archive_id != ctx.input.archive.id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class CoverUpdateInput(CoverInput):
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if ctx.model == Comic:
+ id = ctx.root.archive_id
+ elif ctx.model == Archive:
+ id = ctx.root.id
+
+ if page.archive_id != id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Character")
+@strawberry.input
+class CharactersUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("Artist")
+@strawberry.input
+class ArtistsUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("Circle")
+@strawberry.input
+class CirclesUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("World")
+@strawberry.input
+class WorldsUpdateInput(UpdateInputList):
+ pass
+
+
+@strawberry.input
+class ComicTagsUpdateInput(UpdateInputList):
+ ids: List[str] = strawberry.field(default_factory=lambda: [])
+
+ @classmethod
+ def parse_input(cls, id):
+ try:
+ return [int(i) for i in id.split(":")]
+ except ValueError:
+ raise APIException(
+ InvalidParameterError(
+ parameter="id",
+ text="ComicTag ID must be specified as <namespace_id>:<tag_id>",
+ )
+ )
+
+ @classmethod
+ async def get_from_ids(cls, ids, ctx: MutationContext):
+ comic = ctx.root
+
+ ctags = []
+ remaining = set()
+
+ for id in ids:
+ nid, tid = cls.parse_input(id)
+
+ key = identity_key(ComicTag, (comic.id, nid, tid))
+ item = ctx.session.identity_map.get(key, None)
+
+ if item is not None:
+ ctags.append(item)
+ else:
+ remaining.add((nid, tid))
+
+ if not remaining:
+ return ctags
+
+ nids, tids = zip(*remaining)
+
+ namespaces, missing = await ops.get_all(
+ ctx.session, Namespace, nids, use_identity_map=True
+ )
+ if missing:
+ raise APIException(IDNotFoundError(Namespace, missing.pop()))
+
+ tags, missing = await ops.get_all(ctx.session, Tag, tids, use_identity_map=True)
+ if missing:
+ raise APIException(IDNotFoundError(Tag, missing.pop()))
+
+ for nid, tid in remaining:
+ namespace = ctx.session.identity_map.get(identity_key(Namespace, nid))
+ tag = ctx.session.identity_map.get(identity_key(Tag, tid))
+
+ ctags.append(ComicTag(namespace=namespace, tag=tag))
+
+ return ctags
+
+
+@strawberry.input
+class UpsertOptions:
+ on_missing: OnMissing = OnMissing.IGNORE
+
+
+@strawberry.input
+class UpsertInputList(FetchableName):
+ names: List[str] = strawberry.field(default_factory=lambda: [])
+ options: Optional[UpsertOptions] = UNSET
+
+ async def fetch(self, ctx: MutationContext):
+ if not self.names:
+ return []
+
+ options = self.options or UpsertOptions()
+ return await self.get_from_names(self.names, ctx, on_missing=options.on_missing)
+
+ def update_mode(self):
+ return UpdateMode.ADD
+
+
+@hircine.db.model("Character")
+@strawberry.input
+class CharactersUpsertInput(UpsertInputList):
+ pass
+
+
+@hircine.db.model("Artist")
+@strawberry.input
+class ArtistsUpsertInput(UpsertInputList):
+ pass
+
+
+@hircine.db.model("Circle")
+@strawberry.input
+class CirclesUpsertInput(UpsertInputList):
+ pass
+
+
+@hircine.db.model("World")
+@strawberry.input
+class WorldsUpsertInput(UpsertInputList):
+ pass
+
+
+@strawberry.input
+class ComicTagsUpsertInput(UpsertInputList):
+ @classmethod
+ def parse_input(cls, name):
+ try:
+ namespace, tag = name.split(":")
+
+ if not namespace or not tag:
+ raise ValueError()
+
+ return namespace, tag
+ except ValueError:
+ raise APIException(
+ InvalidParameterError(
+ parameter="name",
+ text="ComicTag name must be specified as <namespace>:<tag>",
+ )
+ )
+
+ @classmethod
+ async def get_from_names(cls, input, ctx: MutationContext, on_missing: OnMissing):
+ comic = ctx.root
+
+ names = set()
+ for name in input:
+ names.add(cls.parse_input(name))
+
+ ctags, missing = await ops.get_ctag_names(ctx.session, comic.id, names)
+
+ if not missing:
+ return ctags
+
+ async def lookup(names, model):
+ have, missing = await ops.get_all_names(
+ ctx.session, model, names, options=model.load_full()
+ )
+ dict = {}
+
+ for item in have:
+ dict[item.name] = (item, True)
+ for item in missing:
+ dict[item] = (model(name=item), False)
+
+ return dict
+
+ remaining_ns, remaining_tags = zip(*missing)
+
+ namespaces = await lookup(remaining_ns, Namespace)
+ tags = await lookup(remaining_tags, Tag)
+
+ if on_missing == OnMissing.CREATE:
+ for ns, tag in missing:
+ namespace, _ = namespaces[ns]
+ tag, _ = tags[tag]
+
+ tag.namespaces.append(namespace)
+
+ ctags.append(ComicTag(namespace=namespace, tag=tag))
+
+ elif on_missing == OnMissing.IGNORE:
+ resident = []
+
+ for ns, tag in missing:
+ namespace, namespace_resident = namespaces[ns]
+ tag, tag_resident = tags[tag]
+
+ if namespace_resident and tag_resident:
+ resident.append((namespace, tag))
+
+ restrictions = await ops.tag_restrictions(
+ ctx.session, [(ns.id, tag.id) for ns, tag in resident]
+ )
+
+ for namespace, tag in resident:
+ if namespace.id in restrictions[tag.id]:
+ ctags.append(ComicTag(namespace=namespace, tag=tag))
+
+ return ctags
+
+
+@strawberry.input
+class UpdateArchiveInput:
+ cover: Optional[CoverUpdateInput] = UNSET
+ organized: Optional[bool] = UNSET
+
+
+@strawberry.input
+class AddComicInput:
+ title: str
+ archive: ArchiveInput
+ pages: UniquePagesInput
+ cover: CoverInput
+
+
+@strawberry.input
+class UpdateComicInput:
+ title: Optional[str] = UNSET
+ original_title: Optional[str] = UNSET
+ cover: Optional[CoverUpdateInput] = UNSET
+ pages: Optional[UniquePagesUpdateInput] = UNSET
+ url: Optional[str] = UNSET
+ language: Optional[Language] = UNSET
+ date: Optional[datetime.date] = UNSET
+ direction: Optional[Direction] = UNSET
+ layout: Optional[Layout] = UNSET
+ rating: Optional[Rating] = UNSET
+ category: Optional[Category] = UNSET
+ censorship: Optional[Censorship] = UNSET
+ tags: Optional[ComicTagsUpdateInput] = UNSET
+ artists: Optional[ArtistsUpdateInput] = UNSET
+ characters: Optional[CharactersUpdateInput] = UNSET
+ circles: Optional[CirclesUpdateInput] = UNSET
+ worlds: Optional[WorldsUpdateInput] = UNSET
+ favourite: Optional[bool] = UNSET
+ organized: Optional[bool] = UNSET
+ bookmarked: Optional[bool] = UNSET
+
+
+@strawberry.input
+class UpsertComicInput:
+ title: Optional[str] = UNSET
+ original_title: Optional[str] = UNSET
+ url: Optional[str] = UNSET
+ language: Optional[Language] = UNSET
+ date: Optional[datetime.date] = UNSET
+ direction: Optional[Direction] = UNSET
+ layout: Optional[Layout] = UNSET
+ rating: Optional[Rating] = UNSET
+ category: Optional[Category] = UNSET
+ censorship: Optional[Censorship] = UNSET
+ tags: Optional[ComicTagsUpsertInput] = UNSET
+ artists: Optional[ArtistsUpsertInput] = UNSET
+ characters: Optional[CharactersUpsertInput] = UNSET
+ circles: Optional[CirclesUpsertInput] = UNSET
+ worlds: Optional[WorldsUpsertInput] = UNSET
+ favourite: Optional[bool] = UNSET
+ organized: Optional[bool] = UNSET
+ bookmarked: Optional[bool] = UNSET
+
+
+@strawberry.input
+class AddNamespaceInput:
+ name: str
+ sort_name: Optional[str] = UNSET
+
+
+@strawberry.input
+class UpdateNamespaceInput:
+ name: Optional[str] = UNSET
+ sort_name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddTagInput:
+ name: str
+ description: Optional[str] = None
+ namespaces: Optional[NamespacesInput] = UNSET
+
+
+@strawberry.input
+class UpdateTagInput:
+ name: Optional[str] = UNSET
+ description: Optional[str] = UNSET
+ namespaces: Optional[NamespacesUpdateInput] = UNSET
+
+
+@strawberry.input
+class AddArtistInput:
+ name: str
+
+
+@strawberry.input
+class UpdateArtistInput:
+ name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddCharacterInput:
+ name: str
+
+
+@strawberry.input
+class UpdateCharacterInput:
+ name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddCircleInput:
+ name: str
+
+
+@strawberry.input
+class UpdateCircleInput:
+ name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddWorldInput:
+ name: str
+
+
+@strawberry.input
+class UpdateWorldInput:
+ name: Optional[str] = UNSET