new data model: pages for collection

This commit is contained in:
Your Name 2022-12-29 14:30:31 -05:00
parent 0454e8830c
commit 8aa6324334
20 changed files with 1475 additions and 820 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
.DS_Store .DS_Store
.venv
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

View file

@ -12,7 +12,23 @@ from functools import partialmethod
from django.db.models import JSONField from django.db.models import JSONField
__all__ = ('BooleanField', 'CharField', 'DateField', 'DateTimeField', 'DecimalField', 'EmailField', 'FloatField', 'IntegerField', 'IPAddressField', 'GenericIPAddressField', 'NullBooleanField', 'TextField', 'TimeField', 'URLField', 'ArrayField') __all__ = (
"BooleanField",
"CharField",
"DateField",
"DateTimeField",
"DecimalField",
"EmailField",
"FloatField",
"IntegerField",
"IPAddressField",
"GenericIPAddressField",
"NullBooleanField",
"TextField",
"TimeField",
"URLField",
"ArrayField",
)
class JSONFieldDescriptor(object): class JSONFieldDescriptor(object):
@ -26,12 +42,12 @@ class JSONFieldDescriptor(object):
if isinstance(json_value, dict): if isinstance(json_value, dict):
if self.field.attname in json_value or not self.field.has_default(): if self.field.attname in json_value or not self.field.has_default():
value = json_value.get(self.field.attname, None) value = json_value.get(self.field.attname, None)
if hasattr(self.field, 'from_json'): if hasattr(self.field, "from_json"):
value = self.field.from_json(value) value = self.field.from_json(value)
return value return value
else: else:
default = self.field.get_default() default = self.field.get_default()
if hasattr(self.field, 'to_json'): if hasattr(self.field, "to_json"):
json_value[self.field.attname] = self.field.to_json(default) json_value[self.field.attname] = self.field.to_json(default)
else: else:
json_value[self.field.attname] = default json_value[self.field.attname] = default
@ -45,7 +61,7 @@ class JSONFieldDescriptor(object):
else: else:
json_value = {} json_value = {}
if hasattr(self.field, 'to_json'): if hasattr(self.field, "to_json"):
value = self.field.to_json(value) value = self.field.to_json(value)
if not value and self.field.blank and not self.field.null: if not value and self.field.blank and not self.field.null:
@ -66,7 +82,7 @@ class JSONFieldMixin(object):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.json_field_name = kwargs.pop('json_field_name', 'metadata') self.json_field_name = kwargs.pop("json_field_name", "metadata")
super(JSONFieldMixin, self).__init__(*args, **kwargs) super(JSONFieldMixin, self).__init__(*args, **kwargs)
def contribute_to_class(self, cls, name, private_only=False): def contribute_to_class(self, cls, name, private_only=False):
@ -81,8 +97,11 @@ class JSONFieldMixin(object):
setattr(cls, self.attname, descriptor) setattr(cls, self.attname, descriptor)
if self.choices is not None: if self.choices is not None:
setattr(cls, 'get_%s_display' % self.name, setattr(
partialmethod(cls._get_FIELD_display, field=self)) cls,
"get_%s_display" % self.name,
partialmethod(cls._get_FIELD_display, field=self),
)
def get_lookup(self, lookup_name): def get_lookup(self, lookup_name):
# Always return None, to make get_transform been called # Always return None, to make get_transform been called
@ -101,15 +120,17 @@ class JSONFieldMixin(object):
lhs.output_field = self.json_field lhs.output_field = self.json_field
transform = self.transform(lhs, **kwargs) transform = self.transform(lhs, **kwargs)
transform._original_get_lookup = transform.get_lookup transform._original_get_lookup = transform.get_lookup
transform.get_lookup = lambda name: transform._original_get_lookup(self.original_lookup) transform.get_lookup = lambda name: transform._original_get_lookup(
self.original_lookup
)
return transform return transform
json_field = self.model._meta.get_field(self.json_field_name) json_field = self.model._meta.get_field(self.json_field_name)
transform = json_field.get_transform(self.name) transform = json_field.get_transform(self.name)
if transform is None: if transform is None:
raise FieldError( raise FieldError(
"JSONField '%s' has no support for key '%s' %s lookup" % "JSONField '%s' has no support for key '%s' %s lookup"
(self.json_field_name, self.name, name) % (self.json_field_name, self.name, name)
) )
return TransformFactoryWrapper(json_field, transform, name) return TransformFactoryWrapper(json_field, transform, name)
@ -118,13 +139,16 @@ class JSONFieldMixin(object):
class BooleanField(JSONFieldMixin, fields.BooleanField): class BooleanField(JSONFieldMixin, fields.BooleanField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(BooleanField, self).__init__(*args, **kwargs) super(BooleanField, self).__init__(*args, **kwargs)
if django.VERSION < (2, ): if django.VERSION < (2,):
self.blank = False self.blank = False
class CharField(JSONFieldMixin, fields.CharField): class CharField(JSONFieldMixin, fields.CharField):
def from_json(self, value): # TODO workaound some bad data in migration, should be removed after clean up def from_json(
self, value
): # TODO workaound some bad data in migration, should be removed after clean up
return value if isinstance(value, str) else None return value if isinstance(value, str) else None
pass pass
@ -133,7 +157,7 @@ class DateField(JSONFieldMixin, fields.DateField):
if value: if value:
if not isinstance(value, (datetime, date)): if not isinstance(value, (datetime, date)):
value = dateparse.parse_date(value) value = dateparse.parse_date(value)
return value.strftime('%Y-%m-%d') return value.strftime("%Y-%m-%d")
def from_json(self, value): def from_json(self, value):
if value is not None: if value is not None:

View file

