add/edit item page
This commit is contained in:
parent
6a392ac954
commit
57ae96873e
31 changed files with 841 additions and 304 deletions
|
@ -62,6 +62,7 @@ INSTALLED_APPS = [
|
|||
"legacy.apps.LegacyConfig",
|
||||
"easy_thumbnails",
|
||||
"user_messages",
|
||||
"jsoneditor",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
@ -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")))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
57
catalog/forms.py
Normal file
57
catalog/forms.py
Normal file
|
@ -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()
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
195
catalog/search/views.py
Normal file
195
catalog/search/views.py
Normal file
|
@ -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
|
|
@ -9,6 +9,7 @@
|
|||
{% load truncate %}
|
||||
{% load strip_scheme %}
|
||||
{% load thumb %}
|
||||
{% load duration %}
|
||||
|
||||
<!-- class specific details -->
|
||||
{% block details %}
|
||||
|
@ -65,7 +66,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
<div>{% if item.duration %}
|
||||
{% trans '时长:' %}{{ item.get_duration_display }}
|
||||
{% trans '时长:' %}{{ item.duration|duration_format }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>{% if item.genre %}
|
||||
|
|
60
catalog/templates/catalog_edit.html
Normal file
60
catalog/templates/catalog_edit.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load admin_url %}
|
||||
{% load mastodon %}
|
||||
{% load oauth_token %}
|
||||
{% load truncate %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site_name }} - {% if form.instance.id %}{% trans '编辑' %} {{ form.instance.title }} {% else %}{% trans '添加' %}{% endif %}</title>
|
||||
{% include "common_libs.html" with jquery=1 %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="page-wrapper">
|
||||
<div id="content-wrapper">
|
||||
{% include "partial/_navbar.html" %}
|
||||
|
||||
<section id="content" class="container">
|
||||
<div class="grid">
|
||||
<div style="float:right;padding-left:16px">
|
||||
<div class="aside-section-wrapper">
|
||||
{% for res in form.instance.external_resources.all %}
|
||||
<div class="action-panel">
|
||||
<div class="action-panel__label">{% trans '源网站' %}: <a href="{{ res.url }}">{{ res.site_name.label }}</a></div>
|
||||
<div class="action-panel__button-group">
|
||||
<form method="post" action="{% url 'catalog:refetch' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="id" value="{{ res.id }}" >
|
||||
<input type="hidden" name="url" value="{{ res.url }}" >
|
||||
<input class="button" type="submit" value="{% trans '从源网站重新抓取' %}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="single-section-wrapper" id="main">
|
||||
<form class="entity-form" action="{{ submit_url }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
{{ form }}
|
||||
<hr/>
|
||||
<input class="button" type="submit" value="{% trans '提交' %}">
|
||||
<a href="javascript:history.go(-1)" style="float:right;">返回</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
{% include "partial/_footer.html" %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
|
||||
</html>
|
|
@ -34,7 +34,7 @@
|
|||
<div class="grid__main">
|
||||
<div class="main-section-wrapper">
|
||||
<div>
|
||||
{% trans '正在连线' %}{{ site.SITE_NAME }}
|
||||
{% trans '正在连线' %}{{ site.SITE_NAME.label }}
|
||||
<div hx-get="{% url 'catalog:fetch_refresh' job_id %}" hx-trigger="load delay:2s" hx-swap="outerHTML"></div>
|
||||
<div id="spinner">
|
||||
<div class="spinner">
|
||||
|
|
|
@ -111,19 +111,30 @@
|
|||
<div class="add-entity-entries__label">
|
||||
{% trans '没有想要的结果?' %}
|
||||
</div>
|
||||
<a href="#">
|
||||
<p>
|
||||
如果在
|
||||
{% for site in sites %}
|
||||
{{ site }}
|
||||
{% if not forloop.last %}/{% endif %}
|
||||
{% endfor %}
|
||||
找到了条目,可以在搜索栏中输入完整链接提交。
|
||||
</p>
|
||||
<p>
|
||||
当然也可以手工创建条目。
|
||||
</p>
|
||||
<a href="{% url 'catalog:create' 'Edition' %}">
|
||||
<button class="add-entity-entries__button">{% trans '添加书' %}</button>
|
||||
</a>
|
||||
<a href="#">
|
||||
<a href="{% url 'catalog:create' 'Movie' %}">
|
||||
<button class="add-entity-entries__button">{% trans '添加电影' %}</button>
|
||||
</a>
|
||||
<a href="#">
|
||||
<a href="{% url 'catalog:create' 'TVShow' %}">
|
||||
<button class="add-entity-entries__button">{% trans '添加剧集' %}</button>
|
||||
</a>
|
||||
<a href="#">
|
||||
<a href="{% url 'catalog:create' 'Album' %}">
|
||||
<button class="add-entity-entries__button">{% trans '添加专辑' %}</button>
|
||||
</a>
|
||||
<a href="#">
|
||||
<a href="{% url 'catalog:create' 'Game' %}">
|
||||
<button class="add-entity-entries__button">{% trans '添加游戏' %}</button>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -18,7 +18,7 @@ def _get_all_url_paths():
|
|||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r"^item/(?P<item_uid>[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<item_uid>[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/<str:item_model>", create, name="create"),
|
||||
re_path(
|
||||
r"^(?P<item_path>"
|
||||
+ _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/<str:job_id>", fetch_refresh, name="fetch_refresh"),
|
||||
path("refetch", refetch, name="refetch"),
|
||||
path("api/", api.urls),
|
||||
]
|
||||
|
|
229
catalog/views.py
229
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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
14
common/templatetags/duration.py
Normal file
14
common/templatetags/duration.py
Normal file
|
@ -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 "")
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
<div class="aside-section-wrapper">
|
||||
<div class="entity-card">
|
||||
<div class="entity-card__img-wrapper">
|
||||
<a href="{% url 'collection:retrieve' collection.id %}">
|
||||
<a href="{{ collection.url }}">
|
||||
<img src="{{ collection.cover|thumb:'normal' }}" alt="" class="entity-card__img">
|
||||
</a>
|
||||
</div>
|
||||
|
@ -115,7 +115,7 @@
|
|||
<div class="action-panel">
|
||||
<div class="action-panel__button-group action-panel__button-group--center">
|
||||
<form>
|
||||
<button class="action-panel__button add-to-list" hx-get="{% url 'collection:share' collection.id %}" hx-target="body" hx-swap="beforeend">{% trans '分享到联邦网络' %}</button>
|
||||
<button class="action-panel__button add-to-list" hx-get="{% url 'journal:collection_share' collection.uuid %}" hx-target="body" hx-swap="beforeend">{% trans '分享到联邦网络' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -40,6 +40,11 @@ urlpatterns = [
|
|||
"collection/edit/<str:collection_uuid>", collection_edit, name="collection_edit"
|
||||
),
|
||||
path("collection/delete/<str:piece_uuid>", piece_delete, name="collection_delete"),
|
||||
path(
|
||||
"collection/share/<str:collection_uuid>",
|
||||
collection_share,
|
||||
name="collection_share",
|
||||
),
|
||||
path(
|
||||
"collection/<str:collection_uuid>/items",
|
||||
collection_retrieve_items,
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue