diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 8fe3576e..20affa68 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ "legacy.apps.LegacyConfig", "easy_thumbnails", "user_messages", + "jsoneditor", ] MIDDLEWARE = [ diff --git a/boofilsic/urls.py b/boofilsic/urls.py index 80b8a115..013a3276 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -37,3 +37,4 @@ if settings.DEBUG: from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns.append(path("__debug__/", include("debug_toolbar.urls"))) diff --git a/catalog/book/models.py b/catalog/book/models.py index cee21862..0305c4d1 100644 --- a/catalog/book/models.py +++ b/catalog/book/models.py @@ -17,6 +17,7 @@ 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 import * @@ -36,37 +37,70 @@ class Edition(Item): METADATA_COPY_LIST = [ "title", - "brief", - # legacy fields "subtitle", "orig_title", + "language", "author", "translator", - "language", "pub_house", "pub_year", "pub_month", - "binding", - "price", - "pages", - "contents", - "series", "imprint", + "binding", + "pages", + "series", + "price", + "brief", + "contents", ] - subtitle = jsondata.CharField(null=True, blank=True, default=None) - orig_title = jsondata.CharField(null=True, blank=True, default=None) - author = jsondata.ArrayField(_("作者"), null=False, blank=False, default=list) - translator = jsondata.ArrayField(_("译者"), null=True, blank=True, default=list) + subtitle = jsondata.CharField( + _("副标题"), null=True, blank=True, default=None, max_length=500 + ) + orig_title = jsondata.CharField( + _("原名"), null=True, blank=True, default=None, max_length=500 + ) + author = jsondata.ArrayField( + verbose_name=_("作者"), + base_field=models.CharField(max_length=500), + null=False, + blank=False, + default=list, + ) + translator = jsondata.ArrayField( + verbose_name=_("译者"), + base_field=models.CharField(max_length=500), + null=True, + blank=True, + default=list, + ) language = jsondata.CharField(_("语言"), null=True, blank=True, default=None) - pub_house = jsondata.CharField(_("出版方"), null=True, blank=True, default=None) - pub_year = jsondata.IntegerField(_("发表年份"), null=True, blank=True) - pub_month = jsondata.IntegerField(_("发表月份"), null=True, blank=True) - binding = jsondata.CharField(null=True, blank=True, default=None) - pages = jsondata.IntegerField(blank=True, default=None) - series = jsondata.CharField(null=True, blank=True, default=None) - contents = jsondata.CharField(null=True, blank=True, default=None) - price = jsondata.CharField(_("价格"), null=True, blank=True) - imprint = jsondata.CharField(_("发表月份"), null=True, blank=True) + pub_house = jsondata.CharField( + _("出版社"), null=True, blank=False, default=None, max_length=500 + ) + pub_year = jsondata.IntegerField( + _("出版年份"), + null=True, + blank=False, + validators=[MinValueValidator(1), MaxValueValidator(2999)], + ) + pub_month = jsondata.IntegerField( + _("出版月份"), + null=True, + blank=False, + validators=[MinValueValidator(1), MaxValueValidator(12)], + ) + binding = jsondata.CharField( + _("装订"), null=True, blank=True, default=None, max_length=500 + ) + pages = jsondata.IntegerField(_("页数"), blank=True, default=None) + series = jsondata.CharField( + _("丛书"), null=True, blank=True, default=None, max_length=500 + ) + contents = jsondata.TextField( + _("目录"), null=True, blank=True, default=None, max_length=500 + ) + price = jsondata.CharField(_("价格"), null=True, blank=True, max_length=500) + imprint = jsondata.CharField(_("出品方"), null=True, blank=True, max_length=500) @property def isbn10(self): @@ -76,6 +110,24 @@ class Edition(Item): def isbn10(self, value): self.isbn = isbn_10_to_13(value) + @classmethod + def lookup_id_type_choices(cls): + id_types = [ + IdType.ISBN, + IdType.ASIN, + IdType.CUBN, + IdType.DoubanBook, + IdType.Goodreads, + IdType.GoogleBooks, + ] + return [(i.value, i.label) for i in id_types] + + @classmethod + def lookup_id_cleanup(cls, lookup_id_type, lookup_id_value): + if lookup_id_type in [IdType.ASIN.value, IdType.ISBN.value]: + return detect_isbn_asin(lookup_id_value) + return super().lookup_id_cleanup(lookup_id_type, lookup_id_value) + def update_linked_items_from_external_resource(self, resource): """add Work from resource.metadata['work'] if not yet""" links = resource.required_resources + resource.related_resources diff --git a/catalog/book/utils.py b/catalog/book/utils.py index 62e08e00..ab86331d 100644 --- a/catalog/book/utils.py +++ b/catalog/book/utils.py @@ -38,15 +38,15 @@ def isbn_13_to_10(isbn): def is_isbn_13(isbn): - return re.match(r"\d{13}", isbn) is not None + return re.match(r"^\d{13}$", isbn) is not None def is_isbn_10(isbn): - return re.match(r"\d{9}[X0-9]", isbn) is not None + return re.match(r"^\d{9}[X0-9]$", isbn) is not None def is_asin(asin): - return re.match(r"B[A-Z0-9]{9}", asin) is not None + return re.match(r"^B[A-Z0-9]{9}$", asin) is not None def detect_isbn_asin(s): diff --git a/catalog/common/jsondata.py b/catalog/common/jsondata.py index ccb6c9b0..0a8dbaba 100644 --- a/catalog/common/jsondata.py +++ b/catalog/common/jsondata.py @@ -1,15 +1,18 @@ import copy from datetime import date, datetime from importlib import import_module +from functools import partialmethod +from django.utils.translation import gettext_lazy as _ import django -from django.conf import settings from django.core.exceptions import FieldError from django.db.models import fields from django.utils import dateparse, timezone -from functools import partialmethod -from django.db.models import JSONField +from django.contrib.postgres.fields import ArrayField as DJANGO_ArrayField + +# from django.db.models import JSONField as DJANGO_JSONField +from jsoneditor.fields.django3_jsonfield import JSONField as DJANGO_JSONField __all__ = ( @@ -18,6 +21,7 @@ __all__ = ( "DateField", "DateTimeField", "DecimalField", + "DurationField", "EmailField", "FloatField", "IntegerField", @@ -28,6 +32,7 @@ __all__ = ( "TimeField", "URLField", "ArrayField", + "JSONField", ) @@ -226,5 +231,17 @@ class URLField(JSONFieldMixin, fields.URLField): pass -class ArrayField(JSONFieldMixin, JSONField): +class ArrayField(JSONFieldMixin, DJANGO_ArrayField): + def __init__(self, *args, **kwargs): + kwargs["help_text"] = _("多项之间以英文逗号分隔") + super().__init__(*args, **kwargs) + + pass + + +class JSONField(JSONFieldMixin, DJANGO_JSONField): + pass + + +class DurationField(JSONFieldMixin, fields.DurationField): pass diff --git a/catalog/common/models.py b/catalog/common/models.py index 0cdec28d..eae1d19b 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.baseconv import base62 from simple_history.models import HistoricalRecords import uuid -from .utils import DEFAULT_ITEM_COVER, item_cover_path +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 @@ -177,19 +177,17 @@ class Item(SoftDeleteMixin, PolymorphicModel): category = None # subclass must specify this demonstrative = None # subclass must specify this uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) - title = models.CharField( - _("title in primary language"), max_length=1000, default="" - ) + title = models.CharField(_("标题"), max_length=1000, default="") brief = models.TextField(_("简介"), blank=True, default="") primary_lookup_id_type = models.CharField( - _("isbn/cubn/imdb"), blank=False, null=True, max_length=50 + _("主要标识类型"), blank=False, null=True, max_length=50 ) primary_lookup_id_value = models.CharField( - _("1234/tt789"), blank=False, null=True, max_length=1000 + _("主要标识数值"), blank=False, null=True, max_length=1000 ) - metadata = models.JSONField(_("其他信息"), blank=True, null=True, default=dict) + metadata = models.JSONField(_("其它信息"), blank=True, null=True, default=dict) cover = models.ImageField( - upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True + _("封面"), upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True ) created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now=True) @@ -222,6 +220,16 @@ class Item(SoftDeleteMixin, PolymorphicModel): def __str__(self): return f"{self.id}|{self.uuid} {self.primary_lookup_id_type}:{self.primary_lookup_id_value if self.primary_lookup_id_value else ''} ({self.title})" + @classmethod + def lookup_id_type_choices(cls): + return IdType.choices + + @classmethod + def lookup_id_cleanup(cls, lookup_id_type, lookup_id_value): + if not lookup_id_type or not lookup_id_value or not lookup_id_value.strip(): + return None, None + return lookup_id_type, lookup_id_value.strip() + @classmethod def get_best_lookup_id(cls, lookup_ids): """get best available lookup id, ideally commonly used""" @@ -302,16 +310,18 @@ class Item(SoftDeleteMixin, PolymorphicModel): def has_cover(self): return self.cover and self.cover != DEFAULT_ITEM_COVER - def merge_data_from_external_resources(self): + def merge_data_from_external_resources(self, ignore_existing_content=False): """Subclass may override this""" lookup_ids = [] for p in self.external_resources.all(): lookup_ids.append((p.id_type, p.id_value)) lookup_ids += p.other_lookup_ids.items() for k in self.METADATA_COPY_LIST: - if not getattr(self, k) and p.metadata.get(k): + if p.metadata.get(k) and ( + not getattr(self, k) or ignore_existing_content + ): setattr(self, k, p.metadata.get(k)) - if not self.has_cover() and p.cover: + if p.cover and (not self.has_cover() or ignore_existing_content): self.cover = p.cover self.update_lookup_ids(lookup_ids) @@ -351,15 +361,19 @@ class ExternalResource(models.Model): _("url to the resource"), blank=False, max_length=1000, unique=True ) cover = models.ImageField( - upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True + upload_to=resource_cover_path, default=DEFAULT_ITEM_COVER, blank=True ) other_lookup_ids = models.JSONField(default=dict) metadata = models.JSONField(default=dict) scraped_time = models.DateTimeField(null=True) created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now=True) - required_resources = jsondata.ArrayField(null=False, blank=False, default=list) - related_resources = jsondata.ArrayField(null=False, blank=False, default=list) + required_resources = jsondata.ArrayField( + models.CharField(), null=False, blank=False, default=list + ) + related_resources = jsondata.ArrayField( + models.CharField(), null=False, blank=False, default=list + ) class Meta: unique_together = [["id_type", "id_value"]] diff --git a/catalog/common/sites.py b/catalog/common/sites.py index 0b33f0ee..ac67487a 100644 --- a/catalog/common/sites.py +++ b/catalog/common/sites.py @@ -163,7 +163,7 @@ class AbstractSite: if auto_save: p.save() if p.item: - p.item.merge_data_from_external_resources() + p.item.merge_data_from_external_resources(ignore_existing_content) p.item.save() if auto_link: for linked_resource in p.required_resources: @@ -221,6 +221,10 @@ class SiteManager: def get_site_by_resource(resource): return SiteManager.get_site_by_id_type(resource.id_type) + @staticmethod + def get_all_sites(): + return SiteManager.register.values() + ExternalResource.get_site = lambda resource: SiteManager.get_site_by_id_type( resource.id_type diff --git a/catalog/common/utils.py b/catalog/common/utils.py index 29f32a93..6fae443f 100644 --- a/catalog/common/utils.py +++ b/catalog/common/utils.py @@ -9,7 +9,7 @@ _logger = logging.getLogger(__name__) DEFAULT_ITEM_COVER = "item/default.svg" -def item_cover_path(resource, filename): +def resource_cover_path(resource, filename): fn = ( timezone.now().strftime("%Y/%m/%d/") + str(uuid.uuid4()) @@ -17,3 +17,13 @@ def item_cover_path(resource, filename): + filename.split(".")[-1] ) return "item/" + resource.id_type + "/" + fn + + +def item_cover_path(item, filename): + fn = ( + timezone.now().strftime("%Y/%m/%d/") + + str(uuid.uuid4()) + + "." + + filename.split(".")[-1] + ) + return "item/" + item.category + "/" + fn diff --git a/catalog/forms.py b/catalog/forms.py new file mode 100644 index 00000000..f29ec21b --- /dev/null +++ b/catalog/forms.py @@ -0,0 +1,57 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from catalog.models import * +from common.forms import PreviewImageInput + + +CatalogForms = {} + + +def _EditForm(item_model: Item): + item_fields = ( + ["id"] + + item_model.METADATA_COPY_LIST + + ["cover"] + + ["primary_lookup_id_type", "primary_lookup_id_value"] + ) + if "media" in item_fields: + # FIXME not sure why this field is always duplicated + item_fields.remove("media") + + class EditForm(forms.ModelForm): + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + primary_lookup_id_type = forms.ChoiceField( + required=False, + choices=item_model.lookup_id_type_choices(), + label=_("主要标识类型"), + ) + primary_lookup_id_value = forms.CharField( + required=False, label=_("主要标识数据通常由系统自动检测,请勿随意更改,不确定留空即可") + ) + + class Meta: + model = item_model + fields = item_fields + widgets = { + "cover": PreviewImageInput(), + } + + def clean(self): + data = super().clean() + t, v = self.Meta.model.lookup_id_cleanup( + data.get("primary_lookup_id_type"), data.get("primary_lookup_id_value") + ) + data["primary_lookup_id_type"] = t + data["primary_lookup_id_value"] = v + return data + + return EditForm + + +def init_forms(): + for cls in Item.__subclasses__(): + CatalogForms[cls.__name__] = _EditForm(cls) + + +init_forms() diff --git a/catalog/game/models.py b/catalog/game/models.py index 295b882f..2626f2a6 100644 --- a/catalog/game/models.py +++ b/catalog/game/models.py @@ -24,44 +24,64 @@ class Game(Item): ] other_title = jsondata.ArrayField( - models.CharField(blank=True, default="", max_length=500), + base_field=models.CharField(blank=True, default="", max_length=500), + verbose_name=_("其他标题"), null=True, blank=True, default=list, ) developer = jsondata.ArrayField( - models.CharField(blank=True, default="", max_length=500), + base_field=models.CharField(blank=True, default="", max_length=500), + verbose_name=_("开发商"), null=True, blank=True, default=list, ) publisher = jsondata.ArrayField( - models.CharField(blank=True, default="", max_length=500), + base_field=models.CharField(blank=True, default="", max_length=500), + verbose_name=_("发行商"), null=True, blank=True, default=list, ) release_date = jsondata.DateField( - auto_now=False, auto_now_add=False, null=True, blank=True + verbose_name=_("发布日期"), + auto_now=False, + auto_now_add=False, + null=True, + blank=True, ) genre = jsondata.ArrayField( - models.CharField(blank=True, default="", max_length=200), + verbose_name=_("类型"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) platform = jsondata.ArrayField( - models.CharField(blank=True, default="", max_length=200), + verbose_name=_("平台"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) official_site = jsondata.CharField( + verbose_name=_("官方网站"), default="", ) + + @classmethod + def lookup_id_type_choices(cls): + id_types = [ + IdType.IGDB, + IdType.Steam, + IdType.DoubanGame, + IdType.Bangumi, + ] + return [(i.value, i.label) for i in id_types] diff --git a/catalog/movie/models.py b/catalog/movie/models.py index ae4a0e6b..ba850965 100644 --- a/catalog/movie/models.py +++ b/catalog/movie/models.py @@ -25,53 +25,61 @@ class Movie(Item): "language", "year", "duration", - "season_number", - "episodes", - "single_episode_length", + # "season_number", + # "episodes", + # "single_episode_length", "brief", ] orig_title = jsondata.CharField( - _("original title"), blank=True, default="", max_length=500 + verbose_name=_("原始标题"), blank=True, default="", max_length=500 ) other_title = jsondata.ArrayField( - models.CharField(_("other title"), blank=True, default="", max_length=500), + base_field=models.CharField(blank=True, default="", max_length=500), + verbose_name=_("其他标题"), null=True, blank=True, default=list, ) director = jsondata.ArrayField( - models.CharField(_("director"), blank=True, default="", max_length=200), + verbose_name=_("导演"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) playwright = jsondata.ArrayField( - models.CharField(_("playwright"), blank=True, default="", max_length=200), + verbose_name=_("编剧"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) actor = jsondata.ArrayField( - models.CharField(_("actor"), blank=True, default="", max_length=200), + verbose_name=_("演员"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) genre = jsondata.ArrayField( - models.CharField(_("genre"), blank=True, default="", max_length=50), + verbose_name=_("类型"), + base_field=models.CharField(blank=True, default="", max_length=50), null=True, blank=True, default=list, ) # , choices=MovieGenreEnum.choices - showtime = jsondata.ArrayField( + showtime = jsondata.JSONField( + _("上映日期"), null=True, blank=True, default=list, ) - site = jsondata.URLField(_("site url"), blank=True, default="", max_length=200) + site = jsondata.URLField( + verbose_name=_("官方网站"), blank=True, default="", max_length=200 + ) area = jsondata.ArrayField( - models.CharField( - _("country or region"), + verbose_name=_("国家地区"), + base_field=models.CharField( blank=True, default="", max_length=100, @@ -81,7 +89,8 @@ class Movie(Item): default=list, ) language = jsondata.ArrayField( - models.CharField( + verbose_name=_("语言"), + base_field=models.CharField( blank=True, default="", max_length=100, @@ -90,8 +99,35 @@ class Movie(Item): blank=True, default=list, ) - year = jsondata.IntegerField(null=True, blank=True) - season_number = jsondata.IntegerField(null=True, blank=True) - episodes = jsondata.IntegerField(null=True, blank=True) - single_episode_length = jsondata.IntegerField(null=True, blank=True) - duration = jsondata.CharField(blank=True, default="", max_length=200) + year = jsondata.IntegerField(verbose_name=_("年份"), null=True, blank=True) + duration = jsondata.CharField( + verbose_name=_("片长"), blank=True, default="", max_length=200 + ) + season_number = jsondata.IntegerField( + null=True, blank=True + ) # TODO remove after migration + episodes = jsondata.IntegerField( + null=True, blank=True + ) # TODO remove after migration + single_episode_length = jsondata.IntegerField( + null=True, blank=True + ) # TODO remove after migration + + @classmethod + def lookup_id_type_choices(cls): + id_types = [ + IdType.IMDB, + IdType.TMDB_Movie, + IdType.DoubanMovie, + IdType.Bangumi, + ] + return [(i.value, i.label) for i in id_types] + + @classmethod + def lookup_id_cleanup(cls, lookup_id_type, lookup_id_value): + if lookup_id_type == IdType.IMDB.value and lookup_id_value: + if lookup_id_value[:2] == "tt": + return lookup_id_type, lookup_id_value + else: + return None, None + return super().lookup_id_cleanup(lookup_id_type, lookup_id_value) diff --git a/catalog/music/models.py b/catalog/music/models.py index 12f777fe..3656507b 100644 --- a/catalog/music/models.py +++ b/catalog/music/models.py @@ -13,38 +13,47 @@ class Album(Item): METADATA_COPY_LIST = [ "title", "other_title", - "album_type", - "media", - "disc_count", "artist", - "genre", - "release_date", - "duration", "company", "track_list", "brief", + "album_type", + "media", + "disc_count", + "genre", + "release_date", + "duration", "bandcamp_album_id", ] - release_date = jsondata.DateField( - _("发行日期"), auto_now=False, auto_now_add=False, null=True, blank=True - ) + release_date = jsondata.DateField(_("发行日期"), null=True, blank=True) duration = jsondata.IntegerField(_("时长"), null=True, blank=True) artist = jsondata.ArrayField( - models.CharField(_("artist"), blank=True, default="", max_length=200), - null=True, - blank=True, + models.CharField(blank=True, default="", max_length=200), + verbose_name=_("艺术家"), default=list, ) genre = jsondata.CharField(_("流派"), blank=True, default="", max_length=100) company = jsondata.ArrayField( models.CharField(blank=True, default="", max_length=500), + verbose_name=_("发行方"), null=True, blank=True, default=list, ) track_list = jsondata.TextField(_("曲目"), blank=True, default="") - other_title = jsondata.CharField(blank=True, default="", max_length=500) - album_type = jsondata.CharField(blank=True, default="", max_length=500) - media = jsondata.CharField(blank=True, default="", max_length=500) + other_title = jsondata.CharField(_("其它标题"), blank=True, default="", max_length=500) + album_type = jsondata.CharField(_("专辑类型"), blank=True, default="", max_length=500) + media = jsondata.CharField(_("介质"), blank=True, default="", max_length=500) bandcamp_album_id = jsondata.CharField(blank=True, default="", max_length=500) - disc_count = jsondata.IntegerField(blank=True, default="", max_length=500) + disc_count = jsondata.IntegerField(_("碟片数"), blank=True, default="", max_length=500) + + @classmethod + def lookup_id_type_choices(cls): + id_types = [ + IdType.GTIN, + IdType.ISRC, + IdType.Spotify_Album, + IdType.Bandcamp, + IdType.DoubanMusic, + ] + return [(i.value, i.label) for i in id_types] diff --git a/catalog/performance/models.py b/catalog/performance/models.py index a0b531ab..743b9b30 100644 --- a/catalog/performance/models.py +++ b/catalog/performance/models.py @@ -1,15 +1,40 @@ from catalog.common import * from django.utils.translation import gettext_lazy as _ +from django.db import models class Performance(Item): category = ItemCategory.Performance url_path = "performance" douban_drama = LookupIdDescriptor(IdType.DoubanDrama) - versions = jsondata.ArrayField(_("版本"), null=False, blank=False, default=list) - directors = jsondata.ArrayField(_("导演"), null=False, blank=False, default=list) - playwrights = jsondata.ArrayField(_("编剧"), null=False, blank=False, default=list) - actors = jsondata.ArrayField(_("主演"), null=False, blank=False, default=list) + versions = jsondata.ArrayField( + verbose_name=_("版本"), + base_field=models.CharField(), + null=False, + blank=False, + default=list, + ) + directors = jsondata.ArrayField( + verbose_name=_("导演"), + base_field=models.CharField(), + null=False, + blank=False, + default=list, + ) + playwrights = jsondata.ArrayField( + verbose_name=_("编剧"), + base_field=models.CharField(), + null=False, + blank=False, + default=list, + ) + actors = jsondata.ArrayField( + verbose_name=_("主演"), + base_field=models.CharField(), + null=False, + blank=False, + default=list, + ) class Meta: proxy = True diff --git a/catalog/podcast/models.py b/catalog/podcast/models.py index 6c808c8a..974b811a 100644 --- a/catalog/podcast/models.py +++ b/catalog/podcast/models.py @@ -1,14 +1,17 @@ from catalog.common import * +from django.db import models +from django.utils.translation import gettext_lazy as _ class Podcast(Item): category = ItemCategory.Podcast url_path = "podcast" + demonstrative = _("这个播客") feed_url = PrimaryLookupIdDescriptor(IdType.Feed) apple_podcast = PrimaryLookupIdDescriptor(IdType.ApplePodcast) # ximalaya = LookupIdDescriptor(IdType.Ximalaya) # xiaoyuzhou = LookupIdDescriptor(IdType.Xiaoyuzhou) - hosts = jsondata.ArrayField(default=list) + hosts = jsondata.ArrayField(models.CharField(), default=list) # class PodcastEpisode(Item): diff --git a/catalog/search/views.py b/catalog/search/views.py new file mode 100644 index 00000000..cf9ea995 --- /dev/null +++ b/catalog/search/views.py @@ -0,0 +1,195 @@ +import uuid +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 ( + HttpResponseBadRequest, + HttpResponseServerError, + HttpResponse, + HttpResponseRedirect, + HttpResponseNotFound, +) +from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import IntegrityError, transaction +from django.db.models import Count +from django.utils import timezone +from django.core.paginator import Paginator +from polymorphic.base import django +from catalog.common.models import SiteName +from catalog.common.sites import AbstractSite, SiteManager +from mastodon import mastodon_request_included +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review +from ..models import * +from django.conf import settings +from django.utils.baseconv import base62 +from journal.models import Mark, ShelfMember, Review +from journal.models import query_visible, query_following +from common.utils import PageLinksGenerator +from common.config import PAGE_LINK_NUMBER +from journal.models import ShelfTypeNames +import django_rq +from rq.job import Job +from .external import ExternalSources + +_logger = logging.getLogger(__name__) + + +class HTTPResponseHXRedirect(HttpResponseRedirect): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self["HX-Redirect"] = self["Location"] + + status_code = 200 + + +@login_required +def fetch_refresh(request, job_id): + retry = request.GET + job = Job.fetch(id=job_id, connection=django_rq.get_connection("fetch")) + item_url = job.result if job else "-" # FIXME job.return_value() in rq 1.12 + if item_url: + if item_url == "-": + return render(request, "fetch_failed.html") + else: + return HTTPResponseHXRedirect(item_url) + else: + retry = int(request.GET.get("retry", 0)) + 1 + if retry > 10: + return render(request, "fetch_failed.html") + else: + return render( + request, + "fetch_refresh.html", + {"job_id": job_id, "retry": retry, "delay": retry * 2}, + ) + + +def fetch(request, url, is_refetch: bool = False, site: AbstractSite = None): + if not site: + site = SiteManager.get_site_by_url(url) + if not site: + return HttpResponseBadRequest() + item = site.get_item() + if item and not is_refetch: + return redirect(item.url) + job_id = uuid.uuid4().hex + django_rq.get_queue("fetch").enqueue(fetch_task, url, is_refetch, job_id=job_id) + return render( + request, + "fetch_pending.html", + { + "site": site, + "job_id": job_id, + }, + ) + + +def search(request): + category = request.GET.get("c", default="all").strip().lower() + if category == "all": + category = None + keywords = request.GET.get("q", default="").strip() + tag = request.GET.get("tag", default="").strip() + p = request.GET.get("page", default="1") + page_number = int(p) if p.isdigit() else 1 + if not (keywords or tag): + return render( + request, + "common/search_result.html", + { + "items": None, + }, + ) + + if request.user.is_authenticated and keywords.find("://") > 0: + site = SiteManager.get_site_by_url(keywords) + if site: + return fetch(request, keywords, site) + if settings.SEARCH_BACKEND is None: + # return limited results if no SEARCH_BACKEND + result = { + "items": Items.objects.filter(title__like=f"%{keywords}%")[:10], + "num_pages": 1, + } + else: + result = Indexer.search(keywords, page=page_number, category=category, tag=tag) + keys = [] + items = [] + urls = [] + for i in result.items: + key = ( + i.isbn + if hasattr(i, "isbn") + else (i.imdb_code if hasattr(i, "imdb_code") else None) + ) + if key is None: + items.append(i) + elif key not in keys: + keys.append(key) + items.append(i) + for res in i.external_resources.all(): + urls.append(res.url) + # if request.path.endswith(".json/"): + # return JsonResponse( + # { + # "num_pages": result.num_pages, + # "items": list(map(lambda i: i.get_json(), items)), + # } + # ) + request.session["search_dedupe_urls"] = urls + return render( + request, + "search_results.html", + { + "items": items, + "pagination": PageLinksGenerator( + PAGE_LINK_NUMBER, page_number, result.num_pages + ), + "categories": ["book", "movie", "music", "game"], + "sites": SiteName.labels, + "hide_category": category is not None, + }, + ) + + +@login_required +def external_search(request): + category = request.GET.get("c", default="all").strip().lower() + if category == "all": + category = None + keywords = request.GET.get("q", default="").strip() + page_number = int(request.GET.get("page", default=1)) + items = ExternalSources.search(category, keywords, page_number) if keywords else [] + dedupe_urls = request.session.get("search_dedupe_urls", []) + items = [i for i in items if i.source_url not in dedupe_urls] + + return render( + request, + "external_search_results.html", + { + "external_items": items, + }, + ) + + +def refetch(request): + url = request.POST.get("url") + if not url: + return HttpResponseBadRequest() + return fetch(request, url, True) + + +def fetch_task(url, is_refetch): + item_url = "-" + try: + site = SiteManager.get_site_by_url(url) + site.get_resource_ready(ignore_existing_content=is_refetch) + item = site.get_item() + if item: + _logger.info(f"fetched {url} {item.url} {item}") + item_url = item.url + finally: + return item_url diff --git a/catalog/templates/album.html b/catalog/templates/album.html index bd4f19d0..3760120e 100644 --- a/catalog/templates/album.html +++ b/catalog/templates/album.html @@ -9,6 +9,7 @@ {% load truncate %} {% load strip_scheme %} {% load thumb %} +{% load duration %} {% block details %} @@ -65,7 +66,7 @@ {% endif %}
{% if item.duration %} - {% trans '时长:' %}{{ item.get_duration_display }} + {% trans '时长:' %}{{ item.duration|duration_format }} {% endif %}
{% if item.genre %} diff --git a/catalog/templates/catalog_edit.html b/catalog/templates/catalog_edit.html new file mode 100644 index 00000000..9bfa346c --- /dev/null +++ b/catalog/templates/catalog_edit.html @@ -0,0 +1,60 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + + {{ site_name }} - {% if form.instance.id %}{% trans '编辑' %} {{ form.instance.title }} {% else %}{% trans '添加' %}{% endif %} + {% include "common_libs.html" with jquery=1 %} + + + +
+
+ {% include "partial/_navbar.html" %} + +
+
+
+
+ {% for res in form.instance.external_resources.all %} +
+
{% trans '源网站' %}: {{ res.site_name.label }}
+
+
+ {% csrf_token %} + + + +
+
+
+ {% endfor %} +
+
+
+
+ {% csrf_token %} + {{ form.media }} + {{ form }} +
+ + 返回 +
+
+ +
+
+ {% include "partial/_footer.html" %} +
+ + + + diff --git a/catalog/templates/fetch_pending.html b/catalog/templates/fetch_pending.html index 8376c6e3..ff757d67 100644 --- a/catalog/templates/fetch_pending.html +++ b/catalog/templates/fetch_pending.html @@ -34,7 +34,7 @@
- {% trans '正在连线' %}{{ site.SITE_NAME }} + {% trans '正在连线' %}{{ site.SITE_NAME.label }}
diff --git a/catalog/templates/search_results.html b/catalog/templates/search_results.html index 88bda511..1e20db3a 100644 --- a/catalog/templates/search_results.html +++ b/catalog/templates/search_results.html @@ -111,19 +111,30 @@
{% trans '没有想要的结果?' %}
- +

+ 如果在 + {% for site in sites %} + {{ site }} + {% if not forloop.last %}/{% endif %} + {% endfor %} + 找到了条目,可以在搜索栏中输入完整链接提交。 +

+

+ 当然也可以手工创建条目。 +

+
- + - + - + - +
diff --git a/catalog/tv/models.py b/catalog/tv/models.py index 9b74e16c..e1d27581 100644 --- a/catalog/tv/models.py +++ b/catalog/tv/models.py @@ -36,8 +36,10 @@ class TVShow(Item): imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_tv = PrimaryLookupIdDescriptor(IdType.TMDB_TV) imdb = PrimaryLookupIdDescriptor(IdType.IMDB) - season_count = models.IntegerField(null=True) - episode_count = models.PositiveIntegerField(null=True) + season_count = models.IntegerField(verbose_name=_("总季数"), null=True, blank=True) + episode_count = models.PositiveIntegerField( + verbose_name=_("总集数"), null=True, blank=True + ) METADATA_COPY_LIST = [ "title", @@ -47,6 +49,7 @@ class TVShow(Item): "director", "playwright", "actor", + "brief", "genre", "showtime", "site", @@ -54,53 +57,59 @@ class TVShow(Item): "language", "year", "duration", - "season_count", "episode_count", "single_episode_length", - "brief", ] orig_title = jsondata.CharField( - _("original title"), blank=True, default="", max_length=500 + verbose_name=_("原始标题"), blank=True, default="", max_length=500 ) other_title = jsondata.ArrayField( - models.CharField(_("other title"), blank=True, default="", max_length=500), + base_field=models.CharField(blank=True, default="", max_length=500), + verbose_name=_("其他标题"), null=True, blank=True, default=list, ) director = jsondata.ArrayField( - models.CharField(_("director"), blank=True, default="", max_length=200), + verbose_name=_("导演"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) playwright = jsondata.ArrayField( - models.CharField(_("playwright"), blank=True, default="", max_length=200), + verbose_name=_("编剧"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) actor = jsondata.ArrayField( - models.CharField(_("actor"), blank=True, default="", max_length=200), + verbose_name=_("演员"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) genre = jsondata.ArrayField( - models.CharField(_("genre"), blank=True, default="", max_length=50), + verbose_name=_("类型"), + base_field=models.CharField(blank=True, default="", max_length=50), null=True, blank=True, default=list, ) # , choices=MovieGenreEnum.choices - showtime = jsondata.ArrayField( + showtime = jsondata.JSONField( + _("播出日期"), null=True, blank=True, default=list, ) - site = jsondata.URLField(_("site url"), blank=True, default="", max_length=200) + site = jsondata.URLField( + verbose_name=_("官方网站"), blank=True, default="", max_length=200 + ) area = jsondata.ArrayField( - models.CharField( - _("country or region"), + verbose_name=_("国家地区"), + base_field=models.CharField( blank=True, default="", max_length=100, @@ -110,7 +119,8 @@ class TVShow(Item): default=list, ) language = jsondata.ArrayField( - models.CharField( + verbose_name=_("语言"), + base_field=models.CharField( blank=True, default="", max_length=100, @@ -119,27 +129,44 @@ class TVShow(Item): blank=True, default=list, ) - year = jsondata.IntegerField(null=True, blank=True) - season_number = jsondata.IntegerField(null=True, blank=True) - single_episode_length = jsondata.IntegerField(null=True, blank=True) - duration = jsondata.CharField(blank=True, default="", max_length=200) + year = jsondata.IntegerField(verbose_name=_("年份"), null=True, blank=True) + single_episode_length = jsondata.IntegerField( + verbose_name=_("单集长度"), null=True, blank=True + ) + season_number = jsondata.IntegerField( + null=True, blank=True + ) # TODO remove after migration + duration = jsondata.CharField( + blank=True, default="", max_length=200 + ) # TODO remove after migration + + @classmethod + def lookup_id_type_choices(cls): + id_types = [ + IdType.IMDB, + IdType.TMDB_TV, + IdType.DoubanMovie, + IdType.Bangumi, + ] + return [(i.value, i.label) for i in id_types] class TVSeason(Item): category = ItemCategory.TV url_path = "tv/season" - demonstrative = _("这部剧集") + demonstrative = _("这季剧集") douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie) imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason) show = models.ForeignKey( TVShow, null=True, on_delete=models.SET_NULL, related_name="seasons" ) - season_number = models.PositiveIntegerField(null=True) - episode_count = models.PositiveIntegerField(null=True) + season_number = models.PositiveIntegerField(verbose_name=_("本季序号"), null=True) + episode_count = models.PositiveIntegerField(verbose_name=_("本季集数"), null=True) METADATA_COPY_LIST = [ "title", + "season_number", "orig_title", "other_title", "director", @@ -152,53 +179,60 @@ class TVSeason(Item): "language", "year", "duration", - "season_number", "episode_count", "single_episode_length", "brief", ] orig_title = jsondata.CharField( - _("original title"), blank=True, default="", max_length=500 + verbose_name=_("原始标题"), blank=True, default="", max_length=500 ) other_title = jsondata.ArrayField( - models.CharField(_("other title"), blank=True, default="", max_length=500), + verbose_name=_("其他标题"), + base_field=models.CharField(blank=True, default="", max_length=500), null=True, blank=True, default=list, ) director = jsondata.ArrayField( - models.CharField(_("director"), blank=True, default="", max_length=200), + verbose_name=_("导演"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) playwright = jsondata.ArrayField( - models.CharField(_("playwright"), blank=True, default="", max_length=200), + verbose_name=_("编剧"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) actor = jsondata.ArrayField( - models.CharField(_("actor"), blank=True, default="", max_length=200), + verbose_name=_("演员"), + base_field=models.CharField(blank=True, default="", max_length=200), null=True, blank=True, default=list, ) genre = jsondata.ArrayField( - models.CharField(_("genre"), blank=True, default="", max_length=50), + verbose_name=_("类型"), + base_field=models.CharField(blank=True, default="", max_length=50), null=True, blank=True, default=list, ) # , choices=MovieGenreEnum.choices - showtime = jsondata.ArrayField( + showtime = jsondata.JSONField( + _("播出日期"), null=True, blank=True, default=list, ) - site = jsondata.URLField(_("site url"), blank=True, default="", max_length=200) + site = jsondata.URLField( + verbose_name=_("官方网站"), blank=True, default="", max_length=200 + ) area = jsondata.ArrayField( - models.CharField( - _("country or region"), + verbose_name=_("国家地区"), + base_field=models.CharField( blank=True, default="", max_length=100, @@ -208,7 +242,8 @@ class TVSeason(Item): default=list, ) language = jsondata.ArrayField( - models.CharField( + verbose_name=_("语言"), + base_field=models.CharField( blank=True, default="", max_length=100, @@ -217,9 +252,22 @@ class TVSeason(Item): blank=True, default=list, ) - year = jsondata.IntegerField(null=True, blank=True) - single_episode_length = jsondata.IntegerField(null=True, blank=True) - duration = jsondata.CharField(blank=True, default="", max_length=200) + year = jsondata.IntegerField(verbose_name=_("年份"), null=True, blank=True) + single_episode_length = jsondata.IntegerField( + verbose_name=_("单集长度"), null=True, blank=True + ) + duration = jsondata.CharField( + blank=True, default="", max_length=200 + ) # TODO remove after migration + + @classmethod + def lookup_id_type_choices(cls): + id_types = [ + IdType.IMDB, + IdType.TMDB_TVSeason, + IdType.DoubanMovie, + ] + return [(i.value, i.label) for i in id_types] def update_linked_items_from_external_resource(self, resource): """add Work from resource.metadata['work'] if not yet""" diff --git a/catalog/urls.py b/catalog/urls.py index 3363eb93..36567fbb 100644 --- a/catalog/urls.py +++ b/catalog/urls.py @@ -18,7 +18,7 @@ def _get_all_url_paths(): urlpatterns = [ re_path( - r"^item/(?P[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})?$", + r"^item/(?P[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$", retrieve_by_uuid, name="retrieve_by_uuid", ), @@ -29,6 +29,7 @@ urlpatterns = [ retrieve, name="retrieve", ), + path("catalog/create/", create, name="create"), re_path( r"^(?P" + _get_all_url_paths() @@ -60,5 +61,6 @@ urlpatterns = [ path("search/", search, name="search"), path("search/external/", external_search, name="external_search"), path("fetch_refresh/", fetch_refresh, name="fetch_refresh"), + path("refetch", refetch, name="refetch"), path("api/", api.urls), ] diff --git a/catalog/views.py b/catalog/views.py index 77182bcb..bd5ebcb5 100644 --- a/catalog/views.py +++ b/catalog/views.py @@ -32,6 +32,9 @@ from journal.models import ShelfTypeNames import django_rq from rq.job import Job from .search.external import ExternalSources +from .forms import * +from .search.views import * +from pprint import pprint _logger = logging.getLogger(__name__) @@ -40,14 +43,6 @@ NUM_REVIEWS_ON_ITEM_PAGE = 5 NUM_REVIEWS_ON_LIST_PAGE = 20 -class HTTPResponseHXRedirect(HttpResponseRedirect): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self["HX-Redirect"] = self["Location"] - - status_code = 200 - - def retrieve_by_uuid(request, item_uid): item = get_object_or_404(Item, uid=item_uid) return redirect(item.url) @@ -109,6 +104,74 @@ def retrieve(request, item_path, item_uuid): return HttpResponseBadRequest() +@login_required +def create(request, item_model): + if request.method == "GET": + form_cls = CatalogForms[item_model] + form = form_cls() + return render( + request, + "catalog_edit.html", + { + "form": form, + }, + ) + elif request.method == "POST": + form_cls = CatalogForms[item_model] + form = form_cls(request.POST, request.FILES) + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + form.instance.save() + return redirect(form.instance.url) + else: + pprint(form.errors) + return HttpResponseBadRequest(form.errors) + else: + return HttpResponseBadRequest() + + +@login_required +def edit(request, item_path, item_uuid): + if request.method == "GET": + item = get_object_or_404(Item, uid=base62.decode(item_uuid)) + form_cls = CatalogForms[item.__class__.__name__] + form = form_cls(instance=item) + if item.external_resources.all().count() > 0: + form.fields["primary_lookup_id_type"].disabled = True + form.fields["primary_lookup_id_value"].disabled = True + return render( + request, + "catalog_edit.html", + { + "form": form, + "is_update": True, + }, + ) + elif request.method == "POST": + item = get_object_or_404(Item, uid=base62.decode(item_uuid)) + form_cls = CatalogForms[item.__class__.__name__] + form = form_cls(request.POST, request.FILES, instance=item) + if item.external_resources.all().count() > 0: + form.fields["primary_lookup_id_type"].disabled = True + form.fields["primary_lookup_id_value"].disabled = True + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + form.instance.save() + return redirect(form.instance.url) + else: + pprint(form.errors) + return HttpResponseBadRequest(form.errors) + else: + return HttpResponseBadRequest() + + +@login_required +def delete(request, item_path, item_uuid): + return HttpResponseBadRequest() + + @login_required def mark_list(request, item_path, item_uuid, following_only=False): item = get_object_or_404(Item, uid=base62.decode(item_uuid)) @@ -155,153 +218,3 @@ def review_list(request, item_path, item_uuid): "item": item, }, ) - - -def fetch_task(url): - try: - site = SiteManager.get_site_by_url(url) - site.get_resource_ready() - item = site.get_item() - return item.url if item else "-" - except Exception: - return "-" - - -@login_required -def fetch_refresh(request, job_id): - retry = request.GET - job = Job.fetch(id=job_id, connection=django_rq.get_connection("fetch")) - item_url = job.result if job else "-" # FIXME job.return_value() in rq 1.12 - if item_url: - if item_url == "-": - return render(request, "fetch_failed.html") - else: - return HTTPResponseHXRedirect(item_url) - else: - retry = int(request.GET.get("retry", 0)) + 1 - if retry > 10: - return render(request, "fetch_failed.html") - else: - return render( - request, - "fetch_refresh.html", - {"job_id": job_id, "retry": retry, "delay": retry * 2}, - ) - - -@login_required -def fetch(request, url, site: AbstractSite = None): - if not site: - site = SiteManager.get_site_by_url(url) - if not site: - return HttpResponseBadRequest() - item = site.get_item() - if item: - return redirect(item.url) - job_id = uuid.uuid4().hex - django_rq.get_queue("fetch").enqueue(fetch_task, url, job_id=job_id) - return render( - request, - "fetch_pending.html", - { - "site": site, - "job_id": job_id, - }, - ) - - -def search(request): - category = request.GET.get("c", default="all").strip().lower() - if category == "all": - category = None - keywords = request.GET.get("q", default="").strip() - tag = request.GET.get("tag", default="").strip() - p = request.GET.get("page", default="1") - page_number = int(p) if p.isdigit() else 1 - if not (keywords or tag): - return render( - request, - "common/search_result.html", - { - "items": None, - }, - ) - - if request.user.is_authenticated and keywords.find("://") > 0: - site = SiteManager.get_site_by_url(keywords) - if site: - return fetch(request, keywords, site) - if settings.SEARCH_BACKEND is None: - # return limited results if no SEARCH_BACKEND - result = { - "items": Items.objects.filter(title__like=f"%{keywords}%")[:10], - "num_pages": 1, - } - else: - result = Indexer.search(keywords, page=page_number, category=category, tag=tag) - keys = [] - items = [] - urls = [] - for i in result.items: - key = ( - i.isbn - if hasattr(i, "isbn") - else (i.imdb_code if hasattr(i, "imdb_code") else None) - ) - if key is None: - items.append(i) - elif key not in keys: - keys.append(key) - items.append(i) - for res in i.external_resources.all(): - urls.append(res.url) - # if request.path.endswith(".json/"): - # return JsonResponse( - # { - # "num_pages": result.num_pages, - # "items": list(map(lambda i: i.get_json(), items)), - # } - # ) - request.session["search_dedupe_urls"] = urls - return render( - request, - "search_results.html", - { - "items": items, - "pagination": PageLinksGenerator( - PAGE_LINK_NUMBER, page_number, result.num_pages - ), - "categories": ["book", "movie", "music", "game"], - "hide_category": category is not None, - }, - ) - - -@login_required -def external_search(request): - category = request.GET.get("c", default="all").strip().lower() - if category == "all": - category = None - keywords = request.GET.get("q", default="").strip() - page_number = int(request.GET.get("page", default=1)) - items = ExternalSources.search(category, keywords, page_number) if keywords else [] - dedupe_urls = request.session.get("search_dedupe_urls", []) - items = [i for i in items if i.source_url not in dedupe_urls] - - return render( - request, - "external_search_results.html", - { - "external_items": items, - }, - ) - - -@login_required -def edit(request, item_uuid): - return HttpResponseBadRequest() - - -@login_required -def delete(request, item_uuid): - return HttpResponseBadRequest() diff --git a/common/static/lib/css/collection.css b/common/static/lib/css/collection.css index 22aa8886..97b09040 100644 --- a/common/static/lib/css/collection.css +++ b/common/static/lib/css/collection.css @@ -30,6 +30,18 @@ cursor: pointer; } +.helptext{ + position: relative; + top: -1em; +} +/*** django-jsoneditor ***/ +div.jsoneditor { + border-color: #ccc !important;; +} +div.jsoneditor-menu { + background-color: #606c76 !important;; + border-color: #606c76 !important; +} /***** MODAL DIALOG ****/ #modal { diff --git a/common/templatetags/duration.py b/common/templatetags/duration.py new file mode 100644 index 00000000..a35ed4ef --- /dev/null +++ b/common/templatetags/duration.py @@ -0,0 +1,14 @@ +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.text import Truncator + +register = template.Library() + + +@register.filter(is_safe=True) +@stringfilter +def duration_format(value): + duration = int(value) + h = duration // 3600000 + m = duration % 3600000 // 60000 + return (f"{h}小时 " if h else "") + (f"{m}分钟" if m else "") diff --git a/common/tests.py b/common/tests.py index 7ce503c2..0f382a86 100644 --- a/common/tests.py +++ b/common/tests.py @@ -1,3 +1,30 @@ -from django.test import TestCase +# from django.test import TestCase -# Create your tests here. +# from django.contrib.staticfiles.testing import StaticLiveServerTestCase +# from selenium.webdriver.common.by import By +# from selenium import webdriver +# from selenium.webdriver.firefox.service import Service as FirefoxService +# from webdriver_manager.firefox import GeckoDriverManager + + +# class MySeleniumTests(StaticLiveServerTestCase): +# @classmethod +# def setUpClass(cls): +# super().setUpClass() +# cls.selenium = webdriver.Firefox( +# service=FirefoxService(GeckoDriverManager().install()) +# ) +# cls.selenium.implicitly_wait(10) + +# @classmethod +# def tearDownClass(cls): +# cls.selenium.quit() +# super().tearDownClass() + +# def test_login(self): +# self.selenium.get("%s%s" % (self.live_server_url, "/404/")) +# username_input = self.selenium.find_element(By.NAME, "username") +# username_input.send_keys("myuser") +# password_input = self.selenium.find_element(By.NAME, "password") +# password_input.send_keys("secret") +# self.selenium.find_element(By.XPATH, '//input[@value="Log in"]').click() diff --git a/doc/install.md b/doc/install.md index a85b1d55..dd849481 100644 --- a/doc/install.md +++ b/doc/install.md @@ -4,13 +4,12 @@ This is a very basic guide with limited detail, contributions welcomed Install ------- -Install PostgreSQL, Redis and Python if not yet +Install PostgreSQL, Redis and Python (3.10 or above) if not yet Setup database ``` CREATE DATABASE neodb ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE template0; \c neodb; -CREATE EXTENSION hstore WITH SCHEMA public; CREATE ROLE neodb with LOGIN ENCRYPTED PASSWORD 'abadface'; GRANT ALL ON DATABASE neodb TO neodb; ``` @@ -102,13 +101,11 @@ rq requeue --all --queue doufen Run in Docker ``` docker-compose build -docker-compose up db && docker exec -it app_db_1 psql -U postgres postgres -c 'CREATE EXTENSION hstore WITH SCHEMA public;' # first time only docker-compose up ``` Run Tests ``` -psql template1 -c 'create extension hstore;' # first time only coverage run --source='.' manage.py test coverage report ``` diff --git a/journal/models.py b/journal/models.py index e73fcfba..7ab6852a 100644 --- a/journal/models.py +++ b/journal/models.py @@ -647,9 +647,7 @@ class Collection(List): url_path = "collection" MEMBER_CLASS = CollectionMember catalog_item = models.OneToOneField(CatalogCollection, on_delete=models.PROTECT) - title = models.CharField( - _("title in primary language"), max_length=1000, default="" - ) + title = models.CharField(_("标题"), max_length=1000, default="") brief = models.TextField(_("简介"), blank=True, default="") cover = models.ImageField( upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True diff --git a/journal/templates/collection.html b/journal/templates/collection.html index 8083d891..c2406981 100644 --- a/journal/templates/collection.html +++ b/journal/templates/collection.html @@ -74,7 +74,7 @@
@@ -115,7 +115,7 @@
- +
diff --git a/journal/urls.py b/journal/urls.py index 1f7ef520..1d511892 100644 --- a/journal/urls.py +++ b/journal/urls.py @@ -40,6 +40,11 @@ urlpatterns = [ "collection/edit/", collection_edit, name="collection_edit" ), path("collection/delete/", piece_delete, name="collection_delete"), + path( + "collection/share/", + collection_share, + name="collection_share", + ), path( "collection//items", collection_retrieve_items, diff --git a/journal/views.py b/journal/views.py index 5a57320a..242b32f9 100644 --- a/journal/views.py +++ b/journal/views.py @@ -175,6 +175,10 @@ def collection_retrieve(request, collection_uuid): ) +def collection_share(request, collection_uuid): + pass + + def collection_retrieve_items(request, collection_uuid, edit=False): collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) if not collection.is_visible_to(request.user): diff --git a/requirements.txt b/requirements.txt index 97809d71..b9a6338a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ dateparser django~=3.2.16 django-hstore django-markdownx @ git+https://github.com/alphatownsman/django-markdownx.git@e69480c64ad9c5d0499f4a8625da78cf2bb7691b +django-jsoneditor @ git+https://github.com/alphatownsman/django-jsoneditor.git@fa2ae41aeeb34447bd8a808a520e843c853fd16e django-sass django-rq django-simple-history