@ -15,94 +15,95 @@ from users.models import User
class SiteName(models.TextChoices): class SiteName(models.TextChoices):
Douban = 'douban', _('豆瓣') Douban = "douban", _("豆瓣")
Goodreads = 'goodreads', _('Goodreads') Goodreads = "goodreads", _("Goodreads")
GoogleBooks = 'googlebooks', _('谷歌图书') GoogleBooks = "googlebooks", _("谷歌图书")
IMDB = 'imdb', _('IMDB') IMDB = "imdb", _("IMDB")
TMDB = 'tmdb', _('The Movie Database') TMDB = "tmdb", _("The Movie Database")
Bandcamp = 'bandcamp', _('Bandcamp') Bandcamp = "bandcamp", _("Bandcamp")
Spotify = 'spotify', _('Spotify') Spotify = "spotify", _("Spotify")
IGDB = 'igdb', _('IGDB') IGDB = "igdb", _("IGDB")
Steam = 'steam', _('Steam') Steam = "steam", _("Steam")
Bangumi = 'bangumi', _('Bangumi') Bangumi = "bangumi", _("Bangumi")
ApplePodcast = 'apple_podcast', _('苹果播客') ApplePodcast = "apple_podcast", _("苹果播客")
class IdType(models.TextChoices): class IdType(models.TextChoices):
WikiData = 'wikidata', _('维基数据') WikiData = "wikidata", _("维基数据")
ISBN10 = 'isbn10', _('ISBN10') ISBN10 = "isbn10", _("ISBN10")
ISBN = 'isbn', _('ISBN') # ISBN 13 ISBN = "isbn", _("ISBN") # ISBN 13
ASIN = 'asin', _('ASIN') ASIN = "asin", _("ASIN")
ISSN = 'issn', _('ISSN') ISSN = "issn", _("ISSN")
CUBN = 'cubn', _('统一书号') CUBN = "cubn", _("统一书号")
ISRC = 'isrc', _('ISRC') # only for songs ISRC = "isrc", _("ISRC") # only for songs
GTIN = 'gtin', _('GTIN UPC EAN码') # ISBN is separate GTIN = "gtin", _("GTIN UPC EAN码") # ISBN is separate
Feed = 'feed', _('Feed URL') Feed = "feed", _("Feed URL")
IMDB = 'imdb', _('IMDb') IMDB = "imdb", _("IMDb")
TMDB_TV = 'tmdb_tv', _('TMDB剧集') TMDB_TV = "tmdb_tv", _("TMDB剧集")
TMDB_TVSeason = 'tmdb_tvseason', _('TMDB剧集') TMDB_TVSeason = "tmdb_tvseason", _("TMDB剧集")
TMDB_TVEpisode = 'tmdb_tvepisode', _('TMDB剧集') TMDB_TVEpisode = "tmdb_tvepisode", _("TMDB剧集")
TMDB_Movie = 'tmdb_movie', _('TMDB电影') TMDB_Movie = "tmdb_movie", _("TMDB电影")
Goodreads = 'goodreads', _('Goodreads') Goodreads = "goodreads", _("Goodreads")
Goodreads_Work = 'goodreads_work', _('Goodreads著作') Goodreads_Work = "goodreads_work", _("Goodreads著作")
GoogleBooks = 'googlebooks', _('谷歌图书') GoogleBooks = "googlebooks", _("谷歌图书")
DoubanBook = 'doubanbook', _('豆瓣读书') DoubanBook = "doubanbook", _("豆瓣读书")
DoubanBook_Work = 'doubanbook_work', _('豆瓣读书著作') DoubanBook_Work = "doubanbook_work", _("豆瓣读书著作")
DoubanMovie = 'doubanmovie', _('豆瓣电影') DoubanMovie = "doubanmovie", _("豆瓣电影")
DoubanMusic = 'doubanmusic', _('豆瓣音乐') DoubanMusic = "doubanmusic", _("豆瓣音乐")
DoubanGame = 'doubangame', _('豆瓣游戏') DoubanGame = "doubangame", _("豆瓣游戏")
DoubanDrama = 'doubandrama', _('豆瓣舞台剧') DoubanDrama = "doubandrama", _("豆瓣舞台剧")
Bandcamp = 'bandcamp', _('Bandcamp') Bandcamp = "bandcamp", _("Bandcamp")
Spotify_Album = 'spotify_album', _('Spotify专辑') Spotify_Album = "spotify_album", _("Spotify专辑")
Spotify_Show = 'spotify_show', _('Spotify播客') Spotify_Show = "spotify_show", _("Spotify播客")
Discogs_Release = 'discogs_release', ('Discogs Release') Discogs_Release = "discogs_release", ("Discogs Release")
Discogs_Master = 'discogs_master', ('Discogs Master') Discogs_Master = "discogs_master", ("Discogs Master")
MusicBrainz = 'musicbrainz', ('MusicBrainz ID') MusicBrainz = "musicbrainz", ("MusicBrainz ID")
DoubanBook_Author = 'doubanbook_author', _('豆瓣读书作者') DoubanBook_Author = "doubanbook_author", _("豆瓣读书作者")
DoubanCelebrity = 'doubanmovie_celebrity', _('豆瓣电影影人') DoubanCelebrity = "doubanmovie_celebrity", _("豆瓣电影影人")
Goodreads_Author = 'goodreads_author', _('Goodreads作者') Goodreads_Author = "goodreads_author", _("Goodreads作者")
Spotify_Artist = 'spotify_artist', _('Spotify艺术家') Spotify_Artist = "spotify_artist", _("Spotify艺术家")
TMDB_Person = 'tmdb_person', _('TMDB影人') TMDB_Person = "tmdb_person", _("TMDB影人")
IGDB = 'igdb', _('IGDB游戏') IGDB = "igdb", _("IGDB游戏")
Steam = 'steam', _('Steam游戏') Steam = "steam", _("Steam游戏")
Bangumi = 'bangumi', _('Bangumi') Bangumi = "bangumi", _("Bangumi")
ApplePodcast = 'apple_podcast', _('苹果播客') ApplePodcast = "apple_podcast", _("苹果播客")
class ItemType(models.TextChoices): class ItemType(models.TextChoices):
Book = 'book', _('') Book = "book", _("")
TV = 'tv', _('剧集') TV = "tv", _("剧集")
TVSeason = 'tvseason', _('剧集分季') TVSeason = "tvseason", _("剧集分季")
TVEpisode = 'tvepisode', _('剧集分集') TVEpisode = "tvepisode", _("剧集分集")
Movie = 'movie', _('电影') Movie = "movie", _("电影")
Music = 'music', _('音乐') Music = "music", _("音乐")
Game = 'game', _('游戏') Game = "game", _("游戏")
Boardgame = 'boardgame', _('桌游') Boardgame = "boardgame", _("桌游")
Podcast = 'podcast', _('播客') Podcast = "podcast", _("播客")
FanFic = 'fanfic', _('网文') FanFic = "fanfic", _("网文")
Performance = 'performance', _('演出') Performance = "performance", _("演出")
Exhibition = 'exhibition', _('展览') Exhibition = "exhibition", _("展览")
Collection = 'collection', _('收藏单') Collection = "collection", _("收藏单")
class ItemCategory(models.TextChoices): class ItemCategory(models.TextChoices):
Book = 'book', _('') Book = "book", _("")
Movie = 'movie', _('电影') Movie = "movie", _("电影")
TV = 'tv', _('剧集') TV = "tv", _("剧集")
Music = 'music', _('音乐') Music = "music", _("音乐")
Game = 'game', _('游戏') Game = "game", _("游戏")
Boardgame = 'boardgame', _('桌游') Boardgame = "boardgame", _("桌游")
Podcast = 'podcast', _('播客') Podcast = "podcast", _("播客")
FanFic = 'fanfic', _('网文') FanFic = "fanfic", _("网文")
Performance = 'performance', _('演出') Performance = "performance", _("演出")
Exhibition = 'exhibition', _('展览') Exhibition = "exhibition", _("展览")
Collection = 'collection', _('收藏单') Collection = "collection", _("收藏单")
class SubItemType(models.TextChoices): class SubItemType(models.TextChoices):
Season = 'season', _('剧集分季') Season = "season", _("剧集分季")
Episode = 'episode', _('剧集分集') Episode = "episode", _("剧集分集")
Version = 'version', _('版本') Version = "version", _("版本")
# class CreditType(models.TextChoices): # class CreditType(models.TextChoices):
# Author = 'author', _('作者') # Author = 'author', _('作者')
@ -176,37 +177,64 @@ class Item(SoftDeleteMixin, PolymorphicModel):
category = None # subclass must specify this category = None # subclass must specify this
demonstrative = None # subclass must specify this demonstrative = None # subclass must specify this
uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) 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(
_("title in primary language"), max_length=1000, default=""
)
brief = models.TextField(_("简介"), blank=True, default="") brief = models.TextField(_("简介"), blank=True, default="")
primary_lookup_id_type = models.CharField(_("isbn/cubn/imdb"), blank=False, null=True, max_length=50) primary_lookup_id_type = models.CharField(
primary_lookup_id_value = models.CharField(_("1234/tt789"), blank=False, null=True, max_length=1000) _("isbn/cubn/imdb"), blank=False, null=True, max_length=50
)
primary_lookup_id_value = models.CharField(
_("1234/tt789"), 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) cover = models.ImageField(
upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True
)
created_time = models.DateTimeField(auto_now_add=True) created_time = models.DateTimeField(auto_now_add=True)
edited_time = models.DateTimeField(auto_now=True) edited_time = models.DateTimeField(auto_now=True)
is_deleted = models.BooleanField(default=False, db_index=True) is_deleted = models.BooleanField(default=False, db_index=True)
history = HistoricalRecords() history = HistoricalRecords()
merged_to_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, default=None, related_name="merged_from_items") merged_to_item = models.ForeignKey(
last_editor = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', null=True, blank=False) "Item",
null=True,
on_delete=models.SET_NULL,
default=None,
related_name="merged_from_items",
)
last_editor = models.ForeignKey(
User, on_delete=models.SET_NULL, related_name="+", null=True, blank=False
)
class Meta: class Meta:
unique_together = [['polymorphic_ctype_id', 'primary_lookup_id_type', 'primary_lookup_id_value']] unique_together = [
[
"polymorphic_ctype_id",
"primary_lookup_id_type",
"primary_lookup_id_value",
]
]
def clear(self): def clear(self):
self.primary_lookup_id_value = None self.primary_lookup_id_value = None
self.primary_lookup_id_type = None self.primary_lookup_id_type = None
def __str__(self): 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})" 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 @classmethod
def get_best_lookup_id(cls, lookup_ids): def get_best_lookup_id(cls, lookup_ids):
""" get best available lookup id, ideally commonly used """ """get best available lookup id, ideally commonly used"""
best_id_types = [ best_id_types = [
IdType.ISBN, IdType.CUBN, IdType.ASIN, IdType.ISBN,
IdType.GTIN, IdType.ISRC, IdType.MusicBrainz, IdType.CUBN,
IdType.ASIN,
IdType.GTIN,
IdType.ISRC,
IdType.MusicBrainz,
IdType.Feed, IdType.Feed,
IdType.IMDB, IdType.TMDB_TVSeason IdType.IMDB,
IdType.TMDB_TVSeason,
] ]
for t in best_id_types: for t in best_id_types:
if lookup_ids.get(t): if lookup_ids.get(t):
@ -215,11 +243,11 @@ class Item(SoftDeleteMixin, PolymorphicModel):
def merge(self, to_item): def merge(self, to_item):
if to_item is None: if to_item is None:
raise(ValueError('cannot merge to an empty item')) raise (ValueError("cannot merge to an empty item"))
elif to_item.merged_to_item is not None: elif to_item.merged_to_item is not None:
raise(ValueError('cannot merge with an item aleady merged')) raise (ValueError("cannot merge with an item aleady merged"))
elif to_item.__class__ != self.__class__: elif to_item.__class__ != self.__class__:
raise(ValueError('cannot merge with an item in different class')) raise (ValueError("cannot merge with an item in different class"))
else: else:
self.merged_to_item = to_item self.merged_to_item = to_item
@ -229,15 +257,15 @@ class Item(SoftDeleteMixin, PolymorphicModel):
@property @property
def url(self): def url(self):
return f'/{self.url_path}/{self.uuid}' if self.url_path else None return f"/{self.url_path}/{self.uuid}" if self.url_path else None
@property @property
def absolute_url(self): def absolute_url(self):
return (settings.APP_WEBSITE + self.url) if self.url_path else None return f"{settings.APP_WEBSITE}{self.url}" if self.url_path else None
@property @property
def api_url(self): def api_url(self):
return ('/api/' + self.url) if self.url_path else None return f"/api/{self.url}" if self.url_path else None
@property @property
def class_name(self): def class_name(self):
@ -245,7 +273,7 @@ class Item(SoftDeleteMixin, PolymorphicModel):
@classmethod @classmethod
def get_by_url(cls, url_or_b62): def get_by_url(cls, url_or_b62):
b62 = url_or_b62.strip().split('/')[-1] b62 = url_or_b62.strip().split("/")[-1]
return cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) return cls.objects.get(uid=uuid.UUID(int=base62.decode(b62)))
# def get_lookup_id(self, id_type: str) -> str: # def get_lookup_id(self, id_type: str) -> str:
@ -258,11 +286,18 @@ class Item(SoftDeleteMixin, PolymorphicModel):
# ll = list(filter(lambda a, b: b, ll)) # ll = list(filter(lambda a, b: b, ll))
pass pass
METADATA_COPY_LIST = ['title', 'brief'] # list of metadata keys to copy from resource to item METADATA_COPY_LIST = [
"title",
"brief",
] # list of metadata keys to copy from resource to item
@classmethod @classmethod
def copy_metadata(cls, metadata): def copy_metadata(cls, metadata):
return dict((k, v) for k, v in metadata.items() if k in cls.METADATA_COPY_LIST and v is not None) return dict(
(k, v)
for k, v in metadata.items()
if k in cls.METADATA_COPY_LIST and v is not None
)
def has_cover(self): def has_cover(self):
return self.cover and self.cover != DEFAULT_ITEM_COVER return self.cover and self.cover != DEFAULT_ITEM_COVER
@ -286,21 +321,38 @@ class Item(SoftDeleteMixin, PolymorphicModel):
class ItemLookupId(models.Model): class ItemLookupId(models.Model):
item = models.ForeignKey(Item, null=True, on_delete=models.SET_NULL, related_name='lookup_ids') item = models.ForeignKey(
id_type = models.CharField(_("源网站"), blank=True, choices=IdType.choices, max_length=50) Item, null=True, on_delete=models.SET_NULL, related_name="lookup_ids"
)
id_type = models.CharField(
_("源网站"), blank=True, choices=IdType.choices, max_length=50
)
id_value = models.CharField(_("源网站ID"), blank=True, max_length=1000) id_value = models.CharField(_("源网站ID"), blank=True, max_length=1000)
raw_url = models.CharField(_("源网站ID"), blank=True, max_length=1000, unique=True) raw_url = models.CharField(_("源网站ID"), blank=True, max_length=1000, unique=True)
class Meta: class Meta:
unique_together = [['id_type', 'id_value']] unique_together = [["id_type", "id_value"]]
class ExternalResource(models.Model): class ExternalResource(models.Model):
item = models.ForeignKey(Item, null=True, on_delete=models.SET_NULL, related_name='external_resources') item = models.ForeignKey(
id_type = models.CharField(_("IdType of the source site"), blank=False, choices=IdType.choices, max_length=50) Item, null=True, on_delete=models.SET_NULL, related_name="external_resources"
id_value = models.CharField(_("Primary Id on the source site"), blank=False, max_length=1000) )
url = models.CharField(_("url to the resource"), blank=False, max_length=1000, unique=True) id_type = models.CharField(
cover = models.ImageField(upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True) _("IdType of the source site"),
blank=False,
choices=IdType.choices,
max_length=50,
)
id_value = models.CharField(
_("Primary Id on the source site"), blank=False, max_length=1000
)
url = models.CharField(
_("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
)
other_lookup_ids = models.JSONField(default=dict) other_lookup_ids = models.JSONField(default=dict)
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
scraped_time = models.DateTimeField(null=True) scraped_time = models.DateTimeField(null=True)
@ -310,10 +362,10 @@ class ExternalResource(models.Model):
related_resources = jsondata.ArrayField(null=False, blank=False, default=list) related_resources = jsondata.ArrayField(null=False, blank=False, default=list)
class Meta: class Meta:
unique_together = [['id_type', 'id_value']] unique_together = [["id_type", "id_value"]]
def __str__(self): def __str__(self):
return f"{self.id}{':' + self.id_type + ':' + self.id_value if self.id_value else ''} ({self.url})" return f"{self.id}:{self.id_type}:{self.id_value if self.id_value else ''} ({self.url})"
@property @property
def site_name(self): def site_name(self):
@ -323,9 +375,12 @@ class ExternalResource(models.Model):
self.other_lookup_ids = resource_content.lookup_ids self.other_lookup_ids = resource_content.lookup_ids
self.metadata = resource_content.metadata self.metadata = resource_content.metadata
if resource_content.cover_image and resource_content.cover_image_extention: if resource_content.cover_image and resource_content.cover_image_extention:
self.cover = SimpleUploadedFile('temp.' + resource_content.cover_image_extention, resource_content.cover_image) self.cover = SimpleUploadedFile(
"temp." + resource_content.cover_image_extention,
resource_content.cover_image,
)
else: else:
self.cover = resource_content.metadata.get('cover_image_path') self.cover = resource_content.metadata.get("cover_image_path")
self.scraped_time = timezone.now() self.scraped_time = timezone.now()
self.save() self.save()
@ -340,11 +395,13 @@ class ExternalResource(models.Model):
return d return d
def get_preferred_model(self): def get_preferred_model(self):
model = self.metadata.get('preferred_model') model = self.metadata.get("preferred_model")
if model: if model:
m = ContentType.objects.filter(app_label='catalog', model=model.lower()).first() m = ContentType.objects.filter(
app_label="catalog", model=model.lower()
).first()
if m: if m:
return m.model_class() return m.model_class()
else: else:
raise ValueError(f'preferred model {model} does not exist') raise ValueError(f"preferred model {model} does not exist")
return None return None

View file

@ -244,7 +244,7 @@
{% endif %} {% endif %}
<span class="review-panel__actions"> <span class="review-panel__actions">
<a href="{% url 'journal:review_edit' item.uuid review.uuid %}">{% trans '编辑' %}</a> <a href="{% url 'journal:review_edit' item.uuid review.uuid %}">{% trans '编辑' %}</a>
<a href="{% url 'journal:review_delete' review.uuid %}">{% trans '删除' %}</a> <a href="{% url 'journal:review_delete' review.uuid %}?return_url={{ item.url }}">{% trans '删除' %}</a>
</span> </span>
<div class="review-panel__time">{{ review.edited_time }}</div> <div class="review-panel__time">{{ review.edited_time }}</div>

View file

@ -3,16 +3,17 @@ from easy_thumbnails.templatetags.thumbnail import thumbnail_url
register = template.Library() register = template.Library()
@register.filter @register.filter
def thumb(source, alias): def thumb(source, alias):
""" """
This filter modifies that from `easy_thumbnails` so that This filter modifies that from `easy_thumbnails` so that
it can neglect .svg file. it can neglect .svg file.
""" """
if source.url.endswith('.svg'): if source.url.endswith(".svg"):
return source.url return source.url
else: else:
try: try:
return thumbnail_url(source, alias) return thumbnail_url(source, alias)
except Exception as e: except Exception:
return '' return ""

View file

@ -4,7 +4,6 @@ from users.models import User
from catalog.common.models import Item, ItemCategory from catalog.common.models import Item, ItemCategory
from .mixins import UserOwnedObjectMixin from .mixins import UserOwnedObjectMixin
from catalog.collection.models import Collection as CatalogCollection from catalog.collection.models import Collection as CatalogCollection
from decimal import *
from enum import Enum from enum import Enum
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from django.utils import timezone from django.utils import timezone
@ -24,12 +23,13 @@ from catalog.models import *
import mistune import mistune
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from markdown import markdown from markdown import markdown
from catalog.common import jsondata
class VisibilityType(models.IntegerChoices): class VisibilityType(models.IntegerChoices):
Public = 0, _('公开') Public = 0, _("公开")
Follower_Only = 1, _('仅关注者') Follower_Only = 1, _("仅关注者")
Private = 2, _('仅自己') Private = 2, _("仅自己")
def q_visible_to(viewer, owner): def q_visible_to(viewer, owner):
@ -44,7 +44,11 @@ def q_visible_to(viewer, owner):
def query_visible(user): def query_visible(user):
return Q(visibility=0) | Q(owner_id__in=user.following, visibility=1) | Q(owner_id=user.id) return (
Q(visibility=0)
| Q(owner_id__in=user.following, visibility=1)
| Q(owner_id=user.id)
)
def query_following(user): def query_following(user):
@ -63,14 +67,26 @@ def query_item_category(item_category):
class Piece(PolymorphicModel, UserOwnedObjectMixin): class Piece(PolymorphicModel, UserOwnedObjectMixin):
url_path = 'piece' # subclass must specify this url_path = "piece" # subclass must specify this
uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
owner = models.ForeignKey(User, on_delete=models.PROTECT) owner = models.ForeignKey(User, on_delete=models.PROTECT)
visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only visibility = models.PositiveSmallIntegerField(
created_time = models.DateTimeField(default=timezone.now) # auto_now_add=True FIXME revert this after migration default=0
edited_time = models.DateTimeField(default=timezone.now) # auto_now=True FIXME revert this after migration ) # 0: Public / 1: Follower only / 2: Self only
created_time = models.DateTimeField(
default=timezone.now
) # auto_now_add=True FIXME revert this after migration
edited_time = models.DateTimeField(
default=timezone.now
) # auto_now=True FIXME revert this after migration
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
attached_to = models.ForeignKey(User, null=True, default=None, on_delete=models.SET_NULL, related_name="attached_with") attached_to = models.ForeignKey(
User,
null=True,
default=None,
on_delete=models.SET_NULL,
related_name="attached_with",
)
@property @property
def uuid(self): def uuid(self):
@ -78,7 +94,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
@property @property
def url(self): def url(self):
return f'/{self.url_path}/{self.uuid}' if self.url_path else None return f"/{self.url_path}/{self.uuid}" if self.url_path else None
@property @property
def absolute_url(self): def absolute_url(self):
@ -86,7 +102,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
@property @property
def api_url(self): def api_url(self):
return ('/api/' + self.url) if self.url_path else None return ("/api/" + self.url) if self.url_path else None
class Content(Piece): class Content(Piece):
@ -106,7 +122,7 @@ class Content(Piece):
class Like(Piece): class Like(Piece):
target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name='likes') target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name="likes")
@staticmethod @staticmethod
def user_like_piece(user, piece): def user_like_piece(user, piece):
@ -117,8 +133,14 @@ class Like(Piece):
like = Like.objects.create(owner=user, target=piece) like = Like.objects.create(owner=user, target=piece)
return like return like
@staticmethod
def user_unlike_piece(user, piece):
if not piece:
return
Like.objects.filter(owner=user, target=piece).delete()
class Note(Content):
class Memo(Content):
pass pass
@ -133,7 +155,9 @@ class Comment(Content):
comment.delete() comment.delete()
comment = None comment = None
elif comment is None: elif comment is None:
comment = Comment.objects.create(owner=user, item=item, text=text, visibility=visibility) comment = Comment.objects.create(
owner=user, item=item, text=text, visibility=visibility
)
elif comment.text != text or comment.visibility != visibility: elif comment.text != text or comment.visibility != visibility:
comment.text = text comment.text = text
comment.visibility = visibility comment.visibility = visibility
@ -142,7 +166,7 @@ class Comment(Content):
class Review(Content): class Review(Content):
url_path = 'review' url_path = "review"
title = models.CharField(max_length=500, blank=False, null=False) title = models.CharField(max_length=500, blank=False, null=False)
body = MarkdownxField() body = MarkdownxField()
@ -154,10 +178,17 @@ class Review(Content):
def rating_grade(self): def rating_grade(self):
return Rating.get_item_rating_by_user(self.item, self.owner) return Rating.get_item_rating_by_user(self.item, self.owner)
@ staticmethod @staticmethod
def review_item_by_user(item, user, title, body, metadata={}, visibility=0): def review_item_by_user(item, user, title, body, metadata={}, visibility=0):
# allow multiple reviews per item per user. # allow multiple reviews per item per user.
review = Review.objects.create(owner=user, item=item, title=title, body=body, metadata=metadata, visibility=visibility) review = Review.objects.create(
owner=user,
item=item,
title=title,
body=body,
metadata=metadata,
visibility=visibility,
)
""" """
review = Review.objects.filter(owner=user, item=item).first() review = Review.objects.filter(owner=user, item=item).first()
if title is None: if title is None:
@ -176,36 +207,44 @@ class Review(Content):
class Rating(Content): class Rating(Content):
grade = models.PositiveSmallIntegerField(default=0, validators=[MaxValueValidator(10), MinValueValidator(1)], null=True) grade = models.PositiveSmallIntegerField(
default=0, validators=[MaxValueValidator(10), MinValueValidator(1)], null=True
)
@ staticmethod @staticmethod
def get_rating_for_item(item): def get_rating_for_item(item):
stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(average=Avg('grade'), count=Count('item')) stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(
return stat['average'] if stat['count'] >= 5 else None average=Avg("grade"), count=Count("item")
)
return stat["average"] if stat["count"] >= 5 else None
@ staticmethod @staticmethod
def get_rating_count_for_item(item): def get_rating_count_for_item(item):
stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(count=Count('item')) stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(
return stat['count'] count=Count("item")
)
return stat["count"]
@ staticmethod @staticmethod
def rate_item_by_user(item, user, rating_grade, visibility=0): def rate_item_by_user(item, user, rating_grade, visibility=0):
if rating_grade and (rating_grade < 1 or rating_grade > 10): if rating_grade and (rating_grade < 1 or rating_grade > 10):
raise ValueError(f'Invalid rating grade: {rating_grade}') raise ValueError(f"Invalid rating grade: {rating_grade}")
rating = Rating.objects.filter(owner=user, item=item).first() rating = Rating.objects.filter(owner=user, item=item).first()
if not rating_grade: if not rating_grade:
if rating: if rating:
rating.delete() rating.delete()
rating = None rating = None
elif rating is None: elif rating is None:
rating = Rating.objects.create(owner=user, item=item, grade=rating_grade, visibility=visibility) rating = Rating.objects.create(
owner=user, item=item, grade=rating_grade, visibility=visibility
)
elif rating.grade != rating_grade or rating.visibility != visibility: elif rating.grade != rating_grade or rating.visibility != visibility:
rating.visibility = visibility rating.visibility = visibility
rating.grade = rating_grade rating.grade = rating_grade
rating.save() rating.save()
return rating return rating
@ staticmethod @staticmethod
def get_item_rating_by_user(item, user): def get_item_rating_by_user(item, user):
rating = Rating.objects.filter(owner=user, item=item).first() rating = Rating.objects.filter(owner=user, item=item).first()
return rating.grade if rating else None return rating.grade if rating else None
@ -216,7 +255,9 @@ Item.rating_count = property(Rating.get_rating_count_for_item)
class Reply(Piece): class Reply(Piece):
reply_to_content = models.ForeignKey(Piece, on_delete=models.SET_NULL, related_name='replies', null=True) reply_to_content = models.ForeignKey(
Piece, on_delete=models.SET_NULL, related_name="replies", null=True
)
title = models.CharField(max_length=500, null=True) title = models.CharField(max_length=500, null=True)
body = MarkdownxField() body = MarkdownxField()
pass pass
@ -234,7 +275,9 @@ class List(Piece):
class Meta: class Meta:
abstract = True abstract = True
_owner = models.ForeignKey(User, on_delete=models.PROTECT) # duplicated owner field to make unique key possible for subclasses _owner = models.ForeignKey(
User, on_delete=models.PROTECT
) # duplicated owner field to make unique key possible for subclasses
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self._owner = self.owner self._owner = self.owner
@ -246,43 +289,56 @@ class List(Piece):
@property @property
def ordered_members(self): def ordered_members(self):
return self.members.all().order_by('position', 'item_id') return self.members.all().order_by("position")
@property @property
def ordered_items(self): def ordered_items(self):
return self.items.all().order_by(self.MEMBER_CLASS.__name__.lower() + '__position') return self.items.all().order_by(
self.MEMBER_CLASS.__name__.lower() + "__position"
)
@property @property
def recent_items(self): def recent_items(self):
return self.items.all().order_by('-' + self.MEMBER_CLASS.__name__.lower() + '__created_time') return self.items.all().order_by(
"-" + self.MEMBER_CLASS.__name__.lower() + "__created_time"
)
@property @property
def recent_members(self): def recent_members(self):
return self.members.all().order_by('-created_time') return self.members.all().order_by("-created_time")
def has_item(self, item): def get_member_for_item(self, item):
return self.members.filter(item=item).count() > 0 return self.members.filter(item=item).first()
def append_item(self, item, **params): def append_item(self, item, **params):
if item is None or self.has_item(item): if item is None or self.get_member_for_item(item):
return None return None
else: else:
ml = self.ordered_members ml = self.ordered_members
p = {'parent': self} p = {"parent": self}
p.update(params) p.update(params)
member = self.MEMBER_CLASS.objects.create(owner=self.owner, position=ml.last().position + 1 if ml.count() else 1, item=item, **p) member = self.MEMBER_CLASS.objects.create(
list_add.send(sender=self.__class__, instance=self, item=item, member=member) owner=self.owner,
position=ml.last().position + 1 if ml.count() else 1,
item=item,
**p,
)
list_add.send(
sender=self.__class__, instance=self, item=item, member=member
)
return member return member
def remove_item(self, item): def remove_item(self, item):
member = self.members.all().filter(item=item).first() member = self.get_member_for_item(item)
if member: if member:
list_remove.send(sender=self.__class__, instance=self, item=item, member=member) list_remove.send(
sender=self.__class__, instance=self, item=item, member=member
)
member.delete() member.delete()
def move_up_item(self, item): def move_up_item(self, item):
members = self.ordered_members members = self.ordered_members
member = members.filter(item=item).first() member = self.get_member_for_item(item)
if member: if member:
other = members.filter(position__lt=member.position).last() other = members.filter(position__lt=member.position).last()
if other: if other:
@ -294,7 +350,7 @@ class List(Piece):
def move_down_item(self, item): def move_down_item(self, item):
members = self.ordered_members members = self.ordered_members
member = members.filter(item=item).first() member = self.get_member_for_item(item)
if member: if member:
other = members.filter(position__gt=member.position).first() other = members.filter(position__gt=member.position).first()
if other: if other:
@ -304,6 +360,12 @@ class List(Piece):
other.save() other.save()
member.save() member.save()
def update_item_metadata(self, item, metadata):
member = self.get_member_for_item(item)
if member:
member.metadata = metadata
member.save()
class ListMember(Piece): class ListMember(Piece):
""" """
@ -312,6 +374,7 @@ class ListMember(Piece):
parent = models.ForeignKey('List', related_name='members', on_delete=models.CASCADE) parent = models.ForeignKey('List', related_name='members', on_delete=models.CASCADE)
""" """
item = models.ForeignKey(Item, on_delete=models.PROTECT) item = models.ForeignKey(Item, on_delete=models.PROTECT)
position = models.PositiveIntegerField() position = models.PositiveIntegerField()
@ -325,7 +388,7 @@ class ListMember(Piece):
abstract = True abstract = True
def __str__(self): def __str__(self):
return f'{self.id}:{self.position} ({self.item})' return f"{self.id}:{self.position} ({self.item})"
""" """
@ -334,48 +397,52 @@ Shelf
class ShelfType(models.TextChoices): class ShelfType(models.TextChoices):
WISHLIST = ('wishlist', '未开始') WISHLIST = ("wishlist", "未开始")
PROGRESS = ('progress', '进行中') PROGRESS = ("progress", "进行中")
COMPLETE = ('complete', '完成') COMPLETE = ("complete", "完成")
# DISCARDED = ('discarded', '放弃') # DISCARDED = ('discarded', '放弃')
ShelfTypeNames = [ ShelfTypeNames = [
[ItemCategory.Book, ShelfType.WISHLIST, _('想读')], [ItemCategory.Book, ShelfType.WISHLIST, _("想读")],
[ItemCategory.Book, ShelfType.PROGRESS, _('在读')], [ItemCategory.Book, ShelfType.PROGRESS, _("在读")],
[ItemCategory.Book, ShelfType.COMPLETE, _('读过')], [ItemCategory.Book, ShelfType.COMPLETE, _("读过")],
[ItemCategory.Movie, ShelfType.WISHLIST, _('想看')], [ItemCategory.Movie, ShelfType.WISHLIST, _("想看")],
[ItemCategory.Movie, ShelfType.PROGRESS, _('在看')], [ItemCategory.Movie, ShelfType.PROGRESS, _("在看")],
[ItemCategory.Movie, ShelfType.COMPLETE, _('看过')], [ItemCategory.Movie, ShelfType.COMPLETE, _("看过")],
[ItemCategory.TV, ShelfType.WISHLIST, _('想看')], [ItemCategory.TV, ShelfType.WISHLIST, _("想看")],
[ItemCategory.TV, ShelfType.PROGRESS, _('在看')], [ItemCategory.TV, ShelfType.PROGRESS, _("在看")],
[ItemCategory.TV, ShelfType.COMPLETE, _('看过')], [ItemCategory.TV, ShelfType.COMPLETE, _("看过")],
[ItemCategory.Music, ShelfType.WISHLIST, _('想听')], [ItemCategory.Music, ShelfType.WISHLIST, _("想听")],
[ItemCategory.Music, ShelfType.PROGRESS, _('在听')], [ItemCategory.Music, ShelfType.PROGRESS, _("在听")],
[ItemCategory.Music, ShelfType.COMPLETE, _('听过')], [ItemCategory.Music, ShelfType.COMPLETE, _("听过")],
[ItemCategory.Game, ShelfType.WISHLIST, _('想玩')], [ItemCategory.Game, ShelfType.WISHLIST, _("想玩")],
[ItemCategory.Game, ShelfType.PROGRESS, _('在玩')], [ItemCategory.Game, ShelfType.PROGRESS, _("在玩")],
[ItemCategory.Game, ShelfType.COMPLETE, _('玩过')], [ItemCategory.Game, ShelfType.COMPLETE, _("玩过")],
] ]
class ShelfMember(ListMember): class ShelfMember(ListMember):
parent = models.ForeignKey('Shelf', related_name='members', on_delete=models.CASCADE) parent = models.ForeignKey(
"Shelf", related_name="members", on_delete=models.CASCADE
)
class Shelf(List): class Shelf(List):
class Meta: class Meta:
unique_together = [['_owner', 'item_category', 'shelf_type']] unique_together = [["_owner", "item_category", "shelf_type"]]
MEMBER_CLASS = ShelfMember MEMBER_CLASS = ShelfMember
items = models.ManyToManyField(Item, through='ShelfMember', related_name="+") items = models.ManyToManyField(Item, through="ShelfMember", related_name="+")
item_category = models.CharField(choices=ItemCategory.choices, max_length=100, null=False, blank=False) item_category = models.CharField(
shelf_type = models.CharField(choices=ShelfType.choices, max_length=100, null=False, blank=False) choices=ItemCategory.choices, max_length=100, null=False, blank=False
)
shelf_type = models.CharField(
choices=ShelfType.choices, max_length=100, null=False, blank=False
)
def __str__(self): def __str__(self):
return f'{self.id} {self.title}' return f"{self.id} {self.title}"
@cached_property @cached_property
def item_category_label(self): def item_category_label(self):
@ -383,26 +450,41 @@ class Shelf(List):
@cached_property @cached_property
def shelf_label(self): def shelf_label(self):
return next(iter([n[2] for n in iter(ShelfTypeNames) if n[0] == self.item_category and n[1] == self.shelf_type]), self.shelf_type) return next(
iter(
[
n[2]
for n in iter(ShelfTypeNames)
if n[0] == self.item_category and n[1] == self.shelf_type
]
),
self.shelf_type,
)
@cached_property @cached_property
def title(self): def title(self):
q = _("{shelf_label}{item_category}").format(shelf_label=self.shelf_label, item_category=self.item_category_label) q = _("{shelf_label}{item_category}").format(
shelf_label=self.shelf_label, item_category=self.item_category_label
)
return q return q
# return _("{user}'s {shelf_name}").format(user=self.owner.mastodon_username, shelf_name=q) # return _("{user}'s {shelf_name}").format(user=self.owner.mastodon_username, shelf_name=q)
class ShelfLogEntry(models.Model): class ShelfLogEntry(models.Model):
owner = models.ForeignKey(User, on_delete=models.PROTECT) owner = models.ForeignKey(User, on_delete=models.PROTECT)
shelf = models.ForeignKey(Shelf, on_delete=models.CASCADE, related_name='entries', null=True) # None means removed from any shelf shelf = models.ForeignKey(
Shelf, on_delete=models.CASCADE, related_name="entries", null=True
) # None means removed from any shelf
item = models.ForeignKey(Item, on_delete=models.PROTECT) item = models.ForeignKey(Item, on_delete=models.PROTECT)
timestamp = models.DateTimeField(default=timezone.now) # this may later be changed by user timestamp = models.DateTimeField(
default=timezone.now
) # this may later be changed by user
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
created_time = models.DateTimeField(auto_now_add=True) created_time = models.DateTimeField(auto_now_add=True)
edited_time = models.DateTimeField(auto_now=True) edited_time = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f'{self.owner}:{self.shelf}:{self.item}:{self.metadata}' return f"{self.owner}:{self.shelf}:{self.item}:{self.metadata}"
class ShelfManager: class ShelfManager:
@ -422,12 +504,16 @@ class ShelfManager:
Shelf.objects.create(owner=self.owner, item_category=ic, shelf_type=qt) Shelf.objects.create(owner=self.owner, item_category=ic, shelf_type=qt)
def _shelf_member_for_item(self, item): def _shelf_member_for_item(self, item):
return ShelfMember.objects.filter(item=item, parent__in=self.owner.shelf_set.all()).first() return ShelfMember.objects.filter(
item=item, parent__in=self.owner.shelf_set.all()
).first()
def _shelf_for_item_and_type(item, shelf_type): def _shelf_for_item_and_type(item, shelf_type):
if not item or not shelf_type: if not item or not shelf_type:
return None return None
return self.owner.shelf_set.all().filter(item_category=item.category, shelf_type=shelf_type) return self.owner.shelf_set.all().filter(
item_category=item.category, shelf_type=shelf_type
)
def locate_item(self, item): def locate_item(self, item):
member = ShelfMember.objects.filter(owner=self.owner, item=item).first() member = ShelfMember.objects.filter(owner=self.owner, item=item).first()
@ -437,7 +523,7 @@ class ShelfManager:
# shelf_type=None means remove from current shelf # shelf_type=None means remove from current shelf
# metadata=None means no change # metadata=None means no change
if not item: if not item:
raise ValueError('empty item') raise ValueError("empty item")
new_shelfmember = None new_shelfmember = None
last_shelfmember = self._shelf_member_for_item(item) last_shelfmember = self._shelf_member_for_item(item)
last_shelf = last_shelfmember.parent if last_shelfmember else None last_shelf = last_shelfmember.parent if last_shelfmember else None
@ -450,9 +536,11 @@ class ShelfManager:
if last_shelf: if last_shelf:
last_shelf.remove_item(item) last_shelf.remove_item(item)
if shelf: if shelf:
new_shelfmember = shelf.append_item(item, visibility=visibility, metadata=metadata or {}) new_shelfmember = shelf.append_item(
item, visibility=visibility, metadata=metadata or {}
)
elif last_shelf is None: elif last_shelf is None:
raise ValueError('empty shelf') raise ValueError("empty shelf")
else: else:
new_shelfmember = last_shelfmember new_shelfmember = last_shelfmember
if metadata is not None and metadata != last_metadata: # change metadata if metadata is not None and metadata != last_metadata: # change metadata
@ -466,29 +554,41 @@ class ShelfManager:
if changed: if changed:
if metadata is None: if metadata is None:
metadata = last_metadata or {} metadata = last_metadata or {}
ShelfLogEntry.objects.create(owner=self.owner, shelf=shelf, item=item, metadata=metadata) ShelfLogEntry.objects.create(
owner=self.owner, shelf=shelf, item=item, metadata=metadata
)
return new_shelfmember return new_shelfmember
def get_log(self): def get_log(self):
return ShelfLogEntry.objects.filter(owner=self.owner).order_by('timestamp') return ShelfLogEntry.objects.filter(owner=self.owner).order_by("timestamp")
def get_log_for_item(self, item): def get_log_for_item(self, item):
return ShelfLogEntry.objects.filter(owner=self.owner, item=item).order_by('timestamp') return ShelfLogEntry.objects.filter(owner=self.owner, item=item).order_by(
"timestamp"
)
def get_shelf(self, item_category, shelf_type): def get_shelf(self, item_category, shelf_type):
return self.owner.shelf_set.all().filter(item_category=item_category, shelf_type=shelf_type).first() return (
self.owner.shelf_set.all()
.filter(item_category=item_category, shelf_type=shelf_type)
.first()
)
def get_items_on_shelf(self, item_category, shelf_type): def get_items_on_shelf(self, item_category, shelf_type):
shelf = self.owner.shelf_set.all().filter(item_category=item_category, shelf_type=shelf_type).first() shelf = (
self.owner.shelf_set.all()
.filter(item_category=item_category, shelf_type=shelf_type)
.first()
)
return shelf.members.all().order_by return shelf.members.all().order_by
@ staticmethod @staticmethod
def get_manager_for_user(user): def get_manager_for_user(user):
return ShelfManager(user) return ShelfManager(user)
User.shelf_manager = cached_property(ShelfManager.get_manager_for_user) User.shelf_manager = cached_property(ShelfManager.get_manager_for_user)
User.shelf_manager.__set_name__(User, 'shelf_manager') User.shelf_manager.__set_name__(User, "shelf_manager")
""" """
@ -497,22 +597,30 @@ Collection
class CollectionMember(ListMember): class CollectionMember(ListMember):
parent = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE) parent = models.ForeignKey(
"Collection", related_name="members", on_delete=models.CASCADE
)
@property note = jsondata.CharField(_("备注"), null=True, blank=True)
def note(self):
return self.metadata.get('comment')
class Collection(List): class Collection(List):
url_path = 'collection' url_path = "collection"
MEMBER_CLASS = CollectionMember MEMBER_CLASS = CollectionMember
catalog_item = models.OneToOneField(CatalogCollection, on_delete=models.PROTECT) catalog_item = models.OneToOneField(CatalogCollection, on_delete=models.PROTECT)
title = models.CharField(_("title in primary language"), max_length=1000, default="") title = models.CharField(
_("title in primary language"), max_length=1000, default=""
)
brief = models.TextField(_("简介"), blank=True, default="") brief = models.TextField(_("简介"), blank=True, default="")
cover = models.ImageField(upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True) cover = models.ImageField(
items = models.ManyToManyField(Item, through='CollectionMember', related_name="collections") upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True
collaborative = models.PositiveSmallIntegerField(default=0) # 0: Editable by owner only / 1: Editable by bi-direction followers )
items = models.ManyToManyField(
Item, through="CollectionMember", related_name="collections"
)
collaborative = models.PositiveSmallIntegerField(
default=0
) # 0: Editable by owner only / 1: Editable by bi-direction followers
@property @property
def html(self): def html(self):
@ -522,12 +630,15 @@ class Collection(List):
@property @property
def plain_description(self): def plain_description(self):
html = markdown(self.brief) html = markdown(self.brief)
return RE_HTML_TAG.sub(' ', html) return RE_HTML_TAG.sub(" ", html)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if getattr(self, 'catalog_item', None) is None: if getattr(self, "catalog_item", None) is None:
self.catalog_item = CatalogCollection() self.catalog_item = CatalogCollection()
if self.catalog_item.title != self.title or self.catalog_item.brief != self.brief: if (
self.catalog_item.title != self.title
or self.catalog_item.brief != self.brief
):
self.catalog_item.title = self.title self.catalog_item.title = self.title
self.catalog_item.brief = self.brief self.catalog_item.brief = self.brief
self.catalog_item.cover = self.cover self.catalog_item.cover = self.cover
@ -541,72 +652,93 @@ Tag
class TagMember(ListMember): class TagMember(ListMember):
parent = models.ForeignKey('Tag', related_name='members', on_delete=models.CASCADE) parent = models.ForeignKey("Tag", related_name="members", on_delete=models.CASCADE)
TagValidators = [RegexValidator(regex=r'\s+', inverse_match=True)] TagValidators = [RegexValidator(regex=r"\s+", inverse_match=True)]
class Tag(List): class Tag(List):
MEMBER_CLASS = TagMember MEMBER_CLASS = TagMember
items = models.ManyToManyField(Item, through='TagMember') items = models.ManyToManyField(Item, through="TagMember")
title = models.CharField(max_length=100, null=False, blank=False, validators=TagValidators) title = models.CharField(
max_length=100, null=False, blank=False, validators=TagValidators
)
# TODO case convert and space removal on save # TODO case convert and space removal on save
# TODO check on save # TODO check on save
class Meta: class Meta:
unique_together = [['_owner', 'title']] unique_together = [["_owner", "title"]]
@ staticmethod @staticmethod
def cleanup_title(title): def cleanup_title(title):
return title.strip().lower() return title.strip().lower()
class TagManager: class TagManager:
@ staticmethod @staticmethod
def public_tags_for_item(item): def public_tags_for_item(item):
tags = item.tag_set.all().filter(visibility=0).values('title').annotate(frequency=Count('owner')).order_by('-frequency')[: 20] tags = (
return sorted(list(map(lambda t: t['title'], tags))) item.tag_set.all()
.filter(visibility=0)
.values("title")
.annotate(frequency=Count("owner"))
.order_by("-frequency")[:20]
)
return sorted(list(map(lambda t: t["title"], tags)))
@ staticmethod @staticmethod
def all_tags_for_user(user): def all_tags_for_user(user):
tags = user.tag_set.all().values('title').annotate(frequency=Count('members__id')).order_by('-frequency') tags = (
return list(map(lambda t: t['title'], tags)) user.tag_set.all()
.values("title")
.annotate(frequency=Count("members__id"))
.order_by("-frequency")
)
return list(map(lambda t: t["title"], tags))
@ staticmethod @staticmethod
def tag_item_by_user(item, user, tag_titles, default_visibility=0): def tag_item_by_user(item, user, tag_titles, default_visibility=0):
titles = set([Tag.cleanup_title(tag_title) for tag_title in tag_titles]) titles = set([Tag.cleanup_title(tag_title) for tag_title in tag_titles])
current_titles = set([m.parent.title for m in TagMember.objects.filter(owner=user, item=item)]) current_titles = set(
[m.parent.title for m in TagMember.objects.filter(owner=user, item=item)]
)
for title in titles - current_titles: for title in titles - current_titles:
tag = Tag.objects.filter(owner=user, title=title).first() tag = Tag.objects.filter(owner=user, title=title).first()
if not tag: if not tag:
tag = Tag.objects.create(owner=user, title=title, visibility=default_visibility) tag = Tag.objects.create(
owner=user, title=title, visibility=default_visibility
)
tag.append_item(item) tag.append_item(item)
for title in current_titles - titles: for title in current_titles - titles:
tag = Tag.objects.filter(owner=user, title=title).first() tag = Tag.objects.filter(owner=user, title=title).first()
tag.remove_item(item) tag.remove_item(item)
@ staticmethod @staticmethod
def get_item_tags_by_user(item, user): def get_item_tags_by_user(item, user):
current_titles = [m.parent.title for m in TagMember.objects.filter(owner=user, item=item)] current_titles = [
m.parent.title for m in TagMember.objects.filter(owner=user, item=item)
]
return current_titles return current_titles
@ staticmethod @staticmethod
def add_tag_by_user(item, tag_title, user, default_visibility=0): def add_tag_by_user(item, tag_title, user, default_visibility=0):
title = Tag.cleanup_title(tag_title) title = Tag.cleanup_title(tag_title)
tag = Tag.objects.filter(owner=user, title=title).first() tag = Tag.objects.filter(owner=user, title=title).first()
if not tag: if not tag:
tag = Tag.objects.create(owner=user, title=title, visibility=default_visibility) tag = Tag.objects.create(
owner=user, title=title, visibility=default_visibility
)
tag.append_item(item) tag.append_item(item)
@ staticmethod @staticmethod
def get_manager_for_user(user): def get_manager_for_user(user):
return TagManager(user) return TagManager(user)
def __init__(self, user): def __init__(self, user):
self.owner = user self.owner = user
@ property @property
def all_tags(self): def all_tags(self):
return TagManager.all_tags_for_user(self.owner) return TagManager.all_tags_for_user(self.owner)
@ -615,83 +747,111 @@ class TagManager:
TagManager.add_tag_by_user(item, tag, self.owner, visibility) TagManager.add_tag_by_user(item, tag, self.owner, visibility)
def get_item_tags(self, item): def get_item_tags(self, item):
return sorted([m['parent__title'] for m in TagMember.objects.filter(parent__owner=self.owner, item=item).values('parent__title')]) return sorted(
[
m["parent__title"]
for m in TagMember.objects.filter(
parent__owner=self.owner, item=item
).values("parent__title")
]
)
Item.tags = property(TagManager.public_tags_for_item) Item.tags = property(TagManager.public_tags_for_item)
User.tags = property(TagManager.all_tags_for_user) User.tags = property(TagManager.all_tags_for_user)
User.tag_manager = cached_property(TagManager.get_manager_for_user) User.tag_manager = cached_property(TagManager.get_manager_for_user)
User.tag_manager.__set_name__(User, 'tag_manager') User.tag_manager.__set_name__(User, "tag_manager")
class Mark: class Mark:
""" this mimics previous mark behaviour """ """this mimics previous mark behaviour"""
def __init__(self, user, item): def __init__(self, user, item):
self.owner = user self.owner = user
self.item = item self.item = item
@ cached_property @cached_property
def shelfmember(self): def shelfmember(self):
return self.owner.shelf_manager.locate_item(self.item) return self.owner.shelf_manager.locate_item(self.item)
@ property @property
def id(self): def id(self):
return self.shelfmember.id if self.shelfmember else None return self.shelfmember.id if self.shelfmember else None
@ property @property
def shelf(self): def shelf(self):
return self.shelfmember.parent if self.shelfmember else None return self.shelfmember.parent if self.shelfmember else None
@ property @property
def shelf_type(self): def shelf_type(self):
return self.shelfmember.parent.shelf_type if self.shelfmember else None return self.shelfmember.parent.shelf_type if self.shelfmember else None
@ property @property
def shelf_label(self): def shelf_label(self):
return self.shelfmember.parent.shelf_label if self.shelfmember else None return self.shelfmember.parent.shelf_label if self.shelfmember else None
@ property @property
def created_time(self): def created_time(self):
return self.shelfmember.created_time if self.shelfmember else None return self.shelfmember.created_time if self.shelfmember else None
@ property @property
def metadata(self): def metadata(self):
return self.shelfmember.metadata if self.shelfmember else None return self.shelfmember.metadata if self.shelfmember else None
@ property @property
def visibility(self): def visibility(self):
return self.shelfmember.visibility if self.shelfmember else None return self.shelfmember.visibility if self.shelfmember else None
@ cached_property @cached_property
def tags(self): def tags(self):
return self.owner.tag_manager.get_item_tags(self.item) return self.owner.tag_manager.get_item_tags(self.item)
@ cached_property @cached_property
def rating(self): def rating(self):
return Rating.get_item_rating_by_user(self.item, self.owner) return Rating.get_item_rating_by_user(self.item, self.owner)
@ cached_property @cached_property
def comment(self): def comment(self):
return Comment.objects.filter(owner=self.owner, item=self.item).first() return Comment.objects.filter(owner=self.owner, item=self.item).first()
@ property @property
def text(self): def text(self):
return self.comment.text if self.comment else None return self.comment.text if self.comment else None
@ cached_property @cached_property
def review(self): def review(self):
return Review.objects.filter(owner=self.owner, item=self.item).first() return Review.objects.filter(owner=self.owner, item=self.item).first()
def update(self, shelf_type, comment_text, rating_grade, visibility, metadata=None, created_time=None, share_to_mastodon=False): def update(
share = share_to_mastodon and shelf_type is not None and (shelf_type != self.shelf_type or comment_text != self.text or rating_grade != self.rating) self,
shelf_type,
comment_text,
rating_grade,
visibility,
metadata=None,
created_time=None,
share_to_mastodon=False,
):
share = (
share_to_mastodon
and shelf_type is not None
and (
shelf_type != self.shelf_type
or comment_text != self.text
or rating_grade != self.rating
)
)
if shelf_type != self.shelf_type or visibility != self.visibility: if shelf_type != self.shelf_type or visibility != self.visibility:
self.shelfmember = self.owner.shelf_manager.move_item(self.item, shelf_type, visibility=visibility, metadata=metadata) self.shelfmember = self.owner.shelf_manager.move_item(
self.item, shelf_type, visibility=visibility, metadata=metadata
)
if self.shelfmember and created_time: if self.shelfmember and created_time:
self.shelfmember.created_time = created_time self.shelfmember.created_time = created_time
self.shelfmember.save() self.shelfmember.save()
if comment_text != self.text or visibility != self.visibility: if comment_text != self.text or visibility != self.visibility:
self.comment = Comment.comment_item_by_user(self.item, self.owner, comment_text, visibility) self.comment = Comment.comment_item_by_user(
self.item, self.owner, comment_text, visibility
)
if rating_grade != self.rating or visibility != self.visibility: if rating_grade != self.rating or visibility != self.visibility:
Rating.rate_item_by_user(self.item, self.owner, rating_grade, visibility) Rating.rate_item_by_user(self.item, self.owner, rating_grade, visibility)
self.rating = rating_grade self.rating = rating_grade
@ -699,15 +859,20 @@ class Mark:
# this is a bit hacky but let's keep it until move to implement ActivityPub, # this is a bit hacky but let's keep it until move to implement ActivityPub,
# by then, we'll just change this to boost # by then, we'll just change this to boost
from mastodon.api import share_mark from mastodon.api import share_mark
self.shared_link = self.shelfmember.metadata.get('shared_link') if self.shelfmember.metadata else None
self.shared_link = (
self.shelfmember.metadata.get("shared_link")
if self.shelfmember.metadata
else None
)
self.translated_status = self.shelf_label self.translated_status = self.shelf_label
self.save = lambda **args: None self.save = lambda **args: None
if not share_mark(self): if not share_mark(self):
raise ValueError("sharing failed") raise ValueError("sharing failed")
if not self.shelfmember.metadata: if not self.shelfmember.metadata:
self.shelfmember.metadata = {} self.shelfmember.metadata = {}
if self.shelfmember.metadata.get('shared_link') != self.shared_link: if self.shelfmember.metadata.get("shared_link") != self.shared_link:
self.shelfmember.metadata['shared_link'] = self.shared_link self.shelfmember.metadata["shared_link"] = self.shared_link
self.shelfmember.save() self.shelfmember.save()
def delete(self): def delete(self):

View file

@ -33,13 +33,17 @@
<div class="dividing-line"></div> <div class="dividing-line"></div>
</div> </div>
<div class="single-section-wrapper"> <div class="single-section-wrapper">
<div class="entity-list" hx-get="{% url 'journal:collection_retrieve_items' collection.uuid %}?edit=1" hx-trigger="load"></div> <div id="collection_items" class="entity-list" hx-get="{% url 'journal:collection_retrieve_items' collection.uuid %}?edit=1" hx-trigger="load"></div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
{% include "partial/_footer.html" %} {% include "partial/_footer.html" %}
</div> </div>
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})
</script>
</body> </body>
</html> </html>

