From 22640a74ab050d4c4ef201d2aa87b7172192d113 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 10 Aug 2023 11:27:31 -0400 Subject: [PATCH] fix unicode site name; add isort; split journal and users models --- .pre-commit-config.yaml | 7 +- boofilsic/settings.py | 2 + boofilsic/urls.py | 9 +- catalog/api.py | 9 +- catalog/apps.py | 6 +- catalog/book/models.py | 2 + catalog/book/tests.py | 1 + catalog/book/utils.py | 1 + catalog/common/__init__.py | 9 +- catalog/common/downloaders.py | 20 +- catalog/common/jsondata.py | 10 +- catalog/common/models.py | 75 +- catalog/common/sites.py | 16 +- catalog/common/utils.py | 2 +- catalog/forms.py | 2 +- catalog/game/models.py | 6 +- catalog/game/tests.py | 1 + catalog/management/commands/cat.py | 4 +- catalog/management/commands/catalog.py | 4 +- catalog/management/commands/crawl.py | 6 +- catalog/management/commands/discover.py | 12 +- catalog/management/commands/index.py | 14 +- catalog/management/commands/podcast.py | 18 +- catalog/migrations/0001_initial.py | 10 +- catalog/migrations/0002_initial.py | 2 +- catalog/migrations/0003_podcast.py | 2 +- .../migrations/0004_podcast_no_real_change.py | 2 +- catalog/migrations/0007_performance.py | 2 +- .../0010_alter_item_polymorphic_ctype.py | 2 +- catalog/models.py | 50 +- catalog/movie/models.py | 5 +- catalog/movie/tests.py | 1 + catalog/music/models.py | 6 +- catalog/music/tests.py | 1 + catalog/performance/models.py | 6 +- catalog/podcast/models.py | 3 +- catalog/podcast/tests.py | 4 +- catalog/search/external.py | 8 +- catalog/search/models.py | 20 +- catalog/search/typesense.py | 20 +- catalog/sites/__init__.py | 31 +- catalog/sites/apple_podcast.py | 4 +- catalog/sites/bandcamp.py | 13 +- catalog/sites/bangumi.py | 4 +- catalog/sites/bookstw.py | 9 +- catalog/sites/discogs.py | 10 +- catalog/sites/douban.py | 2 +- catalog/sites/goodreads.py | 13 +- catalog/sites/google_books.py | 6 +- catalog/sites/igdb.py | 11 +- catalog/sites/imdb.py | 9 +- catalog/sites/rss.py | 20 +- catalog/sites/spotify.py | 14 +- catalog/sites/steam.py | 10 +- catalog/sites/tmdb.py | 6 +- catalog/tests.py | 9 +- catalog/tv/models.py | 4 +- catalog/tv/tests.py | 3 +- catalog/urls.py | 3 +- catalog/views.py | 49 +- catalog/views_edit.py | 31 +- common/api.py | 11 +- common/forms.py | 13 +- common/management/commands/delete_job.py | 5 +- common/management/commands/list_jobs.py | 5 +- common/templatetags/admin_url.py | 1 - common/templatetags/duration.py | 3 +- common/templatetags/highlight.py | 9 +- common/templatetags/mastodon.py | 1 - common/templatetags/prettydate.py | 1 - common/templatetags/truncate.py | 1 - common/urls.py | 1 + common/utils.py | 1 + common/views.py | 4 +- developer/migrations/0001_initial.py | 4 +- .../migrations/0002_alter_application_user.py | 2 +- developer/models.py | 7 +- developer/urls.py | 4 +- developer/views.py | 25 +- journal/api.py | 22 +- journal/apps.py | 5 +- journal/exporters/doufen.py | 8 +- journal/feeds.py | 9 +- journal/forms.py | 6 +- journal/importers/douban.py | 21 +- journal/importers/goodreads.py | 9 +- journal/importers/opml.py | 22 +- journal/management/commands/journal.py | 6 +- journal/migrations/0001_initial.py | 12 +- journal/migrations/0002_initial.py | 2 +- journal/migrations/0003_auto_20230113_0506.py | 3 +- journal/migrations/0005_auto_20230114_1134.py | 2 +- journal/migrations/0006_auto_20230114_2139.py | 2 +- .../0007_alter_collection_catalog_item.py | 2 +- journal/migrations/0009_comment_focus_item.py | 2 +- journal/migrations/0011_performance.py | 2 +- ...ece_polymorphic_ctype_alter_shelf_items.py | 2 +- journal/models.py | 1309 ----------------- journal/models/__init__.py | 31 + journal/models/collection.py | 111 ++ journal/models/comment.py | 63 + journal/models/common.py | 169 +++ journal/models/itemlist.py | 172 +++ journal/models/like.py | 42 + journal/models/mark.py | 225 +++ journal/{ => models}/mixins.py | 0 journal/models/rating.py | 91 ++ journal/{ => models}/renderers.py | 5 +- journal/models/review.py | 79 + journal/models/shelf.py | 276 ++++ journal/models/tag.py | 128 ++ journal/models/utils.py | 65 + journal/templatetags/collection.py | 3 +- journal/templatetags/user_actions.py | 3 +- journal/tests.py | 25 +- journal/urls.py | 5 +- journal/views.py | 15 +- legacy/models.py | 1 + legacy/urls.py | 1 + legacy/views.py | 7 +- management/admin.py | 1 + management/migrations/0001_initial.py | 2 +- management/models.py | 4 +- management/urls.py | 2 +- management/views.py | 6 +- mastodon/admin.py | 7 +- mastodon/api.py | 26 +- mastodon/auth.py | 1 + mastodon/decorators.py | 3 +- mastodon/management/commands/wrong_sites.py | 5 +- requirements-dev.txt | 7 +- requirements.txt | 2 +- social/migrations/0001_initial.py | 7 +- social/migrations/0002_initial.py | 2 +- social/models.py | 25 +- social/tests.py | 10 +- social/urls.py | 2 +- social/views.py | 17 +- users/account.py | 54 +- users/admin.py | 2 +- users/api.py | 5 +- users/data.py | 33 +- users/forms.py | 4 +- .../management/commands/backfill_mastodon.py | 5 +- users/management/commands/disable_user.py | 6 +- .../management/commands/refresh_following.py | 6 +- users/management/commands/refresh_mastodon.py | 1 + users/management/commands/user.py | 6 +- users/migrations/0001_initial.py | 8 +- .../0004_alter_preference_classic_homepage.py | 1 - .../migrations/0005_add_dedicated_username.py | 8 +- .../0007_username_case_insensitive.py | 2 +- users/migrations/0009_add_local_follow.py | 2 +- users/migrations/0010_add_local_mute_block.py | 2 +- .../0011_preference_hidden_categories.py | 1 + users/models/__init__.py | 3 + users/models/preference.py | 53 + users/models/report.py | 47 + users/{models.py => models/user.py} | 110 +- users/tasks.py | 8 +- users/tests.py | 8 +- users/urls.py | 1 + users/views.py | 29 +- 163 files changed, 2318 insertions(+), 1918 deletions(-) delete mode 100644 journal/models.py create mode 100644 journal/models/__init__.py create mode 100644 journal/models/collection.py create mode 100644 journal/models/comment.py create mode 100644 journal/models/common.py create mode 100644 journal/models/itemlist.py create mode 100644 journal/models/like.py create mode 100644 journal/models/mark.py rename journal/{ => models}/mixins.py (100%) create mode 100644 journal/models/rating.py rename journal/{ => models}/renderers.py (99%) create mode 100644 journal/models/review.py create mode 100644 journal/models/shelf.py create mode 100644 journal/models/tag.py create mode 100644 journal/models/utils.py create mode 100644 users/models/__init__.py create mode 100644 users/models/preference.py create mode 100644 users/models/report.py rename users/{models.py => models/user.py} (89%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40823617..7f5a1d36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,8 +12,13 @@ repos: language_version: python3.11 repos: - repo: https://github.com/Riverside-Healthcare/djLint - rev: v1.30.2 + rev: v1.32.1 hooks: - id: djlint-reformat-django - id: djlint-django + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile=black"] diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 39f6e6fd..4b1cd920 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -1,5 +1,7 @@ import os +NEODB_VERSION = "0.8" + PROJECT_ROOT = os.path.abspath(os.path.dirname(__name__)) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) diff --git a/boofilsic/urls.py b/boofilsic/urls.py index e666a94d..ad023bf1 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -13,13 +13,14 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin -from django.urls import path, include from django.conf import settings -from users.views import login -from common.api import api +from django.contrib import admin +from django.urls import include, path from django.views.generic import RedirectView +from common.api import api +from users.views import login + urlpatterns = [ path("api/", api.urls), # type: ignore path("login/", login), diff --git a/catalog/api.py b/catalog/api.py index 012060ea..0cf231f2 100644 --- a/catalog/api.py +++ b/catalog/api.py @@ -1,12 +1,13 @@ +from django.http import Http404, HttpResponse from django.utils.translation import gettext_lazy as _ -from django.http import HttpResponse -from django.http import Http404 from ninja import Schema + from common.api import * -from .models import * + from .common import * +from .models import * +from .search.models import enqueue_fetch, get_fetch_lock, query_index from .sites import * -from .search.models import enqueue_fetch, query_index, get_fetch_lock class SearchResult(Schema): diff --git a/catalog/apps.py b/catalog/apps.py index 74fb6c63..34047075 100644 --- a/catalog/apps.py +++ b/catalog/apps.py @@ -7,11 +7,9 @@ class CatalogConfig(AppConfig): def ready(self): # load key modules in proper order, make sure class inject and signal works as expected - from catalog import models - from catalog import sites + from catalog import api, models, sites + from catalog.models import init_catalog_audit_log, init_catalog_search_models from journal import models as journal_models - from catalog.models import init_catalog_search_models, init_catalog_audit_log - from catalog import api init_catalog_search_models() init_catalog_audit_log() diff --git a/catalog/book/models.py b/catalog/book/models.py index 2d96776b..5a03771d 100644 --- a/catalog/book/models.py +++ b/catalog/book/models.py @@ -20,7 +20,9 @@ work data seems asymmetric (a book links to a work, but may not listed in that w from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ + from catalog.common.models import * + from .utils import * diff --git a/catalog/book/tests.py b/catalog/book/tests.py index 51e0ea2c..f4ecf376 100644 --- a/catalog/book/tests.py +++ b/catalog/book/tests.py @@ -1,4 +1,5 @@ from django.test import TestCase + from catalog.book.models import * from catalog.book.utils import * from catalog.common import * diff --git a/catalog/book/utils.py b/catalog/book/utils.py index ab86331d..15a02a56 100644 --- a/catalog/book/utils.py +++ b/catalog/book/utils.py @@ -1,4 +1,5 @@ import re + from .models import IdType diff --git a/catalog/common/__init__.py b/catalog/common/__init__.py index 54dfeb99..51f055af 100644 --- a/catalog/common/__init__.py +++ b/catalog/common/__init__.py @@ -1,9 +1,8 @@ -from .models import * -from .sites import * -from .downloaders import * -from .scrapers import * from . import jsondata - +from .downloaders import * +from .models import * +from .scrapers import * +from .sites import * __all__ = ( "IdType", diff --git a/catalog/common/downloaders.py b/catalog/common/downloaders.py index 26e79d5a..c571667b 100644 --- a/catalog/common/downloaders.py +++ b/catalog/common/downloaders.py @@ -1,18 +1,18 @@ -import requests -import filetype -from PIL import Image -from io import BytesIO -from requests.exceptions import RequestException -from django.conf import settings -from pathlib import Path import json -from io import StringIO +import logging import re import time -import logging -from lxml import html +from io import BytesIO, StringIO +from pathlib import Path from urllib.parse import quote +import filetype +import requests +from django.conf import settings +from lxml import html +from PIL import Image +from requests.exceptions import RequestException + _logger = logging.getLogger(__name__) diff --git a/catalog/common/jsondata.py b/catalog/common/jsondata.py index 7e3a3e38..1fd8c703 100644 --- a/catalog/common/jsondata.py +++ b/catalog/common/jsondata.py @@ -1,22 +1,20 @@ import copy from datetime import date, datetime -from importlib import import_module from functools import partialmethod -from django.utils.translation import gettext_lazy as _ +from importlib import import_module import django from django.core.exceptions import FieldError from django.db.models import fields from django.utils import dateparse, timezone - -# from django.contrib.postgres.fields import ArrayField as DJANGO_ArrayField -from django_jsonform.models.fields import ArrayField as DJANGO_ArrayField +from django.utils.translation import gettext_lazy as _ # from django.db.models import JSONField as DJANGO_JSONField # from jsoneditor.fields.django3_jsonfield import JSONField as DJANGO_JSONField +# from django.contrib.postgres.fields import ArrayField as DJANGO_ArrayField +from django_jsonform.models.fields import ArrayField as DJANGO_ArrayField from django_jsonform.models.fields import JSONField as DJANGO_JSONField - __all__ = ( "BooleanField", "CharField", diff --git a/catalog/common/models.py b/catalog/common/models.py index 8b8eb98d..001fd52d 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -1,29 +1,32 @@ -from functools import cached_property -from polymorphic.models import PolymorphicModel -from django.db import models import logging import re -from catalog.common import jsondata -from django.utils.translation import gettext_lazy as _ -from django.utils import timezone -from django.core.files.uploadedfile import SimpleUploadedFile -from django.contrib.contenttypes.models import ContentType -from django.utils.baseconv import base62 import uuid +from functools import cached_property from typing import cast -from .utils import DEFAULT_ITEM_COVER, item_cover_path, resource_cover_path -from .mixins import SoftDeleteMixin -from django.conf import settings -from users.models import User -from django.db import connection -from ninja import Schema + from auditlog.context import disable_auditlog -from auditlog.models import LogEntry, AuditlogHistoryField +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.db import connection, models +from django.utils import timezone +from django.utils.baseconv import base62 +from django.utils.translation import gettext_lazy as _ +from ninja import Schema +from polymorphic.models import PolymorphicModel + +from catalog.common import jsondata +from users.models import User + +from .mixins import SoftDeleteMixin +from .utils import DEFAULT_ITEM_COVER, item_cover_path, resource_cover_path _logger = logging.getLogger(__name__) class SiteName(models.TextChoices): + Unknown = "unknown", _("未知站点") Douban = "douban", _("豆瓣") Goodreads = "goodreads", _("Goodreads") GoogleBooks = "googlebooks", _("谷歌图书") @@ -482,6 +485,35 @@ class Item(SoftDeleteMixin, PolymorphicModel): def editable(self): return not self.is_deleted and self.merged_to_item is None + @property + def rating(self): + from journal.models import Rating + + return Rating.get_rating_for_item(self) + + @property + def rating_count(self): + from journal.models import Rating + + return Rating.get_rating_count_for_item(self) + + @property + def rating_dist(self): + from journal.models import Rating + + return Rating.get_rating_distribution_for_item(self) + + @property + def tags(self): + from journal.models import TagManager + + return TagManager.indexable_tags_for_item(self) + + def journal_exists(self): + from journal.models import journal_exists_for_item + + return journal_exists_for_item(self) + class ItemLookupId(models.Model): item = models.ForeignKey( @@ -542,12 +574,17 @@ class ExternalResource(models.Model): self.save() def get_site(self): - """place holder only, this will be injected from SiteManager""" - pass + from .sites import SiteManager + + return SiteManager.get_site_cls_by_id_type(self.id_type) @property def site_name(self): - return getattr(self.get_site(), "SITE_NAME") + try: + return self.get_site().SITE_NAME + except: + _logger.warning(f"Unknown site for {self}") + return SiteName.Unknown def update_content(self, resource_content): self.other_lookup_ids = resource_content.lookup_ids diff --git a/catalog/common/sites.py b/catalog/common/sites.py index 4939a12b..c4f696b3 100644 --- a/catalog/common/sites.py +++ b/catalog/common/sites.py @@ -6,14 +6,15 @@ a Site should map to a unique set of url patterns. a Site may scrape a url and store result in ResourceContent ResourceContent persists as an ExternalResource which may link to an Item """ -from typing import Callable -import re -from .models import ExternalResource, IdType, IdealIdTypes, Item -from dataclasses import dataclass, field -import logging import json +import logging +import re +from dataclasses import dataclass, field +from typing import Callable + import django_rq +from .models import ExternalResource, IdealIdTypes, IdType, Item _logger = logging.getLogger(__name__) @@ -297,11 +298,6 @@ class SiteManager: return SiteManager.register.values() -ExternalResource.get_site = lambda resource: SiteManager.get_site_cls_by_id_type( - resource.id_type -) # type: ignore - - def crawl_related_resources_task(resource_pk): resource = ExternalResource.objects.filter(pk=resource_pk).first() if not resource: diff --git a/catalog/common/utils.py b/catalog/common/utils.py index bd3f1eaa..0882af5d 100644 --- a/catalog/common/utils.py +++ b/catalog/common/utils.py @@ -1,7 +1,7 @@ import logging -from django.utils import timezone import uuid +from django.utils import timezone _logger = logging.getLogger(__name__) diff --git a/catalog/forms.py b/catalog/forms.py index b78d4d7c..a2bd3abe 100644 --- a/catalog/forms.py +++ b/catalog/forms.py @@ -1,9 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ + from catalog.models import * from common.forms import PreviewImageInput - CatalogForms = {} diff --git a/catalog/game/models.py b/catalog/game/models.py index 287ab120..fc8bd5ed 100644 --- a/catalog/game/models.py +++ b/catalog/game/models.py @@ -1,7 +1,9 @@ from datetime import date -from catalog.common.models import * -from django.utils.translation import gettext_lazy as _ + from django.db import models +from django.utils.translation import gettext_lazy as _ + +from catalog.common.models import * class GameInSchema(ItemInSchema): diff --git a/catalog/game/tests.py b/catalog/game/tests.py index 498e4534..f46b0a98 100644 --- a/catalog/game/tests.py +++ b/catalog/game/tests.py @@ -1,4 +1,5 @@ from django.test import TestCase + from catalog.common import * from catalog.models import * diff --git a/catalog/management/commands/cat.py b/catalog/management/commands/cat.py index e9dbc533..6fddbc65 100644 --- a/catalog/management/commands/cat.py +++ b/catalog/management/commands/cat.py @@ -1,5 +1,7 @@ -from django.core.management.base import BaseCommand import pprint + +from django.core.management.base import BaseCommand + from catalog.common import SiteManager from catalog.sites import * diff --git a/catalog/management/commands/catalog.py b/catalog/management/commands/catalog.py index 663fd756..83963435 100644 --- a/catalog/management/commands/catalog.py +++ b/catalog/management/commands/catalog.py @@ -1,6 +1,8 @@ +import pprint + from django.core.management.base import BaseCommand from django.db.models import Count, F -import pprint + from catalog.models import * from journal.models import update_journal_for_merged_item diff --git a/catalog/management/commands/crawl.py b/catalog/management/commands/crawl.py index 68c8311a..cacc368f 100644 --- a/catalog/management/commands/crawl.py +++ b/catalog/management/commands/crawl.py @@ -1,9 +1,11 @@ -from django.core.management.base import BaseCommand -from catalog.common import * import re from urllib.parse import urljoin + +from django.core.management.base import BaseCommand from loguru import logger +from catalog.common import * + class Command(BaseCommand): help = "Crawl content" diff --git a/catalog/management/commands/discover.py b/catalog/management/commands/discover.py index f34f2e3c..7332d737 100644 --- a/catalog/management/commands/discover.py +++ b/catalog/management/commands/discover.py @@ -1,12 +1,14 @@ -from django.core.management.base import BaseCommand -from django.core.cache import cache -from catalog.models import * -from journal.models import ShelfMember, query_item_category, ItemCategory, Comment from datetime import timedelta -from django.utils import timezone + +from django.core.cache import cache +from django.core.management.base import BaseCommand from django.db.models import Count, F +from django.utils import timezone from loguru import logger +from catalog.models import * +from journal.models import Comment, ItemCategory, ShelfMember, query_item_category + MAX_ITEMS_PER_PERIOD = 12 MIN_MARKS = 2 MAX_DAYS_FOR_PERIOD = 96 diff --git a/catalog/management/commands/index.py b/catalog/management/commands/index.py index 3eb96a58..d7916761 100644 --- a/catalog/management/commands/index.py +++ b/catalog/management/commands/index.py @@ -1,12 +1,14 @@ -from django.core.management.base import BaseCommand -from django.conf import settings -from catalog.models import * import pprint -from django.core.paginator import Paginator -from tqdm import tqdm -from time import sleep from datetime import timedelta +from time import sleep + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.core.paginator import Paginator from django.utils import timezone +from tqdm import tqdm + +from catalog.models import * BATCH_SIZE = 1000 diff --git a/catalog/management/commands/podcast.py b/catalog/management/commands/podcast.py index 9c73bd73..206f8a03 100644 --- a/catalog/management/commands/podcast.py +++ b/catalog/management/commands/podcast.py @@ -1,15 +1,17 @@ -from django.core.management.base import BaseCommand +import pprint +from datetime import timedelta +from time import sleep + from django.conf import settings +from django.core.management.base import BaseCommand +from django.core.paginator import Paginator +from django.utils import timezone +from loguru import logger +from tqdm import tqdm + from catalog.common.models import IdType from catalog.models import * from catalog.sites import RSS -import pprint -from django.core.paginator import Paginator -from tqdm import tqdm -from time import sleep -from datetime import timedelta -from django.utils import timezone -from loguru import logger class Command(BaseCommand): diff --git a/catalog/migrations/0001_initial.py b/catalog/migrations/0001_initial.py index 04c16fd5..738d2667 100644 --- a/catalog/migrations/0001_initial.py +++ b/catalog/migrations/0001_initial.py @@ -1,11 +1,13 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 +import uuid + +import django.db.models.deletion +import simple_history.models +from django.db import migrations, models + import catalog.common.mixins import catalog.common.utils -from django.db import migrations, models -import django.db.models.deletion -import simple_history.models -import uuid class Migration(migrations.Migration): diff --git a/catalog/migrations/0002_initial.py b/catalog/migrations/0002_initial.py index cf62f38d..2a5d62e8 100644 --- a/catalog/migrations/0002_initial.py +++ b/catalog/migrations/0002_initial.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/catalog/migrations/0003_podcast.py b/catalog/migrations/0003_podcast.py index 82bdd18f..95ce31b8 100644 --- a/catalog/migrations/0003_podcast.py +++ b/catalog/migrations/0003_podcast.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.16 on 2023-02-02 03:47 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/catalog/migrations/0004_podcast_no_real_change.py b/catalog/migrations/0004_podcast_no_real_change.py index 2f21dfae..99f3b7db 100644 --- a/catalog/migrations/0004_podcast_no_real_change.py +++ b/catalog/migrations/0004_podcast_no_real_change.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.16 on 2023-02-03 21:58 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/catalog/migrations/0007_performance.py b/catalog/migrations/0007_performance.py index 6e714f8c..c61416df 100644 --- a/catalog/migrations/0007_performance.py +++ b/catalog/migrations/0007_performance.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-06-05 02:31 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/catalog/migrations/0010_alter_item_polymorphic_ctype.py b/catalog/migrations/0010_alter_item_polymorphic_ctype.py index cfe6ddd2..ac47fb45 100644 --- a/catalog/migrations/0010_alter_item_polymorphic_ctype.py +++ b/catalog/migrations/0010_alter_item_polymorphic_ctype.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.3 on 2023-07-06 22:53 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/catalog/models.py b/catalog/models.py index 0aea3db4..2a7b2b80 100644 --- a/catalog/models.py +++ b/catalog/models.py @@ -1,37 +1,41 @@ +import logging + +from auditlog.registry import auditlog +from django.conf import settings +from django.contrib.contenttypes.models import ContentType + +from .book.models import Edition, EditionInSchema, EditionSchema, Series, Work +from .collection.models import Collection as CatalogCollection from .common.models import ( ExternalResource, Item, + ItemCategory, ItemSchema, - item_content_types, item_categories, + item_content_types, ) -from .book.models import Edition, Work, Series, EditionSchema, EditionInSchema -from .movie.models import Movie, MovieSchema, MovieInSchema -from .tv.models import ( - TVShow, - TVSeason, - TVEpisode, - TVShowSchema, - TVShowInSchema, - TVSeasonSchema, - TVSeasonInSchema, - TVEpisodeSchema, -) -from .music.models import Album, AlbumSchema, AlbumInSchema -from .game.models import Game, GameSchema, GameInSchema -from .podcast.models import Podcast, PodcastSchema, PodcastInSchema, PodcastEpisode +from .game.models import Game, GameInSchema, GameSchema +from .movie.models import Movie, MovieInSchema, MovieSchema +from .music.models import Album, AlbumInSchema, AlbumSchema from .performance.models import ( Performance, PerformanceProduction, - PerformanceSchema, PerformanceProductionSchema, + PerformanceSchema, ) -from .collection.models import Collection as CatalogCollection -from .search.models import Indexer -from django.contrib.contenttypes.models import ContentType -from django.conf import settings -import logging -from auditlog.registry import auditlog +from .podcast.models import Podcast, PodcastEpisode, PodcastInSchema, PodcastSchema +from .tv.models import ( + TVEpisode, + TVEpisodeSchema, + TVSeason, + TVSeasonInSchema, + TVSeasonSchema, + TVShow, + TVShowInSchema, + TVShowSchema, +) + +from .search.models import Indexer # isort:skip _logger = logging.getLogger(__name__) diff --git a/catalog/movie/models.py b/catalog/movie/models.py index 5c7f8911..b347abb3 100644 --- a/catalog/movie/models.py +++ b/catalog/movie/models.py @@ -1,6 +1,7 @@ -from catalog.common.models import * -from django.utils.translation import gettext_lazy as _ from django.db import models +from django.utils.translation import gettext_lazy as _ + +from catalog.common.models import * class MovieInSchema(ItemInSchema): diff --git a/catalog/movie/tests.py b/catalog/movie/tests.py index 2c860eab..8e642903 100644 --- a/catalog/movie/tests.py +++ b/catalog/movie/tests.py @@ -1,4 +1,5 @@ from django.test import TestCase + from catalog.common import * diff --git a/catalog/music/models.py b/catalog/music/models.py index b17d0063..db076963 100644 --- a/catalog/music/models.py +++ b/catalog/music/models.py @@ -1,7 +1,9 @@ from datetime import date -from catalog.common.models import * -from django.utils.translation import gettext_lazy as _ + from django.db import models +from django.utils.translation import gettext_lazy as _ + +from catalog.common.models import * class AlbumInSchema(ItemInSchema): diff --git a/catalog/music/tests.py b/catalog/music/tests.py index 32dc5cdd..58e622e9 100644 --- a/catalog/music/tests.py +++ b/catalog/music/tests.py @@ -1,4 +1,5 @@ from django.test import TestCase + from catalog.common import * from catalog.models import * from catalog.music.utils import * diff --git a/catalog/performance/models.py b/catalog/performance/models.py index 7d8be4b0..cd8ef054 100644 --- a/catalog/performance/models.py +++ b/catalog/performance/models.py @@ -1,10 +1,12 @@ from functools import cached_property -from django.utils.translation import gettext_lazy as _ + from django.db import models +from django.utils.translation import gettext_lazy as _ +from ninja import Schema + from catalog.common import * from catalog.common.models import ItemSchema from catalog.common.utils import DEFAULT_ITEM_COVER -from ninja import Schema class CrewMemberSchema(Schema): diff --git a/catalog/podcast/models.py b/catalog/podcast/models.py index df1d5fb8..44d59a10 100644 --- a/catalog/podcast/models.py +++ b/catalog/podcast/models.py @@ -1,7 +1,8 @@ -from catalog.common.models import * from django.db import models from django.utils.translation import gettext_lazy as _ +from catalog.common.models import * + class PodcastInSchema(ItemInSchema): genre: list[str] diff --git a/catalog/podcast/tests.py b/catalog/podcast/tests.py index 3929280f..e4aac212 100644 --- a/catalog/podcast/tests.py +++ b/catalog/podcast/tests.py @@ -1,7 +1,7 @@ from django.test import TestCase -from catalog.podcast.models import * -from catalog.common import * +from catalog.common import * +from catalog.podcast.models import * # class ApplePodcastTestCase(TestCase): # def setUp(self): diff --git a/catalog/search/external.py b/catalog/search/external.py index 4e634cd4..9c524af9 100644 --- a/catalog/search/external.py +++ b/catalog/search/external.py @@ -1,11 +1,13 @@ +import logging from urllib.parse import quote_plus + +import requests from django.conf import settings +from lxml import html + from catalog.common import * from catalog.models import * from catalog.sites.spotify import get_spotify_token -import requests -from lxml import html -import logging SEARCH_PAGE_SIZE = 5 # not all apis support page size logger = logging.getLogger(__name__) diff --git a/catalog/search/models.py b/catalog/search/models.py index ef1541ec..3607ea4f 100644 --- a/catalog/search/models.py +++ b/catalog/search/models.py @@ -1,15 +1,17 @@ -import logging -from django.utils.translation import gettext_lazy as _ -from catalog.common.sites import SiteManager -from ..models import TVSeason, Item -from django.conf import settings -import django_rq -from rq.job import Job -from django.core.cache import cache import hashlib -from .typesense import Indexer as TypeSenseIndexer +import logging + +import django_rq from auditlog.context import set_actor +from django.conf import settings from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ +from rq.job import Job + +from catalog.common.sites import SiteManager + +from ..models import Item, TVSeason +from .typesense import Indexer as TypeSenseIndexer # from .meilisearch import Indexer as MeiliSearchIndexer diff --git a/catalog/search/typesense.py b/catalog/search/typesense.py index b636bcd4..966fd5db 100644 --- a/catalog/search/typesense.py +++ b/catalog/search/typesense.py @@ -1,16 +1,18 @@ -from datetime import timedelta -import types import logging -import typesense -from typesense.exceptions import ObjectNotFound -from typesense.collection import Collection -from django.conf import settings -from django.db.models.signals import post_save, post_delete -from catalog.models import Item +import types +from datetime import timedelta from pprint import pprint + import django_rq -from rq.job import Job +import typesense +from django.conf import settings +from django.db.models.signals import post_delete, post_save from django_redis import get_redis_connection +from rq.job import Job +from typesense.collection import Collection +from typesense.exceptions import ObjectNotFound + +from catalog.models import Item INDEX_NAME = "catalog" SEARCHABLE_ATTRIBUTES = [ diff --git a/catalog/sites/__init__.py b/catalog/sites/__init__.py index 739f6472..6fb4f868 100644 --- a/catalog/sites/__init__.py +++ b/catalog/sites/__init__.py @@ -1,22 +1,21 @@ from ..common.sites import SiteManager +from .apple_music import AppleMusic +from .bandcamp import Bandcamp +from .bangumi import Bangumi +from .bookstw import BooksTW +from .discogs import DiscogsMaster, DiscogsRelease +from .douban_book import DoubanBook +from .douban_drama import DoubanDrama +from .douban_game import DoubanGame +from .douban_movie import DoubanMovie +from .douban_music import DoubanMusic +from .goodreads import Goodreads +from .google_books import GoogleBooks +from .igdb import IGDB +from .imdb import IMDB # from .apple_podcast import ApplePodcast from .rss import RSS -from .douban_book import DoubanBook -from .douban_movie import DoubanMovie -from .douban_music import DoubanMusic -from .douban_game import DoubanGame -from .douban_drama import DoubanDrama -from .goodreads import Goodreads -from .google_books import GoogleBooks -from .tmdb import TMDB_Movie -from .imdb import IMDB from .spotify import Spotify -from .igdb import IGDB from .steam import Steam -from .bandcamp import Bandcamp -from .bangumi import Bangumi -from .discogs import DiscogsRelease -from .discogs import DiscogsMaster -from .bookstw import BooksTW -from .apple_music import AppleMusic +from .tmdb import TMDB_Movie diff --git a/catalog/sites/apple_podcast.py b/catalog/sites/apple_podcast.py index d5d53194..fee1e043 100644 --- a/catalog/sites/apple_podcast.py +++ b/catalog/sites/apple_podcast.py @@ -1,6 +1,8 @@ +import logging + from catalog.common import * from catalog.models import * -import logging + from .rss import RSS _logger = logging.getLogger(__name__) diff --git a/catalog/sites/bandcamp.py b/catalog/sites/bandcamp.py index 33a00659..2fcc0ab2 100644 --- a/catalog/sites/bandcamp.py +++ b/catalog/sites/bandcamp.py @@ -1,11 +1,12 @@ +import json +import logging +import re +import urllib.parse + +import dateparser + from catalog.common import * from catalog.models import * -import logging -import urllib.parse -import dateparser -import re -import json - _logger = logging.getLogger(__name__) diff --git a/catalog/sites/bangumi.py b/catalog/sites/bangumi.py index 5c95b957..3af24132 100644 --- a/catalog/sites/bangumi.py +++ b/catalog/sites/bangumi.py @@ -1,7 +1,7 @@ -from catalog.common import * -from catalog.models import * import logging +from catalog.common import * +from catalog.models import * _logger = logging.getLogger(__name__) diff --git a/catalog/sites/bookstw.py b/catalog/sites/bookstw.py index 0c124f61..df5ceca6 100644 --- a/catalog/sites/bookstw.py +++ b/catalog/sites/bookstw.py @@ -1,9 +1,10 @@ -from catalog.common import * -from catalog.book.models import * -from catalog.book.utils import * -from .douban import * import logging +from catalog.book.models import * +from catalog.book.utils import * +from catalog.common import * + +from .douban import * _logger = logging.getLogger(__name__) diff --git a/catalog/sites/discogs.py b/catalog/sites/discogs.py index 5626d3c2..cf035b1c 100644 --- a/catalog/sites/discogs.py +++ b/catalog/sites/discogs.py @@ -1,15 +1,17 @@ """ Discogs. """ +import json +import logging + +import requests from django.conf import settings + from catalog.common import * from catalog.models import * from catalog.music.utils import upc_to_gtin_13 -from .douban import * -import json -import logging -import requests +from .douban import * _logger = logging.getLogger(__name__) diff --git a/catalog/sites/douban.py b/catalog/sites/douban.py index 6f47af2e..da016e60 100644 --- a/catalog/sites/douban.py +++ b/catalog/sites/douban.py @@ -1,6 +1,6 @@ import re -from catalog.common import * +from catalog.common import * RE_NUMBERS = re.compile(r"\d+\d*") RE_WHITESPACES = re.compile(r"\s+") diff --git a/catalog/sites/goodreads.py b/catalog/sites/goodreads.py index b877d5b1..c4df368f 100644 --- a/catalog/sites/goodreads.py +++ b/catalog/sites/goodreads.py @@ -1,12 +1,13 @@ -from django.utils.timezone import make_aware -from datetime import datetime -from catalog.book.models import Edition, Work -from catalog.common import * -from catalog.book.utils import detect_isbn_asin -from lxml import html import json import logging +from datetime import datetime +from django.utils.timezone import make_aware +from lxml import html + +from catalog.book.models import Edition, Work +from catalog.book.utils import detect_isbn_asin +from catalog.common import * _logger = logging.getLogger(__name__) diff --git a/catalog/sites/google_books.py b/catalog/sites/google_books.py index 83ea483a..ec4965f8 100644 --- a/catalog/sites/google_books.py +++ b/catalog/sites/google_books.py @@ -1,8 +1,8 @@ +import logging +import re + from catalog.common import * from catalog.models import * -import re -import logging - _logger = logging.getLogger(__name__) diff --git a/catalog/sites/igdb.py b/catalog/sites/igdb.py index f1274f25..cd80478a 100644 --- a/catalog/sites/igdb.py +++ b/catalog/sites/igdb.py @@ -4,15 +4,16 @@ IGDB use (e.g. "portal-2") as id, which is different from real id in IGDB API """ -from catalog.common import * -from catalog.models import * -from django.conf import settings -from igdb.wrapper import IGDBWrapper -import requests import datetime import json import logging +import requests +from django.conf import settings +from igdb.wrapper import IGDBWrapper + +from catalog.common import * +from catalog.models import * _logger = logging.getLogger(__name__) diff --git a/catalog/sites/imdb.py b/catalog/sites/imdb.py index 34491ed7..bb5ae9e8 100644 --- a/catalog/sites/imdb.py +++ b/catalog/sites/imdb.py @@ -1,10 +1,11 @@ import json -from catalog.common import * -from .tmdb import search_tmdb_by_imdb_id -from catalog.movie.models import * -from catalog.tv.models import * import logging +from catalog.common import * +from catalog.movie.models import * +from catalog.tv.models import * + +from .tmdb import search_tmdb_by_imdb_id _logger = logging.getLogger(__name__) diff --git a/catalog/sites/rss.py b/catalog/sites/rss.py index 2fbae897..7089a511 100644 --- a/catalog/sites/rss.py +++ b/catalog/sites/rss.py @@ -1,21 +1,23 @@ -from catalog.common import * -from catalog.models import * import logging -import podcastparser +import pickle import urllib.request -from django.core.cache import cache -from catalog.podcast.models import PodcastEpisode from datetime import datetime -from django.utils.timezone import make_aware + import bleach -from django.core.validators import URLValidator +import podcastparser +from django.core.cache import cache from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.utils.timezone import make_aware + +from catalog.common import * from catalog.common.downloaders import ( + _local_response_path, get_mock_file, get_mock_mode, - _local_response_path, ) -import pickle +from catalog.models import * +from catalog.podcast.models import PodcastEpisode _logger = logging.getLogger(__name__) diff --git a/catalog/sites/spotify.py b/catalog/sites/spotify.py index 8b2625d9..6da7a1f4 100644 --- a/catalog/sites/spotify.py +++ b/catalog/sites/spotify.py @@ -1,17 +1,19 @@ """ Spotify """ +import datetime +import logging +import time + +import dateparser +import requests from django.conf import settings + from catalog.common import * from catalog.models import * from catalog.music.utils import upc_to_gtin_13 -from .douban import * -import time -import datetime -import requests -import dateparser -import logging +from .douban import * _logger = logging.getLogger(__name__) diff --git a/catalog/sites/steam.py b/catalog/sites/steam.py index dbbc35b5..0dae24d0 100644 --- a/catalog/sites/steam.py +++ b/catalog/sites/steam.py @@ -1,9 +1,11 @@ -from catalog.common import * -from catalog.models import * -from .igdb import search_igdb_by_3p_url -import dateparser import logging +import dateparser + +from catalog.common import * +from catalog.models import * + +from .igdb import search_igdb_by_3p_url _logger = logging.getLogger(__name__) diff --git a/catalog/sites/tmdb.py b/catalog/sites/tmdb.py index 4960d641..28620528 100644 --- a/catalog/sites/tmdb.py +++ b/catalog/sites/tmdb.py @@ -2,14 +2,16 @@ The Movie Database """ +import logging import re + from django.conf import settings + from catalog.common import * -from .douban import * from catalog.movie.models import * from catalog.tv.models import * -import logging +from .douban import * _logger = logging.getLogger(__name__) diff --git a/catalog/tests.py b/catalog/tests.py index 952d0993..1d386a44 100644 --- a/catalog/tests.py +++ b/catalog/tests.py @@ -1,10 +1,11 @@ from django.test import TestCase + from catalog.book.tests import * -from catalog.movie.tests import * -from catalog.tv.tests import * -from catalog.music.tests import * from catalog.game.tests import * -from catalog.podcast.tests import * +from catalog.movie.tests import * +from catalog.music.tests import * from catalog.performance.tests import * +from catalog.podcast.tests import * +from catalog.tv.tests import * # imported tests with same name might be ignored silently diff --git a/catalog/tv/models.py b/catalog/tv/models.py index efa0f4c7..70ddc844 100644 --- a/catalog/tv/models.py +++ b/catalog/tv/models.py @@ -25,10 +25,12 @@ For now, we follow Douban convention, but keep an eye on it in case it breaks it """ from functools import cached_property -from catalog.common.models import * + from django.db import models from django.utils.translation import gettext_lazy as _ +from catalog.common.models import * + class TVShowInSchema(ItemInSchema): season_count: int | None = None diff --git a/catalog/tv/tests.py b/catalog/tv/tests.py index 08cf8dc2..1549c634 100644 --- a/catalog/tv/tests.py +++ b/catalog/tv/tests.py @@ -1,7 +1,8 @@ from django.test import TestCase + from catalog.common import * -from catalog.tv.models import * from catalog.sites.imdb import IMDB +from catalog.tv.models import * class JSONFieldTestCase(TestCase): diff --git a/catalog/urls.py b/catalog/urls.py index c52757c3..94125c23 100644 --- a/catalog/urls.py +++ b/catalog/urls.py @@ -1,6 +1,7 @@ from django.urls import path, re_path -from .views import * + from .models import * +from .views import * app_name = "catalog" diff --git a/catalog/views.py b/catalog/views.py index 4fb6466d..11ac0b3d 100644 --- a/catalog/views.py +++ b/catalog/views.py @@ -1,24 +1,32 @@ import logging -from django.shortcuts import render, get_object_or_404, redirect + from django.contrib.auth.decorators import login_required, permission_required -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import BadRequest, PermissionDenied, ObjectDoesNotExist -from django.db.models import Count -from django.core.paginator import Paginator -from .models import * -from django.views.decorators.clickjacking import xframe_options_exempt -from journal.models import Mark, ShelfMember, Review, Comment, query_item_category -from journal.models import ( - query_visible, - query_following, -) -from common.utils import PageLinksGenerator, get_uuid_or_404 -from common.config import PAGE_LINK_NUMBER -from journal.models import ShelfTypeNames, ShelfType, ItemCategory -from .forms import * -from .search.views import * -from django.http import Http404 from django.core.cache import cache +from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied +from django.core.paginator import Paginator +from django.db.models import Count +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.clickjacking import xframe_options_exempt + +from common.config import PAGE_LINK_NUMBER +from common.utils import PageLinksGenerator, get_uuid_or_404 +from journal.models import ( + Comment, + Mark, + Review, + ShelfMember, + ShelfType, + ShelfTypeNames, + query_following, + query_item_category, + query_visible, +) + +from .forms import * +from .models import * +from .search.views import * from .views_edit import * _logger = logging.getLogger(__name__) @@ -205,7 +213,8 @@ def comments_by_episode(request, item_path, item_uuid): raise Http404() episode_uuid = request.GET.get("episode_uuid") if episode_uuid: - ids = [TVEpisode.get_by_url(episode_uuid).id] + episode = TVEpisode.get_by_url(episode_uuid) + ids = [episode.pk] if episode else [] else: ids = item.child_item_ids queryset = Comment.objects.filter(item_id__in=ids).order_by("-created_time") @@ -250,7 +259,7 @@ def discover(request): raise BadRequest() user = request.user if user.is_authenticated: - layout = user.get_preference().discover_layout + layout = user.preference.discover_layout else: layout = [] diff --git a/catalog/views_edit.py b/catalog/views_edit.py index 707c708e..47c479ec 100644 --- a/catalog/views_edit.py +++ b/catalog/views_edit.py @@ -1,19 +1,22 @@ import logging -from django.shortcuts import render, get_object_or_404, redirect -from django.contrib.auth.decorators import login_required, permission_required -from django.utils.translation import gettext_lazy as _ -from django.http import HttpResponseRedirect -from django.core.exceptions import BadRequest, PermissionDenied, ObjectDoesNotExist -from django.utils import timezone -from django.contrib import messages -from .common.models import ExternalResource, IdType, IdealIdTypes -from .sites.imdb import IMDB -from .models import * -from .forms import * -from .search.views import * -from journal.models import update_journal_for_merged_item -from common.utils import get_uuid_or_404 + from auditlog.context import set_actor +from django.contrib import messages +from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from common.utils import get_uuid_or_404 +from journal.models import update_journal_for_merged_item + +from .common.models import ExternalResource, IdealIdTypes, IdType +from .forms import * +from .models import * +from .search.views import * +from .sites.imdb import IMDB _logger = logging.getLogger(__name__) diff --git a/common/api.py b/common/api.py index 80bf5ced..41db7e76 100644 --- a/common/api.py +++ b/common/api.py @@ -1,13 +1,14 @@ -from ninja import NinjaAPI, Schema -from django.conf import settings +import logging from typing import Any, Callable, List, Optional, Tuple, Type -from ninja.pagination import PageNumberPagination as NinjaPageNumberPagination + +from django.conf import settings from django.db.models import QuerySet +from ninja import NinjaAPI, Schema +from ninja.pagination import PageNumberPagination as NinjaPageNumberPagination from ninja.security import HttpBearer -from oauthlib.oauth2 import Server from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator -import logging +from oauthlib.oauth2 import Server _logger = logging.getLogger(__name__) diff --git a/common/forms.py b/common/forms.py index 89ac990d..3ae7c39c 100644 --- a/common/forms.py +++ b/common/forms.py @@ -1,11 +1,12 @@ -from django import forms -from markdownx.fields import MarkdownxFormField -import django.contrib.postgres.forms as postgres -from django.utils import formats -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ import json +import django.contrib.postgres.forms as postgres +from django import forms +from django.core.exceptions import ValidationError +from django.utils import formats +from django.utils.translation import gettext_lazy as _ +from markdownx.fields import MarkdownxFormField + class PreviewImageInput(forms.FileInput): template_name = "widgets/image.html" diff --git a/common/management/commands/delete_job.py b/common/management/commands/delete_job.py index f74b91aa..66f2690e 100644 --- a/common/management/commands/delete_job.py +++ b/common/management/commands/delete_job.py @@ -1,8 +1,9 @@ -from django.core.management.base import BaseCommand import pprint + +from django.core.management.base import BaseCommand from redis import Redis -from rq.job import Job from rq import Queue +from rq.job import Job class Command(BaseCommand): diff --git a/common/management/commands/list_jobs.py b/common/management/commands/list_jobs.py index 085d6e00..51f189c5 100644 --- a/common/management/commands/list_jobs.py +++ b/common/management/commands/list_jobs.py @@ -1,8 +1,9 @@ -from django.core.management.base import BaseCommand import pprint + +from django.core.management.base import BaseCommand from redis import Redis -from rq.job import Job from rq import Queue +from rq.job import Job class Command(BaseCommand): diff --git a/common/templatetags/admin_url.py b/common/templatetags/admin_url.py index 3199716f..014e16ee 100644 --- a/common/templatetags/admin_url.py +++ b/common/templatetags/admin_url.py @@ -2,7 +2,6 @@ from django import template from django.conf import settings from django.utils.html import format_html - register = template.Library() diff --git a/common/templatetags/duration.py b/common/templatetags/duration.py index 90dff12e..4568dfe9 100644 --- a/common/templatetags/duration.py +++ b/common/templatetags/duration.py @@ -1,7 +1,8 @@ from django import template from django.template.defaultfilters import stringfilter -from django.utils.text import Truncator from django.utils.safestring import mark_safe +from django.utils.text import Truncator + from catalog.common.models import ItemCategory, item_categories from catalog.search.views import visible_categories as _visible_categories diff --git a/common/templatetags/highlight.py b/common/templatetags/highlight.py index b2d74e23..48a23c36 100644 --- a/common/templatetags/highlight.py +++ b/common/templatetags/highlight.py @@ -1,9 +1,10 @@ -from django import template -from django.utils.safestring import mark_safe -from django.template.defaultfilters import stringfilter -from opencc import OpenCC import re +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe +from opencc import OpenCC + cc = OpenCC("t2s") register = template.Library() diff --git a/common/templatetags/mastodon.py b/common/templatetags/mastodon.py index 3befdb24..8d31bf40 100644 --- a/common/templatetags/mastodon.py +++ b/common/templatetags/mastodon.py @@ -3,7 +3,6 @@ from django.conf import settings from django.template.defaultfilters import stringfilter from django.utils.translation import gettext_lazy as _ - register = template.Library() diff --git a/common/templatetags/prettydate.py b/common/templatetags/prettydate.py index d043d34a..05725c23 100644 --- a/common/templatetags/prettydate.py +++ b/common/templatetags/prettydate.py @@ -1,7 +1,6 @@ from django import template from django.utils import timezone - register = template.Library() diff --git a/common/templatetags/truncate.py b/common/templatetags/truncate.py index 6e6764b5..529feb30 100644 --- a/common/templatetags/truncate.py +++ b/common/templatetags/truncate.py @@ -2,7 +2,6 @@ from django import template from django.template.defaultfilters import stringfilter from django.utils.text import Truncator - register = template.Library() diff --git a/common/urls.py b/common/urls.py index e9abe2c7..679dc795 100644 --- a/common/urls.py +++ b/common/urls.py @@ -1,4 +1,5 @@ from django.urls import path + from .views import * app_name = "common" diff --git a/common/utils.py b/common/utils.py index 1ddc52b1..fe43222b 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,4 +1,5 @@ import uuid + from django.http import Http404 from django.utils import timezone from django.utils.baseconv import base62 diff --git a/common/views.py b/common/views.py index ecf7832b..3b26f24b 100644 --- a/common/views.py +++ b/common/views.py @@ -1,7 +1,7 @@ from django.contrib import messages +from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from django.urls import reverse -from django.contrib.auth.decorators import login_required @login_required @@ -11,7 +11,7 @@ def me(request): def home(request): if request.user.is_authenticated: - home = request.user.get_preference().classic_homepage + home = request.user.preference.classic_homepage if home == 1: return redirect(request.user.url) elif home == 2: diff --git a/developer/migrations/0001_initial.py b/developer/migrations/0001_initial.py index eb0d70ca..aa05f04b 100644 --- a/developer/migrations/0001_initial.py +++ b/developer/migrations/0001_initial.py @@ -1,12 +1,12 @@ # Generated by Django 3.2.19 on 2023-06-28 05:09 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import markdownx.models import oauth2_provider.generators import oauth2_provider.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/developer/migrations/0002_alter_application_user.py b/developer/migrations/0002_alter_application_user.py index 0ab7f4e7..0a8c33f0 100644 --- a/developer/migrations/0002_alter_application_user.py +++ b/developer/migrations/0002_alter_application_user.py @@ -1,8 +1,8 @@ # Generated by Django 4.2.3 on 2023-07-06 22:53 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/developer/models.py b/developer/models.py index ace18a19..1ca80821 100644 --- a/developer/models.py +++ b/developer/models.py @@ -1,9 +1,10 @@ -from django.db import models from django.core.validators import RegexValidator +from django.db import models from django.utils.translation import gettext_lazy as _ -from oauth2_provider.models import AbstractApplication from markdownx.models import MarkdownxField -from journal.renderers import render_md +from oauth2_provider.models import AbstractApplication + +from journal.models.renderers import render_md class Application(AbstractApplication): diff --git a/developer/urls.py b/developer/urls.py index 89d93ab8..a0bc6159 100644 --- a/developer/urls.py +++ b/developer/urls.py @@ -1,10 +1,10 @@ -from django.urls import path, re_path, include from django.contrib import admin from django.contrib.auth import views as auth_views +from django.urls import include, path, re_path from oauth2_provider import views as oauth2_views from oauth2_provider.views import oidc as oidc_views -from .views import * +from .views import * _urlpatterns = [ re_path( diff --git a/developer/views.py b/developer/views.py index 8d1248b0..48994674 100644 --- a/developer/views.py +++ b/developer/views.py @@ -1,23 +1,22 @@ -from django.shortcuts import render -from loguru import logger +from dateutil.relativedelta import relativedelta +from django.conf import settings from django.contrib.auth.decorators import login_required +from django.forms.models import modelform_factory +from django.shortcuts import render from django.urls import reverse +from django.utils import timezone +from loguru import logger from oauth2_provider.forms import AllowForm -from oauth2_provider.models import get_application_model +from oauth2_provider.generators import generate_client_id, generate_client_secret +from oauth2_provider.models import AccessToken, RefreshToken, get_application_model +from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ApplicationRegistration as BaseApplicationRegistration from oauth2_provider.views import ApplicationUpdate as BaseApplicationUpdate from oauth2_provider.views.base import AuthorizationView as BaseAuthorizationView -from oauth2_provider.settings import oauth2_settings -from oauth2_provider.generators import generate_client_id, generate_client_secret -from common.api import api from oauthlib.common import generate_token -from oauth2_provider.models import AccessToken -from django.utils import timezone -from dateutil.relativedelta import relativedelta -from oauth2_provider.models import RefreshToken -from django.conf import settings -from .models import Application -from django.forms.models import modelform_factory + +from common.api import api + from .models import Application diff --git a/journal/api.py b/journal/api.py index 86d664f9..6435d345 100644 --- a/journal/api.py +++ b/journal/api.py @@ -1,15 +1,17 @@ -from .models import * -from ninja import Schema -from common.api import * -from oauth2_provider.decorators import protected_resource -from ninja.security import django_auth -from django.contrib.auth.decorators import login_required -from catalog.common.models import * -from typing import List -from ninja.pagination import paginate -from ninja import Field from datetime import datetime +from typing import List + +from django.contrib.auth.decorators import login_required from django.utils import timezone +from ninja import Field, Schema +from ninja.pagination import paginate +from ninja.security import django_auth +from oauth2_provider.decorators import protected_resource + +from catalog.common.models import * +from common.api import * + +from .models import * class MarkSchema(Schema): diff --git a/journal/apps.py b/journal/apps.py index b33dd330..4f5a6268 100644 --- a/journal/apps.py +++ b/journal/apps.py @@ -7,9 +7,10 @@ class JournalConfig(AppConfig): def ready(self): # load key modules in proper order, make sure class inject and signal works as expected - from . import api - from .models import Tag, Rating from catalog.models import Indexer + from . import api + from .models import Rating, Tag + Indexer.register_list_model(Tag) Indexer.register_piece_model(Rating) diff --git a/journal/exporters/doufen.py b/journal/exporters/doufen.py index 8f6053ab..64bbffb1 100644 --- a/journal/exporters/doufen.py +++ b/journal/exporters/doufen.py @@ -1,10 +1,12 @@ -from django.utils.translation import gettext_lazy as _ +import os +from datetime import datetime + from django.conf import settings +from django.utils.translation import gettext_lazy as _ from openpyxl import Workbook + from catalog.common.models import IdType from common.utils import GenerateDateUUIDMediaFilePath -from datetime import datetime -import os from journal.models import * diff --git a/journal/feeds.py b/journal/feeds.py index 770bfb4c..ced67294 100644 --- a/journal/feeds.py +++ b/journal/feeds.py @@ -1,8 +1,11 @@ -from django.contrib.syndication.views import Feed -from journal.renderers import render_md import mimetypes -from .models import * + from django.conf import settings +from django.contrib.syndication.views import Feed + +from journal.models.renderers import render_md + +from .models import * MAX_ITEM_PER_TYPE = 10 diff --git a/journal/forms.py b/journal/forms.py index 479b9383..1b801c94 100644 --- a/journal/forms.py +++ b/journal/forms.py @@ -1,9 +1,11 @@ from django import forms -from markdownx.fields import MarkdownxFormField from django.utils.translation import gettext_lazy as _ -from .models import * +from markdownx.fields import MarkdownxFormField + from common.forms import PreviewImageInput +from .models import * + class ReviewForm(forms.ModelForm): class Meta: diff --git a/journal/importers/douban.py b/journal/importers/douban.py index d7c31baa..1f28dfd5 100644 --- a/journal/importers/douban.py +++ b/journal/importers/douban.py @@ -1,18 +1,21 @@ -import openpyxl -import re -from markdownify import markdownify as md -from datetime import datetime import logging -import pytz -from django.conf import settings -from user_messages import api as msg -import django_rq -from common.utils import GenerateDateUUIDMediaFilePath import os +import re +from datetime import datetime + +import django_rq +import openpyxl +import pytz from auditlog.context import set_actor +from django.conf import settings +from markdownify import markdownify as md +from user_messages import api as msg + from catalog.common import * from catalog.common.downloaders import * +from catalog.models import * from catalog.sites.douban import DoubanDownloader +from common.utils import GenerateDateUUIDMediaFilePath from journal.models import * _logger = logging.getLogger(__name__) diff --git a/journal/importers/goodreads.py b/journal/importers/goodreads.py index e7ba006f..790167f6 100644 --- a/journal/importers/goodreads.py +++ b/journal/importers/goodreads.py @@ -1,14 +1,15 @@ import re from datetime import datetime -from user_messages import api as msg + import django_rq -from django.utils.timezone import make_aware from auditlog.context import set_actor +from django.utils.timezone import make_aware +from user_messages import api as msg + from catalog.common import * +from catalog.common.downloaders import * from catalog.models import * from journal.models import * -from catalog.common.downloaders import * - re_list = r"^https://www.goodreads.com/list/show/\d+" re_shelf = r"^https://www.goodreads.com/review/list/\d+[^?]*\?shelf=[^&]+" diff --git a/journal/importers/opml.py b/journal/importers/opml.py index 33c51f0a..5bb5e532 100644 --- a/journal/importers/opml.py +++ b/journal/importers/opml.py @@ -1,19 +1,21 @@ -from django.core.files import uploadedfile -import listparser -from catalog.sites.rss import RSS -import openpyxl -import re -from markdownify import markdownify as md -from datetime import datetime import logging +import os +import re +from datetime import datetime + +import django_rq +import listparser +import openpyxl import pytz from django.conf import settings +from django.core.files import uploadedfile +from markdownify import markdownify as md from user_messages import api as msg -import django_rq -from common.utils import GenerateDateUUIDMediaFilePath -import os + from catalog.common import * from catalog.common.downloaders import * +from catalog.sites.rss import RSS +from common.utils import GenerateDateUUIDMediaFilePath from journal.models import * diff --git a/journal/management/commands/journal.py b/journal/management/commands/journal.py index a614bc41..3fadaf91 100644 --- a/journal/management/commands/journal.py +++ b/journal/management/commands/journal.py @@ -1,7 +1,9 @@ -from django.core.management.base import BaseCommand import pprint -from journal.models import * + +from django.core.management.base import BaseCommand + from journal.importers.douban import DoubanImporter +from journal.models import * from users.models import User diff --git a/journal/migrations/0001_initial.py b/journal/migrations/0001_initial.py index aba37ed6..858a1710 100644 --- a/journal/migrations/0001_initial.py +++ b/journal/migrations/0001_initial.py @@ -1,13 +1,15 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 -import catalog.common.utils +import uuid + import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone -import journal.mixins import markdownx.models -import uuid +from django.db import migrations, models + +import catalog.common.utils +import journal.models.mixins class Migration(migrations.Migration): @@ -39,7 +41,7 @@ class Migration(migrations.Migration): "abstract": False, "base_manager_name": "objects", }, - bases=(models.Model, journal.mixins.UserOwnedObjectMixin), + bases=(models.Model, journal.models.mixins.UserOwnedObjectMixin), ), migrations.CreateModel( name="Collection", diff --git a/journal/migrations/0002_initial.py b/journal/migrations/0002_initial.py index f148b7ca..eae2261d 100644 --- a/journal/migrations/0002_initial.py +++ b/journal/migrations/0002_initial.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/journal/migrations/0003_auto_20230113_0506.py b/journal/migrations/0003_auto_20230113_0506.py index b54e609d..cf52d7c6 100644 --- a/journal/migrations/0003_auto_20230113_0506.py +++ b/journal/migrations/0003_auto_20230113_0506.py @@ -1,9 +1,10 @@ # Generated by Django 3.2.16 on 2023-01-12 21:06 -import catalog.common.utils from django.conf import settings from django.db import migrations, models +import catalog.common.utils + class Migration(migrations.Migration): dependencies = [ diff --git a/journal/migrations/0005_auto_20230114_1134.py b/journal/migrations/0005_auto_20230114_1134.py index e006295c..7e6d34e1 100644 --- a/journal/migrations/0005_auto_20230114_1134.py +++ b/journal/migrations/0005_auto_20230114_1134.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.16 on 2023-01-14 03:34 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/journal/migrations/0006_auto_20230114_2139.py b/journal/migrations/0006_auto_20230114_2139.py index e077e3d5..c57838ac 100644 --- a/journal/migrations/0006_auto_20230114_2139.py +++ b/journal/migrations/0006_auto_20230114_2139.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.16 on 2023-01-14 13:39 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/journal/migrations/0007_alter_collection_catalog_item.py b/journal/migrations/0007_alter_collection_catalog_item.py index 0196fdf8..49b54389 100644 --- a/journal/migrations/0007_alter_collection_catalog_item.py +++ b/journal/migrations/0007_alter_collection_catalog_item.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.16 on 2023-01-17 03:51 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/journal/migrations/0009_comment_focus_item.py b/journal/migrations/0009_comment_focus_item.py index 43eba068..95a18229 100644 --- a/journal/migrations/0009_comment_focus_item.py +++ b/journal/migrations/0009_comment_focus_item.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.16 on 2023-01-31 20:14 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/journal/migrations/0011_performance.py b/journal/migrations/0011_performance.py index 9655e363..a071c810 100644 --- a/journal/migrations/0011_performance.py +++ b/journal/migrations/0011_performance.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-06-05 02:31 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/journal/migrations/0012_alter_piece_polymorphic_ctype_alter_shelf_items.py b/journal/migrations/0012_alter_piece_polymorphic_ctype_alter_shelf_items.py index 43de17d3..a63e19d0 100644 --- a/journal/migrations/0012_alter_piece_polymorphic_ctype_alter_shelf_items.py +++ b/journal/migrations/0012_alter_piece_polymorphic_ctype_alter_shelf_items.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.3 on 2023-07-06 22:53 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/journal/models.py b/journal/models.py deleted file mode 100644 index 797af410..00000000 --- a/journal/models.py +++ /dev/null @@ -1,1309 +0,0 @@ -import re -import uuid -from functools import cached_property - -import django.dispatch -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied -from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator -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 markdownx.models import MarkdownxField -from polymorphic.models import PolymorphicModel - -from catalog.collection.models import Collection as CatalogCollection -from catalog.common import jsondata -from catalog.common.models import Item, ItemCategory -from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path -from catalog.models import * -from mastodon.api import share_review -from users.models import User - -from .mixins import UserOwnedObjectMixin -from .renderers import render_md, render_text - -_logger = logging.getLogger(__name__) - - -class VisibilityType(models.IntegerChoices): - Public = 0, _("公开") - Follower_Only = 1, _("仅关注者") - Private = 2, _("仅自己") - - -def q_visible_to(viewer, owner): - if viewer == owner: - return Q() - # elif viewer.is_blocked_by(owner): - # return Q(pk__in=[]) - elif viewer.is_authenticated and viewer.is_following(owner): - return Q(visibility__in=[0, 1]) - else: - return Q(visibility=0) - - -def max_visiblity_to(viewer, owner): - if viewer == owner: - return 2 - # elif viewer.is_blocked_by(owner): - # return Q(pk__in=[]) - elif viewer.is_authenticated and viewer.is_following(owner): - return 1 - else: - return 0 - - -def query_visible(user): - return ( - ( - Q(visibility=0) - | Q(owner_id__in=user.following, visibility=1) - | Q(owner_id=user.id) - ) - & ~Q(owner_id__in=user.ignoring) - if user.is_authenticated - else Q(visibility=0) - ) - - -def query_following(user): - return Q(owner_id__in=user.following, visibility__lt=2) | Q(owner_id=user.id) - - -def query_item_category(item_category): - classes = item_categories()[item_category] - # q = Q(item__instance_of=classes[0]) - # for cls in classes[1:]: - # q = q | Q(instance_of=cls) - # return q - ct = item_content_types() - contenttype_ids = [ct[cls] for cls in classes] - return Q(item__polymorphic_ctype__in=contenttype_ids) - - -# class ImportStatus(Enum): -# QUEUED = 0 -# PROCESSING = 1 -# FINISHED = 2 - - -# class ImportSession(models.Model): -# owner = models.ForeignKey(User, on_delete=models.CASCADE) -# status = models.PositiveSmallIntegerField(default=ImportStatus.QUEUED) -# importer = models.CharField(max_length=50) -# file = models.CharField() -# default_visibility = models.PositiveSmallIntegerField() -# total = models.PositiveIntegerField() -# processed = models.PositiveIntegerField() -# skipped = models.PositiveIntegerField() -# imported = models.PositiveIntegerField() -# failed = models.PositiveIntegerField() -# logs = models.JSONField(default=list) -# created_time = models.DateTimeField(auto_now_add=True) -# edited_time = models.DateTimeField(auto_now=True) - -# class Meta: -# indexes = [ -# models.Index(fields=["owner", "importer", "created_time"]), -# ] - - -class Piece(PolymorphicModel, UserOwnedObjectMixin): - url_path = "p" # subclass must specify this - uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) - - @property - def uuid(self): - return base62.encode(self.uid.int) - - @property - def url(self): - return f"/{self.url_path}/{self.uuid}" if self.url_path else None - - @property - def absolute_url(self): - return (settings.SITE_INFO["site_url"] + self.url) if self.url_path else None - - @property - def api_url(self): - return f"/api/{self.url}" if self.url_path else None - - @property - def like_count(self): - return self.likes.all().count() - - @classmethod - def get_by_url(cls, url_or_b62): - 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: - obj = cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) - except: - obj = None - return obj - - -class Content(Piece): - owner = models.ForeignKey(User, on_delete=models.PROTECT) - visibility = models.PositiveSmallIntegerField( - default=0 - ) # 0: Public / 1: Follower only / 2: Self only - created_time = models.DateTimeField(default=timezone.now) - edited_time = models.DateTimeField( - default=timezone.now - ) # auto_now=True FIXME revert this after migration - metadata = models.JSONField(default=dict) - item = models.ForeignKey(Item, on_delete=models.PROTECT) - - def __str__(self): - return f"{self.uuid}@{self.item}" - - class Meta: - abstract = True - - -class Like(Piece): - owner = models.ForeignKey(User, on_delete=models.PROTECT) - visibility = models.PositiveSmallIntegerField( - default=0 - ) # 0: Public / 1: Follower only / 2: Self only - created_time = models.DateTimeField( - default=timezone.now - ) # auto_now_add=True FIXME revert this after migration - edited_time = models.DateTimeField( - default=timezone.now - ) # auto_now=True FIXME revert this after migration - target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name="likes") - - @staticmethod - def user_liked_piece(user, piece): - return Like.objects.filter(owner=user, target=piece).exists() - - @staticmethod - def user_like_piece(user, piece): - if not piece: - return - like = Like.objects.filter(owner=user, target=piece).first() - if not like: - like = Like.objects.create(owner=user, target=piece) - return like - - @staticmethod - def user_unlike_piece(user, piece): - if not piece: - return - Like.objects.filter(owner=user, target=piece).delete() - - @staticmethod - def user_likes_by_class(user, cls): - ctype_id = ContentType.objects.get_for_model(cls) - return Like.objects.filter(owner=user, target__polymorphic_ctype=ctype_id) - - -class Memo(Content): - pass - - -class Comment(Content): - text = models.TextField(blank=False, null=False) - - @property - def html(self): - return render_text(self.text) - - @cached_property - def rating_grade(self): - return Rating.get_item_rating_by_user(self.item, self.owner) - - @cached_property - def mark(self): - m = Mark(self.owner, self.item) - m.comment = self - return m - - @property - def item_url(self): - if self.metadata.get("position"): - return self.item.get_absolute_url_with_position(self.metadata["position"]) - else: - return self.item.url - - @staticmethod - def comment_item_by_user(item, user, text, visibility=0, created_time=None): - comment = Comment.objects.filter(owner=user, item=item).first() - if not text: - if comment is not None: - comment.delete() - comment = None - elif comment is None: - comment = Comment.objects.create( - owner=user, - item=item, - text=text, - visibility=visibility, - created_time=created_time or timezone.now(), - ) - elif comment.text != text or comment.visibility != visibility: - comment.text = text - comment.visibility = visibility - if created_time: - comment.created_time = created_time - comment.save() - return comment - - -class Review(Content): - url_path = "review" - title = models.CharField(max_length=500, blank=False, null=False) - body = MarkdownxField() - - @property - def html_content(self): - return render_md(self.body) - - @property - def plain_content(self): - html = render_md(self.body) - return _RE_HTML_TAG.sub( - " ", _RE_SPOILER_TAG.sub("***", html.replace("\n", " ")) - ) - - @cached_property - def mark(self): - m = Mark(self.owner, self.item) - m.review = self - return m - - @cached_property - def rating_grade(self): - return Rating.get_item_rating_by_user(self.item, self.owner) - - @classmethod - def review_item_by_user( - cls, - item, - user, - title, - body, - visibility=0, - created_time=None, - share_to_mastodon=False, - ): - if title is None: - review = Review.objects.filter(owner=user, item=item).first() - if review is not None: - review.delete() - return None - defaults = { - "title": title, - "body": body, - "visibility": visibility, - } - if created_time: - defaults["created_time"] = ( - created_time if created_time < timezone.now() else timezone.now() - ) - review, created = cls.objects.update_or_create( - item=item, owner=user, defaults=defaults - ) - if share_to_mastodon and user.mastodon_username: - share_review(review) - return review - - -MIN_RATING_COUNT = 5 -RATING_INCLUDES_CHILD_ITEMS = ["tvshow", "performance"] - - -class Rating(Content): - class Meta: - unique_together = [["owner", "item"]] - - grade = models.PositiveSmallIntegerField( - default=0, validators=[MaxValueValidator(10), MinValueValidator(1)], null=True - ) - - @staticmethod - def get_rating_for_item(item): - stat = Rating.objects.filter(grade__isnull=False) - if item.class_name in RATING_INCLUDES_CHILD_ITEMS: - stat = stat.filter(item_id__in=item.child_item_ids + [item.id]) - else: - stat = stat.filter(item=item) - stat = stat.aggregate(average=Avg("grade"), count=Count("item")) - return round(stat["average"], 1) if stat["count"] >= MIN_RATING_COUNT else None - - @staticmethod - def get_rating_count_for_item(item): - stat = Rating.objects.filter(grade__isnull=False) - if item.class_name in RATING_INCLUDES_CHILD_ITEMS: - stat = stat.filter(item_id__in=item.child_item_ids + [item.id]) - else: - stat = stat.filter(item=item) - stat = stat.aggregate(count=Count("item")) - return stat["count"] - - @staticmethod - def get_rating_distribution_for_item(item): - stat = Rating.objects.filter(grade__isnull=False) - if item.class_name in RATING_INCLUDES_CHILD_ITEMS: - stat = stat.filter(item_id__in=item.child_item_ids + [item.id]) - else: - stat = stat.filter(item=item) - stat = stat.values("grade").annotate(count=Count("grade")).order_by("grade") - g = [0] * 11 - t = 0 - for s in stat: - g[s["grade"]] = s["count"] - t += s["count"] - if t < MIN_RATING_COUNT: - return [0] * 5 - r = [ - 100 * (g[1] + g[2]) // t, - 100 * (g[3] + g[4]) // t, - 100 * (g[5] + g[6]) // t, - 100 * (g[7] + g[8]) // t, - 100 * (g[9] + g[10]) // t, - ] - return r - - @staticmethod - def rate_item_by_user(item, user, rating_grade, visibility=0): - if rating_grade and (rating_grade < 1 or rating_grade > 10): - raise ValueError(f"Invalid rating grade: {rating_grade}") - rating = Rating.objects.filter(owner=user, item=item).first() - if not rating_grade: - if rating: - rating.delete() - rating = None - elif rating is None: - rating = Rating.objects.create( - owner=user, item=item, grade=rating_grade, visibility=visibility - ) - elif rating.grade != rating_grade or rating.visibility != visibility: - rating.visibility = visibility - rating.grade = rating_grade - rating.save() - return rating - - @staticmethod - def get_item_rating_by_user(item, user): - rating = Rating.objects.filter(owner=user, item=item).first() - return (rating.grade or None) if rating else None - - -Item.rating = property(Rating.get_rating_for_item) -Item.rating_count = property(Rating.get_rating_count_for_item) -Item.rating_dist = property(Rating.get_rating_distribution_for_item) - - -class Reply(Piece): - reply_to_content = models.ForeignKey( - Piece, on_delete=models.SET_NULL, related_name="replies", null=True - ) - title = models.CharField(max_length=500, null=True) - body = MarkdownxField() - - -""" -List (abstract class) -""" - -list_add = django.dispatch.Signal() -list_remove = django.dispatch.Signal() - - -class List(Piece): - owner = models.ForeignKey(User, on_delete=models.PROTECT) - visibility = models.PositiveSmallIntegerField( - default=0 - ) # 0: Public / 1: Follower only / 2: Self only - created_time = models.DateTimeField( - default=timezone.now - ) # auto_now_add=True FIXME revert this after migration - edited_time = models.DateTimeField( - default=timezone.now - ) # auto_now=True FIXME revert this after migration - metadata = models.JSONField(default=dict) - - class Meta: - abstract = True - - # MEMBER_CLASS = None # subclass must override this - # subclass must add this: - # items = models.ManyToManyField(Item, through='ListMember') - - @property - def ordered_members(self): - return self.members.all().order_by("position") - - @property - def ordered_items(self): - return self.items.all().order_by( - self.MEMBER_CLASS.__name__.lower() + "__position" - ) - - @property - def recent_items(self): - return self.items.all().order_by( - "-" + self.MEMBER_CLASS.__name__.lower() + "__created_time" - ) - - @property - def recent_members(self): - return self.members.all().order_by("-created_time") - - def get_member_for_item(self, item): - return self.members.filter(item=item).first() - - def get_summary(self): - summary = {k: 0 for k in ItemCategory.values} - for c in self.recent_items: - summary[c.category] += 1 - return summary - - def append_item(self, item, **params): - """ - named metadata fields should be specified directly, not in metadata dict! - e.g. collection.append_item(item, note="abc") works, but collection.append_item(item, metadata={"note":"abc"}) doesn't - """ - if item is None: - return None - member = self.get_member_for_item(item) - if member: - return member - ml = self.ordered_members - p = {"parent": self} - p.update(params) - member = self.MEMBER_CLASS.objects.create( - owner=self.owner, - position=ml.last().position + 1 if ml.count() else 1, - item=item, - **p, - ) - list_add.send(sender=self.__class__, instance=self, item=item, member=member) - return member - - def remove_item(self, item): - member = self.get_member_for_item(item) - if member: - list_remove.send( - sender=self.__class__, instance=self, item=item, member=member - ) - member.delete() - - def update_member_order(self, ordered_member_ids): - members = self.ordered_members - for m in self.members.all(): - try: - i = ordered_member_ids.index(m.id) - if m.position != i + 1: - m.position = i + 1 - m.save() - except ValueError: - pass - - def move_up_item(self, item): - members = self.ordered_members - member = self.get_member_for_item(item) - if member: - other = members.filter(position__lt=member.position).last() - if other: - p = other.position - other.position = member.position - member.position = p - other.save() - member.save() - - def move_down_item(self, item): - members = self.ordered_members - member = self.get_member_for_item(item) - if member: - other = members.filter(position__gt=member.position).first() - if other: - p = other.position - other.position = member.position - member.position = p - other.save() - member.save() - - def update_item_metadata(self, item, metadata): - member = self.get_member_for_item(item) - if member: - member.metadata = metadata - member.save() - - -class ListMember(Piece): - """ - ListMember - List class's member class - It's an abstract class, subclass must add this: - - parent = models.ForeignKey('List', related_name='members', on_delete=models.CASCADE) - """ - - owner = models.ForeignKey(User, on_delete=models.PROTECT) - visibility = models.PositiveSmallIntegerField( - default=0 - ) # 0: Public / 1: Follower only / 2: Self only - created_time = models.DateTimeField(default=timezone.now) - edited_time = models.DateTimeField( - default=timezone.now - ) # auto_now=True FIXME revert this after migration - metadata = models.JSONField(default=dict) - item = models.ForeignKey(Item, on_delete=models.PROTECT) - position = models.PositiveIntegerField() - - @cached_property - def mark(self): - m = Mark(self.owner, self.item) - return m - - class Meta: - abstract = True - - def __str__(self): - return f"{self.id}:{self.position} ({self.item})" - - -""" -Shelf -""" - - -class ShelfType(models.TextChoices): - WISHLIST = ("wishlist", "未开始") - PROGRESS = ("progress", "进行中") - COMPLETE = ("complete", "完成") - # DISCARDED = ('discarded', '放弃') - - -ShelfTypeNames = [ - [ItemCategory.Book, ShelfType.WISHLIST, _("想读")], - [ItemCategory.Book, ShelfType.PROGRESS, _("在读")], - [ItemCategory.Book, ShelfType.COMPLETE, _("读过")], - [ItemCategory.Movie, ShelfType.WISHLIST, _("想看")], - [ItemCategory.Movie, ShelfType.PROGRESS, _("在看")], - [ItemCategory.Movie, ShelfType.COMPLETE, _("看过")], - [ItemCategory.TV, ShelfType.WISHLIST, _("想看")], - [ItemCategory.TV, ShelfType.PROGRESS, _("在看")], - [ItemCategory.TV, ShelfType.COMPLETE, _("看过")], - [ItemCategory.Music, ShelfType.WISHLIST, _("想听")], - [ItemCategory.Music, ShelfType.PROGRESS, _("在听")], - [ItemCategory.Music, ShelfType.COMPLETE, _("听过")], - [ItemCategory.Game, ShelfType.WISHLIST, _("想玩")], - [ItemCategory.Game, ShelfType.PROGRESS, _("在玩")], - [ItemCategory.Game, ShelfType.COMPLETE, _("玩过")], - [ItemCategory.Podcast, ShelfType.WISHLIST, _("想听")], - [ItemCategory.Podcast, ShelfType.PROGRESS, _("在听")], - [ItemCategory.Podcast, ShelfType.COMPLETE, _("听过")], - # disable all shelves for PodcastEpisode - [ItemCategory.Performance, ShelfType.WISHLIST, _("想看")], - # disable progress shelf for Performance - [ItemCategory.Performance, ShelfType.PROGRESS, _("")], - [ItemCategory.Performance, ShelfType.COMPLETE, _("看过")], -] - - -class ShelfMember(ListMember): - parent = models.ForeignKey( - "Shelf", related_name="members", on_delete=models.CASCADE - ) - - class Meta: - unique_together = [["owner", "item"]] - indexes = [ - models.Index(fields=["parent_id", "visibility", "created_time"]), - ] - - @cached_property - def mark(self): - m = Mark(self.owner, self.item) - m.shelfmember = self - return m - - @property - def shelf_label(self): - return ShelfManager.get_label(self.parent.shelf_type, self.item.category) - - @property - def shelf_type(self): - return self.parent.shelf_type - - @property - def rating_grade(self): - return self.mark.rating_grade - - @property - def comment_text(self): - return self.mark.comment_text - - @property - def tags(self): - return self.mark.tags - - -class Shelf(List): - class Meta: - unique_together = [["owner", "shelf_type"]] - - MEMBER_CLASS = ShelfMember - items = models.ManyToManyField(Item, through="ShelfMember", related_name="+") - shelf_type = models.CharField( - choices=ShelfType.choices, max_length=100, null=False, blank=False - ) - - def __str__(self): - return f"{self.id} [{self.owner} {self.shelf_type} list]" - - -class ShelfLogEntry(models.Model): - owner = models.ForeignKey(User, on_delete=models.PROTECT) - shelf_type = models.CharField(choices=ShelfType.choices, max_length=100, null=True) - item = models.ForeignKey(Item, on_delete=models.PROTECT) - timestamp = models.DateTimeField() # this may later be changed by user - metadata = models.JSONField(default=dict) - created_time = models.DateTimeField(auto_now_add=True) - edited_time = models.DateTimeField(auto_now=True) - - def __str__(self): - return f"{self.owner}:{self.shelf_type}:{self.item.uuid}:{self.timestamp}:{self.metadata}" - - @property - def action_label(self): - if self.shelf_type: - return ShelfManager.get_action_label(self.shelf_type, self.item.category) - else: - return _("移除标记") - - -class ShelfManager: - """ - ShelfManager - - all shelf operations should go thru this class so that ShelfLogEntry can be properly populated - ShelfLogEntry can later be modified if user wish to change history - """ - - def __init__(self, user): - self.owner = user - qs = Shelf.objects.filter(owner=self.owner) - self.shelf_list = {v.shelf_type: v for v in qs} - if len(self.shelf_list) == 0: - self.initialize() - - def initialize(self): - for qt in ShelfType: - self.shelf_list[qt] = Shelf.objects.create(owner=self.owner, shelf_type=qt) - - def locate_item(self, item) -> ShelfMember | None: - return ShelfMember.objects.filter(item=item, owner=self.owner).first() - - def move_item(self, item, shelf_type, visibility=0, metadata=None, silence=False): - # shelf_type=None means remove from current shelf - # metadata=None means no change - # silence=False means move_item is logged. - if not item: - raise ValueError("empty item") - new_shelfmember = None - last_shelfmember = self.locate_item(item) - last_shelf = last_shelfmember.parent if last_shelfmember else None - last_metadata = last_shelfmember.metadata if last_shelfmember else None - last_visibility = last_shelfmember.visibility if last_shelfmember else None - shelf = self.shelf_list[shelf_type] if shelf_type else None - changed = False - if last_shelf != shelf: # change shelf - changed = True - if last_shelf: - last_shelf.remove_item(item) - if shelf: - new_shelfmember = shelf.append_item( - item, visibility=visibility, metadata=metadata or {} - ) - elif last_shelf is None: - raise ValueError("empty shelf") - else: - new_shelfmember = last_shelfmember - if metadata is not None and metadata != last_metadata: # change metadata - changed = True - last_shelfmember.metadata = metadata - last_shelfmember.visibility = visibility - last_shelfmember.save() - elif visibility != last_visibility: # change visibility - last_shelfmember.visibility = visibility - last_shelfmember.save() - if changed and not silence: - if metadata is None: - metadata = last_metadata or {} - log_time = ( - new_shelfmember.created_time - if new_shelfmember and new_shelfmember != last_shelfmember - else timezone.now() - ) - ShelfLogEntry.objects.create( - owner=self.owner, - shelf_type=shelf_type, - item=item, - metadata=metadata, - timestamp=log_time, - ) - return new_shelfmember - - def get_log(self): - return ShelfLogEntry.objects.filter(owner=self.owner).order_by("timestamp") - - def get_log_for_item(self, item): - return ShelfLogEntry.objects.filter(owner=self.owner, item=item).order_by( - "timestamp" - ) - - def get_shelf(self, shelf_type): - return self.shelf_list[shelf_type] - - def get_latest_members(self, shelf_type, item_category=None): - qs = self.shelf_list[shelf_type].members.all().order_by("-created_time") - if item_category: - return qs.filter(query_item_category(item_category)) - else: - return qs - - # def get_items_on_shelf(self, item_category, shelf_type): - # shelf = ( - # self.owner.shelf_set.all() - # .filter(item_category=item_category, shelf_type=shelf_type) - # .first() - # ) - # return shelf.members.all().order_by - - @classmethod - def get_action_label(cls, shelf_type, item_category): - sts = [ - n[2] for n in ShelfTypeNames if n[0] == item_category and n[1] == shelf_type - ] - return sts[0] if sts else shelf_type - - @classmethod - def get_label(cls, shelf_type, item_category): - ic = ItemCategory(item_category).label - st = cls.get_action_label(shelf_type, item_category) - return ( - _("{shelf_label}的{item_category}").format(shelf_label=st, item_category=ic) - if st - else None - ) - - @staticmethod - def get_manager_for_user(user): - return ShelfManager(user) - - def get_calendar_data(self, max_visiblity): - shelf_id = self.get_shelf(ShelfType.COMPLETE).pk - timezone_offset = timezone.localtime(timezone.now()).strftime("%z") - timezone_offset = timezone_offset[: len(timezone_offset) - 2] - calendar_data = {} - sql = "SELECT to_char(DATE(journal_shelfmember.created_time::timestamp AT TIME ZONE %s), 'YYYY-MM-DD') AS dat, django_content_type.model typ, COUNT(1) count FROM journal_shelfmember, catalog_item, django_content_type WHERE journal_shelfmember.item_id = catalog_item.id AND django_content_type.id = catalog_item.polymorphic_ctype_id AND parent_id = %s AND journal_shelfmember.created_time >= NOW() - INTERVAL '366 days' AND journal_shelfmember.visibility <= %s GROUP BY item_id, dat, typ;" - with connection.cursor() as cursor: - cursor.execute(sql, [timezone_offset, shelf_id, int(max_visiblity)]) - data = cursor.fetchall() - for line in data: - date = line[0] - typ = line[1] - if date not in calendar_data: - calendar_data[date] = {"items": []} - if typ[:2] == "tv": - typ = "movie" - elif typ == "album": - typ = "music" - elif typ == "edition": - typ = "book" - elif typ not in ["book", "movie", "music", "game"]: - typ = "other" - if typ not in calendar_data[date]["items"]: - calendar_data[date]["items"].append(typ) - return calendar_data - - -User.shelf_manager = cached_property(ShelfManager.get_manager_for_user) -User.shelf_manager.__set_name__(User, "shelf_manager") - - -""" -Collection -""" - - -class CollectionMember(ListMember): - parent = models.ForeignKey( - "Collection", related_name="members", on_delete=models.CASCADE - ) - - note = jsondata.CharField(_("备注"), null=True, blank=True) - - -_RE_HTML_TAG = re.compile(r"<[^>]*>") -_RE_SPOILER_TAG = re.compile(r'<(div|span)\sclass="spoiler">.*') - - -class Collection(List): - url_path = "collection" - MEMBER_CLASS = CollectionMember - catalog_item = models.OneToOneField( - CatalogCollection, on_delete=models.PROTECT, related_name="journal_item" - ) - title = models.CharField(_("标题"), max_length=1000, default="") - brief = models.TextField(_("简介"), blank=True, default="") - cover = models.ImageField( - upload_to=piece_cover_path, default=DEFAULT_ITEM_COVER, blank=True - ) - items = models.ManyToManyField( - Item, through="CollectionMember", related_name="collections" - ) - collaborative = models.PositiveSmallIntegerField( - default=0 - ) # 0: Editable by owner only / 1: Editable by bi-direction followers - featured_by_users = models.ManyToManyField( - to=User, related_name="featured_collections", through="FeaturedCollection" - ) - - @property - def html(self): - html = render_md(self.brief) - return html - - @property - def plain_description(self): - html = render_md(self.brief) - return _RE_HTML_TAG.sub(" ", html) - - def featured_by_user_since(self, user): - f = FeaturedCollection.objects.filter(target=self, owner=user).first() - return f.created_time if f else None - - def get_stats_for_user(self, user): - items = list(self.members.all().values_list("item_id", flat=True)) - stats = {"total": len(items)} - for st, shelf in user.shelf_manager.shelf_list.items(): - stats[st] = shelf.members.all().filter(item_id__in=items).count() - stats["percentage"] = ( - round(stats["complete"] * 100 / stats["total"]) if stats["total"] else 0 - ) - return stats - - def get_progress_for_user(self, user): - items = list(self.members.all().values_list("item_id", flat=True)) - if len(items) == 0: - return 0 - shelf = user.shelf_manager.shelf_list["complete"] - return round( - shelf.members.all().filter(item_id__in=items).count() * 100 / len(items) - ) - - def save(self, *args, **kwargs): - if getattr(self, "catalog_item", None) is None: - self.catalog_item = CatalogCollection() - if ( - self.catalog_item.title != self.title - or self.catalog_item.brief != self.brief - ): - self.catalog_item.title = self.title - self.catalog_item.brief = self.brief - self.catalog_item.cover = self.cover - self.catalog_item.save() - super().save(*args, **kwargs) - - -class FeaturedCollection(Piece): - owner = models.ForeignKey(User, on_delete=models.CASCADE) - target = models.ForeignKey(Collection, on_delete=models.CASCADE) - created_time = models.DateTimeField(auto_now_add=True) - edited_time = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = [["owner", "target"]] - - @property - def visibility(self): - return self.target.visibility - - @cached_property - def progress(self): - return self.target.get_progress_for_user(self.owner) - - -""" -Tag -""" - - -class TagMember(ListMember): - parent = models.ForeignKey("Tag", related_name="members", on_delete=models.CASCADE) - - class Meta: - unique_together = [["parent", "item"]] - - -TagValidators = [RegexValidator(regex=r"\s+", inverse_match=True)] - - -class Tag(List): - MEMBER_CLASS = TagMember - items = models.ManyToManyField(Item, through="TagMember") - title = models.CharField( - max_length=100, null=False, blank=False, validators=TagValidators - ) - # TODO case convert and space removal on save - # TODO check on save - - class Meta: - unique_together = [["owner", "title"]] - - @staticmethod - def cleanup_title(title, replace=True): - t = re.sub(r"\s+", " ", title.strip()) - return "_" if not title and replace else t - - @staticmethod - def deep_cleanup_title(title): - """Remove all non-word characters, only for public index purpose""" - return re.sub(r"\W+", " ", title).strip() - - -class TagManager: - @staticmethod - def indexable_tags_for_item(item): - tags = ( - item.tag_set.all() - .filter(visibility=0) - .values("title") - .annotate(frequency=Count("owner")) - .order_by("-frequency")[:20] - ) - tag_titles = sorted( - [ - t - for t in set(map(lambda t: Tag.deep_cleanup_title(t["title"]), tags)) - if t - ] - ) - return tag_titles - - @staticmethod - def all_tags_for_user(user, public_only=False): - tags = ( - user.tag_set.all() - .values("title") - .annotate(frequency=Count("members__id")) - .order_by("-frequency") - ) - if public_only: - tags = tags.filter(visibility=0) - return list(map(lambda t: t["title"], tags)) - - @staticmethod - def tag_item_by_user(item, user, tag_titles, default_visibility=0): - titles = set([Tag.cleanup_title(tag_title) for tag_title in tag_titles]) - current_titles = set( - [m.parent.title for m in TagMember.objects.filter(owner=user, item=item)] - ) - for title in titles - current_titles: - tag = Tag.objects.filter(owner=user, title=title).first() - if not tag: - tag = Tag.objects.create( - owner=user, title=title, visibility=default_visibility - ) - tag.append_item(item, visibility=default_visibility) - for title in current_titles - titles: - tag = Tag.objects.filter(owner=user, title=title).first() - if tag: - tag.remove_item(item) - - @staticmethod - def get_item_tags_by_user(item, user): - current_titles = [ - m.parent.title for m in TagMember.objects.filter(owner=user, item=item) - ] - return current_titles - - @staticmethod - def get_manager_for_user(user): - return TagManager(user) - - def __init__(self, user): - self.owner = user - - @property - def all_tags(self): - return TagManager.all_tags_for_user(self.owner) - - @property - def public_tags(self): - return TagManager.all_tags_for_user(self.owner, public_only=True) - - def get_item_tags(self, item): - return sorted( - [ - m["parent__title"] - for m in TagMember.objects.filter( - parent__owner=self.owner, item=item - ).values("parent__title") - ] - ) - - -Item.tags = property(TagManager.indexable_tags_for_item) -User.tags = property(TagManager.all_tags_for_user) -User.tag_manager = cached_property(TagManager.get_manager_for_user) -User.tag_manager.__set_name__(User, "tag_manager") - - -class Mark: - """ - Holding Mark for an item on an shelf, - which is a combo object of ShelfMember, Comment, Rating and Tags. - it mimics previous mark behaviour. - """ - - def __init__(self, user, item): - self.owner = user - self.item = item - - @cached_property - def shelfmember(self): - return self.owner.shelf_manager.locate_item(self.item) - - @property - def id(self): - return self.shelfmember.id if self.shelfmember else None - - @cached_property - def shelf(self): - return self.shelfmember.parent if self.shelfmember else None - - @property - def shelf_type(self): - return self.shelfmember.parent.shelf_type if self.shelfmember else None - - @property - def action_label(self): - if self.shelfmember: - return ShelfManager.get_action_label(self.shelf_type, self.item.category) - if self.comment: - return ShelfManager.get_action_label( - ShelfType.PROGRESS, self.comment.item.category - ) - return "" - - @property - def shelf_label(self): - return ( - ShelfManager.get_label(self.shelf_type, self.item.category) - if self.shelfmember - else None - ) - - @property - def created_time(self): - return self.shelfmember.created_time if self.shelfmember else None - - @property - def metadata(self): - return self.shelfmember.metadata if self.shelfmember else None - - @property - def visibility(self): - return ( - self.shelfmember.visibility - if self.shelfmember - else self.owner.get_preference().default_visibility - ) - - @cached_property - def tags(self): - return self.owner.tag_manager.get_item_tags(self.item) - - @cached_property - def rating_grade(self): - return Rating.get_item_rating_by_user(self.item, self.owner) - - @cached_property - def comment(self): - return Comment.objects.filter(owner=self.owner, item=self.item).first() - - @property - def comment_text(self): - return (self.comment.text or None) if self.comment else None - - @property - def comment_html(self): - return self.comment.html if self.comment else None - - @cached_property - def review(self): - return Review.objects.filter(owner=self.owner, item=self.item).first() - - def update( - self, - shelf_type, - comment_text, - rating_grade, - visibility, - metadata=None, - created_time=None, - share_to_mastodon=False, - silence=False, - ): - # silence=False means update is logged. - share = ( - share_to_mastodon - and self.owner.mastodon_username - and shelf_type is not None - and ( - shelf_type != self.shelf_type - or comment_text != self.comment_text - or rating_grade != self.rating_grade - ) - ) - if created_time and created_time >= timezone.now(): - created_time = None - share_as_new_post = shelf_type != self.shelf_type - original_visibility = self.visibility - if shelf_type != self.shelf_type or visibility != original_visibility: - self.shelfmember = self.owner.shelf_manager.move_item( - self.item, - shelf_type, - visibility=visibility, - metadata=metadata, - silence=silence, - ) - if not silence and self.shelfmember and created_time: - # if it's an update(not delete) and created_time is specified, - # update the timestamp of the shelfmember and log - log = ShelfLogEntry.objects.filter( - owner=self.owner, - item=self.item, - timestamp=self.shelfmember.created_time, - ).first() - self.shelfmember.created_time = created_time - self.shelfmember.save(update_fields=["created_time"]) - if log: - log.timestamp = created_time - log.save(update_fields=["timestamp"]) - else: - ShelfLogEntry.objects.create( - owner=self.owner, - shelf_type=shelf_type, - item=self.item, - metadata=self.metadata, - timestamp=created_time, - ) - if comment_text != self.comment_text or visibility != original_visibility: - self.comment = Comment.comment_item_by_user( - self.item, - self.owner, - comment_text, - visibility, - self.shelfmember.created_time if self.shelfmember else None, - ) - if rating_grade != self.rating_grade or visibility != original_visibility: - Rating.rate_item_by_user(self.item, self.owner, rating_grade, visibility) - self.rating_grade = rating_grade - if share: - # this is a bit hacky but let's keep it until move to implement ActivityPub, - # by then, we'll just change this to boost - from mastodon.api import share_mark - - self.shared_link = ( - self.shelfmember.metadata.get("shared_link") - if self.shelfmember.metadata and not share_as_new_post - else None - ) - self.save = lambda **args: None - result, code = share_mark(self) - if not result: - if code == 401: - raise PermissionDenied() - else: - raise ValueError(code) - if self.shelfmember.metadata.get("shared_link") != self.shared_link: - self.shelfmember.metadata["shared_link"] = self.shared_link - self.shelfmember.save() - elif share_as_new_post and self.shelfmember: - self.shelfmember.metadata["shared_link"] = None - self.shelfmember.save() - - def delete(self, silence=False): - # self.logs.delete() # When deleting a mark, all logs of the mark are deleted first. - self.update(None, None, None, 0, silence=silence) - - def delete_log(self, log_id): - ShelfLogEntry.objects.filter( - owner=self.owner, item=self.item, id=log_id - ).delete() - - def delete_all_logs(self): - self.logs.delete() - - @property - def logs(self): - return ShelfLogEntry.objects.filter(owner=self.owner, item=self.item).order_by( - "timestamp" - ) - - -def reset_journal_visibility_for_user(user: User, visibility: int): - ShelfMember.objects.filter(owner=user).update(visibility=visibility) - Comment.objects.filter(owner=user).update(visibility=visibility) - Rating.objects.filter(owner=user).update(visibility=visibility) - Review.objects.filter(owner=user).update(visibility=visibility) - - -def remove_data_by_user(user: User): - ShelfMember.objects.filter(owner=user).delete() - Comment.objects.filter(owner=user).delete() - Rating.objects.filter(owner=user).delete() - Review.objects.filter(owner=user).delete() - TagMember.objects.filter(owner=user).delete() - Tag.objects.filter(owner=user).delete() - CollectionMember.objects.filter(owner=user).delete() - Collection.objects.filter(owner=user).delete() - FeaturedCollection.objects.filter(owner=user).delete() - - -def update_journal_for_merged_item(legacy_item_uuid, delete_duplicated=False): - legacy_item = Item.get_by_url(legacy_item_uuid) - if not legacy_item: - _logger.error("update_journal_for_merged_item: unable to find item") - return - new_item = legacy_item.merged_to_item - for cls in list(Content.__subclasses__()) + list(ListMember.__subclasses__()): - for p in cls.objects.filter(item=legacy_item): - try: - p.item = new_item - p.save(update_fields=["item_id"]) - except: - if delete_duplicated: - _logger.warn( - f"deleted piece {p} when merging {cls.__name__}: {legacy_item} -> {new_item}" - ) - p.delete() - else: - _logger.warn( - f"skip piece {p} when merging {cls.__name__}: {legacy_item} -> {new_item}" - ) - - -def journal_exists_for_item(item): - for cls in list(Content.__subclasses__()) + list(ListMember.__subclasses__()): - if cls.objects.filter(item=item).exists(): - return True - return False - - -Item.journal_exists = journal_exists_for_item diff --git a/journal/models/__init__.py b/journal/models/__init__.py new file mode 100644 index 00000000..b35724b1 --- /dev/null +++ b/journal/models/__init__.py @@ -0,0 +1,31 @@ +from .collection import Collection, CollectionMember, FeaturedCollection +from .comment import Comment +from .common import ( + Piece, + UserOwnedObjectMixin, + VisibilityType, + max_visiblity_to, + q_visible_to, + query_following, + query_item_category, + query_visible, +) +from .like import Like +from .mark import Mark +from .rating import Rating +from .review import Review +from .shelf import ( + Shelf, + ShelfLogEntry, + ShelfManager, + ShelfMember, + ShelfType, + ShelfTypeNames, +) +from .tag import Tag, TagManager, TagMember +from .utils import ( + journal_exists_for_item, + remove_data_by_user, + reset_journal_visibility_for_user, + update_journal_for_merged_item, +) diff --git a/journal/models/collection.py b/journal/models/collection.py new file mode 100644 index 00000000..430ea9c5 --- /dev/null +++ b/journal/models/collection.py @@ -0,0 +1,111 @@ +import re +from functools import cached_property + +from django.db import connection, models +from django.utils.translation import gettext_lazy as _ + +from catalog.collection.models import Collection as CatalogCollection +from catalog.common import jsondata +from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path +from catalog.models import Item +from users.models import User + +from .common import Piece +from .itemlist import List, ListMember +from .renderers import render_md + +_RE_HTML_TAG = re.compile(r"<[^>]*>") + + +class CollectionMember(ListMember): + parent = models.ForeignKey( + "Collection", related_name="members", on_delete=models.CASCADE + ) + + note = jsondata.CharField(_("备注"), null=True, blank=True) + + +class Collection(List): + url_path = "collection" + MEMBER_CLASS = CollectionMember + catalog_item = models.OneToOneField( + CatalogCollection, on_delete=models.PROTECT, related_name="journal_item" + ) + title = models.CharField(_("标题"), max_length=1000, default="") + brief = models.TextField(_("简介"), blank=True, default="") + cover = models.ImageField( + upload_to=piece_cover_path, default=DEFAULT_ITEM_COVER, blank=True + ) + items = models.ManyToManyField( + Item, through="CollectionMember", related_name="collections" + ) + collaborative = models.PositiveSmallIntegerField( + default=0 + ) # 0: Editable by owner only / 1: Editable by bi-direction followers + featured_by_users = models.ManyToManyField( + to=User, related_name="featured_collections", through="FeaturedCollection" + ) + + @property + def html(self): + html = render_md(self.brief) + return html + + @property + def plain_description(self): + html = render_md(self.brief) + return _RE_HTML_TAG.sub(" ", html) + + def featured_by_user_since(self, user): + f = FeaturedCollection.objects.filter(target=self, owner=user).first() + return f.created_time if f else None + + def get_stats_for_user(self, user): + items = list(self.members.all().values_list("item_id", flat=True)) + stats = {"total": len(items)} + for st, shelf in user.shelf_manager.shelf_list.items(): + stats[st] = shelf.members.all().filter(item_id__in=items).count() + stats["percentage"] = ( + round(stats["complete"] * 100 / stats["total"]) if stats["total"] else 0 + ) + return stats + + def get_progress_for_user(self, user): + items = list(self.members.all().values_list("item_id", flat=True)) + if len(items) == 0: + return 0 + shelf = user.shelf_manager.shelf_list["complete"] + return round( + shelf.members.all().filter(item_id__in=items).count() * 100 / len(items) + ) + + def save(self, *args, **kwargs): + if getattr(self, "catalog_item", None) is None: + self.catalog_item = CatalogCollection() + if ( + self.catalog_item.title != self.title + or self.catalog_item.brief != self.brief + ): + self.catalog_item.title = self.title + self.catalog_item.brief = self.brief + self.catalog_item.cover = self.cover # type: ignore + self.catalog_item.save() + super().save(*args, **kwargs) + + +class FeaturedCollection(Piece): + owner = models.ForeignKey(User, on_delete=models.CASCADE) + target = models.ForeignKey(Collection, on_delete=models.CASCADE) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [["owner", "target"]] + + @property + def visibility(self): + return self.target.visibility + + @cached_property + def progress(self): + return self.target.get_progress_for_user(self.owner) diff --git a/journal/models/comment.py b/journal/models/comment.py new file mode 100644 index 00000000..05c59e1d --- /dev/null +++ b/journal/models/comment.py @@ -0,0 +1,63 @@ +from functools import cached_property + +from django.db import models +from django.utils import timezone + +from catalog.models import Item +from users.models import User + +from .common import Content +from .rating import Rating +from .renderers import render_text + + +class Comment(Content): + text = models.TextField(blank=False, null=False) + + @property + def html(self): + return render_text(self.text) + + @cached_property + def rating_grade(self): + return Rating.get_item_rating_by_user(self.item, self.owner) + + @cached_property + def mark(self): + from .mark import Mark + + m = Mark(self.owner, self.item) + m.comment = self + return m + + @property + def item_url(self): + if self.metadata.get("position"): + return self.item.get_absolute_url_with_position(self.metadata["position"]) + else: + return self.item.url + + @staticmethod + def comment_item_by_user( + item: Item, user: User, text: str | None, visibility=0, created_time=None + ): + comment = Comment.objects.filter(owner=user, item=item).first() + if not text: + if comment is not None: + comment.delete() + comment = None + elif comment is None: + comment = Comment.objects.create( + owner=user, + item=item, + text=text, + visibility=visibility, + created_time=created_time or timezone.now(), + ) + elif comment.text != text or comment.visibility != visibility: + comment.text = text + comment.visibility = visibility + if created_time: + comment.created_time = created_time + comment.save() + return comment diff --git a/journal/models/common.py b/journal/models/common.py new file mode 100644 index 00000000..a8861e86 --- /dev/null +++ b/journal/models/common.py @@ -0,0 +1,169 @@ +import re +import uuid +from functools import cached_property + +import django.dispatch +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +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 markdownx.models import MarkdownxField +from polymorphic.models import PolymorphicModel + +from catalog.collection.models import Collection as CatalogCollection +from catalog.common import jsondata +from catalog.common.models import Item, ItemCategory +from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path +from catalog.models import * +from mastodon.api import share_review +from users.models import User + +from .mixins import UserOwnedObjectMixin +from .renderers import render_md, render_text + +_logger = logging.getLogger(__name__) + + +class VisibilityType(models.IntegerChoices): + Public = 0, _("公开") + Follower_Only = 1, _("仅关注者") + Private = 2, _("仅自己") + + +def q_visible_to(viewer, owner): + if viewer == owner: + return Q() + # elif viewer.is_blocked_by(owner): + # return Q(pk__in=[]) + elif viewer.is_authenticated and viewer.is_following(owner): + return Q(visibility__in=[0, 1]) + else: + return Q(visibility=0) + + +def max_visiblity_to(viewer, owner): + if viewer == owner: + return 2 + # elif viewer.is_blocked_by(owner): + # return Q(pk__in=[]) + elif viewer.is_authenticated and viewer.is_following(owner): + return 1 + else: + return 0 + + +def query_visible(user): + return ( + ( + Q(visibility=0) + | Q(owner_id__in=user.following, visibility=1) + | Q(owner_id=user.id) + ) + & ~Q(owner_id__in=user.ignoring) + if user.is_authenticated + else Q(visibility=0) + ) + + +def query_following(user): + return Q(owner_id__in=user.following, visibility__lt=2) | Q(owner_id=user.id) + + +def query_item_category(item_category): + classes = item_categories()[item_category] + # q = Q(item__instance_of=classes[0]) + # for cls in classes[1:]: + # q = q | Q(instance_of=cls) + # return q + ct = item_content_types() + contenttype_ids = [ct[cls] for cls in classes] + return Q(item__polymorphic_ctype__in=contenttype_ids) + + +# class ImportStatus(Enum): +# QUEUED = 0 +# PROCESSING = 1 +# FINISHED = 2 + + +# class ImportSession(models.Model): +# owner = models.ForeignKey(User, on_delete=models.CASCADE) +# status = models.PositiveSmallIntegerField(default=ImportStatus.QUEUED) +# importer = models.CharField(max_length=50) +# file = models.CharField() +# default_visibility = models.PositiveSmallIntegerField() +# total = models.PositiveIntegerField() +# processed = models.PositiveIntegerField() +# skipped = models.PositiveIntegerField() +# imported = models.PositiveIntegerField() +# failed = models.PositiveIntegerField() +# logs = models.JSONField(default=list) +# created_time = models.DateTimeField(auto_now_add=True) +# edited_time = models.DateTimeField(auto_now=True) + +# class Meta: +# indexes = [ +# models.Index(fields=["owner", "importer", "created_time"]), +# ] + + +class Piece(PolymorphicModel, UserOwnedObjectMixin): + url_path = "p" # subclass must specify this + uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + + @property + def uuid(self): + return base62.encode(self.uid.int) + + @property + def url(self): + return f"/{self.url_path}/{self.uuid}" if self.url_path else None + + @property + def absolute_url(self): + return (settings.SITE_INFO["site_url"] + self.url) if self.url_path else None + + @property + def api_url(self): + return f"/api/{self.url}" if self.url_path else None + + @property + def like_count(self): + return self.likes.all().count() + + @classmethod + def get_by_url(cls, url_or_b62): + 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: + obj = cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) + except: + obj = None + return obj + + +class Content(Piece): + owner = models.ForeignKey(User, on_delete=models.PROTECT) + visibility = models.PositiveSmallIntegerField( + default=0 + ) # 0: Public / 1: Follower only / 2: Self only + created_time = models.DateTimeField(default=timezone.now) + edited_time = models.DateTimeField( + default=timezone.now + ) # auto_now=True FIXME revert this after migration + metadata = models.JSONField(default=dict) + item = models.ForeignKey(Item, on_delete=models.PROTECT) + + def __str__(self): + return f"{self.uuid}@{self.item}" + + class Meta: + abstract = True diff --git a/journal/models/itemlist.py b/journal/models/itemlist.py new file mode 100644 index 00000000..a5b5b543 --- /dev/null +++ b/journal/models/itemlist.py @@ -0,0 +1,172 @@ +from functools import cached_property + +import django.dispatch +from django.db import models +from django.utils import timezone + +from catalog.models import Item, ItemCategory +from users.models import User + +from .common import Piece + +list_add = django.dispatch.Signal() +list_remove = django.dispatch.Signal() + + +class List(Piece): + """ + List (abstract class) + """ + + owner = models.ForeignKey(User, on_delete=models.PROTECT) + visibility = models.PositiveSmallIntegerField( + default=0 + ) # 0: Public / 1: Follower only / 2: Self only + created_time = models.DateTimeField( + default=timezone.now + ) # auto_now_add=True FIXME revert this after migration + edited_time = models.DateTimeField( + default=timezone.now + ) # auto_now=True FIXME revert this after migration + metadata = models.JSONField(default=dict) + + class Meta: + abstract = True + + # MEMBER_CLASS = None # subclass must override this + # subclass must add this: + # items = models.ManyToManyField(Item, through='ListMember') + + @property + def ordered_members(self): + return self.members.all().order_by("position") + + @property + def ordered_items(self): + return self.items.all().order_by( + self.MEMBER_CLASS.__name__.lower() + "__position" + ) + + @property + def recent_items(self): + return self.items.all().order_by( + "-" + self.MEMBER_CLASS.__name__.lower() + "__created_time" + ) + + @property + def recent_members(self): + return self.members.all().order_by("-created_time") + + def get_member_for_item(self, item): + return self.members.filter(item=item).first() + + def get_summary(self): + summary = {k: 0 for k in ItemCategory.values} + for c in self.recent_items: + summary[c.category] += 1 + return summary + + def append_item(self, item, **params): + """ + named metadata fields should be specified directly, not in metadata dict! + e.g. collection.append_item(item, note="abc") works, but collection.append_item(item, metadata={"note":"abc"}) doesn't + """ + if item is None: + return None + member = self.get_member_for_item(item) + if member: + return member + ml = self.ordered_members + p = {"parent": self} + p.update(params) + member = self.MEMBER_CLASS.objects.create( + owner=self.owner, + position=ml.last().position + 1 if ml.count() else 1, + item=item, + **p, + ) + list_add.send(sender=self.__class__, instance=self, item=item, member=member) + return member + + def remove_item(self, item): + member = self.get_member_for_item(item) + if member: + list_remove.send( + sender=self.__class__, instance=self, item=item, member=member + ) + member.delete() + + def update_member_order(self, ordered_member_ids): + members = self.ordered_members + for m in self.members.all(): + try: + i = ordered_member_ids.index(m.id) + if m.position != i + 1: + m.position = i + 1 + m.save() + except ValueError: + pass + + def move_up_item(self, item): + members = self.ordered_members + member = self.get_member_for_item(item) + if member: + other = members.filter(position__lt=member.position).last() + if other: + p = other.position + other.position = member.position + member.position = p + other.save() + member.save() + + def move_down_item(self, item): + members = self.ordered_members + member = self.get_member_for_item(item) + if member: + other = members.filter(position__gt=member.position).first() + if other: + p = other.position + other.position = member.position + member.position = p + other.save() + member.save() + + def update_item_metadata(self, item, metadata): + member = self.get_member_for_item(item) + if member: + member.metadata = metadata + member.save() + + +class ListMember(Piece): + """ + ListMember - List class's member class + It's an abstract class, subclass must add this: + + parent = models.ForeignKey('List', related_name='members', on_delete=models.CASCADE) + """ + + owner = models.ForeignKey(User, on_delete=models.PROTECT) + visibility = models.PositiveSmallIntegerField( + default=0 + ) # 0: Public / 1: Follower only / 2: Self only + created_time = models.DateTimeField(default=timezone.now) + edited_time = models.DateTimeField( + default=timezone.now + ) # auto_now=True FIXME revert this after migration + metadata = models.JSONField(default=dict) + item = models.ForeignKey(Item, on_delete=models.PROTECT) + position = models.PositiveIntegerField() + + @cached_property + def mark(self): + from .mark import Mark + + m = Mark(self.owner, self.item) + return m + + class Meta: + abstract = True + + def __str__(self): + return f"{self.id}:{self.position} ({self.item})" diff --git a/journal/models/like.py b/journal/models/like.py new file mode 100644 index 00000000..e0150915 --- /dev/null +++ b/journal/models/like.py @@ -0,0 +1,42 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import connection, models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from users.models import User + +from .common import Piece + + +class Like(Piece): + owner = models.ForeignKey(User, on_delete=models.PROTECT) + visibility = models.PositiveSmallIntegerField( + default=0 + ) # 0: Public / 1: Follower only / 2: Self only + created_time = models.DateTimeField(default=timezone.now) + edited_time = models.DateTimeField(default=timezone.now) + target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name="likes") + + @staticmethod + def user_liked_piece(user, piece): + return Like.objects.filter(owner=user, target=piece).exists() + + @staticmethod + def user_like_piece(user, piece): + if not piece: + return + like = Like.objects.filter(owner=user, target=piece).first() + if not like: + like = Like.objects.create(owner=user, target=piece) + return like + + @staticmethod + def user_unlike_piece(user, piece): + if not piece: + return + Like.objects.filter(owner=user, target=piece).delete() + + @staticmethod + def user_likes_by_class(user, cls): + ctype_id = ContentType.objects.get_for_model(cls) + return Like.objects.filter(owner=user, target__polymorphic_ctype=ctype_id) diff --git a/journal/models/mark.py b/journal/models/mark.py new file mode 100644 index 00000000..ac5bbbf1 --- /dev/null +++ b/journal/models/mark.py @@ -0,0 +1,225 @@ +import re +import uuid +from functools import cached_property + +import django.dispatch +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +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 markdownx.models import MarkdownxField +from polymorphic.models import PolymorphicModel + +from catalog.collection.models import Collection as CatalogCollection +from catalog.common import jsondata +from catalog.common.models import Item, ItemCategory +from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path +from catalog.models import * +from mastodon.api import share_review +from users.models import User + +from .comment import Comment +from .rating import Rating +from .review import Review +from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType + +_logger = logging.getLogger(__name__) + + +class Mark: + """ + Holding Mark for an item on an shelf, + which is a combo object of ShelfMember, Comment, Rating and Tags. + it mimics previous mark behaviour. + """ + + def __init__(self, user, item): + self.owner = user + self.item = item + + @cached_property + def shelfmember(self) -> ShelfMember: + return self.owner.shelf_manager.locate_item(self.item) + + @property + def id(self) -> int | None: + return self.shelfmember.id if self.shelfmember else None + + @cached_property + def shelf(self) -> Shelf | None: + return self.shelfmember.parent if self.shelfmember else None + + @property + def shelf_type(self) -> ShelfType | None: + return self.shelfmember.parent.shelf_type if self.shelfmember else None + + @property + def action_label(self) -> str: + if self.shelfmember: + return ShelfManager.get_action_label(self.shelf_type, self.item.category) + if self.comment: + return ShelfManager.get_action_label( + ShelfType.PROGRESS, self.comment.item.category + ) + return "" + + @property + def shelf_label(self) -> str | None: + return ( + ShelfManager.get_label(self.shelf_type, self.item.category) + if self.shelfmember + else None + ) + + @property + def created_time(self): + return self.shelfmember.created_time if self.shelfmember else None + + @property + def metadata(self) -> dict | None: + return self.shelfmember.metadata if self.shelfmember else None + + @property + def visibility(self) -> int: + return ( + self.shelfmember.visibility + if self.shelfmember + else self.owner.preference.default_visibility + ) + + @cached_property + def tags(self) -> list[str]: + return self.owner.tag_manager.get_item_tags(self.item) + + @cached_property + def rating_grade(self) -> int | None: + return Rating.get_item_rating_by_user(self.item, self.owner) + + @cached_property + def comment(self) -> Comment | None: + return Comment.objects.filter(owner=self.owner, item=self.item).first() + + @property + def comment_text(self) -> str | None: + return (self.comment.text or None) if self.comment else None + + @property + def comment_html(self) -> str | None: + return self.comment.html if self.comment else None + + @cached_property + def review(self) -> Review | None: + return Review.objects.filter(owner=self.owner, item=self.item).first() + + def update( + self, + shelf_type: ShelfType | None, + comment_text: str | None, + rating_grade: int | None, + visibility: int, + metadata=None, + created_time=None, + share_to_mastodon=False, + silence=False, + ): + # silence=False means update is logged. + share = ( + share_to_mastodon + and self.owner.mastodon_username + and shelf_type is not None + and ( + shelf_type != self.shelf_type + or comment_text != self.comment_text + or rating_grade != self.rating_grade + ) + ) + if created_time and created_time >= timezone.now(): + created_time = None + share_as_new_post = shelf_type != self.shelf_type + original_visibility = self.visibility + if shelf_type != self.shelf_type or visibility != original_visibility: + self.shelfmember = self.owner.shelf_manager.move_item( + self.item, + shelf_type, + visibility=visibility, + metadata=metadata, + silence=silence, + ) + if not silence and self.shelfmember and created_time: + # if it's an update(not delete) and created_time is specified, + # update the timestamp of the shelfmember and log + log = ShelfLogEntry.objects.filter( + owner=self.owner, + item=self.item, + timestamp=self.shelfmember.created_time, + ).first() + self.shelfmember.created_time = created_time + self.shelfmember.save(update_fields=["created_time"]) + if log: + log.timestamp = created_time + log.save(update_fields=["timestamp"]) + else: + ShelfLogEntry.objects.create( + owner=self.owner, + shelf_type=shelf_type, + item=self.item, + metadata=self.metadata, + timestamp=created_time, + ) + if comment_text != self.comment_text or visibility != original_visibility: + self.comment = Comment.comment_item_by_user( + self.item, + self.owner, + comment_text, + visibility, + self.shelfmember.created_time if self.shelfmember else None, + ) + if rating_grade != self.rating_grade or visibility != original_visibility: + Rating.rate_item_by_user(self.item, self.owner, rating_grade, visibility) + self.rating_grade = rating_grade + if share: + # this is a bit hacky but let's keep it until move to implement ActivityPub, + # by then, we'll just change this to boost + from mastodon.api import share_mark + + self.shared_link = ( + self.shelfmember.metadata.get("shared_link") + if self.shelfmember.metadata and not share_as_new_post + else None + ) + self.save = lambda **args: None + result, code = share_mark(self) + if not result: + if code == 401: + raise PermissionDenied() + else: + raise ValueError(code) + if self.shelfmember.metadata.get("shared_link") != self.shared_link: + self.shelfmember.metadata["shared_link"] = self.shared_link + self.shelfmember.save() + elif share_as_new_post and self.shelfmember: + self.shelfmember.metadata["shared_link"] = None + self.shelfmember.save() + + def delete(self, silence=False): + # self.logs.delete() # When deleting a mark, all logs of the mark are deleted first. + self.update(None, None, None, 0, silence=silence) + + def delete_log(self, log_id): + ShelfLogEntry.objects.filter( + owner=self.owner, item=self.item, id=log_id + ).delete() + + def delete_all_logs(self): + self.logs.delete() + + @property + def logs(self): + return ShelfLogEntry.objects.filter(owner=self.owner, item=self.item).order_by( + "timestamp" + ) diff --git a/journal/mixins.py b/journal/models/mixins.py similarity index 100% rename from journal/mixins.py rename to journal/models/mixins.py diff --git a/journal/models/rating.py b/journal/models/rating.py new file mode 100644 index 00000000..255e049b --- /dev/null +++ b/journal/models/rating.py @@ -0,0 +1,91 @@ +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +from django.db import connection, models +from django.db.models import Avg, Count, Q +from django.utils.translation import gettext_lazy as _ + +from catalog.models import Item, ItemCategory +from users.models import User + +from .common import Content + +MIN_RATING_COUNT = 5 +RATING_INCLUDES_CHILD_ITEMS = ["tvshow", "performance"] + + +class Rating(Content): + class Meta: + unique_together = [["owner", "item"]] + + grade = models.PositiveSmallIntegerField( + default=0, validators=[MaxValueValidator(10), MinValueValidator(1)], null=True + ) + + @staticmethod + def get_rating_for_item(item: Item) -> float | None: + stat = Rating.objects.filter(grade__isnull=False) + if item.class_name in RATING_INCLUDES_CHILD_ITEMS: + stat = stat.filter(item_id__in=item.child_item_ids + [item.id]) + else: + stat = stat.filter(item=item) + stat = stat.aggregate(average=Avg("grade"), count=Count("item")) + return round(stat["average"], 1) if stat["count"] >= MIN_RATING_COUNT else None + + @staticmethod + def get_rating_count_for_item(item: Item) -> int: + stat = Rating.objects.filter(grade__isnull=False) + if item.class_name in RATING_INCLUDES_CHILD_ITEMS: + stat = stat.filter(item_id__in=item.child_item_ids + [item.id]) + else: + stat = stat.filter(item=item) + stat = stat.aggregate(count=Count("item")) + return stat["count"] + + @staticmethod + def get_rating_distribution_for_item(item: Item): + stat = Rating.objects.filter(grade__isnull=False) + if item.class_name in RATING_INCLUDES_CHILD_ITEMS: + stat = stat.filter(item_id__in=item.child_item_ids + [item.id]) + else: + stat = stat.filter(item=item) + stat = stat.values("grade").annotate(count=Count("grade")).order_by("grade") + g = [0] * 11 + t = 0 + for s in stat: + g[s["grade"]] = s["count"] + t += s["count"] + if t < MIN_RATING_COUNT: + return [0] * 5 + r = [ + 100 * (g[1] + g[2]) // t, + 100 * (g[3] + g[4]) // t, + 100 * (g[5] + g[6]) // t, + 100 * (g[7] + g[8]) // t, + 100 * (g[9] + g[10]) // t, + ] + return r + + @staticmethod + def rate_item_by_user( + item: Item, user: User, rating_grade: int | None, visibility: int = 0 + ): + if rating_grade and (rating_grade < 1 or rating_grade > 10): + raise ValueError(f"Invalid rating grade: {rating_grade}") + rating = Rating.objects.filter(owner=user, item=item).first() + if not rating_grade: + if rating: + rating.delete() + rating = None + elif rating is None: + rating = Rating.objects.create( + owner=user, item=item, grade=rating_grade, visibility=visibility + ) + elif rating.grade != rating_grade or rating.visibility != visibility: + rating.visibility = visibility + rating.grade = rating_grade + rating.save() + return rating + + @staticmethod + def get_item_rating_by_user(item: Item, user: User) -> int | None: + rating = Rating.objects.filter(owner=user, item=item).first() + return (rating.grade or None) if rating else None diff --git a/journal/renderers.py b/journal/models/renderers.py similarity index 99% rename from journal/renderers.py rename to journal/models/renderers.py index 2fe78d02..64e866fe 100644 --- a/journal/renderers.py +++ b/journal/models/renderers.py @@ -1,6 +1,7 @@ -from typing import cast -import mistune import re +from typing import cast + +import mistune from django.utils.html import escape MARKDOWNX_MARKDOWNIFY_FUNCTION = "journal.renderers.render_md" diff --git a/journal/models/review.py b/journal/models/review.py new file mode 100644 index 00000000..31424c27 --- /dev/null +++ b/journal/models/review.py @@ -0,0 +1,79 @@ +import re +from functools import cached_property + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from markdownx.models import MarkdownxField + +from catalog.models import Item +from mastodon.api import share_review +from users.models import User + +from .common import Content +from .rating import Rating +from .renderers import render_md + +_RE_HTML_TAG = re.compile(r"<[^>]*>") +_RE_SPOILER_TAG = re.compile(r'<(div|span)\sclass="spoiler">.*') + + +class Review(Content): + url_path = "review" + title = models.CharField(max_length=500, blank=False, null=False) + body = MarkdownxField() + + @property + def html_content(self): + return render_md(self.body) + + @property + def plain_content(self): + html = render_md(self.body) + return _RE_HTML_TAG.sub( + " ", _RE_SPOILER_TAG.sub("***", html.replace("\n", " ")) + ) + + @cached_property + def mark(self): + from .mark import Mark + + m = Mark(self.owner, self.item) + m.review = self + return m + + @cached_property + def rating_grade(self): + return Rating.get_item_rating_by_user(self.item, self.owner) + + @classmethod + def review_item_by_user( + cls, + item: Item, + user: User, + title: str | None, + body: str | None, + visibility=0, + created_time=None, + share_to_mastodon=False, + ): + if title is None: + review = Review.objects.filter(owner=user, item=item).first() + if review is not None: + review.delete() + return None + defaults = { + "title": title, + "body": body, + "visibility": visibility, + } + if created_time: + defaults["created_time"] = ( + created_time if created_time < timezone.now() else timezone.now() + ) + review, created = cls.objects.update_or_create( + item=item, owner=user, defaults=defaults + ) + if share_to_mastodon and user.mastodon_username: + share_review(review) + return review diff --git a/journal/models/shelf.py b/journal/models/shelf.py new file mode 100644 index 00000000..91290c0d --- /dev/null +++ b/journal/models/shelf.py @@ -0,0 +1,276 @@ +from functools import cached_property +from typing import TYPE_CHECKING + +from django.db import connection, models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from catalog.models import Item, ItemCategory +from users.models import User + +from .common import query_item_category +from .itemlist import List, ListMember + +if TYPE_CHECKING: + from .mark import Mark + + +class ShelfType(models.TextChoices): + WISHLIST = ("wishlist", "未开始") + PROGRESS = ("progress", "进行中") + COMPLETE = ("complete", "完成") + # DISCARDED = ('discarded', '放弃') + + +ShelfTypeNames = [ + [ItemCategory.Book, ShelfType.WISHLIST, _("想读")], + [ItemCategory.Book, ShelfType.PROGRESS, _("在读")], + [ItemCategory.Book, ShelfType.COMPLETE, _("读过")], + [ItemCategory.Movie, ShelfType.WISHLIST, _("想看")], + [ItemCategory.Movie, ShelfType.PROGRESS, _("在看")], + [ItemCategory.Movie, ShelfType.COMPLETE, _("看过")], + [ItemCategory.TV, ShelfType.WISHLIST, _("想看")], + [ItemCategory.TV, ShelfType.PROGRESS, _("在看")], + [ItemCategory.TV, ShelfType.COMPLETE, _("看过")], + [ItemCategory.Music, ShelfType.WISHLIST, _("想听")], + [ItemCategory.Music, ShelfType.PROGRESS, _("在听")], + [ItemCategory.Music, ShelfType.COMPLETE, _("听过")], + [ItemCategory.Game, ShelfType.WISHLIST, _("想玩")], + [ItemCategory.Game, ShelfType.PROGRESS, _("在玩")], + [ItemCategory.Game, ShelfType.COMPLETE, _("玩过")], + [ItemCategory.Podcast, ShelfType.WISHLIST, _("想听")], + [ItemCategory.Podcast, ShelfType.PROGRESS, _("在听")], + [ItemCategory.Podcast, ShelfType.COMPLETE, _("听过")], + # disable all shelves for PodcastEpisode + [ItemCategory.Performance, ShelfType.WISHLIST, _("想看")], + # disable progress shelf for Performance + [ItemCategory.Performance, ShelfType.PROGRESS, _("")], + [ItemCategory.Performance, ShelfType.COMPLETE, _("看过")], +] + + +class ShelfMember(ListMember): + parent = models.ForeignKey( + "Shelf", related_name="members", on_delete=models.CASCADE + ) + + class Meta: + unique_together = [["owner", "item"]] + indexes = [ + models.Index(fields=["parent_id", "visibility", "created_time"]), + ] + + @cached_property + def mark(self) -> "Mark": + from .mark import Mark + + m = Mark(self.owner, self.item) + m.shelfmember = self + return m + + @property + def shelf_label(self) -> str | None: + return ShelfManager.get_label(self.parent.shelf_type, self.item.category) + + @property + def shelf_type(self): + return self.parent.shelf_type + + @property + def rating_grade(self): + return self.mark.rating_grade + + @property + def comment_text(self): + return self.mark.comment_text + + @property + def tags(self): + return self.mark.tags + + +class Shelf(List): + """ + Shelf + """ + + class Meta: + unique_together = [["owner", "shelf_type"]] + + MEMBER_CLASS = ShelfMember + items = models.ManyToManyField(Item, through="ShelfMember", related_name="+") + shelf_type = models.CharField( + choices=ShelfType.choices, max_length=100, null=False, blank=False + ) + + def __str__(self): + return f"{self.id} [{self.owner} {self.shelf_type} list]" + + +class ShelfLogEntry(models.Model): + owner = models.ForeignKey(User, on_delete=models.PROTECT) + shelf_type = models.CharField(choices=ShelfType.choices, max_length=100, null=True) + item = models.ForeignKey(Item, on_delete=models.PROTECT) + timestamp = models.DateTimeField() # this may later be changed by user + metadata = models.JSONField(default=dict) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.owner}:{self.shelf_type}:{self.item.uuid}:{self.timestamp}:{self.metadata}" + + @property + def action_label(self): + if self.shelf_type: + return ShelfManager.get_action_label(self.shelf_type, self.item.category) + else: + return _("移除标记") + + +class ShelfManager: + """ + ShelfManager + + all shelf operations should go thru this class so that ShelfLogEntry can be properly populated + ShelfLogEntry can later be modified if user wish to change history + """ + + def __init__(self, user): + self.owner = user + qs = Shelf.objects.filter(owner=self.owner) + self.shelf_list = {v.shelf_type: v for v in qs} + if len(self.shelf_list) == 0: + self.initialize() + + def initialize(self): + for qt in ShelfType: + self.shelf_list[qt] = Shelf.objects.create(owner=self.owner, shelf_type=qt) + + def locate_item(self, item) -> ShelfMember | None: + return ShelfMember.objects.filter(item=item, owner=self.owner).first() + + def move_item(self, item, shelf_type, visibility=0, metadata=None, silence=False): + # shelf_type=None means remove from current shelf + # metadata=None means no change + # silence=False means move_item is logged. + if not item: + raise ValueError("empty item") + new_shelfmember = None + last_shelfmember = self.locate_item(item) + last_shelf = last_shelfmember.parent if last_shelfmember else None + last_metadata = last_shelfmember.metadata if last_shelfmember else None + last_visibility = last_shelfmember.visibility if last_shelfmember else None + shelf = self.shelf_list[shelf_type] if shelf_type else None + changed = False + if last_shelf != shelf: # change shelf + changed = True + if last_shelf: + last_shelf.remove_item(item) + if shelf: + new_shelfmember = shelf.append_item( + item, visibility=visibility, metadata=metadata or {} + ) + elif last_shelf is None: + raise ValueError("empty shelf") + else: + new_shelfmember = last_shelfmember + if last_shelfmember: + if ( + metadata is not None and metadata != last_metadata + ): # change metadata + changed = True + last_shelfmember.metadata = metadata + last_shelfmember.visibility = visibility + last_shelfmember.save() + elif visibility != last_visibility: # change visibility + last_shelfmember.visibility = visibility + last_shelfmember.save() + if changed and not silence: + if metadata is None: + metadata = last_metadata or {} + log_time = ( + new_shelfmember.created_time + if new_shelfmember and new_shelfmember != last_shelfmember + else timezone.now() + ) + ShelfLogEntry.objects.create( + owner=self.owner, + shelf_type=shelf_type, + item=item, + metadata=metadata, + timestamp=log_time, + ) + return new_shelfmember + + def get_log(self): + return ShelfLogEntry.objects.filter(owner=self.owner).order_by("timestamp") + + def get_log_for_item(self, item): + return ShelfLogEntry.objects.filter(owner=self.owner, item=item).order_by( + "timestamp" + ) + + def get_shelf(self, shelf_type): + return self.shelf_list[shelf_type] + + def get_latest_members(self, shelf_type, item_category=None): + qs = self.shelf_list[shelf_type].members.all().order_by("-created_time") + if item_category: + return qs.filter(query_item_category(item_category)) + else: + return qs + + # def get_items_on_shelf(self, item_category, shelf_type): + # shelf = ( + # self.owner.shelf_set.all() + # .filter(item_category=item_category, shelf_type=shelf_type) + # .first() + # ) + # return shelf.members.all().order_by + + @classmethod + def get_action_label(cls, shelf_type, item_category) -> str: + sts = [ + n[2] for n in ShelfTypeNames if n[0] == item_category and n[1] == shelf_type + ] + return sts[0] if sts else str(shelf_type) + + @classmethod + def get_label(cls, shelf_type, item_category): + ic = ItemCategory(item_category).label + st = cls.get_action_label(shelf_type, item_category) + return ( + _("{shelf_label}的{item_category}").format(shelf_label=st, item_category=ic) + if st + else None + ) + + @staticmethod + def get_manager_for_user(user): + return ShelfManager(user) + + def get_calendar_data(self, max_visiblity): + shelf_id = self.get_shelf(ShelfType.COMPLETE).pk + timezone_offset = timezone.localtime(timezone.now()).strftime("%z") + timezone_offset = timezone_offset[: len(timezone_offset) - 2] + calendar_data = {} + sql = "SELECT to_char(DATE(journal_shelfmember.created_time::timestamp AT TIME ZONE %s), 'YYYY-MM-DD') AS dat, django_content_type.model typ, COUNT(1) count FROM journal_shelfmember, catalog_item, django_content_type WHERE journal_shelfmember.item_id = catalog_item.id AND django_content_type.id = catalog_item.polymorphic_ctype_id AND parent_id = %s AND journal_shelfmember.created_time >= NOW() - INTERVAL '366 days' AND journal_shelfmember.visibility <= %s GROUP BY item_id, dat, typ;" + with connection.cursor() as cursor: + cursor.execute(sql, [timezone_offset, shelf_id, int(max_visiblity)]) + data = cursor.fetchall() + for line in data: + date = line[0] + typ = line[1] + if date not in calendar_data: + calendar_data[date] = {"items": []} + if typ[:2] == "tv": + typ = "movie" + elif typ == "album": + typ = "music" + elif typ == "edition": + typ = "book" + elif typ not in ["book", "movie", "music", "game"]: + typ = "other" + if typ not in calendar_data[date]["items"]: + calendar_data[date]["items"].append(typ) + return calendar_data diff --git a/journal/models/tag.py b/journal/models/tag.py new file mode 100644 index 00000000..3b550bcf --- /dev/null +++ b/journal/models/tag.py @@ -0,0 +1,128 @@ +import re +from functools import cached_property + +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +from django.db import connection, models +from django.db.models import Avg, Count, Q +from django.utils.translation import gettext_lazy as _ + +from catalog.collection.models import Collection as CatalogCollection +from catalog.models import Item +from users.models import User + +from .itemlist import List, ListMember + + +class TagMember(ListMember): + parent = models.ForeignKey("Tag", related_name="members", on_delete=models.CASCADE) + + class Meta: + unique_together = [["parent", "item"]] + + +TagValidators = [RegexValidator(regex=r"\s+", inverse_match=True)] + + +class Tag(List): + MEMBER_CLASS = TagMember + items = models.ManyToManyField(Item, through="TagMember") + title = models.CharField( + max_length=100, null=False, blank=False, validators=TagValidators + ) + # TODO case convert and space removal on save + # TODO check on save + + class Meta: + unique_together = [["owner", "title"]] + + @staticmethod + def cleanup_title(title, replace=True): + t = re.sub(r"\s+", " ", title.strip()) + return "_" if not title and replace else t + + @staticmethod + def deep_cleanup_title(title): + """Remove all non-word characters, only for public index purpose""" + return re.sub(r"\W+", " ", title).strip() + + +class TagManager: + @staticmethod + def indexable_tags_for_item(item): + tags = ( + item.tag_set.all() + .filter(visibility=0) + .values("title") + .annotate(frequency=Count("owner")) + .order_by("-frequency")[:20] + ) + tag_titles = sorted( + [ + t + for t in set(map(lambda t: Tag.deep_cleanup_title(t["title"]), tags)) + if t + ] + ) + return tag_titles + + @staticmethod + def all_tags_for_user(user, public_only=False): + tags = ( + user.tag_set.all() + .values("title") + .annotate(frequency=Count("members__id")) + .order_by("-frequency") + ) + if public_only: + tags = tags.filter(visibility=0) + return list(map(lambda t: t["title"], tags)) + + @staticmethod + def tag_item_by_user(item, user, tag_titles, default_visibility=0): + titles = set([Tag.cleanup_title(tag_title) for tag_title in tag_titles]) + current_titles = set( + [m.parent.title for m in TagMember.objects.filter(owner=user, item=item)] + ) + for title in titles - current_titles: + tag = Tag.objects.filter(owner=user, title=title).first() + if not tag: + tag = Tag.objects.create( + owner=user, title=title, visibility=default_visibility + ) + tag.append_item(item, visibility=default_visibility) + for title in current_titles - titles: + tag = Tag.objects.filter(owner=user, title=title).first() + if tag: + tag.remove_item(item) + + @staticmethod + def get_item_tags_by_user(item, user): + current_titles = [ + m.parent.title for m in TagMember.objects.filter(owner=user, item=item) + ] + return current_titles + + @staticmethod + def get_manager_for_user(user): + return TagManager(user) + + def __init__(self, user): + self.owner = user + + @property + def all_tags(self): + return TagManager.all_tags_for_user(self.owner) + + @property + def public_tags(self): + return TagManager.all_tags_for_user(self.owner, public_only=True) + + def get_item_tags(self, item): + return sorted( + [ + m["parent__title"] + for m in TagMember.objects.filter( + parent__owner=self.owner, item=item + ).values("parent__title") + ] + ) diff --git a/journal/models/utils.py b/journal/models/utils.py new file mode 100644 index 00000000..eba94481 --- /dev/null +++ b/journal/models/utils.py @@ -0,0 +1,65 @@ +from django.utils.translation import gettext_lazy as _ +from loguru import logger + +from catalog.models import Item +from users.models import User + +from .collection import Collection, CollectionMember, FeaturedCollection +from .comment import Comment +from .common import Content +from .itemlist import ListMember +from .rating import Rating +from .review import Review +from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember +from .tag import Tag, TagManager, TagMember + + +def reset_journal_visibility_for_user(user: User, visibility: int): + ShelfMember.objects.filter(owner=user).update(visibility=visibility) + Comment.objects.filter(owner=user).update(visibility=visibility) + Rating.objects.filter(owner=user).update(visibility=visibility) + Review.objects.filter(owner=user).update(visibility=visibility) + + +def remove_data_by_user(user: User): + ShelfMember.objects.filter(owner=user).delete() + Comment.objects.filter(owner=user).delete() + Rating.objects.filter(owner=user).delete() + Review.objects.filter(owner=user).delete() + TagMember.objects.filter(owner=user).delete() + Tag.objects.filter(owner=user).delete() + CollectionMember.objects.filter(owner=user).delete() + Collection.objects.filter(owner=user).delete() + FeaturedCollection.objects.filter(owner=user).delete() + + +def update_journal_for_merged_item( + legacy_item_uuid: str, delete_duplicated: bool = False +): + legacy_item = Item.get_by_url(legacy_item_uuid) + if not legacy_item: + logger.error("update_journal_for_merged_item: unable to find item") + return + new_item = legacy_item.merged_to_item + for cls in list(Content.__subclasses__()) + list(ListMember.__subclasses__()): + for p in cls.objects.filter(item=legacy_item): + try: + p.item = new_item + p.save(update_fields=["item_id"]) + except: + if delete_duplicated: + logger.warning( + f"deleted piece {p} when merging {cls.__name__}: {legacy_item} -> {new_item}" + ) + p.delete() + else: + logger.warning( + f"skip piece {p} when merging {cls.__name__}: {legacy_item} -> {new_item}" + ) + + +def journal_exists_for_item(item: Item) -> bool: + for cls in list(Content.__subclasses__()) + list(ListMember.__subclasses__()): + if cls.objects.filter(item=item).exists(): + return True + return False diff --git a/journal/templatetags/collection.py b/journal/templatetags/collection.py index 4db00fc7..b6a577b6 100644 --- a/journal/templatetags/collection.py +++ b/journal/templatetags/collection.py @@ -1,7 +1,8 @@ from django import template -from journal.models import Collection, Like from django.template.defaultfilters import stringfilter +from journal.models import Collection, Like + register = template.Library() diff --git a/journal/templatetags/user_actions.py b/journal/templatetags/user_actions.py index db3c0be3..57e2af54 100644 --- a/journal/templatetags/user_actions.py +++ b/journal/templatetags/user_actions.py @@ -1,7 +1,8 @@ from django import template -from journal.models import Collection, Like from django.shortcuts import reverse +from journal.models import Collection, Like + register = template.Library() diff --git a/journal/tests.py b/journal/tests.py index bce22287..d422bfbb 100644 --- a/journal/tests.py +++ b/journal/tests.py @@ -1,15 +1,18 @@ +import time + from django.test import TestCase -from .models import * + from catalog.models import * from users.models import User -import time + +from .models import * class CollectionTest(TestCase): def setUp(self): self.book1 = Edition.objects.create(title="Hyperion") self.book2 = Edition.objects.create(title="Andymion") - self.user = User.objects.create(email="a@b.com") + self.user = User.register(email="a@b.com") pass def test_collection(self): @@ -39,7 +42,7 @@ class ShelfTest(TestCase): pass def test_shelf(self): - user = User.objects.create(mastodon_site="site", mastodon_username="name") + user = User.register(mastodon_site="site", mastodon_username="name") shelf_manager = ShelfManager(user=user) self.assertEqual(user.shelf_set.all().count(), 3) book1 = Edition.objects.create(title="Hyperion") @@ -120,13 +123,9 @@ class TagTest(TestCase): self.book1 = Edition.objects.create(title="Hyperion") self.book2 = Edition.objects.create(title="Andymion") self.movie1 = Edition.objects.create(title="Hyperion, The Movie") - self.user1 = User.objects.create(mastodon_site="site", mastodon_username="name") - self.user2 = User.objects.create( - mastodon_site="site2", mastodon_username="name2" - ) - self.user3 = User.objects.create( - mastodon_site="site2", mastodon_username="name3" - ) + self.user1 = User.register(mastodon_site="site", mastodon_username="name") + self.user2 = User.register(mastodon_site="site2", mastodon_username="name2") + self.user3 = User.register(mastodon_site="site2", mastodon_username="name3") pass def test_user_tag(self): @@ -142,8 +141,8 @@ class TagTest(TestCase): class MarkTest(TestCase): def setUp(self): self.book1 = Edition.objects.create(title="Hyperion") - self.user1 = User.objects.create(mastodon_site="site", mastodon_username="name") - pref = self.user1.get_preference() + self.user1 = User.register(mastodon_site="site", mastodon_username="name") + pref = self.user1.preference pref.default_visibility = 2 pref.save() diff --git a/journal/urls.py b/journal/urls.py index 39c78d37..fd4ecd78 100644 --- a/journal/urls.py +++ b/journal/urls.py @@ -1,8 +1,9 @@ from django.urls import path, re_path -from .views import * -from .feeds import ReviewFeed + from catalog.models import * +from .feeds import ReviewFeed +from .views import * app_name = "journal" diff --git a/journal/views.py b/journal/views.py index 69574a2e..fa008fa6 100644 --- a/journal/views.py +++ b/journal/views.py @@ -14,8 +14,9 @@ from django.utils.dateparse import parse_datetime from django.utils.translation import gettext_lazy as _ from user_messages import api as msg +from catalog.models import * from common.utils import PageLinksGenerator, get_uuid_or_404 -from journal.renderers import convert_leading_space_in_md +from journal.models.renderers import convert_leading_space_in_md from mastodon.api import ( get_spoiler_text, get_status_id_by_url, @@ -375,13 +376,13 @@ def collection_retrieve(request, collection_uuid): if featured_since: stats = collection.get_stats_for_user(request.user) stats["wishlist_deg"] = ( - stats["wishlist"] / stats["total"] * 360 if stats["total"] else 0 + round(stats["wishlist"] / stats["total"] * 360) if stats["total"] else 0 ) stats["progress_deg"] = ( - stats["progress"] / stats["total"] * 360 if stats["total"] else 0 + round(stats["progress"] / stats["total"] * 360) if stats["total"] else 0 ) stats["complete_deg"] = ( - stats["complete"] / stats["total"] * 360 if stats["total"] else 0 + round(stats["complete"] / stats["total"] * 360) if stats["total"] else 0 ) return render( request, @@ -650,6 +651,8 @@ def review_edit(request, item_uuid, review_uuid=None): mark_date, form.cleaned_data["share_to_mastodon"], ) + if not review: + raise BadRequest() return redirect(reverse("journal:review_retrieve", args=[review.uuid])) else: raise BadRequest() @@ -867,7 +870,7 @@ def profile(request, user_name): return render_user_not_found(request) if user.mastodon_acct != user_name and user.username != user_name: return redirect(user.url) - if not request.user.is_authenticated and user.get_preference().no_anonymous_view: + if not request.user.is_authenticated and user.preference.no_anonymous_view: return render(request, "users/home_anonymous.html", {"user": user}) if user != request.user and ( user.is_blocked_by(request.user) or user.is_blocking(request.user) @@ -936,7 +939,7 @@ def profile(request, user_name): for i in liked_collections.order_by("-edited_time")[:10] ], "liked_collections_count": liked_collections.count(), - "layout": user.get_preference().profile_layout, + "layout": user.preference.profile_layout, }, ) diff --git a/legacy/models.py b/legacy/models.py index ac3175a6..2e5ddd09 100644 --- a/legacy/models.py +++ b/legacy/models.py @@ -1,4 +1,5 @@ from os import link + from django.db import models diff --git a/legacy/urls.py b/legacy/urls.py index 25aba5f7..1635e999 100644 --- a/legacy/urls.py +++ b/legacy/urls.py @@ -1,4 +1,5 @@ from django.urls import path, re_path + from .views import * app_name = "legacy" diff --git a/legacy/views.py b/legacy/views.py index e599d66f..918b5f64 100644 --- a/legacy/views.py +++ b/legacy/views.py @@ -1,9 +1,10 @@ -from django.shortcuts import redirect, render, get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.baseconv import base62 from catalog.collection.models import Collection -from .models import * from catalog.models import Item -from django.utils.baseconv import base62 + +from .models import * def book(request, id): diff --git a/management/admin.py b/management/admin.py index d50c8a94..e26dfdf5 100644 --- a/management/admin.py +++ b/management/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import * admin.site.register(Announcement) diff --git a/management/migrations/0001_initial.py b/management/migrations/0001_initial.py index f85e8856..c4d268ce 100644 --- a/management/migrations/0001_initial.py +++ b/management/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 -from django.db import migrations, models import markdownx.models +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/management/models.py b/management/models.py index 9c845291..ead05886 100644 --- a/management/models.py +++ b/management/models.py @@ -1,10 +1,10 @@ import re + from django.db import models from django.shortcuts import reverse from django.utils.translation import gettext_lazy as _ -from markdownx.models import MarkdownxField from markdown import markdown - +from markdownx.models import MarkdownxField RE_HTML_TAG = re.compile(r"<[^>]*>") diff --git a/management/urls.py b/management/urls.py index e47ee7c8..6e77b67b 100644 --- a/management/urls.py +++ b/management/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views import * +from .views import * app_name = "management" urlpatterns = [ diff --git a/management/views.py b/management/views.py index 232a9bc7..6c753da1 100644 --- a/management/views.py +++ b/management/views.py @@ -1,12 +1,12 @@ -from django.urls import reverse_lazy +from django.contrib.auth.decorators import login_required, user_passes_test from django.shortcuts import get_object_or_404 -from .models import Announcement +from django.urls import reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator -from django.contrib.auth.decorators import login_required, user_passes_test from django.views.generic import * from django.views.generic.edit import ModelFormMixin +from .models import Announcement # https://docs.djangoproject.com/en/3.1/topics/class-based-views/intro/ decorators = [login_required, user_passes_test(lambda u: u.is_superuser)] diff --git a/mastodon/admin.py b/mastodon/admin.py index 636232d8..035446b1 100644 --- a/mastodon/admin.py +++ b/mastodon/admin.py @@ -1,9 +1,10 @@ from django.contrib import admin -from .models import * -from .api import create_app +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ from requests.exceptions import Timeout -from django.core.exceptions import ObjectDoesNotExist + +from .api import create_app +from .models import * # Register your models here. diff --git a/mastodon/api.py b/mastodon/api.py index 3ca824c8..0a43e048 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -52,7 +52,7 @@ API_CREATE_APP = "/api/v1/apps" # GET API_SEARCH = "/api/v2/search" -USER_AGENT = f"{settings.SITE_INFO['site_name']}/1.0" +USER_AGENT = f"NeoDB/{settings.NEODB_VERSION} (+{settings.SITE_INFO.get('site_url', 'undefined')})" get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT) put = functools.partial(requests.put, timeout=settings.MASTODON_TIMEOUT) @@ -192,8 +192,8 @@ def detect_server_info(login_domain): url = f"https://{login_domain}/api/v1/instance" try: response = get(url, headers={"User-Agent": USER_AGENT}) - except: - logger.error(f"Error connecting {login_domain}") + except Exception as e: + logger.error(f"Error connecting {login_domain} {e}") raise Exception(f"无法连接 {login_domain}") if response.status_code != 200: logger.error(f"Error connecting {login_domain}: {response.status_code}") @@ -354,7 +354,7 @@ def get_visibility(visibility, user): return TootVisibilityEnum.DIRECT elif visibility == 1: return TootVisibilityEnum.PRIVATE - elif user.get_preference().mastodon_publish_public: + elif user.preference.mastodon_publish_public: return TootVisibilityEnum.PUBLIC else: return TootVisibilityEnum.UNLISTED @@ -368,16 +368,16 @@ def share_mark(mark): visibility = TootVisibilityEnum.DIRECT elif mark.visibility == 1: visibility = TootVisibilityEnum.PRIVATE - elif user.get_preference().mastodon_publish_public: + elif user.preference.mastodon_publish_public: visibility = TootVisibilityEnum.PUBLIC else: visibility = TootVisibilityEnum.UNLISTED tags = ( "\n" - + user.get_preference().mastodon_append_tag.replace( + + user.preference.mastodon_append_tag.replace( "[category]", str(ItemCategory(mark.item.category).label) ) - if user.get_preference().mastodon_append_tag + if user.preference.mastodon_append_tag else "" ) stars = rating_to_emoji( @@ -416,16 +416,16 @@ def share_review(review): visibility = TootVisibilityEnum.DIRECT elif review.visibility == 1: visibility = TootVisibilityEnum.PRIVATE - elif user.get_preference().mastodon_publish_public: + elif user.preference.mastodon_publish_public: visibility = TootVisibilityEnum.PUBLIC else: visibility = TootVisibilityEnum.UNLISTED tags = ( "\n" - + user.get_preference().mastodon_append_tag.replace( + + user.preference.mastodon_append_tag.replace( "[category]", str(ItemCategory(review.item.category).label) ) - if user.get_preference().mastodon_append_tag + if user.preference.mastodon_append_tag else "" ) content = f"发布了关于《{review.item.display_title}》的评论\n{review.title}\n{review.absolute_url}{tags}" @@ -455,13 +455,13 @@ def share_collection(collection, comment, user, visibility_no): visibility = TootVisibilityEnum.DIRECT elif visibility_no == 1: visibility = TootVisibilityEnum.PRIVATE - elif user.get_preference().mastodon_publish_public: + elif user.preference.mastodon_publish_public: visibility = TootVisibilityEnum.PUBLIC else: visibility = TootVisibilityEnum.UNLISTED tags = ( - "\n" + user.get_preference().mastodon_append_tag.replace("[category]", "收藏单") - if user.get_preference().mastodon_append_tag + "\n" + user.preference.mastodon_append_tag.replace("[category]", "收藏单") + if user.preference.mastodon_append_tag else "" ) user_str = ( diff --git a/mastodon/auth.py b/mastodon/auth.py index d8288a43..53da2065 100644 --- a/mastodon/auth.py +++ b/mastodon/auth.py @@ -1,4 +1,5 @@ from django.contrib.auth.backends import ModelBackend, UserModel + from .api import verify_account diff --git a/mastodon/decorators.py b/mastodon/decorators.py index 122f9411..a7167f0d 100644 --- a/mastodon/decorators.py +++ b/mastodon/decorators.py @@ -1,5 +1,6 @@ -from django.http import HttpResponse import functools + +from django.http import HttpResponse from django.shortcuts import render from django.utils.translation import gettext_lazy as _ from requests.exceptions import Timeout diff --git a/mastodon/management/commands/wrong_sites.py b/mastodon/management/commands/wrong_sites.py index 966553a5..38c3b6a6 100644 --- a/mastodon/management/commands/wrong_sites.py +++ b/mastodon/management/commands/wrong_sites.py @@ -1,7 +1,8 @@ -from django.core.management.base import BaseCommand -from mastodon.models import MastodonApplication from django.conf import settings +from django.core.management.base import BaseCommand + from mastodon.api import get_instance_info +from mastodon.models import MastodonApplication from users.models import User diff --git a/requirements-dev.txt b/requirements-dev.txt index f17fbec2..d4191cc9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ -pre-commit -black +pre-commit~=3.3.3 +isort~=5.12.0 +black~=22.12.0 django-debug-toolbar coverage -djlint +djlint~=1.32.1 diff --git a/requirements.txt b/requirements.txt index 18da8e9b..27070c2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ dateparser mistune rq>=1.12.0 -django~=4.2.3 +django~=4.2.4 django-auditlog django-markdownx @ git+https://github.com/alphatownsman/django-markdownx.git@e69480c64ad9c5d0499f4a8625da78cf2bb7691b django-jsonform diff --git a/social/migrations/0001_initial.py b/social/migrations/0001_initial.py index ff93e37b..b7c5b70b 100644 --- a/social/migrations/0001_initial.py +++ b/social/migrations/0001_initial.py @@ -1,9 +1,10 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone -import journal.mixins +from django.db import migrations, models + +import journal.models.mixins class Migration(migrations.Migration): @@ -52,6 +53,6 @@ class Migration(migrations.Migration): ), ), ], - bases=(models.Model, journal.mixins.UserOwnedObjectMixin), + bases=(models.Model, journal.models.mixins.UserOwnedObjectMixin), ), ] diff --git a/social/migrations/0002_initial.py b/social/migrations/0002_initial.py index cdc8653c..154c0549 100644 --- a/social/migrations/0002_initial.py +++ b/social/migrations/0002_initial.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/social/models.py b/social/models.py index bb6024fd..fe2fffa2 100644 --- a/social/models.py +++ b/social/models.py @@ -6,16 +6,27 @@ ActivityManager generates chronological view for user and, in future, ActivitySt """ -from django.db import models -from users.models import User -from catalog.common.models import Item -from journal.models import * import logging from functools import cached_property -from django.db.models.signals import post_save, post_delete, pre_delete -from django.db.models import Q -from django.conf import settings +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.db.models.signals import post_delete, post_save, pre_delete +from django.utils import timezone + +from catalog.common.models import Item +from journal.models import ( + Collection, + Comment, + FeaturedCollection, + Like, + Piece, + Review, + ShelfMember, + UserOwnedObjectMixin, +) +from users.models import User _logger = logging.getLogger(__name__) diff --git a/social/tests.py b/social/tests.py index 3eb50a98..3d6093f2 100644 --- a/social/tests.py +++ b/social/tests.py @@ -1,19 +1,19 @@ from django.test import TestCase + from catalog.models import * from journal.models import * -from .models import * from users.models import User +from .models import * + class SocialTest(TestCase): def setUp(self): self.book1 = Edition.objects.create(title="Hyperion") self.book2 = Edition.objects.create(title="Andymion") self.movie = Edition.objects.create(title="Fight Club") - self.alice = User.objects.create( - mastodon_site="MySpace", mastodon_username="Alice" - ) - self.bob = User.objects.create(mastodon_site="KKCity", mastodon_username="Bob") + self.alice = User.register(mastodon_site="MySpace", mastodon_username="Alice") + self.bob = User.register(mastodon_site="KKCity", mastodon_username="Bob") def test_timeline(self): # alice see 0 activity in timeline in the beginning diff --git a/social/urls.py b/social/urls.py index 8df11801..4c88e894 100644 --- a/social/urls.py +++ b/social/urls.py @@ -1,6 +1,6 @@ from django.urls import path, re_path -from .views import * +from .views import * app_name = "social" urlpatterns = [ diff --git a/social/views.py b/social/views.py index f6f3f6ca..9a68b775 100644 --- a/social/views.py +++ b/social/views.py @@ -1,12 +1,15 @@ import logging -from django.shortcuts import render -from django.contrib.auth.decorators import login_required, permission_required -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import BadRequest -from .models import * -from django.conf import settings -from management.models import Announcement +from django.conf import settings +from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import BadRequest +from django.shortcuts import render +from django.utils.translation import gettext_lazy as _ + +from catalog.models import * +from journal.models import * + +from .models import * _logger = logging.getLogger(__name__) diff --git a/users/account.py b/users/account.py index 4da010ee..1f6499bb 100644 --- a/users/account.py +++ b/users/account.py @@ -1,32 +1,32 @@ -from django.shortcuts import redirect, render, get_object_or_404 -from django.urls import reverse -from django.contrib.auth.decorators import login_required -from django.contrib import auth -from django.contrib.auth import authenticate -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ObjectDoesNotExist, BadRequest -from django.db.models import Count -from .models import User, Preference -from mastodon.api import * -from mastodon import mastodon_request_included -from common.config import * -from mastodon.api import verify_account -from django.conf import settings -from urllib.parse import quote -import django_rq -from .tasks import * from datetime import timedelta -from django.utils import timezone -from django.contrib import messages -from journal.models import remove_data_by_user -from django.db.models import Q -from django.core.cache import cache -from django.db.models import Count +from urllib.parse import quote + +import django_rq from django import forms -from django.core.signing import TimestampSigner +from django.conf import settings +from django.contrib import auth, messages +from django.contrib.auth import authenticate +from django.contrib.auth.decorators import login_required +from django.core.cache import cache +from django.core.exceptions import BadRequest, ObjectDoesNotExist from django.core.mail import send_mail -from loguru import logger +from django.core.signing import TimestampSigner 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.translation import gettext_lazy as _ +from loguru import logger + +from common.config import * +from journal.models import remove_data_by_user +from mastodon import mastodon_request_included +from mastodon.api import * +from mastodon.api import verify_account + +from .models import Preference, User +from .tasks import * # the 'login' page that user can see @@ -193,9 +193,7 @@ def OAuth2_login(request): def register_new_user(request, **param): - new_user = User(**param) - new_user.save() - Preference.objects.create(user=new_user) + new_user = User.register(**param) request.session["new_user"] = True auth_login(request, new_user) return redirect(reverse("users:register")) diff --git a/users/admin.py b/users/admin.py index 4d85cf17..2ff86b6a 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import * +from .models import * admin.site.register(Report) admin.site.register(User) diff --git a/users/api.py b/users/api.py index 62028d5c..3b354c72 100644 --- a/users/api.py +++ b/users/api.py @@ -1,7 +1,8 @@ from ninja import Schema -from common.api import * -from oauth2_provider.decorators import protected_resource from ninja.security import django_auth +from oauth2_provider.decorators import protected_resource + +from common.api import * class UserSchema(Schema): diff --git a/users/data.py b/users/data.py index eba2dc09..27590a1d 100644 --- a/users/data.py +++ b/users/data.py @@ -1,29 +1,30 @@ +import django_rq +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse from django.shortcuts import redirect, render from django.urls import reverse -from django.http import HttpResponse -from django.contrib.auth.decorators import login_required from django.utils.translation import gettext_lazy as _ -from mastodon.api import * -from mastodon import mastodon_request_included -from common.config import * -from django.conf import settings -import django_rq -from .account import * -from .tasks import * -from django.contrib import messages -from journal.importers.opml import OPMLImporter +from common.config import * +from journal.exporters.doufen import export_marks_task from journal.importers.douban import DoubanImporter from journal.importers.goodreads import GoodreadsImporter -from journal.exporters.doufen import export_marks_task +from journal.importers.opml import OPMLImporter from journal.models import reset_journal_visibility_for_user +from mastodon import mastodon_request_included +from mastodon.api import * from social.models import reset_social_visibility_for_user +from .account import * +from .tasks import * + @mastodon_request_included @login_required def preferences(request): - preference = request.user.get_preference() + preference = request.user.preference if request.method == "POST": preference.default_visibility = int(request.POST.get("default_visibility")) preference.default_no_share = bool(request.POST.get("default_no_share")) @@ -61,8 +62,8 @@ def data(request): "users/data.html", { "allow_any_site": settings.MASTODON_ALLOW_ANY_SITE, - "import_status": request.user.get_preference().import_status, - "export_status": request.user.get_preference().export_status, + "import_status": request.user.preference.import_status, + "export_status": request.user.preference.export_status, }, ) @@ -85,7 +86,7 @@ def data_import_status(request): request, "users/data_import_status.html", { - "import_status": request.user.get_preference().import_status, + "import_status": request.user.preference.import_status, }, ) diff --git a/users/forms.py b/users/forms.py index a0d86146..85d1c0e3 100644 --- a/users/forms.py +++ b/users/forms.py @@ -1,8 +1,10 @@ from django import forms -from .models import Report from django.utils.translation import gettext_lazy as _ + from common.forms import PreviewImageInput +from .models import Report + class ReportForm(forms.ModelForm): class Meta: diff --git a/users/management/commands/backfill_mastodon.py b/users/management/commands/backfill_mastodon.py index fe0e0ac1..5a312b15 100644 --- a/users/management/commands/backfill_mastodon.py +++ b/users/management/commands/backfill_mastodon.py @@ -1,6 +1,7 @@ -from django.core.management.base import BaseCommand -from users.models import User from django.contrib.sessions.models import Session +from django.core.management.base import BaseCommand + +from users.models import User class Command(BaseCommand): diff --git a/users/management/commands/disable_user.py b/users/management/commands/disable_user.py index f0556316..4e38d7de 100644 --- a/users/management/commands/disable_user.py +++ b/users/management/commands/disable_user.py @@ -1,8 +1,10 @@ -from django.core.management.base import BaseCommand -from users.models import User from datetime import timedelta + +from django.core.management.base import BaseCommand from django.utils import timezone +from users.models import User + class Command(BaseCommand): help = "disable user" diff --git a/users/management/commands/refresh_following.py b/users/management/commands/refresh_following.py index 1f1f7754..82e2e9f6 100644 --- a/users/management/commands/refresh_following.py +++ b/users/management/commands/refresh_following.py @@ -1,9 +1,11 @@ -from django.core.management.base import BaseCommand -from users.models import User from datetime import timedelta + +from django.core.management.base import BaseCommand from django.utils import timezone from tqdm import tqdm +from users.models import User + class Command(BaseCommand): help = "Refresh following data for all users" diff --git a/users/management/commands/refresh_mastodon.py b/users/management/commands/refresh_mastodon.py index 22d7036f..8d6ee755 100644 --- a/users/management/commands/refresh_mastodon.py +++ b/users/management/commands/refresh_mastodon.py @@ -1,4 +1,5 @@ from django.core.management.base import BaseCommand + from users.tasks import refresh_all_mastodon_data_task diff --git a/users/management/commands/user.py b/users/management/commands/user.py index 12fc302a..1287cac8 100644 --- a/users/management/commands/user.py +++ b/users/management/commands/user.py @@ -1,9 +1,11 @@ -from django.core.management.base import BaseCommand -from users.models import User, Preference from datetime import timedelta + +from django.core.management.base import BaseCommand from django.utils import timezone from tqdm import tqdm +from users.models import Preference, User + class Command(BaseCommand): help = "Check integrity all users" diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 6fe4fce4..9e000a06 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,12 +1,14 @@ # Generated by Django 3.2.16 on 2023-01-12 01:32 -from django.conf import settings import django.contrib.auth.models import django.core.serializers.json -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + import users.models +from users.models.report import report_image_path class Migration(migrations.Migration): @@ -193,7 +195,7 @@ class Migration(migrations.Migration): ( "image", models.ImageField( - blank=True, default="", upload_to=users.models.report_image_path + blank=True, default="", upload_to=report_image_path ), ), ("is_read", models.BooleanField(default=False)), diff --git a/users/migrations/0004_alter_preference_classic_homepage.py b/users/migrations/0004_alter_preference_classic_homepage.py index 8ddc0bf6..d0bf919a 100644 --- a/users/migrations/0004_alter_preference_classic_homepage.py +++ b/users/migrations/0004_alter_preference_classic_homepage.py @@ -2,7 +2,6 @@ from django.db import migrations, models - sql = 'ALTER TABLE users_preference ALTER COLUMN "classic_homepage" TYPE SMALLINT USING CASE WHEN classic_homepage THEN 1 ELSE 0 END;' diff --git a/users/migrations/0005_add_dedicated_username.py b/users/migrations/0005_add_dedicated_username.py index 72f8a3fa..fe8dad6c 100644 --- a/users/migrations/0005_add_dedicated_username.py +++ b/users/migrations/0005_add_dedicated_username.py @@ -1,9 +1,11 @@ # Generated by Django 3.2.19 on 2023-06-30 02:39 import django.contrib.auth.validators -from django.db import migrations, models -import users.models from django.conf import settings +from django.db import migrations, models + +import users.models +from users.models.user import UsernameValidator def move_username(apps, schema_editor): @@ -59,7 +61,7 @@ class Migration(migrations.Migration): max_length=100, null=True, unique=True, - validators=[users.models.UsernameValidator()], + validators=[UsernameValidator()], verbose_name="username", ), ), diff --git a/users/migrations/0007_username_case_insensitive.py b/users/migrations/0007_username_case_insensitive.py index ac03d7d4..feeb1494 100644 --- a/users/migrations/0007_username_case_insensitive.py +++ b/users/migrations/0007_username_case_insensitive.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.3 on 2023-07-07 00:16 -from django.db import migrations, models import django.db.models.functions.text +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/users/migrations/0009_add_local_follow.py b/users/migrations/0009_add_local_follow.py index 6cccbb58..591890ca 100644 --- a/users/migrations/0009_add_local_follow.py +++ b/users/migrations/0009_add_local_follow.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.19 on 2023-07-06 20:56 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/users/migrations/0010_add_local_mute_block.py b/users/migrations/0010_add_local_mute_block.py index 3f9e9508..2c68111f 100644 --- a/users/migrations/0010_add_local_mute_block.py +++ b/users/migrations/0010_add_local_mute_block.py @@ -1,8 +1,8 @@ # Generated by Django 4.2.3 on 2023-07-07 07:22 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/users/migrations/0011_preference_hidden_categories.py b/users/migrations/0011_preference_hidden_categories.py index 3798587d..b64370b8 100644 --- a/users/migrations/0011_preference_hidden_categories.py +++ b/users/migrations/0011_preference_hidden_categories.py @@ -1,6 +1,7 @@ # Generated by Django 4.2.3 on 2023-07-12 03:46 from django.db import migrations, models + import users.models diff --git a/users/models/__init__.py b/users/models/__init__.py new file mode 100644 index 00000000..d1e45854 --- /dev/null +++ b/users/models/__init__.py @@ -0,0 +1,3 @@ +from .preference import Preference +from .report import Report +from .user import User diff --git a/users/models/preference.py b/users/models/preference.py new file mode 100644 index 00000000..6cc96ef9 --- /dev/null +++ b/users/models/preference.py @@ -0,0 +1,53 @@ +import hashlib +import re +from functools import cached_property + +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.core import validators +from django.core.exceptions import ValidationError +from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.db.models import F, Q, Value +from django.db.models.functions import Concat, Lower +from django.templatetags.static import static +from django.urls import reverse +from django.utils import timezone +from django.utils.deconstruct import deconstructible +from django.utils.translation import gettext_lazy as _ +from loguru import logger + +from common.utils import GenerateDateUUIDMediaFilePath +from management.models import Announcement +from mastodon.api import * + +from .user import User + + +class Preference(models.Model): + user = models.OneToOneField(User, models.CASCADE, primary_key=True) + profile_layout = models.JSONField( + blank=True, + default=list, + ) + discover_layout = models.JSONField( + blank=True, + default=list, + ) + export_status = models.JSONField( + blank=True, null=True, encoder=DjangoJSONEncoder, default=dict + ) + import_status = models.JSONField( + blank=True, null=True, encoder=DjangoJSONEncoder, default=dict + ) + default_no_share = models.BooleanField(default=False) + default_visibility = models.PositiveSmallIntegerField(default=0) + classic_homepage = models.PositiveSmallIntegerField(null=False, default=0) + mastodon_publish_public = models.BooleanField(null=False, default=False) + mastodon_append_tag = models.CharField(max_length=2048, default="") + show_last_edit = models.PositiveSmallIntegerField(default=0) + no_anonymous_view = models.PositiveSmallIntegerField(default=0) + hidden_categories = models.JSONField(default=list) + + def __str__(self): + return str(self.user) diff --git a/users/models/report.py b/users/models/report.py new file mode 100644 index 00000000..caabd49c --- /dev/null +++ b/users/models/report.py @@ -0,0 +1,47 @@ +import hashlib +import re +from functools import cached_property + +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.core import validators +from django.core.exceptions import ValidationError +from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.db.models import F, Q, Value +from django.db.models.functions import Concat, Lower +from django.templatetags.static import static +from django.urls import reverse +from django.utils import timezone +from django.utils.deconstruct import deconstructible +from django.utils.translation import gettext_lazy as _ +from loguru import logger + +from common.utils import GenerateDateUUIDMediaFilePath +from management.models import Announcement +from mastodon.api import * + +from .user import User + + +def report_image_path(instance, filename): + return GenerateDateUUIDMediaFilePath( + instance, filename, settings.REPORT_MEDIA_PATH_ROOT + ) + + +class Report(models.Model): + submit_user = models.ForeignKey( + User, on_delete=models.SET_NULL, related_name="sumbitted_reports", null=True + ) + reported_user = models.ForeignKey( + User, on_delete=models.SET_NULL, related_name="accused_reports", null=True + ) + image = models.ImageField( + upload_to=report_image_path, + blank=True, + default="", + ) + is_read = models.BooleanField(default=False) + submitted_time = models.DateTimeField(auto_now_add=True) + message = models.CharField(max_length=1000) diff --git a/users/models.py b/users/models/user.py similarity index 89% rename from users/models.py rename to users/models/user.py index be1a348f..e4db652a 100644 --- a/users/models.py +++ b/users/models/user.py @@ -1,24 +1,26 @@ -from functools import cached_property +import hashlib import re +from functools import cached_property +from typing import TYPE_CHECKING + +from django.contrib.auth.models import AbstractUser from django.core import validators from django.core.exceptions import ValidationError -from django.utils.deconstruct import deconstructible from django.db import models -from django.db.models.functions import Lower -from django.contrib.auth.models import AbstractUser +from django.db.models import F, Q, Value +from django.db.models.functions import Concat, Lower +from django.templatetags.static import static +from django.urls import reverse from django.utils import timezone -from django.core.serializers.json import DjangoJSONEncoder +from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ -from common.utils import GenerateDateUUIDMediaFilePath -from django.conf import settings +from loguru import logger + from management.models import Announcement from mastodon.api import * -from django.urls import reverse -from django.db.models import Q, F, Value -from django.db.models.functions import Concat -from django.templatetags.static import static -import hashlib -from loguru import logger + +if TYPE_CHECKING: + from .preference import Preference _RESERVED_USERNAMES = [ "connect", @@ -34,8 +36,7 @@ _RESERVED_USERNAMES = [ class UsernameValidator(validators.RegexValidator): regex = r"^[a-zA-Z0-9_]{2,30}$" message = _( - "Enter a valid username. This value may contain only unaccented lowercase a-z " - "and uppercase A-Z letters, numbers, and _ characters." + "Enter a valid username. This value may contain only unaccented lowercase a-z and uppercase A-Z letters, numbers, and _ characters." ) flags = re.ASCII @@ -45,12 +46,6 @@ class UsernameValidator(validators.RegexValidator): return super().__call__(value) -def report_image_path(instance, filename): - return GenerateDateUUIDMediaFilePath( - instance, filename, settings.REPORT_MEDIA_PATH_ROOT - ) - - class User(AbstractUser): preference: "Preference" username_validator = UsernameValidator() @@ -146,6 +141,15 @@ class User(AbstractUser): ), ] + @staticmethod + def register(**param): + from .preference import Preference + + new_user = User(**param) + new_user.save() + Preference.objects.create(user=new_user) + return new_user + @cached_property def mastodon_acct(self): return ( @@ -288,12 +292,6 @@ class User(AbstractUser): return True return False - def get_preference(self): - pref = Preference.objects.filter(user=self).first() # self.preference - if not pref: - pref = Preference.objects.create(user=self) - return pref - def clear(self): if self.mastodon_site == "removed" and not self.is_active: return @@ -534,34 +532,23 @@ class User(AbstractUser): return None return User.objects.filter(**query_kwargs).first() + @property + def tags(self): + from journal.models import TagManager -class Preference(models.Model): - user = models.OneToOneField(User, models.CASCADE, primary_key=True) - profile_layout = models.JSONField( - blank=True, - default=list, - ) - discover_layout = models.JSONField( - blank=True, - default=list, - ) - export_status = models.JSONField( - blank=True, null=True, encoder=DjangoJSONEncoder, default=dict - ) - import_status = models.JSONField( - blank=True, null=True, encoder=DjangoJSONEncoder, default=dict - ) - default_no_share = models.BooleanField(default=False) - default_visibility = models.PositiveSmallIntegerField(default=0) - classic_homepage = models.PositiveSmallIntegerField(null=False, default=0) - mastodon_publish_public = models.BooleanField(null=False, default=False) - mastodon_append_tag = models.CharField(max_length=2048, default="") - show_last_edit = models.PositiveSmallIntegerField(default=0) - no_anonymous_view = models.PositiveSmallIntegerField(default=0) - hidden_categories = models.JSONField(default=list) + return TagManager.all_tags_for_user(self) - def __str__(self): - return str(self.user) + @cached_property + def tag_manager(self): + from journal.models import TagManager + + return TagManager.get_manager_for_user(self) + + @cached_property + def shelf_manager(self): + from journal.models import ShelfManager + + return ShelfManager.get_manager_for_user(self) class Follow(models.Model): @@ -583,20 +570,3 @@ class Mute(models.Model): target = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now=True) - - -class Report(models.Model): - submit_user = models.ForeignKey( - User, on_delete=models.SET_NULL, related_name="sumbitted_reports", null=True - ) - reported_user = models.ForeignKey( - User, on_delete=models.SET_NULL, related_name="accused_reports", null=True - ) - image = models.ImageField( - upload_to=report_image_path, - blank=True, - default="", - ) - is_read = models.BooleanField(default=False) - submitted_time = models.DateTimeField(auto_now_add=True) - message = models.CharField(max_length=1000) diff --git a/users/tasks.py b/users/tasks.py index b21c0a95..06d684a1 100644 --- a/users/tasks.py +++ b/users/tasks.py @@ -1,9 +1,11 @@ -from django.conf import settings -from .models import User from datetime import timedelta + +from django.conf import settings from django.utils import timezone -from tqdm import tqdm from loguru import logger +from tqdm import tqdm + +from .models import User def refresh_mastodon_data_task(user_id, token=None): diff --git a/users/tests.py b/users/tests.py index 3d351146..3e801a29 100644 --- a/users/tests.py +++ b/users/tests.py @@ -1,13 +1,13 @@ from django.test import TestCase + from .models import * +from .models.user import Block, Follow, Mute class UserTest(TestCase): def setUp(self): - self.alice = User.objects.create( - mastodon_site="MySpace", mastodon_username="Alice" - ) - self.bob = User.objects.create(mastodon_site="KKCity", mastodon_username="Bob") + self.alice = User.register(mastodon_site="MySpace", mastodon_username="Alice") + self.bob = User.register(mastodon_site="KKCity", mastodon_username="Bob") def test_local_follow(self): self.assertTrue(self.alice.follow(self.bob)) diff --git a/users/urls.py b/users/urls.py index 90edb615..c634650c 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,4 +1,5 @@ from django.urls import path + from .views import * app_name = "users" diff --git a/users/views.py b/users/views.py index 117d5ef5..62282203 100644 --- a/users/views.py +++ b/users/views.py @@ -1,18 +1,21 @@ -from django.shortcuts import redirect, render, get_object_or_404 -from django.urls import reverse -from django.contrib.auth.decorators import login_required -from django.utils.translation import gettext_lazy as _ -from management.models import Announcement -from .models import User, Report, Preference -from .forms import ReportForm -from mastodon.api import * -from common.config import * -from .account import * -from .data import * import json + +from discord import SyncWebhook +from django.contrib.auth.decorators import login_required from django.core.exceptions import BadRequest, PermissionDenied from django.http import HttpResponseRedirect -from discord import SyncWebhook +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from common.config import * +from management.models import Announcement +from mastodon.api import * + +from .account import * +from .data import * +from .forms import ReportForm +from .models import Preference, Report, User def render_user_not_found(request): @@ -150,7 +153,7 @@ def report(request): form.save() dw = settings.DISCORD_WEBHOOKS.get("user-report") if dw: - webhook = SyncWebhook.from_url(dw) + webhook = SyncWebhook.from_url(dw) # type: ignore webhook.send( f"New report from {request.user} about {form.instance.reported_user} : {form.instance.message}" )