diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8895cf9a..5f3f4311 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.11'] + python-version: ['3.11', '3.12'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -25,7 +25,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.11'] + python-version: ['3.11', '3.12'] steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 92818cd0..233806ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.11'] + python-version: ['3.11', '3.12'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/catalog/book/models.py b/catalog/book/models.py index 7f118188..e9ca1439 100644 --- a/catalog/book/models.py +++ b/catalog/book/models.py @@ -18,6 +18,7 @@ """ from os.path import exists +from typing import TYPE_CHECKING from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -62,6 +63,8 @@ class EditionSchema(EditionInSchema, BaseSchema): class Edition(Item): + if TYPE_CHECKING: + works: "models.ManyToManyField[Work, Edition]" category = ItemCategory.Book url_path = "book" @@ -164,17 +167,17 @@ def lookup_id_cleanup(cls, lookup_id_type: str | IdType, lookup_id_value: str): return detect_isbn_asin(lookup_id_value) return super().lookup_id_cleanup(lookup_id_type, lookup_id_value) - def merge_to(self, to_item: "Edition | None"): + def merge_to(self, to_item: "Edition | None"): # type: ignore[reportIncompatibleMethodOverride] super().merge_to(to_item) 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): + def delete(self, using=None, keep_parents=False, soft=True, *args, **kwargs): if soft: self.works.clear() - return super().delete(using, soft, *args, **kwargs) + return super().delete(using, soft, keep_parents, *args, **kwargs) def update_linked_items_from_external_resource(self, resource): """add Work from resource.metadata['work'] if not yet""" @@ -279,7 +282,7 @@ def lookup_id_type_choices(cls): ] return [(i.value, i.label) for i in id_types] - def merge_to(self, to_item: "Work | None"): + def merge_to(self, to_item: "Work | None"): # type: ignore[reportIncompatibleMethodOverride] super().merge_to(to_item) if to_item: for edition in self.editions.all(): @@ -293,10 +296,10 @@ def merge_to(self, to_item: "Work | None"): to_item.other_title += [self.title] # type: ignore to_item.save() - def delete(self, using=None, soft=True, *args, **kwargs): + def delete(self, using=None, keep_parents=False, soft=True, *args, **kwargs): if soft: self.editions.clear() - return super().delete(using, soft, *args, **kwargs) + return super().delete(using, keep_parents, soft, *args, **kwargs) class Series(Item): diff --git a/catalog/collection/models.py b/catalog/collection/models.py index 9412c033..d33924e5 100644 --- a/catalog/collection/models.py +++ b/catalog/collection/models.py @@ -1,7 +1,13 @@ -from catalog.common import * +from typing import TYPE_CHECKING + +from catalog.common import Item, ItemCategory class Collection(Item): + if TYPE_CHECKING: + from journal.models import Collection as JournalCollection + + journal_item: "JournalCollection" category = ItemCategory.Collection @property diff --git a/catalog/common/mixins.py b/catalog/common/mixins.py index 7b88220f..fa722f70 100644 --- a/catalog/common/mixins.py +++ b/catalog/common/mixins.py @@ -13,10 +13,11 @@ def clear(self): def clear(self): pass - def delete(self, using=None, soft=True, *args, **kwargs): + def delete(self, using=None, keep_parents=False, soft=True, *args, **kwargs): if soft: self.clear() self.is_deleted = True self.save(using=using) # type: ignore + return 0, {} else: - return super().delete(using=using, *args, **kwargs) # type: ignore + return super().delete(using=using, keep_parents=keep_parents, *args, **kwargs) # type: ignore diff --git a/catalog/common/models.py b/catalog/common/models.py index 3c7ed647..d8efaf56 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -1,17 +1,18 @@ import re import uuid from functools import cached_property -from typing import TYPE_CHECKING, Any, Iterable, Type, cast +from typing import TYPE_CHECKING, Any, Iterable, Self, Type, cast from auditlog.context import disable_auditlog from auditlog.models import AuditlogHistoryField, LogEntry from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.signing import b62_decode, b62_encode from django.db import connection, models -from django.db.models import QuerySet +from django.db.models import QuerySet, Value +from django.template.defaultfilters import default from django.utils import timezone -from django.utils.baseconv import base62 from django.utils.translation import gettext_lazy as _ from loguru import logger from ninja import Field, Schema @@ -19,78 +20,94 @@ from catalog.common import jsondata -from .mixins import SoftDeleteMixin from .utils import DEFAULT_ITEM_COVER, item_cover_path, resource_cover_path if TYPE_CHECKING: + from journal.models import Collection from users.models import User + from .sites import ResourceContent + class SiteName(models.TextChoices): - Unknown = "unknown", _("Unknown") - Douban = "douban", _("Douban") - Goodreads = "goodreads", _("Goodreads") - GoogleBooks = "googlebooks", _("Google Books") - BooksTW = "bookstw", _("BooksTW") - IMDB = "imdb", _("IMDb") - TMDB = "tmdb", _("TMDB") - Bandcamp = "bandcamp", _("Bandcamp") - Spotify = "spotify", _("Spotify") - IGDB = "igdb", _("IGDB") - Steam = "steam", _("Steam") - Bangumi = "bangumi", _("Bangumi") - BGG = "bgg", _("BGG") - # ApplePodcast = "apple_podcast", _("Apple Podcast") - RSS = "rss", _("RSS") - Discogs = "discogs", _("Discogs") - AppleMusic = "apple_music", _("Apple Music") - Fediverse = "fedi", _("Fediverse") + Unknown = "unknown", _("Unknown") # type:ignore[reportCallIssue] + Douban = "douban", _("Douban") # type:ignore[reportCallIssue] + Goodreads = "goodreads", _("Goodreads") # type:ignore[reportCallIssue] + GoogleBooks = "googlebooks", _("Google Books") # type:ignore[reportCallIssue] + BooksTW = "bookstw", _("BooksTW") # type:ignore[reportCallIssue] + IMDB = "imdb", _("IMDb") # type:ignore[reportCallIssue] + TMDB = "tmdb", _("TMDB") # type:ignore[reportCallIssue] + Bandcamp = "bandcamp", _("Bandcamp") # type:ignore[reportCallIssue] + Spotify = "spotify", _("Spotify") # type:ignore[reportCallIssue] + IGDB = "igdb", _("IGDB") # type:ignore[reportCallIssue] + Steam = "steam", _("Steam") # type:ignore[reportCallIssue] + Bangumi = "bangumi", _("Bangumi") # type:ignore[reportCallIssue] + BGG = "bgg", _("BGG") # type:ignore[reportCallIssue] + # ApplePodcast = "apple_podcast", _("Apple Podcast") # type:ignore[reportCallIssue] + RSS = "rss", _("RSS") # type:ignore[reportCallIssue] + Discogs = "discogs", _("Discogs") # type:ignore[reportCallIssue] + AppleMusic = "apple_music", _("Apple Music") # type:ignore[reportCallIssue] + Fediverse = "fedi", _("Fediverse") # type:ignore[reportCallIssue] class IdType(models.TextChoices): - WikiData = "wikidata", _("WikiData") - ISBN10 = "isbn10", _("ISBN10") - ISBN = "isbn", _("ISBN") # ISBN 13 - ASIN = "asin", _("ASIN") - ISSN = "issn", _("ISSN") - CUBN = "cubn", _("CUBN") - ISRC = "isrc", _("ISRC") # only for songs - GTIN = "gtin", _("GTIN UPC EAN") # GTIN-13, ISBN is separate - RSS = "rss", _("RSS Feed URL") - IMDB = "imdb", _("IMDb") - TMDB_TV = "tmdb_tv", _("TMDB TV Serie") - TMDB_TVSeason = "tmdb_tvseason", _("TMDB TV Season") - TMDB_TVEpisode = "tmdb_tvepisode", _("TMDB TV Episode") - TMDB_Movie = "tmdb_movie", _("TMDB Movie") - Goodreads = "goodreads", _("Goodreads") - Goodreads_Work = "goodreads_work", _("Goodreads Work") - GoogleBooks = "googlebooks", _("Google Books") - DoubanBook = "doubanbook", _("Douban Book") - DoubanBook_Work = "doubanbook_work", _("Douban Book Work") - DoubanMovie = "doubanmovie", _("Douban Movie") - DoubanMusic = "doubanmusic", _("Douban Music") - DoubanGame = "doubangame", _("Douban Game") - DoubanDrama = "doubandrama", _("Douban Drama") - DoubanDramaVersion = "doubandrama_version", _("Douban Drama Version") - BooksTW = "bookstw", _("BooksTW Book") - Bandcamp = "bandcamp", _("Bandcamp") - Spotify_Album = "spotify_album", _("Spotify Album") - Spotify_Show = "spotify_show", _("Spotify Podcast") - Discogs_Release = "discogs_release", ("Discogs Release") - Discogs_Master = "discogs_master", ("Discogs Master") - MusicBrainz = "musicbrainz", ("MusicBrainz ID") - # DoubanBook_Author = "doubanbook_author", _("Douban Book Author") - # DoubanCelebrity = "doubanmovie_celebrity", _("Douban Movie Celebrity") - # Goodreads_Author = "goodreads_author", _("Goodreads Author") - # Spotify_Artist = "spotify_artist", _("Spotify Artist") - # TMDB_Person = "tmdb_person", _("TMDB Person") - IGDB = "igdb", _("IGDB Game") - BGG = "bgg", _("BGG Boardgame") - Steam = "steam", _("Steam Game") - Bangumi = "bangumi", _("Bangumi") - ApplePodcast = "apple_podcast", _("Apple Podcast") - AppleMusic = "apple_music", _("Apple Music") - Fediverse = "fedi", _("Fediverse") + WikiData = "wikidata", _("WikiData") # type:ignore[reportCallIssue] + ISBN10 = "isbn10", _("ISBN10") # type:ignore[reportCallIssue] + ISBN = "isbn", _("ISBN") # ISBN 13 # type:ignore[reportCallIssue] + ASIN = "asin", _("ASIN") # type:ignore[reportCallIssue] + ISSN = "issn", _("ISSN") # type:ignore[reportCallIssue] + CUBN = "cubn", _("CUBN") # type:ignore[reportCallIssue] + ISRC = "isrc", _("ISRC") # only for songs # type:ignore[reportCallIssue] + GTIN = "gtin", _( + "GTIN UPC EAN" + ) # GTIN-13, ISBN is separate # type:ignore[reportCallIssue] + RSS = "rss", _("RSS Feed URL") # type:ignore[reportCallIssue] + IMDB = "imdb", _("IMDb") # type:ignore[reportCallIssue] + TMDB_TV = "tmdb_tv", _("TMDB TV Serie") # type:ignore[reportCallIssue] + TMDB_TVSeason = "tmdb_tvseason", _("TMDB TV Season") # type:ignore[reportCallIssue] + TMDB_TVEpisode = "tmdb_tvepisode", _( + "TMDB TV Episode" + ) # type:ignore[reportCallIssue] + TMDB_Movie = "tmdb_movie", _("TMDB Movie") # type:ignore[reportCallIssue] + Goodreads = "goodreads", _("Goodreads") # type:ignore[reportCallIssue] + Goodreads_Work = "goodreads_work", _( + "Goodreads Work" + ) # type:ignore[reportCallIssue] + GoogleBooks = "googlebooks", _("Google Books") # type:ignore[reportCallIssue] + DoubanBook = "doubanbook", _("Douban Book") # type:ignore[reportCallIssue] + DoubanBook_Work = "doubanbook_work", _( + "Douban Book Work" + ) # type:ignore[reportCallIssue] + DoubanMovie = "doubanmovie", _("Douban Movie") # type:ignore[reportCallIssue] + DoubanMusic = "doubanmusic", _("Douban Music") # type:ignore[reportCallIssue] + DoubanGame = "doubangame", _("Douban Game") # type:ignore[reportCallIssue] + DoubanDrama = "doubandrama", _("Douban Drama") # type:ignore[reportCallIssue] + DoubanDramaVersion = "doubandrama_version", _( + "Douban Drama Version" + ) # type:ignore[reportCallIssue] + BooksTW = "bookstw", _("BooksTW Book") # type:ignore[reportCallIssue] + Bandcamp = "bandcamp", _("Bandcamp") # type:ignore[reportCallIssue] + Spotify_Album = "spotify_album", _("Spotify Album") # type:ignore[reportCallIssue] + Spotify_Show = "spotify_show", _("Spotify Podcast") # type:ignore[reportCallIssue] + Discogs_Release = "discogs_release", _( + "Discogs Release" + ) # type:ignore[reportCallIssue] + Discogs_Master = "discogs_master", _( + "Discogs Master" + ) # type:ignore[reportCallIssue] + MusicBrainz = "musicbrainz", _("MusicBrainz ID") # type:ignore[reportCallIssue] + # DoubanBook_Author = "doubanbook_author", _("Douban Book Author") # type:ignore[reportCallIssue] + # DoubanCelebrity = "doubanmovie_celebrity", _("Douban Movie Celebrity") # type:ignore[reportCallIssue] + # Goodreads_Author = "goodreads_author", _("Goodreads Author") # type:ignore[reportCallIssue] + # Spotify_Artist = "spotify_artist", _("Spotify Artist") # type:ignore[reportCallIssue] + # TMDB_Person = "tmdb_person", _("TMDB Person") # type:ignore[reportCallIssue] + IGDB = "igdb", _("IGDB Game") # type:ignore[reportCallIssue] + BGG = "bgg", _("BGG Boardgame") # type:ignore[reportCallIssue] + Steam = "steam", _("Steam Game") # type:ignore[reportCallIssue] + Bangumi = "bangumi", _("Bangumi") # type:ignore[reportCallIssue] + ApplePodcast = "apple_podcast", _("Apple Podcast") # type:ignore[reportCallIssue] + AppleMusic = "apple_music", _("Apple Music") # type:ignore[reportCallIssue] + Fediverse = "fedi", _("Fediverse") # type:ignore[reportCallIssue] IdealIdTypes = [ @@ -106,49 +123,53 @@ class IdType(models.TextChoices): class ItemType(models.TextChoices): - Book = "book", _("Book") - TVShow = "tvshow", _("TV Serie") - TVSeason = "tvseason", _("TV Season") - TVEpisode = "tvepisode", _("TV Episode") - Movie = "movie", _("Movie") - Album = "music", _("Album") - Game = "game", _("Game") - Podcast = "podcast", _("Podcast Program") - PodcastEpisode = "podcastepisode", _("Podcast Episode") - Performance = "performance", _("Performance") - PerformanceProduction = "production", _("Production") - FanFic = "fanfic", _("Fanfic") - Exhibition = "exhibition", _("Exhibition") - Collection = "collection", _("Collection") + Book = "book", _("Book") # type:ignore[reportCallIssue] + TVShow = "tvshow", _("TV Serie") # type:ignore[reportCallIssue] + TVSeason = "tvseason", _("TV Season") # type:ignore[reportCallIssue] + TVEpisode = "tvepisode", _("TV Episode") # type:ignore[reportCallIssue] + Movie = "movie", _("Movie") # type:ignore[reportCallIssue] + Album = "music", _("Album") # type:ignore[reportCallIssue] + Game = "game", _("Game") # type:ignore[reportCallIssue] + Podcast = "podcast", _("Podcast Program") # type:ignore[reportCallIssue] + PodcastEpisode = "podcastepisode", _( + "Podcast Episode" + ) # type:ignore[reportCallIssue] + Performance = "performance", _("Performance") # type:ignore[reportCallIssue] + PerformanceProduction = "production", _( + "Production" + ) # type:ignore[reportCallIssue] + FanFic = "fanfic", _("Fanfic") # type:ignore[reportCallIssue] + Exhibition = "exhibition", _("Exhibition") # type:ignore[reportCallIssue] + Collection = "collection", _("Collection") # type:ignore[reportCallIssue] class ItemCategory(models.TextChoices): - Book = "book", _("Book") - Movie = "movie", _("Movie") - TV = "tv", _("TV") - Music = "music", _("Music") - Game = "game", _("Game") - Podcast = "podcast", _("Podcast") - Performance = "performance", _("Performance") - FanFic = "fanfic", _("FanFic") - Exhibition = "exhibition", _("Exhibition") - Collection = "collection", _("Collection") + Book = "book", _("Book") # type:ignore[reportCallIssue] + Movie = "movie", _("Movie") # type:ignore[reportCallIssue] + TV = "tv", _("TV") # type:ignore[reportCallIssue] + Music = "music", _("Music") # type:ignore[reportCallIssue] + Game = "game", _("Game") # type:ignore[reportCallIssue] + Podcast = "podcast", _("Podcast") # type:ignore[reportCallIssue] + Performance = "performance", _("Performance") # type:ignore[reportCallIssue] + FanFic = "fanfic", _("FanFic") # type:ignore[reportCallIssue] + Exhibition = "exhibition", _("Exhibition") # type:ignore[reportCallIssue] + Collection = "collection", _("Collection") # type:ignore[reportCallIssue] class AvailableItemCategory(models.TextChoices): - Book = "book", _("Book") - Movie = "movie", _("Movie") - TV = "tv", _("TV") - Music = "music", _("Music") - Game = "game", _("Game") - Podcast = "podcast", _("Podcast") - Performance = "performance", _("Performance") + Book = "book", _("Book") # type:ignore[reportCallIssue] + Movie = "movie", _("Movie") # type:ignore[reportCallIssue] + TV = "tv", _("TV") # type:ignore[reportCallIssue] + Music = "music", _("Music") # type:ignore[reportCallIssue] + Game = "game", _("Game") # type:ignore[reportCallIssue] + Podcast = "podcast", _("Podcast") # type:ignore[reportCallIssue] + Performance = "performance", _("Performance") # type:ignore[reportCallIssue] # class SubItemType(models.TextChoices): -# Season = "season", _("season") -# Episode = "episode", _("episode") -# Version = "production", _("production") +# Season = "season", _("season") # type:ignore[reportCallIssue] +# Episode = "episode", _("episode") # type:ignore[reportCallIssue] +# Version = "production", _("production") # type:ignore[reportCallIssue] # class CreditType(models.TextChoices): @@ -168,14 +189,16 @@ class PrimaryLookupIdDescriptor(object): # TODO make it mixin of Field def __init__(self, id_type: IdType): self.id_type = id_type - def __get__(self, instance, cls=None): + def __get__( + self, instance: "Item | None", cls: type[Any] | None = None + ) -> str | Self | None: if instance is None: return self if self.id_type != instance.primary_lookup_id_type: return None return instance.primary_lookup_id_value - def __set__(self, instance, id_value): + def __set__(self, instance: "Item", id_value: str | None): if id_value: instance.primary_lookup_id_type = self.id_type instance.primary_lookup_id_value = id_value @@ -246,12 +269,16 @@ class ItemSchema(BaseSchema, ItemInSchema): pass -class Item(PolymorphicModel, SoftDeleteMixin): +class Item(PolymorphicModel): + if TYPE_CHECKING: + external_resources: QuerySet["ExternalResource"] + collections: QuerySet["Collection"] + merged_from_items: QuerySet["Item"] + merged_to_item_id: int + category: ItemCategory # subclass must specify this 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 parent_class = None # subclass may specify this to allow create child item - category: ItemCategory # subclass must specify this uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) title = models.CharField(_("title"), max_length=1000, default="") brief = models.TextField(_("description"), blank=True, default="") @@ -288,6 +315,24 @@ class Meta: ] ] + def delete( + self, + using: Any = None, + keep_parents: bool = False, + soft: bool = True, + *args: tuple[Any, ...], + **kwargs: dict[str, Any], + ) -> tuple[int, dict[str, int]]: + if soft: + self.clear() + self.is_deleted = True + self.save(using=using) + return 0, {} + else: + return super().delete( + using=using, keep_parents=keep_parents, *args, **kwargs + ) + @cached_property def history(self): # can't use AuditlogHistoryField bc it will only return history with current content type @@ -324,7 +369,7 @@ def lookup_id_cleanup( return lookup_id_type, lookup_id_value.strip() @classmethod - def get_best_lookup_id(cls, lookup_ids: dict[IdType, str]) -> tuple[IdType, str]: + def get_best_lookup_id(cls, lookup_ids: dict[str, str]) -> tuple[str, str]: """get best available lookup id, ideally commonly used""" for t in IdealIdTypes: if lookup_ids.get(t): @@ -406,7 +451,7 @@ def merge_to(self, to_item: "Item | None"): res.item = to_item res.save() - def recast_to(self, model: "type[Item]") -> "Item": + def recast_to(self, model: "type[Any]") -> "Item": logger.warning(f"recast item {self} to {model}") if isinstance(self, model): return self @@ -430,7 +475,7 @@ def recast_to(self, model: "type[Item]") -> "Item": @property def uuid(self): - return base62.encode(self.uid.int).zfill(22) + return b62_encode(self.uid.int).zfill(22) @property def url(self): @@ -453,14 +498,14 @@ def display_title(self) -> str: return self.title @classmethod - def get_by_url(cls, url_or_b62: str) -> "Item | None": + def get_by_url(cls, url_or_b62: str) -> "Self | 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) if r: b62 = r[0] try: - item = cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) + item = cls.objects.get(uid=uuid.UUID(int=b62_decode(b62))) except Exception: item = None return item @@ -469,7 +514,7 @@ def get_by_url(cls, url_or_b62: str) -> "Item | None": # prefix = id_type.strip().lower() + ':' # return next((x[len(prefix):] for x in self.lookup_ids if x.startswith(prefix)), None) - def update_lookup_ids(self, lookup_ids): + def update_lookup_ids(self, lookup_ids: list[tuple[str, str]]): for t, v in lookup_ids: if t in IdealIdTypes and self.primary_lookup_id_type not in IdealIdTypes: self.primary_lookup_id_type = t @@ -484,25 +529,25 @@ def update_lookup_ids(self, lookup_ids): ] # list of metadata keys to copy from resource to item @classmethod - def copy_metadata(cls, metadata): + def copy_metadata(cls, metadata: dict[str, Any]) -> dict[str, Any]: return dict( (k, v) for k, v in metadata.items() if k in cls.METADATA_COPY_LIST and v is not None ) - def has_cover(self): - return self.cover and self.cover != DEFAULT_ITEM_COVER + def has_cover(self) -> bool: + return bool(self.cover) and self.cover != DEFAULT_ITEM_COVER @property - def cover_image_url(self): + def cover_image_url(self) -> str | None: return ( - f"{settings.SITE_INFO['site_url']}{self.cover.url}" + f"{settings.SITE_INFO['site_url']}{self.cover.url}" # type:ignore if self.cover and self.cover != DEFAULT_ITEM_COVER else None ) - def merge_data_from_external_resources(self, ignore_existing_content=False): + def merge_data_from_external_resources(self, ignore_existing_content: bool = False): """Subclass may override this""" lookup_ids = [] for p in self.external_resources.all(): @@ -517,7 +562,7 @@ def merge_data_from_external_resources(self, ignore_existing_content=False): self.cover = p.cover self.update_lookup_ids(list(set(lookup_ids))) - def update_linked_items_from_external_resource(self, resource): + def update_linked_items_from_external_resource(self, resource: "ExternalResource"): """Subclass should override this""" pass @@ -575,6 +620,9 @@ class Meta: class ExternalResource(models.Model): + if TYPE_CHECKING: + required_resources: list[dict[str, str]] + related_resources: list[dict[str, str]] item = models.ForeignKey( Item, null=True, on_delete=models.SET_NULL, related_name="external_resources" ) @@ -598,15 +646,21 @@ class ExternalResource(models.Model): scraped_time = models.DateTimeField(null=True) created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now=True) + required_resources = jsondata.ArrayField( models.CharField(), null=False, blank=False, default=list - ) # links required to generate Item from this resource, e.g. parent TVShow of TVSeason + ) # type: ignore + """ links required to generate Item from this resource, e.g. parent TVShow of TVSeason """ + related_resources = jsondata.ArrayField( models.CharField(), null=False, blank=False, default=list - ) # links related to this resource which may be fetched later, e.g. sub TVSeason of TVShow + ) # type: ignore + """links related to this resource which may be fetched later, e.g. sub TVSeason of TVShow""" + prematched_resources = jsondata.ArrayField( models.CharField(), null=False, blank=False, default=list - ) # links to help match an existing Item from this resource + ) + """links to help match an existing Item from this resource""" class Meta: unique_together = [["id_type", "id_value"]] @@ -645,7 +699,7 @@ def site_label(self) -> str: return n or domain return self.site_name.label - def update_content(self, resource_content): + def update_content(self, resource_content: "ResourceContent"): self.other_lookup_ids = resource_content.lookup_ids self.metadata = resource_content.metadata if resource_content.cover_image and resource_content.cover_image_extention: @@ -662,13 +716,15 @@ def update_content(self, resource_content): def ready(self): return bool(self.metadata and self.scraped_time) - def get_all_lookup_ids(self): + def get_all_lookup_ids(self) -> dict[str, str]: d = self.other_lookup_ids.copy() d[self.id_type] = self.id_value d = {k: v for k, v in d.items() if bool(v)} return d - def get_lookup_ids(self, default_model): + def get_lookup_ids( + self, default_model: type[Item] | None = None + ) -> list[tuple[str, str]]: lookup_ids = self.get_all_lookup_ids() model = self.get_item_model(default_model) bt, bv = model.get_best_lookup_id(lookup_ids) @@ -677,23 +733,30 @@ def get_lookup_ids(self, default_model): ids = [(bt, bv)] + ids return ids - def get_item_model(self, default_model: type[Item]) -> type[Item]: + def get_item_model(self, default_model: type[Item] | None) -> type[Item]: model = self.metadata.get("preferred_model") if model: m = ContentType.objects.filter( app_label="catalog", model=model.lower() ).first() if m: - return cast(Item, m).model_class() + mc: type[Item] | None = m.model_class() # type: ignore + if not mc: + raise ValueError( + f"preferred model {model} does not exist in ContentType" + ) + return mc else: raise ValueError(f"preferred model {model} does not exist") + if not default_model: + raise ValueError("no default preferred model specified") return default_model _CONTENT_TYPE_LIST = None -def item_content_types(): +def item_content_types() -> dict[type[Item], int]: global _CONTENT_TYPE_LIST if _CONTENT_TYPE_LIST is None: _CONTENT_TYPE_LIST = {} diff --git a/catalog/jobs/discover.py b/catalog/jobs/discover.py index 0e22e755..f176fcff 100644 --- a/catalog/jobs/discover.py +++ b/catalog/jobs/discover.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.conf import settings from django.core.cache import cache from django.db.models import Count, F from django.utils import timezone diff --git a/catalog/management/commands/catalog.py b/catalog/management/commands/catalog.py index 83963435..f06f55b7 100644 --- a/catalog/management/commands/catalog.py +++ b/catalog/management/commands/catalog.py @@ -1,5 +1,6 @@ import pprint +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand from django.db.models import Count, F @@ -111,12 +112,14 @@ def integrity(self): else: self.stdout.write(f"! no season {i} : {i.absolute_url}?skipcheck=1") if self.fix: - i.recast_to(i.merged_to_item.__class__) + i.recast_to(i.merged_to_item.__class__) # type:ignore self.stdout.write(f"Checking TVSeason is child of other class...") for i in TVSeason.objects.filter(show__isnull=False).exclude( show__polymorphic_ctype_id=tvshow_ct_id ): + if not i.show: + continue self.stdout.write(f"! {i.show} : {i.show.absolute_url}?skipcheck=1") if self.fix: i.show = None @@ -124,6 +127,8 @@ def integrity(self): self.stdout.write(f"Checking deleted item with child TV Season...") for i in TVSeason.objects.filter(show__is_deleted=True): + if not i.show: + continue self.stdout.write(f"! {i.show} : {i.show.absolute_url}?skipcheck=1") if self.fix: i.show.is_deleted = False @@ -131,6 +136,8 @@ def integrity(self): self.stdout.write(f"Checking merged item with child TV Season...") for i in TVSeason.objects.filter(show__merged_to_item__isnull=False): + if not i.show: + continue self.stdout.write(f"! {i.show} : {i.show.absolute_url}?skipcheck=1") if self.fix: i.show = i.show.merged_to_item diff --git a/catalog/models.py b/catalog/models.py index dd22d32e..d5025826 100644 --- a/catalog/models.py +++ b/catalog/models.py @@ -98,3 +98,52 @@ def init_catalog_audit_log(): ) # logger.debug(f"Catalog audit log initialized for {item_content_types().values()}") + + +__all__ = [ + "Edition", + "EditionInSchema", + "EditionSchema", + "Series", + "Work", + "CatalogCollection", + "AvailableItemCategory", + "ExternalResource", + "IdType", + "Item", + "ItemCategory", + "ItemInSchema", + "ItemSchema", + "ItemType", + "SiteName", + "item_categories", + "item_content_types", + "Game", + "GameInSchema", + "GameSchema", + "Movie", + "MovieInSchema", + "MovieSchema", + "Album", + "AlbumInSchema", + "AlbumSchema", + "Performance", + "PerformanceProduction", + "PerformanceProductionSchema", + "PerformanceSchema", + "Podcast", + "PodcastEpisode", + "PodcastInSchema", + "PodcastSchema", + "TVEpisode", + "TVEpisodeSchema", + "TVSeason", + "TVSeasonInSchema", + "TVSeasonSchema", + "TVShow", + "TVShowInSchema", + "TVShowSchema", + "Indexer", + "init_catalog_search_models", + "init_catalog_audit_log", +] diff --git a/catalog/performance/models.py b/catalog/performance/models.py index 795dd33a..c4affea6 100644 --- a/catalog/performance/models.py +++ b/catalog/performance/models.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import TYPE_CHECKING from django.db import models from django.utils.translation import gettext_lazy as _ @@ -100,6 +101,8 @@ def _crew_by_role(crew): class Performance(Item): + if TYPE_CHECKING: + productions: models.QuerySet["PerformanceProduction"] type = ItemType.Performance child_class = "PerformanceProduction" category = ItemCategory.Performance @@ -371,10 +374,10 @@ class PerformanceProduction(Item): ] @property - def parent_item(self): + def parent_item(self) -> Performance | None: # type:ignore return self.show - def set_parent_item(self, value): + def set_parent_item(self, value: Performance | None): # type:ignore self.show = value @classmethod @@ -389,23 +392,23 @@ def display_title(self): return f"{self.show.title if self.show else '♢'} {self.title}" @property - def cover_image_url(self): + def cover_image_url(self) -> str | None: return ( - self.cover.url + self.cover.url # type:ignore if self.cover and self.cover != DEFAULT_ITEM_COVER else self.show.cover_image_url if self.show else None ) - def update_linked_items_from_external_resource(self, resource): + def update_linked_items_from_external_resource(self, resource: ExternalResource): for r in resource.required_resources: if r["model"] == "Performance": - resource = ExternalResource.objects.filter( + res = ExternalResource.objects.filter( id_type=r["id_type"], id_value=r["id_value"] ).first() - if resource and resource.item: - self.show = resource.item + if res and res.item: + self.show = res.item @cached_property def crew_by_role(self): diff --git a/catalog/podcast/models.py b/catalog/podcast/models.py index d0f3b348..ff1f0eee 100644 --- a/catalog/podcast/models.py +++ b/catalog/podcast/models.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from django.db import models from django.utils.translation import gettext_lazy as _ @@ -26,6 +28,8 @@ class PodcastSchema(PodcastInSchema, BaseSchema): class Podcast(Item): + if TYPE_CHECKING: + episodes: models.QuerySet["PodcastEpisode"] category = ItemCategory.Podcast child_class = "PodcastEpisode" url_path = "podcast" @@ -107,10 +111,10 @@ class PodcastEpisode(Item): ] @property - def parent_item(self): + def parent_item(self) -> Podcast | None: # type:ignore return self.program - def set_parent_item(self, value): + def set_parent_item(self, value: Podcast | None): # type:ignore self.program = value @property @@ -123,7 +127,7 @@ def cover_image_url(self) -> str | None: self.program.cover_image_url if self.program else None ) - def get_url_with_position(self, position=None): + def get_url_with_position(self, position: int | str | None = None): return ( self.url if position is None or position == "" diff --git a/catalog/sites/fedi.py b/catalog/sites/fedi.py index 55f222b6..92cef330 100644 --- a/catalog/sites/fedi.py +++ b/catalog/sites/fedi.py @@ -1,5 +1,6 @@ import re +from django.conf import settings from django.core.validators import URLValidator from loguru import logger diff --git a/catalog/sites/rss.py b/catalog/sites/rss.py index e7195ef4..6035a303 100644 --- a/catalog/sites/rss.py +++ b/catalog/sites/rss.py @@ -5,6 +5,7 @@ import bleach import podcastparser +from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.validators import URLValidator diff --git a/catalog/tv/models.py b/catalog/tv/models.py index c187ee27..7de26c8c 100644 --- a/catalog/tv/models.py +++ b/catalog/tv/models.py @@ -26,9 +26,13 @@ """ import re from functools import cached_property +from typing import TYPE_CHECKING, overload +from auditlog.diff import ForeignKey +from auditlog.models import QuerySet from django.db import models from django.utils.translation import gettext_lazy as _ +from typing_extensions import override from catalog.common import ( BaseSchema, @@ -90,6 +94,8 @@ class TVEpisodeSchema(ItemSchema): class TVShow(Item): + if TYPE_CHECKING: + seasons: QuerySet["TVSeason"] type = ItemType.TVShow child_class = "TVSeason" category = ItemCategory.TV @@ -249,6 +255,8 @@ def child_items(self): class TVSeason(Item): + if TYPE_CHECKING: + episodes: models.QuerySet["TVEpisode"] type = ItemType.TVSeason category = ItemCategory.TV url_path = "tv/season" @@ -424,10 +432,10 @@ def all_episodes(self): return self.episodes.all().order_by("episode_number") @property - def parent_item(self): + def parent_item(self) -> TVShow | None: # type:ignore return self.show - def set_parent_item(self, value): + def set_parent_item(self, value: TVShow | None): # type:ignore self.show = value @property @@ -462,10 +470,10 @@ def display_title(self): ) @property - def parent_item(self): + def parent_item(self) -> TVSeason | None: # type:ignore return self.season - def set_parent_item(self, value): + def set_parent_item(self, value: TVSeason | None): # type:ignore self.season = value @classmethod @@ -476,7 +484,7 @@ def lookup_id_type_choices(cls): ] return [(i.value, i.label) for i in id_types] - def update_linked_items_from_external_resource(self, resource): + def update_linked_items_from_external_resource(self, resource: ExternalResource): for w in resource.required_resources: if w["model"] == "TVSeason": p = ExternalResource.objects.filter( diff --git a/catalog/views.py b/catalog/views.py index 86574722..40b0a1c7 100644 --- a/catalog/views.py +++ b/catalog/views.py @@ -129,7 +129,7 @@ def retrieve(request, item_path, item_uuid): def episode_data(request, item_uuid): - item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) + item = get_object_or_404(Podcast, uid=get_uuid_or_404(item_uuid)) qs = item.episodes.all().order_by("-pub_date") if request.GET.get("last"): qs = qs.filter(pub_date__lt=request.GET.get("last")) diff --git a/catalog/views_edit.py b/catalog/views_edit.py index 0b452b40..fa709399 100644 --- a/catalog/views_edit.py +++ b/catalog/views_edit.py @@ -224,13 +224,13 @@ def assign_parent(request, item_path, item_uuid): @require_http_methods(["POST"]) @login_required def remove_unused_seasons(request, item_path, item_uuid): - item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) + item = get_object_or_404(TVShow, uid=get_uuid_or_404(item_uuid)) sl = list(item.seasons.all()) for s in sl: if not s.journal_exists(): s.delete() - ol = [s.id for s in sl] - nl = [s.id for s in item.seasons.all()] + ol = [s.pk for s in sl] + nl = [s.pk for s in item.seasons.all()] discord_send( "audit", f"{item.absolute_url}\n{ol} ➡ {nl}\nby [@{request.user.username}]({request.user.absolute_url})", @@ -244,7 +244,7 @@ def remove_unused_seasons(request, item_path, item_uuid): @require_http_methods(["POST"]) @login_required def fetch_tvepisodes(request, item_path, item_uuid): - item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) + item = get_object_or_404(TVSeason, uid=get_uuid_or_404(item_uuid)) if item.class_name != "tvseason" or not item.imdb or item.season_number is None: raise BadRequest(_("TV Season with IMDB id and season number required.")) item.log_action({"!fetch_tvepisodes": ["", ""]}) @@ -257,7 +257,7 @@ def fetch_tvepisodes(request, item_path, item_uuid): def fetch_episodes_for_season_task(item_uuid, user): with set_actor(user): - season = Item.get_by_url(item_uuid) + season = TVSeason.get_by_url(item_uuid) if not season: return episodes = season.episode_uuids @@ -313,8 +313,8 @@ def merge(request, item_path, item_uuid): @require_http_methods(["POST"]) @login_required def link_edition(request, item_path, item_uuid): - item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) - new_item = Item.get_by_url(request.POST.get("target_item_url")) + item = get_object_or_404(Edition, uid=get_uuid_or_404(item_uuid)) + new_item = Edition.get_by_url(request.POST.get("target_item_url")) if ( not new_item or new_item.is_deleted @@ -325,7 +325,7 @@ def link_edition(request, item_path, item_uuid): if item.class_name != "edition" or new_item.class_name != "edition": raise BadRequest(_("Cannot link items other than editions")) if request.POST.get("sure", 0) != "1": - new_item = Item.get_by_url(request.POST.get("target_item_url")) + new_item = Edition.get_by_url(request.POST.get("target_item_url")) # type: ignore return render( request, "catalog_merge.html", @@ -345,7 +345,7 @@ def link_edition(request, item_path, item_uuid): @require_http_methods(["POST"]) @login_required def unlink_works(request, item_path, item_uuid): - item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) + item = get_object_or_404(Edition, uid=get_uuid_or_404(item_uuid)) if not request.user.is_staff and item.journal_exists(): raise PermissionDenied(_("Insufficient permission")) item.unlink_from_all_works() diff --git a/common/utils.py b/common/utils.py index 1b25963e..077afd75 100644 --- a/common/utils.py +++ b/common/utils.py @@ -5,9 +5,9 @@ from discord import SyncWebhook from django.conf import settings from django.core.exceptions import PermissionDenied +from django.core.signing import b62_decode, b62_encode from django.http import Http404, HttpRequest, HttpResponseRedirect, QueryDict from django.utils import timezone -from django.utils.baseconv import base62 from django.utils.translation import gettext_lazy as _ from .config import PAGE_LINK_NUMBER @@ -195,7 +195,7 @@ def GenerateDateUUIDMediaFilePath(filename, path_root): def get_uuid_or_404(uuid_b62): try: - i = base62.decode(uuid_b62) + i = b62_decode(uuid_b62) return uuid.UUID(int=i) except ValueError: raise Http404("Malformed Base62 UUID") diff --git a/journal/management/commands/collection.py b/journal/management/commands/collection.py index 568f3de6..652d0570 100644 --- a/journal/management/commands/collection.py +++ b/journal/management/commands/collection.py @@ -47,7 +47,7 @@ def process_export(self, collection_uuid): { "title": member.item.title, "url": member.item.absolute_url, - "note": member.note, + "note": member.note, # type:ignore } ) print(json.dumps(data, indent=2)) diff --git a/journal/models/collection.py b/journal/models/collection.py index bc637d37..b7155530 100644 --- a/journal/models/collection.py +++ b/journal/models/collection.py @@ -1,5 +1,6 @@ import re from functools import cached_property +from typing import TYPE_CHECKING from django.db import models from django.utils.translation import gettext_lazy as _ @@ -40,6 +41,8 @@ def ap_object(self): class Collection(List): + if TYPE_CHECKING: + members: models.QuerySet[CollectionMember] url_path = "collection" MEMBER_CLASS = CollectionMember catalog_item = models.OneToOneField( diff --git a/journal/models/common.py b/journal/models/common.py index 77c6c80e..1c6921e1 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -4,10 +4,10 @@ from typing import TYPE_CHECKING from django.conf import settings +from django.core.signing import b62_decode, b62_encode from django.db import connection, models from django.db.models import Avg, CharField, Count, Q from django.utils import timezone -from django.utils.baseconv import base62 from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel @@ -21,11 +21,13 @@ if TYPE_CHECKING: from takahe.models import Post + from .like import Like + class VisibilityType(models.IntegerChoices): - Public = 0, _("Public") - Follower_Only = 1, _("Followers Only") - Private = 2, _("Mentioned Only") + Public = 0, _("Public") # type:ignore[reportCallIssue] + Follower_Only = 1, _("Followers Only") # type:ignore[reportCallIssue] + Private = 2, _("Mentioned Only") # type:ignore[reportCallIssue] def q_owned_piece_visible_to_user(viewing_user: User, owner: APIdentity): @@ -112,6 +114,8 @@ def q_item_in_category(item_category: ItemCategory): class Piece(PolymorphicModel, UserOwnedObjectMixin): + if TYPE_CHECKING: + likes: models.QuerySet["Like"] url_path = "p" # subclass must specify this uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) local = models.BooleanField(default=True) @@ -121,7 +125,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): @property def uuid(self): - return base62.encode(self.uid.int) + return b62_encode(self.uid.int) @property def url(self): @@ -173,7 +177,7 @@ def get_by_url(cls, url_or_b62): if r: b62 = r[0] try: - obj = cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) + obj = cls.objects.get(uid=uuid.UUID(int=b62_decode(b62))) except Exception: obj = None return obj @@ -257,7 +261,7 @@ class Content(Piece): owner = models.ForeignKey(APIdentity, on_delete=models.PROTECT) visibility = models.PositiveSmallIntegerField( default=0 - ) # 0: Public / 1: Follower only / 2: Self only + ) # 0: Public / 1: Follower only / 2: Self only # type:ignore created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(auto_now=True) metadata = models.JSONField(default=dict) @@ -275,7 +279,7 @@ class Debris(Content): class_name = CharField(max_length=50) @classmethod - def create_from_piece(cls, c: Piece): + def create_from_piece(cls, c: Content): return cls.objects.create( class_name=c.__class__.__name__, owner=c.owner, diff --git a/journal/models/itemlist.py b/journal/models/itemlist.py index 758cdf40..f1096f49 100644 --- a/journal/models/itemlist.py +++ b/journal/models/itemlist.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import TYPE_CHECKING import django.dispatch from django.db import models @@ -18,10 +19,14 @@ class List(Piece): List (abstract model) """ + if TYPE_CHECKING: + MEMBER_CLASS: "type[ListMember]" + members: "models.QuerySet[ListMember]" + items: "models.ManyToManyField[Item, List]" owner = models.ForeignKey(APIdentity, on_delete=models.PROTECT) visibility = models.PositiveSmallIntegerField( default=0 - ) # 0: Public / 1: Follower only / 2: Self only + ) # 0: Public / 1: Follower only / 2: Self only # type:ignore created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(auto_now=True) metadata = models.JSONField(default=dict) @@ -29,7 +34,6 @@ class List(Piece): class Meta: abstract = True - MEMBER_CLASS: Piece # MEMBER_CLASS = None # subclass must override this # subclass must add this: # items = models.ManyToManyField(Item, through='ListMember') @@ -76,9 +80,10 @@ def append_item(self, item, **params): ml = self.ordered_members p = {"parent": self} p.update(params) + lm = ml.last() member = self.MEMBER_CLASS.objects.create( owner=self.owner, - position=ml.last().position + 1 if ml.count() else 1, + position=lm.position + 1 if lm else 1, item=item, **p, ) @@ -96,7 +101,7 @@ def remove_item(self, item): def update_member_order(self, ordered_member_ids): for m in self.members.all(): try: - i = ordered_member_ids.index(m.id) + i = ordered_member_ids.index(m.pk) if m.position != i + 1: m.position = i + 1 m.save() @@ -142,10 +147,12 @@ class ListMember(Piece): parent = models.ForeignKey('List', related_name='members', on_delete=models.CASCADE) """ + if TYPE_CHECKING: + parent: models.ForeignKey["ListMember", "List"] owner = models.ForeignKey(APIdentity, on_delete=models.PROTECT) visibility = models.PositiveSmallIntegerField( default=0 - ) # 0: Public / 1: Follower only / 2: Self only + ) # 0: Public / 1: Follower only / 2: Self only # type:ignore[reportAssignmentType] created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(auto_now=True) metadata = models.JSONField(default=dict) diff --git a/journal/models/like.py b/journal/models/like.py index 6fc53499..de34ffaa 100644 --- a/journal/models/like.py +++ b/journal/models/like.py @@ -12,7 +12,7 @@ class Like(Piece): # TODO remove owner = models.ForeignKey(APIdentity, on_delete=models.PROTECT) visibility = models.PositiveSmallIntegerField( default=0 - ) # 0: Public / 1: Follower only / 2: Self only + ) # 0: Public / 1: Follower only / 2: Self only # type: ignore created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(auto_now=True) target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name="likes") diff --git a/journal/models/mark.py b/journal/models/mark.py index beee595a..384ad1d5 100644 --- a/journal/models/mark.py +++ b/journal/models/mark.py @@ -10,7 +10,6 @@ from django.db import connection, models from django.db.models import Avg, Count, Q from django.utils import timezone -from django.utils.baseconv import base62 from django.utils.translation import gettext_lazy as _ from loguru import logger from markdownx.models import MarkdownxField @@ -42,7 +41,7 @@ def __init__(self, owner: APIdentity, item: Item): self.item = item @cached_property - def shelfmember(self) -> ShelfMember: + def shelfmember(self) -> ShelfMember | None: return self.owner.shelf_manager.locate_item(self.item) @property @@ -198,7 +197,7 @@ def update( last_visibility = self.visibility if last_shelf_type else None if shelf_type is None: # TODO change this use case to DEFERRED status # take item off shelf - if last_shelf_type: + if self.shelfmember: Takahe.delete_posts(self.shelfmember.all_post_ids) self.shelfmember.log_and_delete() if self.comment: @@ -207,7 +206,7 @@ def update( self.rating.delete() return # create/update shelf member and shelf log if necessary - if last_shelf_type == shelf_type: + if self.shelfmember and last_shelf_type == shelf_type: shelfmember_changed = False log_entry = self.shelfmember.ensure_log_entry() if metadata is not None and metadata != self.shelfmember.metadata: diff --git a/journal/models/mixins.py b/journal/models/mixins.py index 8c7da950..41787ddb 100644 --- a/journal/models/mixins.py +++ b/journal/models/mixins.py @@ -18,7 +18,9 @@ class UserOwnedObjectMixin: """ if TYPE_CHECKING: - owner: ForeignKey[APIdentity, Piece] + owner: ForeignKey[Piece, APIdentity] + # owner: ForeignKey[APIdentity, Piece] + owner_id: int visibility: int def is_visible_to( diff --git a/journal/models/shelf.py b/journal/models/shelf.py index 40e7df1f..f5683a70 100644 --- a/journal/models/shelf.py +++ b/journal/models/shelf.py @@ -18,10 +18,10 @@ class ShelfType(models.TextChoices): - WISHLIST = ("wishlist", _("WISHLIST")) - PROGRESS = ("progress", _("PROGRESS")) - COMPLETE = ("complete", _("COMPLETE")) - DROPPED = ("dropped", _("DROPPED")) + WISHLIST = "wishlist", _("WISHLIST") # type:ignore[reportCallIssue] + PROGRESS = "progress", _("PROGRESS") # type:ignore[reportCallIssue] + COMPLETE = "complete", _("COMPLETE") # type:ignore[reportCallIssue] + DROPPED = "dropped", _("DROPPED") # type:ignore[reportCallIssue] _REVIEWED = "reviewed" @@ -272,6 +272,9 @@ class ShelfType(models.TextChoices): class ShelfMember(ListMember): + if TYPE_CHECKING: + parent: models.ForeignKey["ShelfMember", "Shelf"] + parent = models.ForeignKey( "Shelf", related_name="members", on_delete=models.CASCADE ) @@ -375,6 +378,9 @@ class Shelf(List): Shelf """ + if TYPE_CHECKING: + members: models.QuerySet[ShelfMember] + class Meta: unique_together = [["owner", "shelf_type"]] diff --git a/journal/models/tag.py b/journal/models/tag.py index 9ec3ae2b..cb3fc6f4 100644 --- a/journal/models/tag.py +++ b/journal/models/tag.py @@ -1,5 +1,6 @@ import re from functools import cached_property +from typing import TYPE_CHECKING from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import connection, models @@ -14,6 +15,8 @@ class TagMember(ListMember): + if TYPE_CHECKING: + parent: models.ForeignKey["TagMember", "Tag"] parent = models.ForeignKey("Tag", related_name="members", on_delete=models.CASCADE) class Meta: diff --git a/journal/views/collection.py b/journal/views/collection.py index 63aa9b4f..f2e85eed 100644 --- a/journal/views/collection.py +++ b/journal/views/collection.py @@ -247,7 +247,7 @@ def collection_update_item_note(request: AuthedHttpRequest, collection_uuid, ite ) return collection_retrieve_items(request, collection_uuid, True) else: - member = collection.get_member_for_item(item) + member: CollectionMember = collection.get_member_for_item(item) # type:ignore return render( request, "collection_update_item_note.html", diff --git a/journal/views/common.py b/journal/views/common.py index 25a2fb93..bfcfb834 100644 --- a/journal/views/common.py +++ b/journal/views/common.py @@ -99,7 +99,7 @@ def render_list( if year: year = int(year) queryset = queryset.filter(created_time__year=year) - paginator = Paginator(queryset, PAGE_SIZE) + paginator = Paginator(queryset, PAGE_SIZE) # type:ignore page_number = int(request.GET.get("page", default=1)) members = paginator.get_page(page_number) pagination = PageLinksGenerator(page_number, paginator.num_pages, request.GET) diff --git a/legacy/views.py b/legacy/views.py index 918b5f64..987a2945 100644 --- a/legacy/views.py +++ b/legacy/views.py @@ -1,5 +1,5 @@ +from django.core.signing import b62_decode, b62_encode from django.shortcuts import get_object_or_404, redirect, render -from django.utils.baseconv import base62 from catalog.collection.models import Collection from catalog.models import Item @@ -9,54 +9,54 @@ def book(request, id): link = get_object_or_404(BookLink, old_id=id) - return redirect(f"/book/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/book/{b62_encode(link.new_uid.int)}", permanent=True) def movie(request, id): link = get_object_or_404(MovieLink, old_id=id) - return redirect(f"/movie/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/movie/{b62_encode(link.new_uid.int)}", permanent=True) def album(request, id): link = get_object_or_404(AlbumLink, old_id=id) - return redirect(f"/album/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/album/{b62_encode(link.new_uid.int)}", permanent=True) def song(request, id): link = get_object_or_404(SongLink, old_id=id) - return redirect(f"/album/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/album/{b62_encode(link.new_uid.int)}", permanent=True) def game(request, id): link = get_object_or_404(GameLink, old_id=id) - return redirect(f"/game/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/game/{b62_encode(link.new_uid.int)}", permanent=True) def collection(request, id): link = get_object_or_404(CollectionLink, old_id=id) - return redirect(f"/collection/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/collection/{b62_encode(link.new_uid.int)}", permanent=True) def book_review(request, id): link = get_object_or_404(ReviewLink, module="book", old_id=id) - return redirect(f"/review/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/review/{b62_encode(link.new_uid.int)}", permanent=True) def movie_review(request, id): link = get_object_or_404(ReviewLink, module="movie", old_id=id) - return redirect(f"/review/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/review/{b62_encode(link.new_uid.int)}", permanent=True) def album_review(request, id): link = get_object_or_404(ReviewLink, module="album", old_id=id) - return redirect(f"/review/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/review/{b62_encode(link.new_uid.int)}", permanent=True) def song_review(request, id): link = get_object_or_404(ReviewLink, module="song", old_id=id) - return redirect(f"/review/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/review/{b62_encode(link.new_uid.int)}", permanent=True) def game_review(request, id): link = get_object_or_404(ReviewLink, module="game", old_id=id) - return redirect(f"/review/{base62.encode(link.new_uid.int)}", permanent=True) + return redirect(f"/review/{b62_encode(link.new_uid.int)}", permanent=True) diff --git a/mastodon/api.py b/mastodon/api.py index cea608d0..6be55dc8 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -291,16 +291,17 @@ def get_related_acct_list(site, token, api): ) url = None if response.status_code == 200: + r: list[dict[str, str]] = response.json() results.extend( map( - lambda u: ( + lambda u: ( # type: ignore u["acct"] if u["acct"].find("@") != -1 else u["acct"] + "@" + site ) if "acct" in u else u, - response.json(), + r, ) ) if "Link" in response.headers: diff --git a/pyproject.toml b/pyproject.toml index e3b6bd9c..d51ddbf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.pyright] -exclude = [ "media", ".venv", ".git", "playground", "catalog/*/tests.py", "neodb", "**/migrations", "**/sites/douban_*", "neodb-takahe" ] +exclude = [ "media", ".venv", ".git", "playground", "catalog/*/tests.py", "journal/tests.py", "neodb", "**/migrations", "**/sites/douban_*", "neodb-takahe" ] reportIncompatibleVariableOverride = false reportUnusedImport = false reportUnknownVariableType = false @@ -10,6 +10,8 @@ reportUnknownArgumentType = false reportAny = false reportImplicitOverride = false reportUninitializedInstanceVariable = false +reportMissingTypeStubs = false +reportIgnoreCommentWithoutRule = false [tool.djlint] ignore="T002,T003,H005,H006,H019,H020,H021,H023,H030,H031,D018" diff --git a/requirements-dev.txt b/requirements-dev.txt index e457f41d..5f80990f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,10 @@ black~=22.12.0 coverage django-debug-toolbar -django-stubs~=4.2.7 +django-stubs djlint~=1.34.0 isort~=5.13.2 lxml-stubs pre-commit -pyright==1.1.350 +pyright==1.1.365 ruff diff --git a/requirements.txt b/requirements.txt index ac5e4677..9760a887 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ django-maintenance-mode django-markdownx django-ninja django-oauth-toolkit==2.3.0 -django-polymorphic +django-polymorphic @ git+https://github.com/jazzband/django-polymorphic/@v4.0.0a django-redis django-rq django-sass-processor diff --git a/takahe/management/commands/backfill_takahe.py b/takahe/management/commands/backfill_takahe.py index d100d0a8..71c50b4d 100644 --- a/takahe/management/commands/backfill_takahe.py +++ b/takahe/management/commands/backfill_takahe.py @@ -1,6 +1,7 @@ import time from contextlib import nullcontext -from datetime import datetime, timezone +from datetime import datetime +from datetime import timezone as dttz from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -23,7 +24,7 @@ BATCH_SIZE = 1000 -TIMELINE_START = datetime.datetime(2023, 7, 1, tzinfo=timezone.utc) +TIMELINE_START = datetime.datetime(2023, 7, 1, tzinfo=dttz.utc) def content_type_id(cls): diff --git a/takahe/models.py b/takahe/models.py index 518a1131..e1bddef0 100644 --- a/takahe/models.py +++ b/takahe/models.py @@ -869,8 +869,9 @@ class Post(models.Model): A post (status, toot) that is either local or remote. """ - interactions: "models.QuerySet[PostInteraction]" - attachments: "models.QuerySet[PostAttachment]" + if TYPE_CHECKING: + interactions: "models.QuerySet[PostInteraction]" + attachments: "models.QuerySet[PostAttachment]" class Visibilities(models.IntegerChoices): public = 0 diff --git a/takahe/utils.py b/takahe/utils.py index 00f37a92..20ebbf3c 100644 --- a/takahe/utils.py +++ b/takahe/utils.py @@ -533,7 +533,8 @@ def visibility_n2t(visibility: int, post_public_mode: int) -> Visibilities: @staticmethod def post_collection(collection: "Collection"): existing_post = collection.latest_post - user = collection.owner.user + owner: APIdentity = collection.owner + user = owner.user if not user: raise ValueError(f"Cannot find user for collection {collection}") visibility = Takahe.visibility_n2t( diff --git a/users/account.py b/users/account.py index 790982c1..2668cc97 100644 --- a/users/account.py +++ b/users/account.py @@ -10,13 +10,12 @@ from django.core.cache import cache from django.core.exceptions import BadRequest, ObjectDoesNotExist from django.core.mail import send_mail -from django.core.signing import TimestampSigner +from django.core.signing import TimestampSigner, b62_decode, b62_encode from django.core.validators import EmailValidator from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone -from django.utils.baseconv import base62 from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods from loguru import logger @@ -86,7 +85,7 @@ def connect(request): {"msg": _("Invalid email address")}, ) user = User.objects.filter(email__iexact=login_email).first() - code = base62.encode(random.randint(pow(62, 4), pow(62, 5) - 1)) + code = b62_encode(random.randint(pow(62, 4), pow(62, 5) - 1)) cache.set(f"login_{code}", login_email, timeout=60 * 15) request.session["login_email"] = login_email action = "login" if user else "register" diff --git a/users/models/task.py b/users/models/task.py index 424fa64b..d4f36f01 100644 --- a/users/models/task.py +++ b/users/models/task.py @@ -14,10 +14,10 @@ class Task(models.Model): DefaultMetadata = {} class States(models.IntegerChoices): - pending = 0, "Pending" - started = 1, "Started" - complete = 2, "Complete" - failed = 3, "Failed" + pending = 0, _("Pending") # type:ignore[reportCallIssue] + started = 1, _("Started") # type:ignore[reportCallIssue] + complete = 2, _("Complete") # type:ignore[reportCallIssue] + failed = 3, _("Failed") # type:ignore[reportCallIssue] user = models.ForeignKey(User, models.CASCADE, null=False) type = models.CharField(max_length=20, null=False)