View file

@ -2,7 +2,7 @@
{% load i18n %} {% load i18n %}
{% load l10n %} {% load l10n %}
<ul class="entity-list__entities"> <ul class="entity-list__entities">
{% for member in collection.members.all %} {% for member in collection.ordered_members %}
{% with "list_item_"|add:member.item.class_name|add:".html" as template %} {% with "list_item_"|add:member.item.class_name|add:".html" as template %}
{% include template with item=member.item mark=None collection_member=member %} {% include template with item=member.item mark=None collection_member=member %}
{% endwith %} {% endwith %}

View file

@ -0,0 +1,5 @@
<form hx-post="{% url 'journal:collection_update_item_note' collection.uuid item.uuid %}" hx-target="#collection_items">
<input name="note" value="{{ note }}">
<input type="submit" style="width:unset;" value="修改">
<button style="width:unset;" hx-get="{% url 'journal:collection_retrieve_items' collection.uuid %}?edit=1" hx-target="#collection_items">取消</button>
</form>

View file

@ -19,12 +19,12 @@
{% if collection_edit %} {% if collection_edit %}
<div class="collection-item-position-edit"> <div class="collection-item-position-edit">
{% if not forloop.first %} {% if not forloop.first %}
<a hx-target=".entity-list" hx-post="{% url 'journal:collection_move_up_item' form.instance.uuid collection_member.id %}"></a> <a hx-target=".entity-list" hx-post="{% url 'journal:collection_move_item' form.instance.uuid 'up' item.uuid %}"></a>
{% endif %} {% endif %}
{% if not forloop.last %} {% if not forloop.last %}
<a hx-target=".entity-list" hx-post="{% url 'journal:collection_move_down_item' form.instance.uuid collection_member.id %}"></a> <a hx-target=".entity-list" hx-post="{% url 'journal:collection_move_item' form.instance.uuid 'down' item.uuid %}"></a>
{% endif %} {% endif %}
<a hx-target=".entity-list" hx-post="{% url 'journal:collection_delete_item' form.instance.uuid collection_member.id %}"></a> <a hx-target=".entity-list" hx-post="{% url 'journal:collection_remove_item' form.instance.uuid item.uuid %}"></a>
</div> </div>
{% endif %} {% endif %}
@ -129,7 +129,7 @@
{{ collection_member.note }} {{ collection_member.note }}
{% if collection_edit %} {% if collection_edit %}
<a class="action-icon" hx-get="{% url 'journal:collection_update_item_note' collection.uuid collection_member.uuid %}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19,20H5a1,1,0,0,0,0,2H19a1,1,0,0,0,0-2Z"/><path d="M5,18h.09l4.17-.38a2,2,0,0,0,1.21-.57l9-9a1.92,1.92,0,0,0-.07-2.71h0L16.66,2.6A2,2,0,0,0,14,2.53l-9,9a2,2,0,0,0-.57,1.21L4,16.91a1,1,0,0,0,.29.8A1,1,0,0,0,5,18ZM15.27,4,18,6.73,16,8.68,13.32,6Zm-8.9,8.91L12,7.32l2.7,2.7-5.6,5.6-3,.28Z"/></g></svg></a> <a class="action-icon" hx-get="{% url 'journal:collection_update_item_note' collection.uuid item.uuid %}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19,20H5a1,1,0,0,0,0,2H19a1,1,0,0,0,0-2Z"/><path d="M5,18h.09l4.17-.38a2,2,0,0,0,1.21-.57l9-9a1.92,1.92,0,0,0-.07-2.71h0L16.66,2.6A2,2,0,0,0,14,2.53l-9,9a2,2,0,0,0-.57,1.21L4,16.91a1,1,0,0,0,.29.8A1,1,0,0,0,5,18ZM15.27,4,18,6.73,16,8.68,13.32,6Zm-8.9,8.91L12,7.32l2.7,2.7-5.6,5.6-3,.28Z"/></g></svg></a>
{% endif %} {% endif %}
</p> </p>

