diff --git a/catalog/book/models.py b/catalog/book/models.py index b4cef44c..7f118188 100644 --- a/catalog/book/models.py +++ b/catalog/book/models.py @@ -159,15 +159,16 @@ class Edition(Item): return [(i.value, i.label) for i in id_types] @classmethod - def lookup_id_cleanup(cls, lookup_id_type, lookup_id_value): + def lookup_id_cleanup(cls, lookup_id_type: str | IdType, lookup_id_value: str): if lookup_id_type in [IdType.ASIN.value, IdType.ISBN.value]: return detect_isbn_asin(lookup_id_value) return super().lookup_id_cleanup(lookup_id_type, lookup_id_value) - def merge_to(self, to_item): + def merge_to(self, to_item: "Edition | None"): super().merge_to(to_item) - for work in self.works.all(): - to_item.works.add(work) + if to_item: + for work in self.works.all(): + to_item.works.add(work) self.works.clear() def delete(self, using=None, soft=True, *args, **kwargs): @@ -278,17 +279,18 @@ class Work(Item): ] return [(i.value, i.label) for i in id_types] - def merge_to(self, to_item): + def merge_to(self, to_item: "Work | None"): super().merge_to(to_item) - for edition in self.editions.all(): - to_item.editions.add(edition) + if to_item: + for edition in self.editions.all(): + to_item.editions.add(edition) self.editions.clear() if ( to_item and self.title != to_item.title and self.title not in to_item.other_title ): - to_item.other_title += [self.title] + to_item.other_title += [self.title] # type: ignore to_item.save() def delete(self, using=None, soft=True, *args, **kwargs): diff --git a/catalog/book/utils.py b/catalog/book/utils.py index 5436e6f6..41c5eb3b 100644 --- a/catalog/book/utils.py +++ b/catalog/book/utils.py @@ -50,14 +50,15 @@ def is_asin(asin): return re.match(r"^B[A-Z0-9]{9}$", asin) is not None -def detect_isbn_asin(s): +def detect_isbn_asin(s: str) -> tuple[IdType, str] | tuple[None, None]: if not s: return None, None n = re.sub(r"[^0-9A-Z]", "", s.upper()) if is_isbn_13(n): return IdType.ISBN, n if is_isbn_10(n): - return IdType.ISBN, isbn_10_to_13(n) + v = isbn_10_to_13(n) + return (IdType.ISBN, v) if v else (None, None) if is_asin(n): return IdType.ASIN, n return None, None diff --git a/catalog/common/models.py b/catalog/common/models.py index 1bc55180..3c7ed647 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -1,7 +1,7 @@ import re import uuid from functools import cached_property -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, Iterable, Type, cast from auditlog.context import disable_auditlog from auditlog.models import AuditlogHistoryField, LogEntry @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection, models +from django.db.models import QuerySet from django.utils import timezone from django.utils.baseconv import base62 from django.utils.translation import gettext_lazy as _ @@ -145,26 +146,26 @@ class AvailableItemCategory(models.TextChoices): # class SubItemType(models.TextChoices): -# Season = "season", _("剧集分季") -# Episode = "episode", _("剧集分集") -# Version = "version", _("版本") +# Season = "season", _("season") +# Episode = "episode", _("episode") +# Version = "production", _("production") # class CreditType(models.TextChoices): -# Author = 'author', _('作者') -# Translater = 'translater', _('译者') -# Producer = 'producer', _('出品人') -# Director = 'director', _('电影') -# Actor = 'actor', _('演员') -# Playwright = 'playwright', _('播客') -# VoiceActor = 'voiceactor', _('配音') -# Host = 'host', _('主持人') -# Developer = 'developer', _('开发者') -# Publisher = 'publisher', _('出版方') +# Author = 'author', _('author') +# Translater = 'translater', _('translater') +# Producer = 'producer', _('producer') +# Director = 'director', _('director') +# Actor = 'actor', _('actor') +# Playwright = 'playwright', _('playwright') +# VoiceActor = 'voiceactor', _('voiceactor') +# Host = 'host', _('host') +# Developer = 'developer', _('developer') +# Publisher = 'publisher', _('publisher') class PrimaryLookupIdDescriptor(object): # TODO make it mixin of Field - def __init__(self, id_type): + def __init__(self, id_type: IdType): self.id_type = id_type def __get__(self, instance, cls=None): @@ -184,7 +185,7 @@ class PrimaryLookupIdDescriptor(object): # TODO make it mixin of Field class LookupIdDescriptor(object): # TODO make it mixin of Field - def __init__(self, id_type): + def __init__(self, id_type: IdType): self.id_type = id_type def __get__(self, instance, cls=None): @@ -198,14 +199,14 @@ class LookupIdDescriptor(object): # TODO make it mixin of Field # class ItemId(models.Model): # item = models.ForeignKey('Item', models.CASCADE) -# id_type = models.CharField(_("源网站"), blank=False, choices=IdType.choices, max_length=50) -# id_value = models.CharField(_("源网站ID"), blank=False, max_length=1000) +# id_type = models.CharField(_("Id Type"), blank=False, choices=IdType.choices, max_length=50) +# id_value = models.CharField(_("ID Value"), blank=False, max_length=1000) # class ItemCredit(models.Model): # item = models.ForeignKey('Item', models.CASCADE) -# credit_type = models.CharField(_("类型"), choices=CreditType.choices, blank=False, max_length=50) -# name = models.CharField(_("名字"), blank=False, max_length=1000) +# credit_type = models.CharField(_("Credit Type"), choices=CreditType.choices, blank=False, max_length=50) +# name = models.CharField(_("Name"), blank=False, max_length=1000) # def check_source_id(sid): @@ -241,11 +242,11 @@ class ItemInSchema(Schema): rating_count: int | None -class ItemSchema(ItemInSchema, BaseSchema): +class ItemSchema(BaseSchema, ItemInSchema): pass -class Item(SoftDeleteMixin, PolymorphicModel): +class Item(PolymorphicModel, SoftDeleteMixin): url_path = "item" # subclass must specify this type = None # subclass must specify this child_class = None # subclass may specify this to allow link to parent item @@ -315,13 +316,15 @@ class Item(SoftDeleteMixin, PolymorphicModel): return IdType.choices @classmethod - def lookup_id_cleanup(cls, lookup_id_type, lookup_id_value): + def lookup_id_cleanup( + cls, lookup_id_type: str | IdType, lookup_id_value: str + ) -> tuple[str | IdType, str] | tuple[None, None]: if not lookup_id_type or not lookup_id_value or not lookup_id_value.strip(): return None, None return lookup_id_type, lookup_id_value.strip() @classmethod - def get_best_lookup_id(cls, lookup_ids): + def get_best_lookup_id(cls, lookup_ids: dict[IdType, str]) -> tuple[IdType, str]: """get best available lookup id, ideally commonly used""" for t in IdealIdTypes: if lookup_ids.get(t): @@ -333,23 +336,23 @@ class Item(SoftDeleteMixin, PolymorphicModel): return None @property - def child_items(self): + def child_items(self) -> "QuerySet[Item]": return Item.objects.none() @property - def child_item_ids(self): + def child_item_ids(self) -> list[int]: return list(self.child_items.values_list("id", flat=True)) - def set_parent_item(self, value): + def set_parent_item(self, value: "Item | None"): # raise ValueError("cannot set parent item") pass @property - def parent_uuid(self): + def parent_uuid(self) -> str | None: return self.parent_item.uuid if self.parent_item else None @property - def sibling_items(self): + def sibling_items(self) -> "QuerySet[Item]": return Item.objects.none() @property @@ -357,19 +360,19 @@ class Item(SoftDeleteMixin, PolymorphicModel): return "" @property - def sibling_item_ids(self): + def sibling_item_ids(self) -> list[int]: return list(self.sibling_items.values_list("id", flat=True)) @classmethod - def get_ap_object_type(cls): + def get_ap_object_type(cls) -> str: return cls.__name__ @property - def ap_object_type(self): + def ap_object_type(self) -> str: return self.get_ap_object_type() @property - def ap_object_ref(self): + def ap_object_ref(self) -> dict[str, Any]: o = { "type": self.get_ap_object_type(), "href": self.absolute_url, @@ -379,12 +382,12 @@ class Item(SoftDeleteMixin, PolymorphicModel): o["image"] = self.cover_image_url return o - def log_action(self, changes): + def log_action(self, changes: dict[str, Any]): LogEntry.objects.log_create( # type: ignore self, action=LogEntry.Action.UPDATE, changes=changes ) - def merge_to(self, to_item): + def merge_to(self, to_item: "Item | None"): if to_item is None: if self.merged_to_item is not None: self.merged_to_item = None @@ -394,7 +397,7 @@ class Item(SoftDeleteMixin, PolymorphicModel): raise ValueError("cannot merge to self") if to_item.merged_to_item is not None: raise ValueError("cannot merge to item which is merged to another item") - if to_item.__class__ != self.__class__: + if not isinstance(to_item, self.__class__): raise ValueError("cannot merge to item in a different model") self.log_action({"!merged": [str(self.merged_to_item), str(to_item)]}) self.merged_to_item = to_item @@ -403,11 +406,11 @@ class Item(SoftDeleteMixin, PolymorphicModel): res.item = to_item res.save() - def recast_to(self, model): + def recast_to(self, model: "type[Item]") -> "Item": logger.warning(f"recast item {self} to {model}") - if self.__class__ == model: + if isinstance(self, model): return self - if model not in Item.__subclasses__(): + if not issubclass(model, Item): raise ValueError("invalid model to recast to") ct = ContentType.objects.get_for_model(model) old_ct = self.polymorphic_ctype @@ -442,15 +445,15 @@ class Item(SoftDeleteMixin, PolymorphicModel): return f"/api{self.url}" @property - def class_name(self): + def class_name(self) -> str: return self.__class__.__name__.lower() @property - def display_title(self): + def display_title(self) -> str: return self.title @classmethod - def get_by_url(cls, url_or_b62): + def get_by_url(cls, url_or_b62: str) -> "Item | None": b62 = url_or_b62.strip().split("/")[-1] if len(b62) not in [21, 22]: r = re.search(r"[A-Za-z0-9]{21,22}", url_or_b62) @@ -633,7 +636,7 @@ class ExternalResource(models.Model): return SiteName.Unknown @property - def site_label(self): + def site_label(self) -> str: if self.id_type == IdType.Fediverse: from takahe.utils import Takahe @@ -704,7 +707,7 @@ def item_content_types(): _CATEGORY_LIST = None -def item_categories(): +def item_categories() -> dict[ItemCategory, list[type[Item]]]: global _CATEGORY_LIST if _CATEGORY_LIST is None: _CATEGORY_LIST = {} diff --git a/catalog/podcast/models.py b/catalog/podcast/models.py index 8fbda661..d0f3b348 100644 --- a/catalog/podcast/models.py +++ b/catalog/podcast/models.py @@ -114,12 +114,14 @@ class PodcastEpisode(Item): self.program = value @property - def display_title(self): - return f"{self.program.title} - {self.title}" + def display_title(self) -> str: + return f"{self.program.title} - {self.title}" if self.program else self.title @property - def cover_image_url(self): - return self.cover_url or self.program.cover_image_url + def cover_image_url(self) -> str | None: + return self.cover_url or ( + self.program.cover_image_url if self.program else None + ) def get_url_with_position(self, position=None): return ( diff --git a/catalog/sites/bookstw.py b/catalog/sites/bookstw.py index 32be8229..54c7093c 100644 --- a/catalog/sites/bookstw.py +++ b/catalog/sites/bookstw.py @@ -135,7 +135,7 @@ class BooksTW(AbstractSite): } pd = ResourceContent(metadata=data) - t, n = detect_isbn_asin(isbn) + t, n = detect_isbn_asin(str(isbn)) if t: pd.lookup_ids[t] = n pd.cover_image, pd.cover_image_extention = BasicImageDownloader.download_image( diff --git a/journal/api.py b/journal/api.py index 8cef0a54..0529d7af 100644 --- a/journal/api.py +++ b/journal/api.py @@ -169,7 +169,7 @@ def list_reviews(request, category: AvailableItemCategory | None = None): """ queryset = Review.objects.filter(owner=request.user.identity) if category: - queryset = queryset.filter(q_item_in_category(category)) + queryset = queryset.filter(q_item_in_category(category)) # type: ignore[arg-type] return queryset.prefetch_related("item") diff --git a/journal/models/__init__.py b/journal/models/__init__.py index 4f409333..fa71eb3a 100644 --- a/journal/models/__init__.py +++ b/journal/models/__init__.py @@ -4,7 +4,6 @@ from .common import ( Piece, PieceInteraction, PiecePost, - UserOwnedObjectMixin, VisibilityType, max_visiblity_to_user, q_item_in_category, @@ -14,6 +13,7 @@ from .common import ( ) from .like import Like from .mark import Mark +from .mixins import UserOwnedObjectMixin from .rating import Rating from .renderers import render_md from .review import Review @@ -25,3 +25,37 @@ from .utils import ( reset_journal_visibility_for_user, update_journal_for_merged_item, ) + +__all__ = [ + "Collection", + "CollectionMember", + "FeaturedCollection", + "Comment", + "Piece", + "PieceInteraction", + "PiecePost", + "UserOwnedObjectMixin", + "VisibilityType", + "max_visiblity_to_user", + "q_item_in_category", + "q_owned_piece_visible_to_user", + "q_piece_in_home_feed_of_user", + "q_piece_visible_to_user", + "Like", + "Mark", + "Rating", + "render_md", + "Review", + "Shelf", + "ShelfLogEntry", + "ShelfManager", + "ShelfMember", + "ShelfType", + "Tag", + "TagManager", + "TagMember", + "journal_exists_for_item", + "remove_data_by_user", + "reset_journal_visibility_for_user", + "update_journal_for_merged_item", +] diff --git a/journal/models/common.py b/journal/models/common.py index 7307a9ec..77c6c80e 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -73,7 +73,7 @@ def q_piece_in_home_feed_of_user(viewing_user: User): return Q(owner_id__in=viewer.following, visibility__lt=2) | Q(owner_id=viewer.pk) -def q_item_in_category(item_category: ItemCategory | AvailableItemCategory): +def q_item_in_category(item_category: ItemCategory): classes = item_categories()[item_category] # q = Q(item__instance_of=classes[0]) # for cls in classes[1:]: diff --git a/journal/models/mixins.py b/journal/models/mixins.py index a534500f..8c7da950 100644 --- a/journal/models/mixins.py +++ b/journal/models/mixins.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING -from users.models import APIdentity, User - if TYPE_CHECKING: from django.db.models import ForeignKey + from users.models import APIdentity, User + from .common import Piece @@ -21,7 +21,9 @@ class UserOwnedObjectMixin: owner: ForeignKey[APIdentity, Piece] visibility: int - def is_visible_to(self: "Piece", viewing_user: User) -> bool: # noqa # type: ignore + def is_visible_to( + self: "Piece", viewing_user: "User" # noqa # type: ignore + ) -> bool: owner = self.owner if not owner or not owner.is_active: return False @@ -41,7 +43,7 @@ class UserOwnedObjectMixin: else: return True - def is_editable_by(self: "Piece", viewing_user: User): # type: ignore + def is_editable_by(self: "Piece", viewing_user: "User"): # type: ignore return viewing_user.is_authenticated and ( viewing_user.is_staff or viewing_user.is_superuser diff --git a/journal/templates/_list_item.html b/journal/templates/_list_item.html index f46c1ffd..15b34337 100644 --- a/journal/templates/_list_item.html +++ b/journal/templates/_list_item.html @@ -71,7 +71,7 @@
@@ -41,7 +48,7 @@ {% endif %}
{% empty %} -