View file

@ -0,0 +1,59 @@
{% 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 }} - {% trans '确认删除' %}</title>
{% include "common_libs.html" with jquery=0 %}
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
{% include "partial/_navbar.html" %}
<section id="content">
<div class="grid">
<div class="single-section-wrapper" id="main">
<h5>{% trans '确认删除吗?' %}</h5>
<div class="dividing-line"></div>
<div class="review-head">
<h5 class="review-head__title">
{{ piece.title }}
</h5>
{% if piece.visibility > 0 %}
<span class="icon-lock">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" />
</svg>
</span>
{% endif %}
<div class="review-head__body">
<div class="review-head__info">
<a href="{% url 'users:home' piece.owner.mastodon_username %}" class="review-head__owner-link">{{ piece.owner.username }}</a>
<span class="review-head__time">{{ piece.edited_time }}</span>
</div>
</div>
</div>
<div class="dividing-line"></div>
<div class="clearfix">
<form action="?return_url={{ return_url }}" method="post" class="float-right">
{% csrf_token %}
<input class="button" type="submit" value="{% trans '确认' %}">
</form>
<button onclick="history.back()" class="button button-clear float-right">{% trans '返回' %}</button>
</div>
</div>
</div>
</section>
{% include "partial/_footer.html" %}
</div>
</body>
</html>

View file

@ -52,12 +52,9 @@
<ul class="entity-sort__entity-list"> <ul class="entity-sort__entity-list">
{% for member in shelf.members %} {% for member in shelf.members %}
<li class="entity-sort__entity"> <li class="entity-sort__entity">
<a href="{{ member.item.url }}"> <a href="{{ member.item.url }}">
<img src="{{ member.item.cover|thumb:'normal' }}" <img src="{{ member.item.cover.url }}" alt="{{ member.item.title }}" class="entity-sort__entity-img">
alt="{{ member.item.title }}" class="entity-sort__entity-img"> <div class="entity-sort__entity-name" title="{{ member.item.title }}"> {{ member.item.title }}</div>
<div class="entity-sort__entity-name" title="{{ member.item.title }}">
{{ member.item.title }}</div>
</a> </a>
</li> </li>
{% empty %} {% empty %}

View file

@ -1,93 +0,0 @@
{% 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 }} - {% trans '删除评论' %}</title>
<script src="https://cdn.staticfile.org/jquery/3.6.1/jquery.min.js"></script>
<link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}">
<link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}">
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
{% include "partial/_navbar.html" %}
<section id="content">
<div class="grid">
<div class="single-section-wrapper" id="main">
<h5>{% trans '确认删除这篇评论吗?' %}</h5>
<div class="dividing-line"></div>
<div class="review-head">
<h5 class="review-head__title">
{{ review.title }}
</h5>
{% if review.visibility > 0 %}
<span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"><svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<path
d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" />
</svg></span>
{% endif %}
<div class="review-head__body">
<div class="review-head__info">
<a href="{% url 'users:home' review.owner.mastodon_username %}"
class="review-head__owner-link">{{ review.owner.username }}</a>
<span class="review-head__time">{{ review.edited_time }}</span>
</div>
</div>
</div>
<div id="rawContent" class="delete-preview">
{{ form.body }}
</div>
{{ form.media }}
<div class="dividing-line"></div>
<div class="clearfix">
<form action="{% url 'journal:review_delete' review.uuid %}" method="post" class="float-right">
{% csrf_token %}
<input class="button" type="submit" value="{% trans '确认' %}">
</form>
<button onclick="history.back()"
class="button button-clear float-right">{% trans '返回' %}</button>
</div>
</div>
</div>
</section>
</div>
{% include "partial/_footer.html" %}
</div>
<script>
$(".markdownx textarea").hide();
$(".markdownx .markdownx-preview").show();
</script>
</body>
</html>

View file

@ -0,0 +1,90 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load humanize %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load highlight %}
{% load thumb %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} - {{ user.mastodon_username }} - 收藏单</title>
{% include "common_libs.html" with jquery=0 %}
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
{% include "partial/_navbar.html" %}
<section id="content">
<div class="grid">
<div class="grid__main" id="main">
<div class="main-section-wrapper">
<div class="entity-reviews">
<h5 class="entity-reviews__title entity-reviews__title--stand-alone">
{{ title }}
</h5>
<ul class="entity-reviews__review-list">
{% for collection in collections %}
<li class="entity-reviews__review entity-reviews__review--wider">
<img src="{{ collection.cover.url }}" style="width:40px; float:right"class="entity-card__img">
<span class="entity-reviews__review-title"><a href="{{ collection.url }}">{{ collection.title }}</a></span>
<span class="entity-reviews__review-time">{{ collection.edited_time }}</span>
{% if collection.visibility > 0 %}
<span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span>
{% endif %}
</li>
{% empty %}
<div>{% trans '无结果' %}</div>
{% endfor %}
</ul>
</div>
<div class="pagination">
{% if collections.pagination.has_prev %}
<a href="?page=1" class="pagination__nav-link pagination__nav-link">&laquo;</a>
<a href="?page={{ collections.previous_page_number }}"
class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">&lsaquo;</a>
{% endif %}
{% for page in collections.pagination.page_range %}
{% if page == collections.pagination.current_page %}
<a href="?page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a>
{% else %}
<a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a>
{% endif %}
{% endfor %}
{% if collections.pagination.has_next %}
<a href="?page={{ collections.next_page_number }}"
class="pagination__nav-link pagination__nav-link--left-margin">&rsaquo;</a>
<a href="?page={{ collections.pagination.last_page }}" class="pagination__nav-link">&raquo;</a>
{% endif %}
</div>
</div>
</div>
</div>
</section>
</div>
{% include "partial/_footer.html" %}
</div>
</body>
</html>

View file

@ -15,13 +15,17 @@ class CollectionTest(TestCase):
collection = Collection.objects.create(title="test", owner=self.user) collection = Collection.objects.create(title="test", owner=self.user)
collection = Collection.objects.filter(title="test", owner=self.user).first() collection = Collection.objects.filter(title="test", owner=self.user).first()
self.assertEqual(collection.catalog_item.title, "test") self.assertEqual(collection.catalog_item.title, "test")
collection.append_item(self.book1) member1 = collection.append_item(self.book1)
member1.note = "my notes"
member1.save()
collection.append_item(self.book2) collection.append_item(self.book2)
self.assertEqual(list(collection.ordered_items), [self.book1, self.book2]) self.assertEqual(list(collection.ordered_items), [self.book1, self.book2])
collection.move_up_item(self.book1) collection.move_up_item(self.book1)
self.assertEqual(list(collection.ordered_items), [self.book1, self.book2]) self.assertEqual(list(collection.ordered_items), [self.book1, self.book2])
collection.move_up_item(self.book2) collection.move_up_item(self.book2)
self.assertEqual(list(collection.ordered_items), [self.book2, self.book1]) self.assertEqual(list(collection.ordered_items), [self.book2, self.book1])
member1 = collection.get_member_for_item(self.book1)
self.assertEqual(member1.note, "my notes")
class ShelfTest(TestCase): class ShelfTest(TestCase):
@ -47,26 +51,28 @@ class ShelfTest(TestCase):
shelf_manager.move_item(book1, ShelfType.PROGRESS) shelf_manager.move_item(book1, ShelfType.PROGRESS)
self.assertEqual(q1.members.all().count(), 1) self.assertEqual(q1.members.all().count(), 1)
self.assertEqual(q2.members.all().count(), 1) self.assertEqual(q2.members.all().count(), 1)
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={'progress': 1}) shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={"progress": 1})
self.assertEqual(q1.members.all().count(), 1) self.assertEqual(q1.members.all().count(), 1)
self.assertEqual(q2.members.all().count(), 1) self.assertEqual(q2.members.all().count(), 1)
log = shelf_manager.get_log_for_item(book1) log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 3) self.assertEqual(log.count(), 3)
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={'progress': 1}) shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={"progress": 1})
log = shelf_manager.get_log_for_item(book1) log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 3) self.assertEqual(log.count(), 3)
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={'progress': 10}) shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={"progress": 10})
log = shelf_manager.get_log_for_item(book1) log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 4) self.assertEqual(log.count(), 4)
shelf_manager.move_item(book1, ShelfType.PROGRESS) shelf_manager.move_item(book1, ShelfType.PROGRESS)
log = shelf_manager.get_log_for_item(book1) log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 4) self.assertEqual(log.count(), 4)
self.assertEqual(log.last().metadata, {'progress': 10}) self.assertEqual(log.last().metadata, {"progress": 10})
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={'progress': 90}) shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={"progress": 90})
log = shelf_manager.get_log_for_item(book1) log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 5) self.assertEqual(log.count(), 5)
self.assertEqual(Mark(user, book1).visibility, 0) self.assertEqual(Mark(user, book1).visibility, 0)
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={'progress': 90}, visibility=1) shelf_manager.move_item(
book1, ShelfType.PROGRESS, metadata={"progress": 90}, visibility=1
)
self.assertEqual(Mark(user, book1).visibility, 1) self.assertEqual(Mark(user, book1).visibility, 1)
self.assertEqual(shelf_manager.get_log_for_item(book1).count(), 5) self.assertEqual(shelf_manager.get_log_for_item(book1).count(), 5)
@ -82,18 +88,18 @@ class TagTest(TestCase):
pass pass
def test_user_tag(self): def test_user_tag(self):
t1 = 'tag-1' t1 = "tag-1"
t2 = 'tag-2' t2 = "tag-2"
t3 = 'tag-3' t3 = "tag-3"
TagManager.tag_item_by_user(self.book1, self.user2, [t1, t3]) TagManager.tag_item_by_user(self.book1, self.user2, [t1, t3])
self.assertEqual(self.book1.tags, [t1, t3]) self.assertEqual(self.book1.tags, [t1, t3])
TagManager.tag_item_by_user(self.book1, self.user2, [t2, t3]) TagManager.tag_item_by_user(self.book1, self.user2, [t2, t3])
self.assertEqual(self.book1.tags, [t2, t3]) self.assertEqual(self.book1.tags, [t2, t3])
def test_tag(self): def test_tag(self):
t1 = 'tag-1' t1 = "tag-1"
t2 = 'tag-2' t2 = "tag-2"
t3 = 'tag-3' t3 = "tag-3"
TagManager.add_tag_by_user(self.book1, t3, self.user2) TagManager.add_tag_by_user(self.book1, t3, self.user2)
TagManager.add_tag_by_user(self.book1, t1, self.user1) TagManager.add_tag_by_user(self.book1, t1, self.user1)
TagManager.add_tag_by_user(self.book1, t1, self.user2) TagManager.add_tag_by_user(self.book1, t1, self.user2)
@ -129,21 +135,21 @@ class MarkTest(TestCase):
self.assertEqual(mark.visibility, None) self.assertEqual(mark.visibility, None)
self.assertEqual(mark.review, None) self.assertEqual(mark.review, None)
self.assertEqual(mark.tags, []) self.assertEqual(mark.tags, [])
mark.update(ShelfType.WISHLIST, 'a gentle comment', 9, 1) mark.update(ShelfType.WISHLIST, "a gentle comment", 9, 1)
mark = Mark(self.user1, self.book1) mark = Mark(self.user1, self.book1)
self.assertEqual(mark.shelf_type, ShelfType.WISHLIST) self.assertEqual(mark.shelf_type, ShelfType.WISHLIST)
self.assertEqual(mark.shelf_label, '想读') self.assertEqual(mark.shelf_label, "想读")
self.assertEqual(mark.text, 'a gentle comment') self.assertEqual(mark.text, "a gentle comment")
self.assertEqual(mark.rating, 9) self.assertEqual(mark.rating, 9)
self.assertEqual(mark.visibility, 1) self.assertEqual(mark.visibility, 1)
self.assertEqual(mark.review, None) self.assertEqual(mark.review, None)
self.assertEqual(mark.tags, []) self.assertEqual(mark.tags, [])
review = Review.review_item_by_user(self.book1, self.user1, 'Critic', 'Review') review = Review.review_item_by_user(self.book1, self.user1, "Critic", "Review")
mark = Mark(self.user1, self.book1) mark = Mark(self.user1, self.book1)
self.assertEqual(mark.review, review) self.assertEqual(mark.review, review)
TagManager.tag_item_by_user(self.book1, self.user1, [' Sci-Fi ', ' fic ']) TagManager.tag_item_by_user(self.book1, self.user1, [" Sci-Fi ", " fic "])
mark = Mark(self.user1, self.book1) mark = Mark(self.user1, self.book1)
self.assertEqual(mark.tags, ['fic', 'sci-fi']) self.assertEqual(mark.tags, ["fic", "sci-fi"])

View file

@ -3,7 +3,7 @@ from .views import *
from catalog.models import * from catalog.models import *
app_name = 'journal' app_name = "journal"
def _get_all_categories(): def _get_all_categories():
@ -16,34 +16,88 @@ def _get_all_shelf_types():
urlpatterns = [ urlpatterns = [
path('wish/<str:item_uuid>', wish, name='wish'), path("wish/<str:item_uuid>", wish, name="wish"),
path('like/<str:piece_uuid>', like, name='like'), path("like/<str:piece_uuid>", like, name="like"),
path('mark/<str:item_uuid>', mark, name='mark'), path("mark/<str:item_uuid>", mark, name="mark"),
path('add_to_collection/<str:item_uuid>', add_to_collection, name='add_to_collection'), path(
"add_to_collection/<str:item_uuid>", add_to_collection, name="add_to_collection"
path('review/<str:review_uuid>', review_retrieve, name='review_retrieve'), ),
path('review/create/<str:item_uuid>/', review_edit, name='review_create'), path("review/<str:review_uuid>", review_retrieve, name="review_retrieve"),
path('review/edit/<str:item_uuid>/<str:review_uuid>', review_edit, name='review_edit'), path("review/create/<str:item_uuid>/", review_edit, name="review_create"),
path('review/delete/<str:review_uuid>', review_delete, name='review_delete'), path(
"review/edit/<str:item_uuid>/<str:review_uuid>", review_edit, name="review_edit"
path('collection/<str:collection_uuid>', collection_retrieve, name='collection_retrieve'), ),
path('collection/create/', collection_edit, name='collection_create'), path("review/delete/<str:piece_uuid>", piece_delete, name="review_delete"),
path('collection/edit/<str:collection_uuid>', collection_edit, name='collection_edit'), path(
path('collection/delete/<str:collection_uuid>', collection_delete, name='collection_delete'), "collection/<str:collection_uuid>",
path('collection/<str:collection_uuid>/items', collection_retrieve_items, name='collection_retrieve_items'), collection_retrieve,
path('collection/<str:collection_uuid>/append_item', collection_append_item, name='collection_append_item'), name="collection_retrieve",
path('collection/<str:collection_uuid>/delete_item/<str:collection_member_uuid>', collection_delete_item, name='collection_delete_item'), ),
path('collection/<str:collection_uuid>/move_up_item/<str:collection_member_uuid>', collection_move_up_item, name='collection_move_up_item'), path("collection/create/", collection_edit, name="collection_create"),
path('collection/<str:collection_uuid>/move_down_item/<str:collection_member_uuid>', collection_move_down_item, name='collection_move_down_item'), path(
path('collection/<str:collection_uuid>/update_item_note/<str:collection_member_uuid>', collection_update_item_note, name='collection_update_item_note'), "collection/edit/<str:collection_uuid>", collection_edit, name="collection_edit"
),
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/(?P<shelf_type>' + _get_all_shelf_types() + ')/(?P<item_category>' + _get_all_categories() + ')/$', user_mark_list, name='user_mark_list'), path("collection/delete/<str:piece_uuid>", piece_delete, name="collection_delete"),
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/reviews/(?P<item_category>' + _get_all_categories() + ')/$', user_review_list, name='user_review_list'), path(
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/tags/(?P<tag_title>[^/]+)/$', user_tag_member_list, name='user_tag_member_list'), "collection/<str:collection_uuid>/items",
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/collections/$', user_collection_list, name='user_collection_list'), collection_retrieve_items,
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/like/collections/$', user_liked_collection_list, name='user_liked_collection_list'), name="collection_retrieve_items",
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/tags/$', user_tag_list, name='user_tag_list'), ),
path(
re_path(r'^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/$', home, name='user_profile'), "collection/<str:collection_uuid>/append_item",
collection_append_item,
name="collection_append_item",
),
path(
"collection/<str:collection_uuid>/remove_item/<str:item_uuid>",
collection_remove_item,
name="collection_remove_item",
),
path(
"collection/<str:collection_uuid>/move_item/<str:direction>/<str:item_uuid>",
collection_move_item,
name="collection_move_item",
),
path(
"collection/<str:collection_uuid>/update_item_note/<str:item_uuid>",
collection_update_item_note,
name="collection_update_item_note",
),
re_path(
r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/(?P<shelf_type>"
+ _get_all_shelf_types()
+ ")/(?P<item_category>"
+ _get_all_categories()
+ ")/$",
user_mark_list,
name="user_mark_list",
),
re_path(
r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/reviews/(?P<item_category>"
+ _get_all_categories()
+ ")/$",
user_review_list,
name="user_review_list",
),
re_path(
r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/tags/(?P<tag_title>[^/]+)/$",
user_tag_member_list,
name="user_tag_member_list",
),
re_path(
r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/collections/$",
user_collection_list,
name="user_collection_list",
),
re_path(
r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/like/collections/$",
user_liked_collection_list,
name="user_liked_collection_list",
),
re_path(
r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/tags/$",
user_tag_list,
name="user_tag_list",
),
re_path(r"^user/(?P<user_name>[A-Za-z0-9_\-.@]+)/$", home, name="user_profile"),
] ]

View file

@ -2,9 +2,13 @@ import logging
from django.shortcuts import render, get_object_or_404, redirect, reverse from django.shortcuts import render, get_object_or_404, redirect, reverse
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseServerError, HttpResponseNotFound from django.http import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseServerError,
HttpResponseNotFound,
)
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import IntegrityError, transaction
from django.db.models import Count from django.db.models import Count
from django.utils import timezone from django.utils import timezone
from django.core.paginator import Paginator from django.core.paginator import Paginator
@ -24,187 +28,257 @@ from users.models import User, Report, Preference
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
PAGE_SIZE = 10 PAGE_SIZE = 10
_checkmark = "✔️".encode("utf-8")
@login_required @login_required
def wish(request, item_uuid): def wish(request, item_uuid):
if request.method == 'POST': if request.method == "POST":
item = get_object_or_404(Item, uid=base62.decode(item_uuid)) item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if not item: if not item:
return HttpResponseNotFound("item not found") return HttpResponseNotFound(b"item not found")
request.user.shelf_manager.move_item(item, ShelfType.WISHLIST) request.user.shelf_manager.move_item(item, ShelfType.WISHLIST)
return HttpResponse("✔️") if request.GET.get("back"):
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
return HttpResponse(_checkmark)
else: else:
return HttpResponseBadRequest("invalid request") return HttpResponseBadRequest(b"invalid request")
@login_required @login_required
def like(request, piece_uuid): def like(request, piece_uuid):
if request.method == 'POST': if request.method == "POST":
piece = get_object_or_404(Collection, uid=base62.decode(piece_uuid)) piece = get_object_or_404(Collection, uid=base62.decode(piece_uuid))
if not piece: if not piece:
return HttpResponseNotFound("piece not found") return HttpResponseNotFound(b"piece not found")
Like.user_like_piece(request.user, piece) Like.user_like_piece(request.user, piece)
return HttpResponse("✔️") return HttpResponse(_checkmark)
else: else:
return HttpResponseBadRequest("invalid request") return HttpResponseBadRequest(b"invalid request")
@login_required
def unlike(request, piece_uuid):
if request.method == "POST":
piece = get_object_or_404(Collection, uid=base62.decode(piece_uuid))
if not piece:
return HttpResponseNotFound(b"piece not found")
Like.user_unlike_piece(request.user, piece)
if request.GET.get("back"):
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
return HttpResponse(_checkmark)
else:
return HttpResponseBadRequest(b"invalid request")
@login_required @login_required
def add_to_collection(request, item_uuid): def add_to_collection(request, item_uuid):
item = get_object_or_404(Item, uid=base62.decode(item_uuid)) item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if request.method == 'GET': if request.method == "GET":
collections = Collection.objects.filter(owner=request.user) collections = Collection.objects.filter(owner=request.user)
return render( return render(
request, request,
'add_to_collection.html', "add_to_collection.html",
{ {
'item': item, "item": item,
'collections': collections, "collections": collections,
} },
) )
else: else:
cid = int(request.POST.get('collection_id', default=0)) cid = int(request.POST.get("collection_id", default=0))
if not cid: if not cid:
cid = Collection.objects.create(owner=request.user, title=f'{request.user.username}的收藏单').id cid = Collection.objects.create(
owner=request.user, title=f"{request.user.username}的收藏单"
).id
collection = Collection.objects.get(owner=request.user, id=cid) collection = Collection.objects.get(owner=request.user, id=cid)
collection.append_item(item, metadata={'comment': request.POST.get('comment')}) collection.append_item(item, metadata={"comment": request.POST.get("comment")})
return HttpResponseRedirect(request.META.get('HTTP_REFERER')) return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
def render_relogin(request): def render_relogin(request):
return render(request, 'common/error.html', { return render(
'url': reverse("users:connect") + '?domain=' + request.user.mastodon_site, request,
'msg': _("信息已保存,但是未能分享到联邦网络"), "common/error.html",
'secondary_msg': _("可能是你在联邦网络(Mastodon/Pleroma/...)的登录状态过期了,正在跳转到联邦网络重新登录😼")}) {
"url": reverse("users:connect") + "?domain=" + request.user.mastodon_site,
"msg": _("信息已保存,但是未能分享到联邦网络"),
"secondary_msg": _(
"可能是你在联邦网络(Mastodon/Pleroma/...)的登录状态过期了,正在跳转到联邦网络重新登录😼"
),
},
)
@login_required @login_required
def mark(request, item_uuid): def mark(request, item_uuid):
item = get_object_or_404(Item, uid=base62.decode(item_uuid)) item = get_object_or_404(Item, uid=base62.decode(item_uuid))
mark = Mark(request.user, item) mark = Mark(request.user, item)
if request.method == 'GET': if request.method == "GET":
tags = TagManager.get_item_tags_by_user(item, request.user) tags = TagManager.get_item_tags_by_user(item, request.user)
shelf_types = [(n[1], n[2]) for n in iter(ShelfTypeNames) if n[0] == item.category] shelf_types = [
shelf_type = request.GET.get('shelf_type', mark.shelf_type) (n[1], n[2]) for n in iter(ShelfTypeNames) if n[0] == item.category
return render(request, 'mark.html', { ]
'item': item, shelf_type = request.GET.get("shelf_type", mark.shelf_type)
'mark': mark, return render(
'shelf_type': shelf_type, request,
'tags': ','.join(tags), "mark.html",
'shelf_types': shelf_types, {
}) "item": item,
elif request.method == 'POST': "mark": mark,
if request.POST.get('delete', default=False): "shelf_type": shelf_type,
"tags": ",".join(tags),
"shelf_types": shelf_types,
},
)
elif request.method == "POST":
if request.POST.get("delete", default=False):
mark.delete() mark.delete()
return HttpResponseRedirect(request.META.get('HTTP_REFERER')) return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
else: else:
visibility = int(request.POST.get('visibility', default=0)) visibility = int(request.POST.get("visibility", default=0))
rating = request.POST.get('rating', default=0) rating = request.POST.get("rating", default=0)
rating = int(rating) if rating else None rating = int(rating) if rating else None
status = ShelfType(request.POST.get('status')) status = ShelfType(request.POST.get("status"))
text = request.POST.get('text') text = request.POST.get("text")
tags = request.POST.get('tags') tags = request.POST.get("tags")
tags = tags.split(',') if tags else [] tags = tags.split(",") if tags else []
share_to_mastodon = bool(request.POST.get('share_to_mastodon', default=False)) share_to_mastodon = bool(
request.POST.get("share_to_mastodon", default=False)
)
TagManager.tag_item_by_user(item, request.user, tags, visibility) TagManager.tag_item_by_user(item, request.user, tags, visibility)
try: try:
mark.update(status, text, rating, visibility, share_to_mastodon=share_to_mastodon) mark.update(
status,
text,
rating,
visibility,
share_to_mastodon=share_to_mastodon,
)
except Exception: except Exception:
return render_relogin(request) return render_relogin(request)
return HttpResponseRedirect(request.META.get('HTTP_REFERER')) return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
def collection_retrieve(request, collection_uuid): def collection_retrieve(request, collection_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_visible_to(request.user): if not collection.is_visible_to(request.user):
raise PermissionDenied() raise PermissionDenied()
return render(request, 'collection.html', {'collection': collection}) return render(request, "collection.html", {"collection": collection})
def collection_retrieve_items(request, collection_uuid): def collection_retrieve_items(request, collection_uuid, edit=False):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_visible_to(request.user): if not collection.is_visible_to(request.user):
raise PermissionDenied() raise PermissionDenied()
form = CollectionForm(instance=collection) form = CollectionForm(instance=collection)
return render( return render(
request, request,
'collection_items.html', "collection_items.html",
{ {
'collection': collection, "collection": collection,
'form': form, "form": form,
'collection_edit': request.GET.get('edit'), # collection.is_editable_by(request.user), "collection_edit": edit or request.GET.get("edit"),
} },
) )
@login_required
def collection_update_item_note(request, collection_uuid, collection_member_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user):
raise PermissionDenied()
@login_required @login_required
def collection_append_item(request, collection_uuid): def collection_append_item(request, collection_uuid):
if request.method != "POST":
return HttpResponseBadRequest()
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user): if not collection.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
url = request.POST.get("url")
note = request.POST.get("note")
item = Item.get_by_url(url)
collection.append_item(item, metadata={"note": note})
collection.save()
return collection_retrieve_items(request, collection_uuid, True)
@login_required @login_required
def collection_delete_item(request, collection_uuid, collection_member_uuid): def collection_remove_item(request, collection_uuid, item_uuid):
if request.method != "POST":
return HttpResponseBadRequest()
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if not collection.is_editable_by(request.user):
raise PermissionDenied()
collection.remove_item(item)
return collection_retrieve_items(request, collection_uuid, True)
@login_required
def collection_move_item(request, direction, collection_uuid, item_uuid):
if request.method != "POST":
return HttpResponseBadRequest()
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user): if not collection.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if direction == "up":
collection.move_up_item(item)
else:
collection.move_down_item(item)
return collection_retrieve_items(request, collection_uuid, True)
@login_required @login_required
def collection_move_up_item(request, collection_uuid, collection_member_uuid): def collection_update_item_note(request, collection_uuid, item_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user): if not collection.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
@login_required
def collection_move_down_item(request, collection_uuid, collection_member_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user): if not collection.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
if request.method == "POST":
collection.update_item_metadata(
@login_required item, {"note": request.POST.get("note", default="")}
def collection_edit(request, collection_uuid=None): )
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) if collection_uuid else None return collection_retrieve_items(request, collection_uuid, True)
if collection and not collection.is_editable_by(request.user): elif request.method == "GET":
raise PermissionDenied() member = collection.get_member_for_item(item)
if request.method == 'GET': return render(
form = CollectionForm(instance=collection) if collection else CollectionForm() request,
return render(request, 'collection_edit.html', {'form': form, 'collection': collection}) "collection_update_item_note.html",
elif request.method == 'POST': {"collection": collection, "item": item, "note": member.note},
form = CollectionForm(request.POST, instance=collection) if collection else CollectionForm(request.POST) )
if form.is_valid():
if not collection:
form.instance.owner = request.user
form.instance.edited_time = timezone.now()
form.save()
return redirect(reverse("journal:collection_retrieve", args=[form.instance.uuid]))
else:
return HttpResponseBadRequest(form.errors)
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@login_required @login_required
def collection_delete(request, collection_uuid): def collection_edit(request, collection_uuid=None):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid)) collection = (
if not collection.is_editable_by(request.user): get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if collection_uuid
else None
)
if collection and not collection.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
if request.method == 'GET': if request.method == "GET":
collection_form = CollectionForm(instance=collection) form = CollectionForm(instance=collection) if collection else CollectionForm()
return render(request, 'collection_delete.html', {'form': collection_form, 'collection': collection}) return render(
elif request.method == 'POST': request, "collection_edit.html", {"form": form, "collection": collection}
collection.delete() )
return redirect(reverse("users:home")) elif request.method == "POST":
form = (
CollectionForm(request.POST, instance=collection)
if collection
else CollectionForm(request.POST)
)
if form.is_valid():
if not collection:
form.instance.owner = request.user
form.instance.edited_time = timezone.now()
form.save()
return redirect(
reverse("journal:collection_retrieve", args=[form.instance.uuid])
)
else:
return HttpResponseBadRequest(form.errors)
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@ -213,31 +287,45 @@ def review_retrieve(request, review_uuid):
piece = get_object_or_404(Review, uid=base62.decode(review_uuid)) piece = get_object_or_404(Review, uid=base62.decode(review_uuid))
if not piece.is_visible_to(request.user): if not piece.is_visible_to(request.user):
raise PermissionDenied() raise PermissionDenied()
return render(request, 'review.html', {'review': piece}) return render(request, "review.html", {"review": piece})
@login_required @login_required
def review_edit(request, item_uuid, review_uuid=None): def review_edit(request, item_uuid, review_uuid=None):
item = get_object_or_404(Item, uid=base62.decode(item_uuid)) item = get_object_or_404(Item, uid=base62.decode(item_uuid))
review = get_object_or_404(Review, uid=base62.decode(review_uuid)) if review_uuid else None review = (
get_object_or_404(Review, uid=base62.decode(review_uuid))
if review_uuid
else None
)
if review and not review.is_editable_by(request.user): if review and not review.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
if request.method == 'GET': if request.method == "GET":
form = ReviewForm(instance=review) if review else ReviewForm(initial={'item': item.id}) form = (
return render(request, 'review_edit.html', {'form': form, 'item': item}) ReviewForm(instance=review)
elif request.method == 'POST': if review
form = ReviewForm(request.POST, instance=review) if review else ReviewForm(request.POST) else ReviewForm(initial={"item": item.id})
)
return render(request, "review_edit.html", {"form": form, "item": item})
elif request.method == "POST":
form = (
ReviewForm(request.POST, instance=review)
if review
else ReviewForm(request.POST)
)
if form.is_valid(): if form.is_valid():
if not review: if not review:
form.instance.owner = request.user form.instance.owner = request.user
form.instance.edited_time = timezone.now() form.instance.edited_time = timezone.now()
form.save() form.save()
if form.cleaned_data['share_to_mastodon']: if form.cleaned_data["share_to_mastodon"]:
form.instance.save = lambda **args: None form.instance.save = lambda **args: None
form.instance.shared_link = None form.instance.shared_link = None
if not share_review(form.instance): if not share_review(form.instance):
return render_relogin(request) return render_relogin(request)
return redirect(reverse("journal:review_retrieve", args=[form.instance.uuid])) return redirect(
reverse("journal:review_retrieve", args=[form.instance.uuid])
)
else: else:
return HttpResponseBadRequest(form.errors) return HttpResponseBadRequest(form.errors)
else: else:
@ -245,17 +333,18 @@ def review_edit(request, item_uuid, review_uuid=None):
@login_required @login_required
def review_delete(request, review_uuid): def piece_delete(request, piece_uuid):
review = get_object_or_404(Review, uid=base62.decode(review_uuid)) piece = get_object_or_404(Piece, uid=base62.decode(piece_uuid))
if not review.is_editable_by(request.user): return_url = request.GET.get("return_url", None) or "/"
if not piece.is_editable_by(request.user):
raise PermissionDenied() raise PermissionDenied()
if request.method == 'GET': if request.method == "GET":
review_form = ReviewForm(instance=review) return render(
return render(request, 'review_delete.html', {'form': review_form, 'review': review}) request, "piece_delete.html", {"piece": piece, "return_url": return_url}
elif request.method == 'POST': )
item = review.item elif request.method == "POST":
review.delete() piece.delete()
return redirect(item.url) return redirect(return_url)
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@ -264,57 +353,67 @@ def render_list_not_fount(request):
msg = _("相关列表不存在") msg = _("相关列表不存在")
return render( return render(
request, request,
'common/error.html', "common/error.html",
{ {
'msg': msg, "msg": msg,
} },
) )
def _render_list(request, user_name, type, shelf_type=None, item_category=None, tag_title=None): def _render_list(
request, user_name, type, shelf_type=None, item_category=None, tag_title=None
):
user = User.get(user_name) user = User.get(user_name)
if user is None: if user is None:
return render_user_not_found(request) return render_user_not_found(request)
if user != request.user and (request.user.is_blocked_by(user) or request.user.is_blocking(user)): if user != request.user and (
request.user.is_blocked_by(user) or request.user.is_blocking(user)
):
return render_user_blocked(request) return render_user_blocked(request)
if type == 'mark': if type == "mark":
shelf = user.shelf_manager.get_shelf(item_category, shelf_type) shelf = user.shelf_manager.get_shelf(item_category, shelf_type)
queryset = ShelfMember.objects.filter(owner=user, parent=shelf) queryset = ShelfMember.objects.filter(owner=user, parent=shelf)
elif type == 'tagmember': elif type == "tagmember":
tag = Tag.objects.filter(owner=user, title=tag_title).first() tag = Tag.objects.filter(owner=user, title=tag_title).first()
if not tag: if not tag:
return render_list_not_fount(request) return render_list_not_fount(request)
if tag.visibility != 0 and user != request.user: if tag.visibility != 0 and user != request.user:
return render_list_not_fount(request) return render_list_not_fount(request)
queryset = TagMember.objects.filter(parent=tag) queryset = TagMember.objects.filter(parent=tag)
elif type == 'review': elif type == "review":
queryset = Review.objects.filter(owner=user) queryset = Review.objects.filter(owner=user)
queryset = queryset.filter(query_item_category(item_category)) queryset = queryset.filter(query_item_category(item_category))
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
queryset = queryset.filter(q_visible_to(request.user, user)) queryset = queryset.filter(q_visible_to(request.user, user))
paginator = Paginator(queryset, PAGE_SIZE) paginator = Paginator(queryset, PAGE_SIZE)
page_number = request.GET.get('page', default=1) page_number = request.GET.get("page", default=1)
members = paginator.get_page(page_number) members = paginator.get_page(page_number)
return render(request, f'user_{type}_list.html', { return render(
'user': user, request,
'members': members, f"user_{type}_list.html",
}) {
"user": user,
"members": members,
},
)
@login_required @login_required
def user_mark_list(request, user_name, shelf_type, item_category): def user_mark_list(request, user_name, shelf_type, item_category):
return _render_list(request, user_name, 'mark', shelf_type=shelf_type, item_category=item_category) return _render_list(
request, user_name, "mark", shelf_type=shelf_type, item_category=item_category
)
@login_required @login_required
def user_tag_member_list(request, user_name, tag_title): def user_tag_member_list(request, user_name, tag_title):
return _render_list(request, user_name, 'tagmember', tag_title=tag_title) return _render_list(request, user_name, "tagmember", tag_title=tag_title)
@login_required @login_required
def user_review_list(request, user_name, item_category): def user_review_list(request, user_name, item_category):
return _render_list(request, user_name, 'review', item_category=item_category) return _render_list(request, user_name, "review", item_category=item_category)
@login_required @login_required
@ -322,17 +421,23 @@ def user_tag_list(request, user_name):
user = User.get(user_name) user = User.get(user_name)
if user is None: if user is None:
return render_user_not_found(request) return render_user_not_found(request)
if user != request.user and (request.user.is_blocked_by(user) or request.user.is_blocking(user)): if user != request.user and (
request.user.is_blocked_by(user) or request.user.is_blocking(user)
):
return render_user_blocked(request) return render_user_blocked(request)
tags = Tag.objects.filter(owner=user) tags = Tag.objects.filter(owner=user)
tags = user.tag_set.all() tags = user.tag_set.all()
if user != request.user: if user != request.user:
tags = tags.filter(visibility=0) tags = tags.filter(visibility=0)
tags = tags.values('title').annotate(total=Count('members')).order_by('-total') tags = tags.values("title").annotate(total=Count("members")).order_by("-total")
return render(request, 'user_tag_list.html', { return render(
'user': user, request,
'tags': tags, "user_tag_list.html",
}) {
"user": user,
"tags": tags,
},
)
@login_required @login_required
@ -340,18 +445,24 @@ def user_collection_list(request, user_name):
user = User.get(user_name) user = User.get(user_name)
if user is None: if user is None:
return render_user_not_found(request) return render_user_not_found(request)
if user != request.user and (request.user.is_blocked_by(user) or request.user.is_blocking(user)): if user != request.user and (
request.user.is_blocked_by(user) or request.user.is_blocking(user)
):
return render_user_blocked(request) return render_user_blocked(request)
collections = Tag.objects.filter(owner=user) collections = Collection.objects.filter(owner=user)
if user != request.user: if user != request.user:
if request.user.is_following(user): if request.user.is_following(user):
collections = collections.filter(visibility__ne=2) collections = collections.filter(visibility__ne=2)
else: else:
collections = collections.filter(visibility=0) collections = collections.filter(visibility=0)
return render(request, 'user_collection_list.html', { return render(
'user': user, request,
'collections': collections, "user_collection_list.html",
}) {
"user": user,
"collections": collections,
},
)
@login_required @login_required
@ -359,27 +470,37 @@ def user_liked_collection_list(request, user_name):
user = User.get(user_name) user = User.get(user_name)
if user is None: if user is None:
return render_user_not_found(request) return render_user_not_found(request)
if user != request.user and (request.user.is_blocked_by(user) or request.user.is_blocking(user)): if user != request.user and (
request.user.is_blocked_by(user) or request.user.is_blocking(user)
):
return render_user_blocked(request) return render_user_blocked(request)
collections = Collection.objects.filter(likes__owner=user) collections = Collection.objects.filter(likes__owner=user)
if user != request.user: if user != request.user:
collections = collections.filter(query_visible(request.user)) collections = collections.filter(query_visible(request.user))
return render(request, 'user_collection_list.html', { return render(
'user': user, request,
'collections': collections, "user_collection_list.html",
}) {
"user": user,
"collections": collections,
},
)
def home_anonymous(request, id): def home_anonymous(request, id):
login_url = settings.LOGIN_URL + "?next=" + request.get_full_path() login_url = settings.LOGIN_URL + "?next=" + request.get_full_path()
try: try:
username = id.split('@')[0] username = id.split("@")[0]
site = id.split('@')[1] site = id.split("@")[1]
return render(request, 'users/home_anonymous.html', { return render(
'login_url': login_url, request,
'username': username, "users/home_anonymous.html",
'site': site, {
}) "login_url": login_url,
"username": username,
"site": site,
},
)
except Exception: except Exception:
return redirect(login_url) return redirect(login_url)
@ -387,7 +508,7 @@ def home_anonymous(request, id):
def home(request, user_name): def home(request, user_name):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return home_anonymous(request, user_name) return home_anonymous(request, user_name)
if request.method != 'GET': if request.method != "GET":
return HttpResponseBadRequest() return HttpResponseBadRequest()
user = User.get(user_name) user = User.get(user_name)
if user is None: if user is None:
@ -395,14 +516,13 @@ def home(request, user_name):
# access one's own home page # access one's own home page
if user == request.user: if user == request.user:
reports = Report.objects.order_by( reports = Report.objects.order_by("-submitted_time").filter(is_read=False)
'-submitted_time').filter(is_read=False)
unread_announcements = Announcement.objects.filter( unread_announcements = Announcement.objects.filter(
pk__gt=request.user.read_announcement_index).order_by('-pk') pk__gt=request.user.read_announcement_index
).order_by("-pk")
try: try:
request.user.read_announcement_index = Announcement.objects.latest( request.user.read_announcement_index = Announcement.objects.latest("pk").pk
'pk').pk request.user.save(update_fields=["read_announcement_index"])
request.user.save(update_fields=['read_announcement_index'])
except ObjectDoesNotExist: except ObjectDoesNotExist:
# when there is no annoucenment # when there is no annoucenment
pass pass
@ -416,42 +536,51 @@ def home(request, user_name):
qv = q_visible_to(request.user, user) qv = q_visible_to(request.user, user)
shelf_list = {} shelf_list = {}
visbile_categories = [ItemCategory.Book, ItemCategory.Movie, ItemCategory.TV, ItemCategory.Music, ItemCategory.Game] visbile_categories = [
ItemCategory.Book,
ItemCategory.Movie,
ItemCategory.TV,
ItemCategory.Music,
ItemCategory.Game,
]
for category in visbile_categories: for category in visbile_categories:
shelf_list[category] = {} shelf_list[category] = {}
for shelf_type in ShelfType: for shelf_type in ShelfType:
shelf = user.shelf_manager.get_shelf(category, shelf_type) shelf = user.shelf_manager.get_shelf(category, shelf_type)
members = shelf.recent_members.filter(qv) members = shelf.recent_members.filter(qv)
shelf_list[category][shelf_type] = { shelf_list[category][shelf_type] = {
'title': shelf.title, "title": shelf.title,
'count': members.count(), "count": members.count(),
'members': members[:5].prefetch_related('item'), "members": members[:5].prefetch_related("item"),
} }
reviews = Review.objects.filter(owner=user).filter(qv) reviews = Review.objects.filter(owner=user).filter(qv)
shelf_list[category]['reviewed'] = { shelf_list[category]["reviewed"] = {
'title': '评论过的' + category.label, "title": "评论过的" + category.label,
'count': reviews.count(), "count": reviews.count(),
'members': reviews[:5].prefetch_related('item'), "members": reviews[:5].prefetch_related("item"),
} }
collections = Collection.objects.filter(owner=user).filter(qv).order_by("-edited_time") collections = (
liked_collections = Collection.objects.filter(likes__owner=user).order_by("-edited_time") Collection.objects.filter(owner=user).filter(qv).order_by("-edited_time")
)
liked_collections = (
Collection.objects.none().filter(likes__owner=user).order_by("-edited_time")
)
if user != request.user: if user != request.user:
liked_collections = liked_collections.filter(query_visible(request.user)) liked_collections = liked_collections.filter(query_visible(request.user))
layout = user.get_preference().get_serialized_home_layout() layout = user.get_preference().get_serialized_home_layout()
return render( return render(
request, request,
'profile.html', "profile.html",
{ {
'user': user, "user": user,
'shelf_list': shelf_list, "shelf_list": shelf_list,
'collections': collections[:5], "collections": collections[:5],
'collections_count': collections.count(), "collections_count": collections.count(),
'liked_collections': liked_collections.order_by("-edited_time")[:5], "liked_collections": liked_collections.order_by("-edited_time")[:5],
'liked_collections_count': liked_collections.count(), "liked_collections_count": liked_collections.count(),
'layout': layout, "layout": layout,
'reports': reports, "reports": reports,
'unread_announcements': unread_announcements, "unread_announcements": unread_announcements,
} },
) )

View file

@ -14,6 +14,7 @@ from catalog.common import *
from catalog.models import * from catalog.models import *
from catalog.sites import * from catalog.sites import *
from journal.models import * from journal.models import *
# from social import models as social_models # from social import models as social_models
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.paginator import Paginator from django.core.paginator import Paginator
@ -51,29 +52,53 @@ shelf_map = {
} }
tag_map = { tag_map = {
BookMark: 'bookmark_tags', BookMark: "bookmark_tags",
MovieMark: 'moviemark_tags', MovieMark: "moviemark_tags",
AlbumMark: 'albummark_tags', AlbumMark: "albummark_tags",
GameMark: 'gamemark_tags', GameMark: "gamemark_tags",
} }
class Command(BaseCommand): class Command(BaseCommand):
help = 'Migrate legacy marks to user journal' help = "Migrate legacy marks to user journal"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--book', dest='types', action='append_const', const=BookMark) parser.add_argument(
parser.add_argument('--movie', dest='types', action='append_const', const=MovieMark) "--book", dest="types", action="append_const", const=BookMark
parser.add_argument('--album', dest='types', action='append_const', const=AlbumMark) )
parser.add_argument('--game', dest='types', action='append_const', const=GameMark) parser.add_argument(
parser.add_argument('--mark', help='migrate shelves/tags/ratings, then exit', action='store_true') "--movie", dest="types", action="append_const", const=MovieMark
parser.add_argument('--review', help='migrate reviews, then exit', action='store_true') )
parser.add_argument('--collection', help='migrate collections, then exit', action='store_true') parser.add_argument(
parser.add_argument('--id', help='id to convert; or, if using with --max-id, the min id') "--album", dest="types", action="append_const", const=AlbumMark
parser.add_argument('--maxid', help='max id to convert') )
parser.add_argument('--failstop', help='stop on fail', action='store_true') parser.add_argument(
parser.add_argument('--initshelf', help='initialize shelves for users, then exit', action='store_true') "--game", dest="types", action="append_const", const=GameMark
parser.add_argument('--clear', help='clear all user pieces, then exit', action='store_true') )
parser.add_argument(
"--mark",
help="migrate shelves/tags/ratings, then exit",
action="store_true",
)
parser.add_argument(
"--review", help="migrate reviews, then exit", action="store_true"
)
parser.add_argument(
"--collection", help="migrate collections, then exit", action="store_true"
)
parser.add_argument(
"--id", help="id to convert; or, if using with --max-id, the min id"
)
parser.add_argument("--maxid", help="max id to convert")
parser.add_argument("--failstop", help="stop on fail", action="store_true")
parser.add_argument(
"--initshelf",
help="initialize shelves for users, then exit",
action="store_true",
)
parser.add_argument(
"--clear", help="clear all user pieces, then exit", action="store_true"
)
def initshelf(self): def initshelf(self):
print("Initialize shelves") print("Initialize shelves")
@ -91,7 +116,11 @@ class Command(BaseCommand):
def collection(self, options): def collection(self, options):
collection_map = {} collection_map = {}
with transaction.atomic(): with transaction.atomic():
qs = Legacy_Collection.objects.all().filter(owner__is_active=True).order_by('id') qs = (
Legacy_Collection.objects.all()
.filter(owner__is_active=True)
.order_by("id")
)
for entity in tqdm(qs): for entity in tqdm(qs):
c = Collection.objects.create( c = Collection.objects.create(
owner_id=entity.owner_id, owner_id=entity.owner_id,
@ -115,11 +144,15 @@ class Command(BaseCommand):
if old_id: if old_id:
item_link = LinkModel.objects.get(old_id=old_id) item_link = LinkModel.objects.get(old_id=old_id)
item = Item.objects.get(uid=item_link.new_uid) item = Item.objects.get(uid=item_link.new_uid)
c.append_item(item, metadata={'comment': citem.comment}) c.append_item(item, metadata={"note": citem.comment})
else: else:
# TODO convert song to album # TODO convert song to album
print(f'{c.owner} {c.id} {c.title} {citem.item} were skipped') print(f"{c.owner} {c.id} {c.title} {citem.item} were skipped")
qs = Legacy_CollectionMark.objects.all().filter(owner__is_active=True).order_by('id') qs = (
Legacy_CollectionMark.objects.all()
.filter(owner__is_active=True)
.order_by("id")
)
for entity in tqdm(qs): for entity in tqdm(qs):
Like.objects.create( Like.objects.create(
owner_id=entity.owner_id, owner_id=entity.owner_id,
@ -132,12 +165,14 @@ class Command(BaseCommand):
for typ in [GameReview, AlbumReview, BookReview, MovieReview]: for typ in [GameReview, AlbumReview, BookReview, MovieReview]:
print(typ) print(typ)
LinkModel = model_link[typ] LinkModel = model_link[typ]
qs = typ.objects.all().filter(owner__is_active=True).order_by('id') qs = typ.objects.all().filter(owner__is_active=True).order_by("id")
if options['id']: if options["id"]:
if options['maxid']: if options["maxid"]:
qs = qs.filter(id__gte=int(options['id']), id__lte=int(options['maxid'])) qs = qs.filter(
id__gte=int(options["id"]), id__lte=int(options["maxid"])
)
else: else:
qs = qs.filter(id=int(options['id'])) qs = qs.filter(id=int(options["id"]))
pg = Paginator(qs, BATCH_SIZE) pg = Paginator(qs, BATCH_SIZE)
for p in tqdm(pg.page_range): for p in tqdm(pg.page_range):
with transaction.atomic(): with transaction.atomic():
@ -145,28 +180,42 @@ class Command(BaseCommand):
try: try:
item_link = LinkModel.objects.get(old_id=entity.item.id) item_link = LinkModel.objects.get(old_id=entity.item.id)
item = Item.objects.get(uid=item_link.new_uid) item = Item.objects.get(uid=item_link.new_uid)
Review.objects.create(owner=entity.owner, item=item, title=entity.title, body=entity.content, metadata={'shared_link': entity.shared_link}, visibility=entity.visibility, created_time=entity.created_time, edited_time=entity.edited_time) Review.objects.create(
owner=entity.owner,
item=item,
title=entity.title,
body=entity.content,
metadata={"shared_link": entity.shared_link},
visibility=entity.visibility,
created_time=entity.created_time,
edited_time=entity.edited_time,
)
except Exception as e: except Exception as e:
print(f'Convert failed for {typ} {entity.id}: {e}') print(f"Convert failed for {typ} {entity.id}: {e}")
if options['failstop']: if options["failstop"]:
raise(e) raise (e)
def mark(self, options): def mark(self, options):
types = options['types'] or [GameMark, AlbumMark, MovieMark, BookMark] types = options["types"] or [GameMark, AlbumMark, MovieMark, BookMark]
print('Preparing cache') print("Preparing cache")
tag_cache = {f'{t.owner_id}_{t.title}': t.id for t in Tag.objects.all()} tag_cache = {f"{t.owner_id}_{t.title}": t.id for t in Tag.objects.all()}
shelf_cache = {f'{s.owner_id}_{s.item_category}_{shelf_map[s.shelf_type]}': s.id for s in Shelf.objects.all()} shelf_cache = {
f"{s.owner_id}_{s.item_category}_{shelf_map[s.shelf_type]}": s.id
for s in Shelf.objects.all()
}
for typ in types: for typ in types:
print(typ) print(typ)
LinkModel = model_link[typ] LinkModel = model_link[typ]
tag_field = tag_map[typ] tag_field = tag_map[typ]
qs = typ.objects.all().filter(owner__is_active=True).order_by('id') qs = typ.objects.all().filter(owner__is_active=True).order_by("id")
if options['id']: if options["id"]:
if options['maxid']: if options["maxid"]:
qs = qs.filter(id__gte=int(options['id']), id__lte=int(options['maxid'])) qs = qs.filter(
id__gte=int(options["id"]), id__lte=int(options["maxid"])
)
else: else:
qs = qs.filter(id=int(options['id'])) qs = qs.filter(id=int(options["id"]))
pg = Paginator(qs, BATCH_SIZE) pg = Paginator(qs, BATCH_SIZE)
for p in tqdm(pg.page_range): for p in tqdm(pg.page_range):
@ -193,22 +242,42 @@ class Command(BaseCommand):
visibility = entity.visibility visibility = entity.visibility
created_time = entity.created_time created_time = entity.created_time
if entity.rating: if entity.rating:
Rating.objects.create(owner_id=user_id, item_id=item_id, grade=entity.rating, visibility=visibility) Rating.objects.create(
owner_id=user_id,
item_id=item_id,
grade=entity.rating,
visibility=visibility,
)
if entity.text: if entity.text:
Comment.objects.create(owner_id=user_id, item_id=item_id, text=entity.text, visibility=visibility) Comment.objects.create(
shelf = shelf_cache[f'{user_id}_{item.category}_{entity.status}'] owner_id=user_id,
item_id=item_id,
text=entity.text,
visibility=visibility,
)
shelf = shelf_cache[
f"{user_id}_{item.category}_{entity.status}"
]
ShelfMember.objects.create( ShelfMember.objects.create(
parent_id=shelf, parent_id=shelf,
owner_id=user_id, owner_id=user_id,
position=0, position=0,
item_id=item_id, item_id=item_id,
metadata={'shared_link': entity.shared_link}, metadata={"shared_link": entity.shared_link},
created_time=created_time) created_time=created_time,
ShelfLogEntry.objects.create(owner_id=user_id, shelf_id=shelf, item_id=item_id, timestamp=created_time) )
ShelfLogEntry.objects.create(
owner_id=user_id,
shelf_id=shelf,
item_id=item_id,
timestamp=created_time,
)
for title in tags: for title in tags:
tag_key = f'{user_id}_{title}' tag_key = f"{user_id}_{title}"
if tag_key not in tag_cache: if tag_key not in tag_cache:
tag = Tag.objects.create(owner_id=user_id, title=title, visibility=0).id tag = Tag.objects.create(
owner_id=user_id, title=title, visibility=0
).id
tag_cache[tag_key] = tag tag_cache[tag_key] = tag
else: else:
tag = tag_cache[tag_key] tag = tag_cache[tag_key]
@ -217,28 +286,31 @@ class Command(BaseCommand):
owner_id=user_id, owner_id=user_id,
position=0, position=0,
item_id=item_id, item_id=item_id,
created_time=created_time) created_time=created_time,
)
except Exception as e: except Exception as e:
print(f'Convert failed for {typ} {entity.id}: {e}') print(f"Convert failed for {typ} {entity.id}: {e}")
if options['failstop']: if options["failstop"]:
raise(e) raise (e)
def handle(self, *args, **options): def handle(self, *args, **options):
if options['initshelf']: if options["initshelf"]:
self.initshelf() self.initshelf()
elif options['collection']: elif options["collection"]:
if options['clear']: if options["clear"]:
self.clear([Collection, Like]) self.clear([Collection, Like])
else: else:
self.collection(options) self.collection(options)
elif options['review']: elif options["review"]:
if options['clear']: if options["clear"]:
self.clear([Review]) self.clear([Review])
else: else:
self.review(options) self.review(options)
elif options['mark']: elif options["mark"]:
if options['clear']: if options["clear"]:
self.clear([Comment, Rating, TagMember, Tag, ShelfLogEntry, ShelfMember]) self.clear(
[Comment, Rating, TagMember, Tag, ShelfLogEntry, ShelfMember]
)
else: else:
self.mark(options) self.mark(options)
self.stdout.write(self.style.SUCCESS(f'Done.')) self.stdout.write(self.style.SUCCESS(f"Done."))

View file

@ -19,47 +19,47 @@ logger = logging.getLogger(__name__)
# returns user info # returns user info
# retruns the same info as verify account credentials # retruns the same info as verify account credentials
# GET # GET
API_GET_ACCOUNT = '/api/v1/accounts/:id' API_GET_ACCOUNT = "/api/v1/accounts/:id"
# returns user info if valid, 401 if invalid # returns user info if valid, 401 if invalid
# GET # GET
API_VERIFY_ACCOUNT = '/api/v1/accounts/verify_credentials' API_VERIFY_ACCOUNT = "/api/v1/accounts/verify_credentials"
# obtain token # obtain token
# GET # GET
API_OBTAIN_TOKEN = '/oauth/token' API_OBTAIN_TOKEN = "/oauth/token"
# obatin auth code # obatin auth code
# GET # GET
API_OAUTH_AUTHORIZE = '/oauth/authorize' API_OAUTH_AUTHORIZE = "/oauth/authorize"
# revoke token # revoke token
# POST # POST
API_REVOKE_TOKEN = '/oauth/revoke' API_REVOKE_TOKEN = "/oauth/revoke"
# relationships # relationships
# GET # GET
API_GET_RELATIONSHIPS = '/api/v1/accounts/relationships' API_GET_RELATIONSHIPS = "/api/v1/accounts/relationships"
# toot # toot
# POST # POST
API_PUBLISH_TOOT = '/api/v1/statuses' API_PUBLISH_TOOT = "/api/v1/statuses"
# create new app # create new app
# POST # POST
API_CREATE_APP = '/api/v1/apps' API_CREATE_APP = "/api/v1/apps"
# search # search
# GET # GET
API_SEARCH = '/api/v2/search' API_SEARCH = "/api/v2/search"
TWITTER_DOMAIN = 'twitter.com' TWITTER_DOMAIN = "twitter.com"
TWITTER_API_ME = 'https://api.twitter.com/2/users/me' TWITTER_API_ME = "https://api.twitter.com/2/users/me"
TWITTER_API_POST = 'https://api.twitter.com/2/tweets' TWITTER_API_POST = "https://api.twitter.com/2/tweets"
TWITTER_API_TOKEN = 'https://api.twitter.com/2/oauth2/token' TWITTER_API_TOKEN = "https://api.twitter.com/2/oauth2/token"
USER_AGENT = f"{settings.CLIENT_NAME}/1.0" USER_AGENT = f"{settings.CLIENT_NAME}/1.0"
@ -70,43 +70,38 @@ post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT)
# low level api below # low level api below
def get_relationships(site, id_list, token): # no longer in use def get_relationships(site, id_list, token): # no longer in use
url = 'https://' + site + API_GET_RELATIONSHIPS url = "https://" + site + API_GET_RELATIONSHIPS
payload = {'id[]': id_list} payload = {"id[]": id_list}
headers = { headers = {"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
'User-Agent': USER_AGENT,
'Authorization': f'Bearer {token}'
}
response = get(url, headers=headers, params=payload) response = get(url, headers=headers, params=payload)
return response.json() return response.json()
def post_toot(site, content, visibility, token, local_only=False, update_id=None): def post_toot(site, content, visibility, token, local_only=False, update_id=None):
headers = { headers = {
'User-Agent': USER_AGENT, "User-Agent": USER_AGENT,
'Authorization': f'Bearer {token}', "Authorization": f"Bearer {token}",
'Idempotency-Key': random_string_generator(16) "Idempotency-Key": random_string_generator(16),
} }
if site == TWITTER_DOMAIN: if site == TWITTER_DOMAIN:
url = TWITTER_API_POST url = TWITTER_API_POST
payload = { payload = {"text": content if len(content) <= 150 else content[0:150] + "..."}
'text': content if len(content) <= 150 else content[0:150] + '...'
}
response = post(url, headers=headers, json=payload) response = post(url, headers=headers, json=payload)
if response.status_code == 201: if response.status_code == 201:
response.status_code = 200 response.status_code = 200
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Error {url} {response.status_code}") logger.error(f"Error {url} {response.status_code}")
else: else:
url = 'https://' + site + API_PUBLISH_TOOT url = "https://" + site + API_PUBLISH_TOOT
payload = { payload = {
'status': content, "status": content,
'visibility': visibility, "visibility": visibility,
} }
if local_only: if local_only:
payload['local_only'] = True payload["local_only"] = True
try: try:
if update_id: if update_id:
response = put(url + '/' + update_id, headers=headers, data=payload) response = put(url + "/" + update_id, headers=headers, data=payload)
if update_id is None or response.status_code != 200: if update_id is None or response.status_code != 200:
response = post(url, headers=headers, data=payload) response = post(url, headers=headers, data=payload)
if response.status_code == 201: if response.status_code == 201:
@ -120,78 +115,81 @@ def post_toot(site, content, visibility, token, local_only=False, update_id=None
def get_instance_info(domain_name): def get_instance_info(domain_name):
if domain_name.lower().strip() == TWITTER_DOMAIN: if domain_name.lower().strip() == TWITTER_DOMAIN:
return TWITTER_DOMAIN, '' return TWITTER_DOMAIN, ""
try: try:
url = f'https://{domain_name}/api/v1/instance' url = f"https://{domain_name}/api/v1/instance"
response = get(url, headers={'User-Agent': USER_AGENT}) response = get(url, headers={"User-Agent": USER_AGENT})
j = response.json() j = response.json()
return j['uri'].lower().split('//')[-1].split('/')[0], j['version'] return j["uri"].lower().split("//")[-1].split("/")[0], j["version"]
except Exception: except Exception:
logger.error(f"Error {url}") logger.error(f"Error {url}")
return domain_name, '' return domain_name, ""
def create_app(domain_name): def create_app(domain_name):
# naive protocal strip # naive protocal strip
is_http = False is_http = False
if domain_name.startswith("https://"): if domain_name.startswith("https://"):
domain_name = domain_name.replace("https://", '') domain_name = domain_name.replace("https://", "")
elif domain_name.startswith("http://"): elif domain_name.startswith("http://"):
is_http = True is_http = True
domain_name = domain_name.replace("http://", '') domain_name = domain_name.replace("http://", "")
if domain_name.endswith('/'): if domain_name.endswith("/"):
domain_name = domain_name[0:-1] domain_name = domain_name[0:-1]
if not is_http: if not is_http:
url = 'https://' + domain_name + API_CREATE_APP url = "https://" + domain_name + API_CREATE_APP
else: else:
url = 'http://' + domain_name + API_CREATE_APP url = "http://" + domain_name + API_CREATE_APP
payload = { payload = {
'client_name': settings.CLIENT_NAME, "client_name": settings.CLIENT_NAME,
'scopes': settings.MASTODON_CLIENT_SCOPE, "scopes": settings.MASTODON_CLIENT_SCOPE,
'redirect_uris': settings.REDIRECT_URIS, "redirect_uris": settings.REDIRECT_URIS,
'website': settings.APP_WEBSITE "website": settings.APP_WEBSITE,
} }
response = post(url, data=payload, headers={'User-Agent': USER_AGENT}) response = post(url, data=payload, headers={"User-Agent": USER_AGENT})
return response return response
def get_site_id(username, user_site, target_site, token): def get_site_id(username, user_site, target_site, token):
url = 'https://' + target_site + API_SEARCH url = "https://" + target_site + API_SEARCH
payload = { payload = {
'limit': 1, "limit": 1,
'type': 'accounts', "type": "accounts",
'resolve': True, "resolve": True,
'q': f"{username}@{user_site}" "q": f"{username}@{user_site}",
}
headers = {
'User-Agent': USER_AGENT,
'Authorization': f'Bearer {token}'
} }
headers = {"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
response = get(url, params=payload, headers=headers) response = get(url, params=payload, headers=headers)
try: try:
data = response.json() data = response.json()
except Exception: except Exception:
logger.error(f"Error parsing JSON from {url}") logger.error(f"Error parsing JSON from {url}")
return None return None
if 'accounts' not in data: if "accounts" not in data:
return None return None
elif len(data['accounts']) == 0: # target site may return empty if no cache of this user elif (
len(data["accounts"]) == 0
): # target site may return empty if no cache of this user
return None return None
elif data['accounts'][0]['acct'] != f"{username}@{user_site}": # or return another user with a similar id which needs to be skipped elif (
data["accounts"][0]["acct"] != f"{username}@{user_site}"
): # or return another user with a similar id which needs to be skipped
return None return None
else: else:
return data['accounts'][0]['id'] return data["accounts"][0]["id"]
# high level api below # high level api below
def get_relationship(request_user, target_user, useless_token=None): def get_relationship(request_user, target_user, useless_token=None):
return [{ return [
'blocked_by': target_user.is_blocking(request_user), {
'following': request_user.is_following(target_user), "blocked_by": target_user.is_blocking(request_user),
}] "following": request_user.is_following(target_user),
}
]
def get_cross_site_id(target_user, target_site, token): def get_cross_site_id(target_user, target_site, token):
@ -209,19 +207,22 @@ def get_cross_site_id(target_user, target_site, token):
try: try:
cross_site_info = CrossSiteUserInfo.objects.get( cross_site_info = CrossSiteUserInfo.objects.get(
uid=f"{target_user.username}@{target_user.mastodon_site}", uid=f"{target_user.username}@{target_user.mastodon_site}",
target_site=target_site target_site=target_site,
) )
except ObjectDoesNotExist: except ObjectDoesNotExist:
cross_site_id = get_site_id( cross_site_id = get_site_id(
target_user.username, target_user.mastodon_site, target_site, token) target_user.username, target_user.mastodon_site, target_site, token
)
if not cross_site_id: if not cross_site_id:
logger.error(f'unable to find cross_site_id for {target_user} on {target_site}') logger.error(
f"unable to find cross_site_id for {target_user} on {target_site}"
)
return None return None
cross_site_info = CrossSiteUserInfo.objects.create( cross_site_info = CrossSiteUserInfo.objects.create(
uid=f"{target_user.username}@{target_user.mastodon_site}", uid=f"{target_user.username}@{target_user.mastodon_site}",
target_site=target_site, target_site=target_site,
site_id=cross_site_id, site_id=cross_site_id,
local_id=target_user.id local_id=target_user.id,
) )
return cross_site_info.site_id return cross_site_info.site_id
@ -229,31 +230,41 @@ def get_cross_site_id(target_user, target_site, token):
# utils below # utils below
def random_string_generator(n): def random_string_generator(n):
s = string.ascii_letters + string.punctuation + string.digits s = string.ascii_letters + string.punctuation + string.digits
return ''.join(random.choice(s) for i in range(n)) return "".join(random.choice(s) for i in range(n))
def verify_account(site, token): def verify_account(site, token):
if site == TWITTER_DOMAIN: if site == TWITTER_DOMAIN:
url = TWITTER_API_ME + '?user.fields=id,username,name,description,profile_image_url,created_at,protected' url = (
TWITTER_API_ME
+ "?user.fields=id,username,name,description,profile_image_url,created_at,protected"
)
try: try:
response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) response = get(
url,
headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"},
)
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Error {url} {response.status_code}") logger.error(f"Error {url} {response.status_code}")
return response.status_code, None return response.status_code, None
r = response.json()['data'] r = response.json()["data"]
r['display_name'] = r['name'] r["display_name"] = r["name"]
r['note'] = r['description'] r["note"] = r["description"]
r['avatar'] = r['profile_image_url'] r["avatar"] = r["profile_image_url"]
r['avatar_static'] = r['profile_image_url'] r["avatar_static"] = r["profile_image_url"]
r['locked'] = r['protected'] r["locked"] = r["protected"]
r['url'] = f'https://{TWITTER_DOMAIN}/{r["username"]}' r["url"] = f'https://{TWITTER_DOMAIN}/{r["username"]}'
return 200, r return 200, r
except Exception: except Exception:
return -1, None return -1, None
url = 'https://' + site + API_VERIFY_ACCOUNT url = "https://" + site + API_VERIFY_ACCOUNT
try: try:
response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) response = get(
return response.status_code, (response.json() if response.status_code == 200 else None) url, headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
)
return response.status_code, (
response.json() if response.status_code == 200 else None
)
except Exception: except Exception:
return -1, None return -1, None
@ -261,94 +272,130 @@ def verify_account(site, token):
def get_related_acct_list(site, token, api): def get_related_acct_list(site, token, api):
if site == TWITTER_DOMAIN: if site == TWITTER_DOMAIN:
return [] return []
url = 'https://' + site + api url = "https://" + site + api
results = [] results = []
while url: while url:
response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) response = get(
url, headers={"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
)
url = None url = None
if response.status_code == 200: if response.status_code == 200:
results.extend(map(lambda u: (u['acct'] if u['acct'].find('@') != -1 else u['acct'] + '@' + site) if 'acct' in u else u, response.json())) results.extend(
if 'Link' in response.headers: map(
for ls in response.headers['Link'].split(','): lambda u: (
li = ls.strip().split(';') u["acct"]
if u["acct"].find("@") != -1
else u["acct"] + "@" + site
)
if "acct" in u
else u,
response.json(),
)
)
if "Link" in response.headers:
for ls in response.headers["Link"].split(","):
li = ls.strip().split(";")
if li[1].strip() == 'rel="next"': if li[1].strip() == 'rel="next"':
url = li[0].strip().replace('>', '').replace('<', '') url = li[0].strip().replace(">", "").replace("<", "")
return results return results
class TootVisibilityEnum: class TootVisibilityEnum:
PUBLIC = 'public' PUBLIC = "public"
PRIVATE = 'private' PRIVATE = "private"
DIRECT = 'direct' DIRECT = "direct"
UNLISTED = 'unlisted' UNLISTED = "unlisted"
def get_mastodon_application(domain): def get_mastodon_application(domain):
app = MastodonApplication.objects.filter(domain_name=domain).first() app = MastodonApplication.objects.filter(domain_name=domain).first()
if app is not None: if app is not None:
return app, '' return app, ""
if domain == TWITTER_DOMAIN: if domain == TWITTER_DOMAIN:
return None, 'Twitter未配置' return None, "Twitter未配置"
error_msg = None error_msg = None
try: try:
response = create_app(domain) response = create_app(domain)
except (requests.exceptions.Timeout, ConnectionError): except (requests.exceptions.Timeout, ConnectionError):
error_msg = "联邦网络请求超时。" error_msg = "联邦网络请求超时。"
logger.error(f'Error creating app for {domain}: Timeout') logger.error(f"Error creating app for {domain}: Timeout")
except Exception as e: except Exception as e:
error_msg = "联邦网络请求失败 " + str(e) error_msg = "联邦网络请求失败 " + str(e)
logger.error(f'Error creating app for {domain}: {e}') logger.error(f"Error creating app for {domain}: {e}")
else: else:
# fill the form with returned data # fill the form with returned data
if response.status_code != 200: if response.status_code != 200:
error_msg = "实例连接错误,代码: " + str(response.status_code) error_msg = "实例连接错误,代码: " + str(response.status_code)
logger.error(f'Error creating app for {domain}: {response.status_code}') logger.error(f"Error creating app for {domain}: {response.status_code}")
else: else:
try: try:
data = response.json() data = response.json()
except Exception: except Exception:
error_msg = "实例返回内容无法识别" error_msg = "实例返回内容无法识别"
logger.error(f'Error creating app for {domain}: unable to parse response') logger.error(
f"Error creating app for {domain}: unable to parse response"
)
else: else:
if settings.MASTODON_ALLOW_ANY_SITE: if settings.MASTODON_ALLOW_ANY_SITE:
app = MastodonApplication.objects.create(domain_name=domain, app_id=data['id'], client_id=data['client_id'], app = MastodonApplication.objects.create(
client_secret=data['client_secret'], vapid_key=data['vapid_key'] if 'vapid_key' in data else '') domain_name=domain,
app_id=data["id"],
client_id=data["client_id"],
client_secret=data["client_secret"],
vapid_key=data["vapid_key"] if "vapid_key" in data else "",
)
else: else:
error_msg = "不支持其它实例登录" error_msg = "不支持其它实例登录"
logger.error(f'Disallowed to create app for {domain}') logger.error(f"Disallowed to create app for {domain}")
return app, error_msg return app, error_msg
def get_mastodon_login_url(app, login_domain, version, request): def get_mastodon_login_url(app, login_domain, version, request):
url = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') url = request.scheme + "://" + request.get_host() + reverse("users:OAuth2_login")
if login_domain == TWITTER_DOMAIN: if login_domain == TWITTER_DOMAIN:
return f"https://twitter.com/i/oauth2/authorize?response_type=code&client_id={app.client_id}&redirect_uri={quote(url)}&scope={quote(settings.TWITTER_CLIENT_SCOPE)}&state=state&code_challenge=challenge&code_challenge_method=plain" return f"https://twitter.com/i/oauth2/authorize?response_type=code&client_id={app.client_id}&redirect_uri={quote(url)}&scope={quote(settings.TWITTER_CLIENT_SCOPE)}&state=state&code_challenge=challenge&code_challenge_method=plain"
scope = settings.MASTODON_LEGACY_CLIENT_SCOPE if 'Pixelfed' in version else settings.MASTODON_CLIENT_SCOPE scope = (
return "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(scope) + "&redirect_uri=" + url + "&response_type=code" settings.MASTODON_LEGACY_CLIENT_SCOPE
if "Pixelfed" in version
else settings.MASTODON_CLIENT_SCOPE
)
return (
"https://"
+ login_domain
+ "/oauth/authorize?client_id="
+ app.client_id
+ "&scope="
+ quote(scope)
+ "&redirect_uri="
+ url
+ "&response_type=code"
)
def obtain_token(site, request, code): def obtain_token(site, request, code):
""" Returns token if success else None. """ """Returns token if success else None."""
mast_app = MastodonApplication.objects.get(domain_name=site) mast_app = MastodonApplication.objects.get(domain_name=site)
redirect_uri = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') redirect_uri = (
request.scheme + "://" + request.get_host() + reverse("users:OAuth2_login")
)
payload = { payload = {
'client_id': mast_app.client_id, "client_id": mast_app.client_id,
'client_secret': mast_app.client_secret, "client_secret": mast_app.client_secret,
'redirect_uri': redirect_uri, "redirect_uri": redirect_uri,
'grant_type': 'authorization_code', "grant_type": "authorization_code",
'code': code "code": code,
} }
headers = {'User-Agent': USER_AGENT} headers = {"User-Agent": USER_AGENT}
auth = None auth = None
if mast_app.is_proxy: if mast_app.is_proxy:
url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN url = "https://" + mast_app.proxy_to + API_OBTAIN_TOKEN
elif site == TWITTER_DOMAIN: elif site == TWITTER_DOMAIN:
url = TWITTER_API_TOKEN url = TWITTER_API_TOKEN
auth = (mast_app.client_id, mast_app.client_secret) auth = (mast_app.client_id, mast_app.client_secret)
del payload['client_secret'] del payload["client_secret"]
payload['code_verifier'] = 'challenge' payload["code_verifier"] = "challenge"
else: else:
url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN url = "https://" + mast_app.domain_name + API_OBTAIN_TOKEN
try: try:
response = post(url, data=payload, headers=headers, auth=auth) response = post(url, data=payload, headers=headers, auth=auth)
# {"token_type":"bearer","expires_in":7200,"access_token":"VGpkOEZGR3FQRDJ5NkZ0dmYyYWIwS0dqeHpvTnk4eXp0NV9nWDJ2TEpmM1ZTOjE2NDg3ODMxNTU4Mzc6MToxOmF0OjE","scope":"block.read follows.read offline.access tweet.write users.read mute.read","refresh_token":"b1pXbGEzeUF1WE5yZHJOWmxTeWpvMTBrQmZPd0czLU0tQndZQTUyU3FwRDVIOjE2NDg3ODMxNTU4Mzg6MToxOnJ0OjE"} # {"token_type":"bearer","expires_in":7200,"access_token":"VGpkOEZGR3FQRDJ5NkZ0dmYyYWIwS0dqeHpvTnk4eXp0NV9nWDJ2TEpmM1ZTOjE2NDg3ODMxNTU4Mzc6MToxOmF0OjE","scope":"block.read follows.read offline.access tweet.write users.read mute.read","refresh_token":"b1pXbGEzeUF1WE5yZHJOWmxTeWpvMTBrQmZPd0czLU0tQndZQTUyU3FwRDVIOjE2NDg3ODMxNTU4Mzg6MToxOnJ0OjE"}
@ -359,7 +406,7 @@ def obtain_token(site, request, code):
logger.error(f"Error {url} {e}") logger.error(f"Error {url} {e}")
return None, None return None, None
data = response.json() data = response.json()
return data.get('access_token'), data.get('refresh_token', '') return data.get("access_token"), data.get("refresh_token", "")
def refresh_access_token(site, refresh_token): def refresh_access_token(site, refresh_token):
@ -368,34 +415,34 @@ def refresh_access_token(site, refresh_token):
mast_app = MastodonApplication.objects.get(domain_name=site) mast_app = MastodonApplication.objects.get(domain_name=site)
url = TWITTER_API_TOKEN url = TWITTER_API_TOKEN
payload = { payload = {
'client_id': mast_app.client_id, "client_id": mast_app.client_id,
'refresh_token': refresh_token, "refresh_token": refresh_token,
'grant_type': 'refresh_token', "grant_type": "refresh_token",
} }
headers = {'User-Agent': USER_AGENT} headers = {"User-Agent": USER_AGENT}
auth = (mast_app.client_id, mast_app.client_secret) auth = (mast_app.client_id, mast_app.client_secret)
response = post(url, data=payload, headers=headers, auth=auth) response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Error {url} {response.status_code}") logger.error(f"Error {url} {response.status_code}")
return None return None
data = response.json() data = response.json()
return data.get('access_token') return data.get("access_token")
def revoke_token(site, token): def revoke_token(site, token):
mast_app = MastodonApplication.objects.get(domain_name=site) mast_app = MastodonApplication.objects.get(domain_name=site)
payload = { payload = {
'client_id': mast_app.client_id, "client_id": mast_app.client_id,
'client_secret': mast_app.client_secret, "client_secret": mast_app.client_secret,
'token': token "token": token,
} }
if mast_app.is_proxy: if mast_app.is_proxy:
url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN url = "https://" + mast_app.proxy_to + API_REVOKE_TOKEN
else: else:
url = 'https://' + site + API_REVOKE_TOKEN url = "https://" + site + API_REVOKE_TOKEN
post(url, data=payload, headers={'User-Agent': USER_AGENT}) post(url, data=payload, headers={"User-Agent": USER_AGENT})
def share_mark(mark): def share_mark(mark):
@ -408,22 +455,38 @@ def share_mark(mark):
visibility = TootVisibilityEnum.PUBLIC visibility = TootVisibilityEnum.PUBLIC
else: else:
visibility = TootVisibilityEnum.UNLISTED visibility = TootVisibilityEnum.UNLISTED
tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', str(mark.item.verbose_category_name)) if user.get_preference().mastodon_append_tag else '' tags = (
stars = rating_to_emoji(mark.rating, MastodonApplication.objects.get(domain_name=user.mastodon_site).star_mode) "\n"
+ user.get_preference().mastodon_append_tag.replace(
"[category]", str(mark.item.verbose_category_name)
)
if user.get_preference().mastodon_append_tag
else ""
)
stars = rating_to_emoji(
mark.rating,
MastodonApplication.objects.get(domain_name=user.mastodon_site).star_mode,
)
content = f"{mark.translated_status}{mark.item.title}{stars}\n{mark.item.absolute_url}\n{mark.text}{tags}" content = f"{mark.translated_status}{mark.item.title}{stars}\n{mark.item.absolute_url}\n{mark.text}{tags}"
update_id = None update_id = None
if mark.shared_link: # "https://mastodon.social/@username/1234567890" if mark.shared_link: # "https://mastodon.social/@username/1234567890"
r = re.match(r'.+/(\w+)$', mark.shared_link) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit r = re.match(
r".+/(\w+)$", mark.shared_link
) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit
update_id = r[1] if r else None update_id = r[1] if r else None
response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token, False, update_id) response = post_toot(
user.mastodon_site, content, visibility, user.mastodon_token, False, update_id
)
if response and response.status_code in [200, 201]: if response and response.status_code in [200, 201]:
j = response.json() j = response.json()
if 'url' in j: if "url" in j:
mark.shared_link = j['url'] mark.shared_link = j["url"]
elif 'data' in j: elif "data" in j:
mark.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" mark.shared_link = (
f"https://twitter.com/{user.username}/status/{j['data']['id']}"
)
if mark.shared_link: if mark.shared_link:
mark.save(update_fields=['shared_link']) mark.save(update_fields=["shared_link"])
return True return True
else: else:
return False return False
@ -439,21 +502,34 @@ def share_review(review):
visibility = TootVisibilityEnum.PUBLIC visibility = TootVisibilityEnum.PUBLIC
else: else:
visibility = TootVisibilityEnum.UNLISTED visibility = TootVisibilityEnum.UNLISTED
tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', str(review.item.verbose_category_name)) if user.get_preference().mastodon_append_tag else '' tags = (
"\n"
+ user.get_preference().mastodon_append_tag.replace(
"[category]", str(review.item.verbose_category_name)
)
if user.get_preference().mastodon_append_tag
else ""
)
content = f"发布了关于《{review.item.title}》的评论\n{review.url}\n{review.title}{tags}" content = f"发布了关于《{review.item.title}》的评论\n{review.url}\n{review.title}{tags}"
update_id = None update_id = None
if review.shared_link: # "https://mastodon.social/@username/1234567890" if review.shared_link: # "https://mastodon.social/@username/1234567890"
r = re.match(r'.+/(\w+)$', review.shared_link) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit r = re.match(
r".+/(\w+)$", review.shared_link
) # might be re.match(r'.+/([^/]+)$', u) if Pleroma supports edit
update_id = r[1] if r else None update_id = r[1] if r else None
response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token, False, update_id) response = post_toot(
user.mastodon_site, content, visibility, user.mastodon_token, False, update_id
)
if response and response.status_code in [200, 201]: if response and response.status_code in [200, 201]:
j = response.json() j = response.json()
if 'url' in j: if "url" in j:
review.shared_link = j['url'] review.shared_link = j["url"]
elif 'data' in j: elif "data" in j:
review.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" review.shared_link = (
f"https://twitter.com/{user.username}/status/{j['data']['id']}"
)
if review.shared_link: if review.shared_link:
review.save(update_fields=['shared_link']) review.save(update_fields=["shared_link"])
return True return True
else: else:
return False return False
@ -468,15 +544,21 @@ def share_collection(collection, comment, user, visibility_no):
visibility = TootVisibilityEnum.PUBLIC visibility = TootVisibilityEnum.PUBLIC
else: else:
visibility = TootVisibilityEnum.UNLISTED visibility = TootVisibilityEnum.UNLISTED
tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', '收藏单') if user.get_preference().mastodon_append_tag else '' tags = (
"\n" + user.get_preference().mastodon_append_tag.replace("[category]", "收藏单")
if user.get_preference().mastodon_append_tag
else ""
)
content = f"分享收藏单《{collection.title}\n{collection.absolute_url}\n{comment}{tags}" content = f"分享收藏单《{collection.title}\n{collection.absolute_url}\n{comment}{tags}"
response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token)
if response and response.status_code in [200, 201]: if response and response.status_code in [200, 201]:
j = response.json() j = response.json()
if 'url' in j: if "url" in j:
shared_link = j['url'] shared_link = j["url"]
elif 'data' in j: elif "data" in j:
shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" shared_link = (
f"https://twitter.com/{user.username}/status/{j['data']['id']}"
)
if shared_link: if shared_link:
pass pass
return True return True

2
pyproject.toml Normal file
View file

@ -0,0 +1,2 @@
[tool.pyright]
exclude = [ "media", ".venv", ".git" ]