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
.venv
# Byte-compiled / optimized / DLL files
__pycache__/

View file

@ -12,7 +12,23 @@ from functools import partialmethod
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):
@ -26,12 +42,12 @@ class JSONFieldDescriptor(object):
if isinstance(json_value, dict):
if self.field.attname in json_value or not self.field.has_default():
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)
return value
else:
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)
else:
json_value[self.field.attname] = default
@ -45,7 +61,7 @@ class JSONFieldDescriptor(object):
else:
json_value = {}
if hasattr(self.field, 'to_json'):
if hasattr(self.field, "to_json"):
value = self.field.to_json(value)
if not value and self.field.blank and not self.field.null:
@ -66,7 +82,7 @@ class JSONFieldMixin(object):
"""
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)
def contribute_to_class(self, cls, name, private_only=False):
@ -81,8 +97,11 @@ class JSONFieldMixin(object):
setattr(cls, self.attname, descriptor)
if self.choices is not None:
setattr(cls, 'get_%s_display' % self.name,
partialmethod(cls._get_FIELD_display, field=self))
setattr(
cls,
"get_%s_display" % self.name,
partialmethod(cls._get_FIELD_display, field=self),
)
def get_lookup(self, lookup_name):
# Always return None, to make get_transform been called
@ -101,15 +120,17 @@ class JSONFieldMixin(object):
lhs.output_field = self.json_field
transform = self.transform(lhs, **kwargs)
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
json_field = self.model._meta.get_field(self.json_field_name)
transform = json_field.get_transform(self.name)
if transform is None:
raise FieldError(
"JSONField '%s' has no support for key '%s' %s lookup" %
(self.json_field_name, self.name, name)
"JSONField '%s' has no support for key '%s' %s lookup"
% (self.json_field_name, self.name, name)
)
return TransformFactoryWrapper(json_field, transform, name)
@ -123,8 +144,11 @@ class BooleanField(JSONFieldMixin, fields.BooleanField):
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
pass
@ -133,7 +157,7 @@ class DateField(JSONFieldMixin, fields.DateField):
if value:
if not isinstance(value, (datetime, date)):
value = dateparse.parse_date(value)
return value.strftime('%Y-%m-%d')
return value.strftime("%Y-%m-%d")
def from_json(self, value):
if value is not None:

View file

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

View file

@ -244,7 +244,7 @@
{% endif %}
<span class="review-panel__actions">
<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>
<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.filter
def thumb(source, alias):
"""
This filter modifies that from `easy_thumbnails` so that
it can neglect .svg file.
"""
if source.url.endswith('.svg'):
if source.url.endswith(".svg"):
return source.url
else:
try:
return thumbnail_url(source, alias)
except Exception as e:
return ''
except Exception:
return ""

View file

@ -4,7 +4,6 @@ from users.models import User
from catalog.common.models import Item, ItemCategory
from .mixins import UserOwnedObjectMixin
from catalog.collection.models import Collection as CatalogCollection
from decimal import *
from enum import Enum
from markdownx.models import MarkdownxField
from django.utils import timezone
@ -24,12 +23,13 @@ from catalog.models import *
import mistune
from django.contrib.contenttypes.models import ContentType
from markdown import markdown
from catalog.common import jsondata
class VisibilityType(models.IntegerChoices):
Public = 0, _('公开')
Follower_Only = 1, _('仅关注者')
Private = 2, _('仅自己')
Public = 0, _("公开")
Follower_Only = 1, _("仅关注者")
Private = 2, _("仅自己")
def q_visible_to(viewer, owner):
@ -44,7 +44,11 @@ def q_visible_to(viewer, owner):
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):
@ -63,14 +67,26 @@ def query_item_category(item_category):
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)
owner = models.ForeignKey(User, on_delete=models.PROTECT)
visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only
created_time = models.DateTimeField(default=timezone.now) # auto_now_add=True FIXME revert this after migration
edited_time = models.DateTimeField(default=timezone.now) # auto_now=True FIXME revert this after migration
visibility = models.PositiveSmallIntegerField(
default=0
) # 0: Public / 1: Follower only / 2: Self only
created_time = models.DateTimeField(
default=timezone.now
) # auto_now_add=True FIXME revert this after migration
edited_time = models.DateTimeField(
default=timezone.now
) # auto_now=True FIXME revert this after migration
metadata = models.JSONField(default=dict)
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
def uuid(self):
@ -78,7 +94,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
@property
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
def absolute_url(self):
@ -86,7 +102,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
@property
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):
@ -106,7 +122,7 @@ class Content(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
def user_like_piece(user, piece):
@ -117,8 +133,14 @@ class Like(Piece):
like = Like.objects.create(owner=user, target=piece)
return like
@staticmethod
def user_unlike_piece(user, piece):
if not piece:
return
Like.objects.filter(owner=user, target=piece).delete()
class Note(Content):
class Memo(Content):
pass
@ -133,7 +155,9 @@ class Comment(Content):
comment.delete()
comment = 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:
comment.text = text
comment.visibility = visibility
@ -142,7 +166,7 @@ class Comment(Content):
class Review(Content):
url_path = 'review'
url_path = "review"
title = models.CharField(max_length=500, blank=False, null=False)
body = MarkdownxField()
@ -157,7 +181,14 @@ class Review(Content):
@staticmethod
def review_item_by_user(item, user, title, body, metadata={}, visibility=0):
# 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()
if title is None:
@ -176,29 +207,37 @@ class Review(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
def get_rating_for_item(item):
stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(average=Avg('grade'), count=Count('item'))
return stat['average'] if stat['count'] >= 5 else None
stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(
average=Avg("grade"), count=Count("item")
)
return stat["average"] if stat["count"] >= 5 else None
@staticmethod
def get_rating_count_for_item(item):
stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(count=Count('item'))
return stat['count']
stat = Rating.objects.filter(item=item, grade__isnull=False).aggregate(
count=Count("item")
)
return stat["count"]
@staticmethod
def rate_item_by_user(item, user, rating_grade, visibility=0):
if rating_grade and (rating_grade < 1 or rating_grade > 10):
raise ValueError(f'Invalid rating grade: {rating_grade}')
raise ValueError(f"Invalid rating grade: {rating_grade}")
rating = Rating.objects.filter(owner=user, item=item).first()
if not rating_grade:
if rating:
rating.delete()
rating = None
elif rating is None:
rating = Rating.objects.create(owner=user, item=item, grade=rating_grade, visibility=visibility)
rating = Rating.objects.create(
owner=user, item=item, grade=rating_grade, visibility=visibility
)
elif rating.grade != rating_grade or rating.visibility != visibility:
rating.visibility = visibility
rating.grade = rating_grade
@ -216,7 +255,9 @@ Item.rating_count = property(Rating.get_rating_count_for_item)
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)
body = MarkdownxField()
pass
@ -234,7 +275,9 @@ class List(Piece):
class Meta:
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):
self._owner = self.owner
@ -246,43 +289,56 @@ class List(Piece):
@property
def ordered_members(self):
return self.members.all().order_by('position', 'item_id')
return self.members.all().order_by("position")
@property
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
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
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):
return self.members.filter(item=item).count() > 0
def get_member_for_item(self, item):
return self.members.filter(item=item).first()
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
else:
ml = self.ordered_members
p = {'parent': self}
p = {"parent": self}
p.update(params)
member = self.MEMBER_CLASS.objects.create(owner=self.owner, position=ml.last().position + 1 if ml.count() else 1, item=item, **p)
list_add.send(sender=self.__class__, instance=self, item=item, member=member)
member = self.MEMBER_CLASS.objects.create(
owner=self.owner,
position=ml.last().position + 1 if ml.count() else 1,
item=item,
**p,
)
list_add.send(
sender=self.__class__, instance=self, item=item, member=member
)
return member
def remove_item(self, item):
member = self.members.all().filter(item=item).first()
member = self.get_member_for_item(item)
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()
def move_up_item(self, item):
members = self.ordered_members
member = members.filter(item=item).first()
member = self.get_member_for_item(item)
if member:
other = members.filter(position__lt=member.position).last()
if other:
@ -294,7 +350,7 @@ class List(Piece):
def move_down_item(self, item):
members = self.ordered_members
member = members.filter(item=item).first()
member = self.get_member_for_item(item)
if member:
other = members.filter(position__gt=member.position).first()
if other:
@ -304,6 +360,12 @@ class List(Piece):
other.save()
member.save()
def update_item_metadata(self, item, metadata):
member = self.get_member_for_item(item)
if member:
member.metadata = metadata
member.save()
class ListMember(Piece):
"""
@ -312,6 +374,7 @@ class ListMember(Piece):
parent = models.ForeignKey('List', related_name='members', on_delete=models.CASCADE)
"""
item = models.ForeignKey(Item, on_delete=models.PROTECT)
position = models.PositiveIntegerField()
@ -325,7 +388,7 @@ class ListMember(Piece):
abstract = True
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):
WISHLIST = ('wishlist', '未开始')
PROGRESS = ('progress', '进行中')
COMPLETE = ('complete', '完成')
WISHLIST = ("wishlist", "未开始")
PROGRESS = ("progress", "进行中")
COMPLETE = ("complete", "完成")
# DISCARDED = ('discarded', '放弃')
ShelfTypeNames = [
[ItemCategory.Book, ShelfType.WISHLIST, _('想读')],
[ItemCategory.Book, ShelfType.PROGRESS, _('在读')],
[ItemCategory.Book, ShelfType.COMPLETE, _('读过')],
[ItemCategory.Movie, ShelfType.WISHLIST, _('想看')],
[ItemCategory.Movie, ShelfType.PROGRESS, _('在看')],
[ItemCategory.Movie, ShelfType.COMPLETE, _('看过')],
[ItemCategory.TV, ShelfType.WISHLIST, _('想看')],
[ItemCategory.TV, ShelfType.PROGRESS, _('在看')],
[ItemCategory.TV, ShelfType.COMPLETE, _('看过')],
[ItemCategory.Music, ShelfType.WISHLIST, _('想听')],
[ItemCategory.Music, ShelfType.PROGRESS, _('在听')],
[ItemCategory.Music, ShelfType.COMPLETE, _('听过')],
[ItemCategory.Game, ShelfType.WISHLIST, _('想玩')],
[ItemCategory.Game, ShelfType.PROGRESS, _('在玩')],
[ItemCategory.Game, ShelfType.COMPLETE, _('玩过')],
[ItemCategory.Book, ShelfType.WISHLIST, _("想读")],
[ItemCategory.Book, ShelfType.PROGRESS, _("在读")],
[ItemCategory.Book, ShelfType.COMPLETE, _("读过")],
[ItemCategory.Movie, ShelfType.WISHLIST, _("想看")],
[ItemCategory.Movie, ShelfType.PROGRESS, _("在看")],
[ItemCategory.Movie, ShelfType.COMPLETE, _("看过")],
[ItemCategory.TV, ShelfType.WISHLIST, _("想看")],
[ItemCategory.TV, ShelfType.PROGRESS, _("在看")],
[ItemCategory.TV, ShelfType.COMPLETE, _("看过")],
[ItemCategory.Music, ShelfType.WISHLIST, _("想听")],
[ItemCategory.Music, ShelfType.PROGRESS, _("在听")],
[ItemCategory.Music, ShelfType.COMPLETE, _("听过")],
[ItemCategory.Game, ShelfType.WISHLIST, _("想玩")],
[ItemCategory.Game, ShelfType.PROGRESS, _("在玩")],
[ItemCategory.Game, ShelfType.COMPLETE, _("玩过")],
]
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 Meta:
unique_together = [['_owner', 'item_category', 'shelf_type']]
unique_together = [["_owner", "item_category", "shelf_type"]]
MEMBER_CLASS = ShelfMember
items = models.ManyToManyField(Item, through='ShelfMember', related_name="+")
item_category = models.CharField(choices=ItemCategory.choices, max_length=100, null=False, blank=False)
shelf_type = models.CharField(choices=ShelfType.choices, max_length=100, null=False, blank=False)
items = models.ManyToManyField(Item, through="ShelfMember", related_name="+")
item_category = models.CharField(
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):
return f'{self.id} {self.title}'
return f"{self.id} {self.title}"
@cached_property
def item_category_label(self):
@ -383,26 +450,41 @@ class Shelf(List):
@cached_property
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
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 _("{user}'s {shelf_name}").format(user=self.owner.mastodon_username, shelf_name=q)
class ShelfLogEntry(models.Model):
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)
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)
created_time = models.DateTimeField(auto_now_add=True)
edited_time = models.DateTimeField(auto_now=True)
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:
@ -422,12 +504,16 @@ class ShelfManager:
Shelf.objects.create(owner=self.owner, item_category=ic, shelf_type=qt)
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):
if not item or not shelf_type:
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):
member = ShelfMember.objects.filter(owner=self.owner, item=item).first()
@ -437,7 +523,7 @@ class ShelfManager:
# shelf_type=None means remove from current shelf
# metadata=None means no change
if not item:
raise ValueError('empty item')
raise ValueError("empty item")
new_shelfmember = None
last_shelfmember = self._shelf_member_for_item(item)
last_shelf = last_shelfmember.parent if last_shelfmember else None
@ -450,9 +536,11 @@ class ShelfManager:
if last_shelf:
last_shelf.remove_item(item)
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:
raise ValueError('empty shelf')
raise ValueError("empty shelf")
else:
new_shelfmember = last_shelfmember
if metadata is not None and metadata != last_metadata: # change metadata
@ -466,20 +554,32 @@ class ShelfManager:
if changed:
if metadata is None:
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
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):
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):
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):
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
@staticmethod
@ -488,7 +588,7 @@ class ShelfManager:
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):
parent = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE)
parent = models.ForeignKey(
"Collection", related_name="members", on_delete=models.CASCADE
)
@property
def note(self):
return self.metadata.get('comment')
note = jsondata.CharField(_("备注"), null=True, blank=True)
class Collection(List):
url_path = 'collection'
url_path = "collection"
MEMBER_CLASS = CollectionMember
catalog_item = models.OneToOneField(CatalogCollection, on_delete=models.PROTECT)
title = models.CharField(_("title in primary language"), max_length=1000, default="")
title = models.CharField(
_("title in primary language"), max_length=1000, default=""
)
brief = models.TextField(_("简介"), blank=True, default="")
cover = models.ImageField(upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True)
items = models.ManyToManyField(Item, through='CollectionMember', related_name="collections")
collaborative = models.PositiveSmallIntegerField(default=0) # 0: Editable by owner only / 1: Editable by bi-direction followers
cover = models.ImageField(
upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True
)
items = models.ManyToManyField(
Item, through="CollectionMember", related_name="collections"
)
collaborative = models.PositiveSmallIntegerField(
default=0
) # 0: Editable by owner only / 1: Editable by bi-direction followers
@property
def html(self):
@ -522,12 +630,15 @@ class Collection(List):
@property
def plain_description(self):
html = markdown(self.brief)
return RE_HTML_TAG.sub(' ', html)
return RE_HTML_TAG.sub(" ", html)
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()
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.brief = self.brief
self.catalog_item.cover = self.cover
@ -541,21 +652,23 @@ Tag
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):
MEMBER_CLASS = TagMember
items = models.ManyToManyField(Item, through='TagMember')
title = models.CharField(max_length=100, null=False, blank=False, validators=TagValidators)
items = models.ManyToManyField(Item, through="TagMember")
title = models.CharField(
max_length=100, null=False, blank=False, validators=TagValidators
)
# TODO case convert and space removal on save
# TODO check on save
class Meta:
unique_together = [['_owner', 'title']]
unique_together = [["_owner", "title"]]
@staticmethod
def cleanup_title(title):
@ -565,22 +678,37 @@ class Tag(List):
class TagManager:
@staticmethod
def public_tags_for_item(item):
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)))
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
def all_tags_for_user(user):
tags = user.tag_set.all().values('title').annotate(frequency=Count('members__id')).order_by('-frequency')
return list(map(lambda t: t['title'], tags))
tags = (
user.tag_set.all()
.values("title")
.annotate(frequency=Count("members__id"))
.order_by("-frequency")
)
return list(map(lambda t: t["title"], tags))
@staticmethod
def tag_item_by_user(item, user, tag_titles, default_visibility=0):
titles = set([Tag.cleanup_title(tag_title) for tag_title in tag_titles])
current_titles = set([m.parent.title for m in TagMember.objects.filter(owner=user, item=item)])
current_titles = set(
[m.parent.title for m in TagMember.objects.filter(owner=user, item=item)]
)
for title in titles - current_titles:
tag = Tag.objects.filter(owner=user, title=title).first()
if not tag:
tag = Tag.objects.create(owner=user, title=title, visibility=default_visibility)
tag = Tag.objects.create(
owner=user, title=title, visibility=default_visibility
)
tag.append_item(item)
for title in current_titles - titles:
tag = Tag.objects.filter(owner=user, title=title).first()
@ -588,7 +716,9 @@ class TagManager:
@staticmethod
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
@staticmethod
@ -596,7 +726,9 @@ class TagManager:
title = Tag.cleanup_title(tag_title)
tag = Tag.objects.filter(owner=user, title=title).first()
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)
@staticmethod
@ -615,13 +747,20 @@ class TagManager:
TagManager.add_tag_by_user(item, tag, self.owner, visibility)
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)
User.tags = property(TagManager.all_tags_for_user)
User.tag_manager = cached_property(TagManager.get_manager_for_user)
User.tag_manager.__set_name__(User, 'tag_manager')
User.tag_manager.__set_name__(User, "tag_manager")
class Mark:
@ -683,15 +822,36 @@ class Mark:
def review(self):
return Review.objects.filter(owner=self.owner, item=self.item).first()
def update(self, shelf_type, comment_text, rating_grade, visibility, metadata=None, created_time=None, share_to_mastodon=False):
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)
def update(
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:
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:
self.shelfmember.created_time = created_time
self.shelfmember.save()
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:
Rating.rate_item_by_user(self.item, self.owner, rating_grade, visibility)
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,
# by then, we'll just change this to boost
from mastodon.api import share_mark
self.shared_link = self.shelfmember.metadata.get('shared_link') if self.shelfmember.metadata else None
self.shared_link = (
self.shelfmember.metadata.get("shared_link")
if self.shelfmember.metadata
else None
)
self.translated_status = self.shelf_label
self.save = lambda **args: None
if not share_mark(self):
raise ValueError("sharing failed")
if not self.shelfmember.metadata:
self.shelfmember.metadata = {}
if self.shelfmember.metadata.get('shared_link') != self.shared_link:
self.shelfmember.metadata['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.save()
def delete(self):

View file

@ -33,13 +33,17 @@
<div class="dividing-line"></div>
</div>
<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>
</section>
</div>
{% include "partial/_footer.html" %}
</div>
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})
</script>
</body>
</html>

View file

@ -2,7 +2,7 @@
{% load i18n %}
{% load l10n %}
<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 %}
{% include template with item=member.item mark=None collection_member=member %}
{% 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 %}
<div class="collection-item-position-edit">
{% 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 %}
{% 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 %}
<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>
{% endif %}
@ -129,7 +129,7 @@
{{ collection_member.note }}
{% 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 %}
</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">
{% for member in shelf.members %}
<li class="entity-sort__entity">
<a href="{{ member.item.url }}">
<img src="{{ member.item.cover|thumb:'normal' }}"
alt="{{ member.item.title }}" class="entity-sort__entity-img">
<div class="entity-sort__entity-name" title="{{ member.item.title }}">
{{ member.item.title }}</div>
<img src="{{ member.item.cover.url }}" alt="{{ member.item.title }}" class="entity-sort__entity-img">
<div class="entity-sort__entity-name" title="{{ member.item.title }}"> {{ member.item.title }}</div>
</a>
</li>
{% 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.filter(title="test", owner=self.user).first()
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)
self.assertEqual(list(collection.ordered_items), [self.book1, self.book2])
collection.move_up_item(self.book1)
self.assertEqual(list(collection.ordered_items), [self.book1, self.book2])
collection.move_up_item(self.book2)
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):
@ -47,26 +51,28 @@ class ShelfTest(TestCase):
shelf_manager.move_item(book1, ShelfType.PROGRESS)
self.assertEqual(q1.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(q2.members.all().count(), 1)
log = shelf_manager.get_log_for_item(book1)
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)
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)
self.assertEqual(log.count(), 4)
shelf_manager.move_item(book1, ShelfType.PROGRESS)
log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 4)
self.assertEqual(log.last().metadata, {'progress': 10})
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={'progress': 90})
self.assertEqual(log.last().metadata, {"progress": 10})
shelf_manager.move_item(book1, ShelfType.PROGRESS, metadata={"progress": 90})
log = shelf_manager.get_log_for_item(book1)
self.assertEqual(log.count(), 5)
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(shelf_manager.get_log_for_item(book1).count(), 5)
@ -82,18 +88,18 @@ class TagTest(TestCase):
pass
def test_user_tag(self):
t1 = 'tag-1'
t2 = 'tag-2'
t3 = 'tag-3'
t1 = "tag-1"
t2 = "tag-2"
t3 = "tag-3"
TagManager.tag_item_by_user(self.book1, self.user2, [t1, t3])
self.assertEqual(self.book1.tags, [t1, t3])
TagManager.tag_item_by_user(self.book1, self.user2, [t2, t3])
self.assertEqual(self.book1.tags, [t2, t3])
def test_tag(self):
t1 = 'tag-1'
t2 = 'tag-2'
t3 = 'tag-3'
t1 = "tag-1"
t2 = "tag-2"
t3 = "tag-3"
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.user2)
@ -129,21 +135,21 @@ class MarkTest(TestCase):
self.assertEqual(mark.visibility, None)
self.assertEqual(mark.review, None)
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)
self.assertEqual(mark.shelf_type, ShelfType.WISHLIST)
self.assertEqual(mark.shelf_label, '想读')
self.assertEqual(mark.text, 'a gentle comment')
self.assertEqual(mark.shelf_label, "想读")
self.assertEqual(mark.text, "a gentle comment")
self.assertEqual(mark.rating, 9)
self.assertEqual(mark.visibility, 1)
self.assertEqual(mark.review, None)
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)
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)
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 *
app_name = 'journal'
app_name = "journal"
def _get_all_categories():
@ -16,34 +16,88 @@ def _get_all_shelf_types():
urlpatterns = [
path('wish/<str:item_uuid>', wish, name='wish'),
path('like/<str:piece_uuid>', like, name='like'),
path('mark/<str:item_uuid>', mark, name='mark'),
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/edit/<str:item_uuid>/<str:review_uuid>', review_edit, name='review_edit'),
path('review/delete/<str:review_uuid>', review_delete, name='review_delete'),
path('collection/<str:collection_uuid>', collection_retrieve, name='collection_retrieve'),
path('collection/create/', collection_edit, name='collection_create'),
path('collection/edit/<str:collection_uuid>', collection_edit, name='collection_edit'),
path('collection/delete/<str:collection_uuid>', collection_delete, name='collection_delete'),
path('collection/<str:collection_uuid>/items', collection_retrieve_items, name='collection_retrieve_items'),
path('collection/<str:collection_uuid>/append_item', collection_append_item, name='collection_append_item'),
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/<str:collection_uuid>/move_down_item/<str:collection_member_uuid>', collection_move_down_item, name='collection_move_down_item'),
path('collection/<str:collection_uuid>/update_item_note/<str:collection_member_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'),
path("wish/<str:item_uuid>", wish, name="wish"),
path("like/<str:piece_uuid>", like, name="like"),
path("mark/<str:item_uuid>", mark, name="mark"),
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/edit/<str:item_uuid>/<str:review_uuid>", review_edit, name="review_edit"
),
path("review/delete/<str:piece_uuid>", piece_delete, name="review_delete"),
path(
"collection/<str:collection_uuid>",
collection_retrieve,
name="collection_retrieve",
),
path("collection/create/", collection_edit, name="collection_create"),
path(
"collection/edit/<str:collection_uuid>", collection_edit, name="collection_edit"
),
path("collection/delete/<str:piece_uuid>", piece_delete, name="collection_delete"),
path(
"collection/<str:collection_uuid>/items",
collection_retrieve_items,
name="collection_retrieve_items",
),
path(
"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.contrib.auth.decorators import login_required, permission_required
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.db import IntegrityError, transaction
from django.db.models import Count
from django.utils import timezone
from django.core.paginator import Paginator
@ -24,187 +28,257 @@ from users.models import User, Report, Preference
_logger = logging.getLogger(__name__)
PAGE_SIZE = 10
_checkmark = "✔️".encode("utf-8")
@login_required
def wish(request, item_uuid):
if request.method == 'POST':
if request.method == "POST":
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if not item:
return HttpResponseNotFound("item not found")
return HttpResponseNotFound(b"item not found")
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:
return HttpResponseBadRequest("invalid request")
return HttpResponseBadRequest(b"invalid request")
@login_required
def like(request, piece_uuid):
if request.method == 'POST':
if request.method == "POST":
piece = get_object_or_404(Collection, uid=base62.decode(piece_uuid))
if not piece:
return HttpResponseNotFound("piece not found")
return HttpResponseNotFound(b"piece not found")
Like.user_like_piece(request.user, piece)
return HttpResponse("✔️")
return HttpResponse(_checkmark)
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
def add_to_collection(request, 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)
return render(
request,
'add_to_collection.html',
"add_to_collection.html",
{
'item': item,
'collections': collections,
}
"item": item,
"collections": collections,
},
)
else:
cid = int(request.POST.get('collection_id', default=0))
cid = int(request.POST.get("collection_id", default=0))
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.append_item(item, metadata={'comment': request.POST.get('comment')})
return HttpResponseRedirect(request.META.get('HTTP_REFERER'))
collection.append_item(item, metadata={"comment": request.POST.get("comment")})
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
def render_relogin(request):
return render(request, 'common/error.html', {
'url': reverse("users:connect") + '?domain=' + request.user.mastodon_site,
'msg': _("信息已保存,但是未能分享到联邦网络"),
'secondary_msg': _("可能是你在联邦网络(Mastodon/Pleroma/...)的登录状态过期了,正在跳转到联邦网络重新登录😼")})
return render(
request,
"common/error.html",
{
"url": reverse("users:connect") + "?domain=" + request.user.mastodon_site,
"msg": _("信息已保存,但是未能分享到联邦网络"),
"secondary_msg": _(
"可能是你在联邦网络(Mastodon/Pleroma/...)的登录状态过期了,正在跳转到联邦网络重新登录😼"
),
},
)
@login_required
def mark(request, item_uuid):
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
mark = Mark(request.user, item)
if request.method == 'GET':
if request.method == "GET":
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_type = request.GET.get('shelf_type', mark.shelf_type)
return render(request, 'mark.html', {
'item': item,
'mark': mark,
'shelf_type': shelf_type,
'tags': ','.join(tags),
'shelf_types': shelf_types,
})
elif request.method == 'POST':
if request.POST.get('delete', default=False):
shelf_types = [
(n[1], n[2]) for n in iter(ShelfTypeNames) if n[0] == item.category
]
shelf_type = request.GET.get("shelf_type", mark.shelf_type)
return render(
request,
"mark.html",
{
"item": item,
"mark": mark,
"shelf_type": shelf_type,
"tags": ",".join(tags),
"shelf_types": shelf_types,
},
)
elif request.method == "POST":
if request.POST.get("delete", default=False):
mark.delete()
return HttpResponseRedirect(request.META.get('HTTP_REFERER'))
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
else:
visibility = int(request.POST.get('visibility', default=0))
rating = request.POST.get('rating', default=0)
visibility = int(request.POST.get("visibility", default=0))
rating = request.POST.get("rating", default=0)
rating = int(rating) if rating else None
status = ShelfType(request.POST.get('status'))
text = request.POST.get('text')
tags = request.POST.get('tags')
tags = tags.split(',') if tags else []
share_to_mastodon = bool(request.POST.get('share_to_mastodon', default=False))
status = ShelfType(request.POST.get("status"))
text = request.POST.get("text")
tags = request.POST.get("tags")
tags = tags.split(",") if tags else []
share_to_mastodon = bool(
request.POST.get("share_to_mastodon", default=False)
)
TagManager.tag_item_by_user(item, request.user, tags, visibility)
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:
return render_relogin(request)
return HttpResponseRedirect(request.META.get('HTTP_REFERER'))
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
def collection_retrieve(request, collection_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_visible_to(request.user):
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))
if not collection.is_visible_to(request.user):
raise PermissionDenied()
form = CollectionForm(instance=collection)
return render(
request,
'collection_items.html',
"collection_items.html",
{
'collection': collection,
'form': form,
'collection_edit': request.GET.get('edit'), # collection.is_editable_by(request.user),
}
"collection": collection,
"form": form,
"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
def collection_append_item(request, collection_uuid):
if request.method != "POST":
return HttpResponseBadRequest()
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user):
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
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))
if not collection.is_editable_by(request.user):
raise PermissionDenied()
@login_required
def collection_move_up_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):
raise PermissionDenied()
@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):
raise PermissionDenied()
@login_required
def collection_edit(request, collection_uuid=None):
collection = 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()
if request.method == 'GET':
form = CollectionForm(instance=collection) if collection else CollectionForm()
return render(request, 'collection_edit.html', {'form': form, 'collection': collection})
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]))
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if direction == "up":
collection.move_up_item(item)
else:
return HttpResponseBadRequest(form.errors)
collection.move_down_item(item)
return collection_retrieve_items(request, collection_uuid, True)
@login_required
def collection_update_item_note(request, collection_uuid, item_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user):
raise PermissionDenied()
item = get_object_or_404(Item, uid=base62.decode(item_uuid))
if not collection.is_editable_by(request.user):
raise PermissionDenied()
if request.method == "POST":
collection.update_item_metadata(
item, {"note": request.POST.get("note", default="")}
)
return collection_retrieve_items(request, collection_uuid, True)
elif request.method == "GET":
member = collection.get_member_for_item(item)
return render(
request,
"collection_update_item_note.html",
{"collection": collection, "item": item, "note": member.note},
)
else:
return HttpResponseBadRequest()
@login_required
def collection_delete(request, collection_uuid):
collection = get_object_or_404(Collection, uid=base62.decode(collection_uuid))
if not collection.is_editable_by(request.user):
def collection_edit(request, collection_uuid=None):
collection = (
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()
if request.method == 'GET':
collection_form = CollectionForm(instance=collection)
return render(request, 'collection_delete.html', {'form': collection_form, 'collection': collection})
elif request.method == 'POST':
collection.delete()
return redirect(reverse("users:home"))
if request.method == "GET":
form = CollectionForm(instance=collection) if collection else CollectionForm()
return render(
request, "collection_edit.html", {"form": form, "collection": collection}
)
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:
return HttpResponseBadRequest()
@ -213,31 +287,45 @@ def review_retrieve(request, review_uuid):
piece = get_object_or_404(Review, uid=base62.decode(review_uuid))
if not piece.is_visible_to(request.user):
raise PermissionDenied()
return render(request, 'review.html', {'review': piece})
return render(request, "review.html", {"review": piece})
@login_required
def review_edit(request, item_uuid, review_uuid=None):
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):
raise PermissionDenied()
if request.method == 'GET':
form = ReviewForm(instance=review) if review 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 request.method == "GET":
form = (
ReviewForm(instance=review)
if review
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 not review:
form.instance.owner = request.user
form.instance.edited_time = timezone.now()
form.save()
if form.cleaned_data['share_to_mastodon']:
if form.cleaned_data["share_to_mastodon"]:
form.instance.save = lambda **args: None
form.instance.shared_link = None
if not share_review(form.instance):
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:
return HttpResponseBadRequest(form.errors)
else:
@ -245,17 +333,18 @@ def review_edit(request, item_uuid, review_uuid=None):
@login_required
def review_delete(request, review_uuid):
review = get_object_or_404(Review, uid=base62.decode(review_uuid))
if not review.is_editable_by(request.user):
def piece_delete(request, piece_uuid):
piece = get_object_or_404(Piece, uid=base62.decode(piece_uuid))
return_url = request.GET.get("return_url", None) or "/"
if not piece.is_editable_by(request.user):
raise PermissionDenied()
if request.method == 'GET':
review_form = ReviewForm(instance=review)
return render(request, 'review_delete.html', {'form': review_form, 'review': review})
elif request.method == 'POST':
item = review.item
review.delete()
return redirect(item.url)
if request.method == "GET":
return render(
request, "piece_delete.html", {"piece": piece, "return_url": return_url}
)
elif request.method == "POST":
piece.delete()
return redirect(return_url)
else:
return HttpResponseBadRequest()
@ -264,57 +353,67 @@ def render_list_not_fount(request):
msg = _("相关列表不存在")
return render(
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)
if user is None:
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)
if type == 'mark':
if type == "mark":
shelf = user.shelf_manager.get_shelf(item_category, shelf_type)
queryset = ShelfMember.objects.filter(owner=user, parent=shelf)
elif type == 'tagmember':
elif type == "tagmember":
tag = Tag.objects.filter(owner=user, title=tag_title).first()
if not tag:
return render_list_not_fount(request)
if tag.visibility != 0 and user != request.user:
return render_list_not_fount(request)
queryset = TagMember.objects.filter(parent=tag)
elif type == 'review':
elif type == "review":
queryset = Review.objects.filter(owner=user)
queryset = queryset.filter(query_item_category(item_category))
else:
return HttpResponseBadRequest()
queryset = queryset.filter(q_visible_to(request.user, user))
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)
return render(request, f'user_{type}_list.html', {
'user': user,
'members': members,
})
return render(
request,
f"user_{type}_list.html",
{
"user": user,
"members": members,
},
)
@login_required
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
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
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
@ -322,17 +421,23 @@ def user_tag_list(request, user_name):
user = User.get(user_name)
if user is None:
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)
tags = Tag.objects.filter(owner=user)
tags = user.tag_set.all()
if user != request.user:
tags = tags.filter(visibility=0)
tags = tags.values('title').annotate(total=Count('members')).order_by('-total')
return render(request, 'user_tag_list.html', {
'user': user,
'tags': tags,
})
tags = tags.values("title").annotate(total=Count("members")).order_by("-total")
return render(
request,
"user_tag_list.html",
{
"user": user,
"tags": tags,
},
)
@login_required
@ -340,18 +445,24 @@ def user_collection_list(request, user_name):
user = User.get(user_name)
if user is None:
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)
collections = Tag.objects.filter(owner=user)
collections = Collection.objects.filter(owner=user)
if user != request.user:
if request.user.is_following(user):
collections = collections.filter(visibility__ne=2)
else:
collections = collections.filter(visibility=0)
return render(request, 'user_collection_list.html', {
'user': user,
'collections': collections,
})
return render(
request,
"user_collection_list.html",
{
"user": user,
"collections": collections,
},
)
@login_required
@ -359,27 +470,37 @@ def user_liked_collection_list(request, user_name):
user = User.get(user_name)
if user is None:
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)
collections = Collection.objects.filter(likes__owner=user)
if user != request.user:
collections = collections.filter(query_visible(request.user))
return render(request, 'user_collection_list.html', {
'user': user,
'collections': collections,
})
return render(
request,
"user_collection_list.html",
{
"user": user,
"collections": collections,
},
)
def home_anonymous(request, id):
login_url = settings.LOGIN_URL + "?next=" + request.get_full_path()
try:
username = id.split('@')[0]
site = id.split('@')[1]
return render(request, 'users/home_anonymous.html', {
'login_url': login_url,
'username': username,
'site': site,
})
username = id.split("@")[0]
site = id.split("@")[1]
return render(
request,
"users/home_anonymous.html",
{
"login_url": login_url,
"username": username,
"site": site,
},
)
except Exception:
return redirect(login_url)
@ -387,7 +508,7 @@ def home_anonymous(request, id):
def home(request, user_name):
if not request.user.is_authenticated:
return home_anonymous(request, user_name)
if request.method != 'GET':
if request.method != "GET":
return HttpResponseBadRequest()
user = User.get(user_name)
if user is None:
@ -395,14 +516,13 @@ def home(request, user_name):
# access one's own home page
if user == request.user:
reports = Report.objects.order_by(
'-submitted_time').filter(is_read=False)
reports = Report.objects.order_by("-submitted_time").filter(is_read=False)
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:
request.user.read_announcement_index = Announcement.objects.latest(
'pk').pk
request.user.save(update_fields=['read_announcement_index'])
request.user.read_announcement_index = Announcement.objects.latest("pk").pk
request.user.save(update_fields=["read_announcement_index"])
except ObjectDoesNotExist:
# when there is no annoucenment
pass
@ -416,42 +536,51 @@ def home(request, user_name):
qv = q_visible_to(request.user, user)
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:
shelf_list[category] = {}
for shelf_type in ShelfType:
shelf = user.shelf_manager.get_shelf(category, shelf_type)
members = shelf.recent_members.filter(qv)
shelf_list[category][shelf_type] = {
'title': shelf.title,
'count': members.count(),
'members': members[:5].prefetch_related('item'),
"title": shelf.title,
"count": members.count(),
"members": members[:5].prefetch_related("item"),
}
reviews = Review.objects.filter(owner=user).filter(qv)
shelf_list[category]['reviewed'] = {
'title': '评论过的' + category.label,
'count': reviews.count(),
'members': reviews[:5].prefetch_related('item'),
shelf_list[category]["reviewed"] = {
"title": "评论过的" + category.label,
"count": reviews.count(),
"members": reviews[:5].prefetch_related("item"),
}
collections = Collection.objects.filter(owner=user).filter(qv).order_by("-edited_time")
liked_collections = Collection.objects.filter(likes__owner=user).order_by("-edited_time")
collections = (
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:
liked_collections = liked_collections.filter(query_visible(request.user))
layout = user.get_preference().get_serialized_home_layout()
return render(
request,
'profile.html',
"profile.html",
{
'user': user,
'shelf_list': shelf_list,
'collections': collections[:5],
'collections_count': collections.count(),
'liked_collections': liked_collections.order_by("-edited_time")[:5],
'liked_collections_count': liked_collections.count(),
'layout': layout,
'reports': reports,
'unread_announcements': unread_announcements,
}
"user": user,
"shelf_list": shelf_list,
"collections": collections[:5],
"collections_count": collections.count(),
"liked_collections": liked_collections.order_by("-edited_time")[:5],
"liked_collections_count": liked_collections.count(),
"layout": layout,
"reports": reports,
"unread_announcements": unread_announcements,
},
)

View file

@ -14,6 +14,7 @@ from catalog.common import *
from catalog.models import *
from catalog.sites import *
from journal.models import *
# from social import models as social_models
from django.core.management.base import BaseCommand
from django.core.paginator import Paginator
@ -51,29 +52,53 @@ shelf_map = {
}
tag_map = {
BookMark: 'bookmark_tags',
MovieMark: 'moviemark_tags',
AlbumMark: 'albummark_tags',
GameMark: 'gamemark_tags',
BookMark: "bookmark_tags",
MovieMark: "moviemark_tags",
AlbumMark: "albummark_tags",
GameMark: "gamemark_tags",
}
class Command(BaseCommand):
help = 'Migrate legacy marks to user journal'
help = "Migrate legacy marks to user journal"
def add_arguments(self, parser):
parser.add_argument('--book', dest='types', action='append_const', const=BookMark)
parser.add_argument('--movie', dest='types', action='append_const', const=MovieMark)
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('--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')
parser.add_argument(
"--book", dest="types", action="append_const", const=BookMark
)
parser.add_argument(
"--movie", dest="types", action="append_const", const=MovieMark
)
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(
"--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):
print("Initialize shelves")
@ -91,7 +116,11 @@ class Command(BaseCommand):
def collection(self, options):
collection_map = {}
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):
c = Collection.objects.create(
owner_id=entity.owner_id,
@ -115,11 +144,15 @@ class Command(BaseCommand):
if old_id:
item_link = LinkModel.objects.get(old_id=old_id)
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:
# TODO convert song to album
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')
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")
)
for entity in tqdm(qs):
Like.objects.create(
owner_id=entity.owner_id,
@ -132,12 +165,14 @@ class Command(BaseCommand):
for typ in [GameReview, AlbumReview, BookReview, MovieReview]:
print(typ)
LinkModel = model_link[typ]
qs = typ.objects.all().filter(owner__is_active=True).order_by('id')
if options['id']:
if options['maxid']:
qs = qs.filter(id__gte=int(options['id']), id__lte=int(options['maxid']))
qs = typ.objects.all().filter(owner__is_active=True).order_by("id")
if options["id"]:
if options["maxid"]:
qs = qs.filter(
id__gte=int(options["id"]), id__lte=int(options["maxid"])
)
else:
qs = qs.filter(id=int(options['id']))
qs = qs.filter(id=int(options["id"]))
pg = Paginator(qs, BATCH_SIZE)
for p in tqdm(pg.page_range):
with transaction.atomic():
@ -145,28 +180,42 @@ class Command(BaseCommand):
try:
item_link = LinkModel.objects.get(old_id=entity.item.id)
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:
print(f'Convert failed for {typ} {entity.id}: {e}')
if options['failstop']:
print(f"Convert failed for {typ} {entity.id}: {e}")
if options["failstop"]:
raise (e)
def mark(self, options):
types = options['types'] or [GameMark, AlbumMark, MovieMark, BookMark]
print('Preparing cache')
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()}
types = options["types"] or [GameMark, AlbumMark, MovieMark, BookMark]
print("Preparing cache")
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()
}
for typ in types:
print(typ)
LinkModel = model_link[typ]
tag_field = tag_map[typ]
qs = typ.objects.all().filter(owner__is_active=True).order_by('id')
if options['id']:
if options['maxid']:
qs = qs.filter(id__gte=int(options['id']), id__lte=int(options['maxid']))
qs = typ.objects.all().filter(owner__is_active=True).order_by("id")
if options["id"]:
if options["maxid"]:
qs = qs.filter(
id__gte=int(options["id"]), id__lte=int(options["maxid"])
)
else:
qs = qs.filter(id=int(options['id']))
qs = qs.filter(id=int(options["id"]))
pg = Paginator(qs, BATCH_SIZE)
for p in tqdm(pg.page_range):
@ -193,22 +242,42 @@ class Command(BaseCommand):
visibility = entity.visibility
created_time = entity.created_time
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:
Comment.objects.create(owner_id=user_id, item_id=item_id, text=entity.text, visibility=visibility)
shelf = shelf_cache[f'{user_id}_{item.category}_{entity.status}']
Comment.objects.create(
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(
parent_id=shelf,
owner_id=user_id,
position=0,
item_id=item_id,
metadata={'shared_link': entity.shared_link},
created_time=created_time)
ShelfLogEntry.objects.create(owner_id=user_id, shelf_id=shelf, item_id=item_id, timestamp=created_time)
metadata={"shared_link": entity.shared_link},
created_time=created_time,
)
ShelfLogEntry.objects.create(
owner_id=user_id,
shelf_id=shelf,
item_id=item_id,
timestamp=created_time,
)
for title in tags:
tag_key = f'{user_id}_{title}'
tag_key = f"{user_id}_{title}"
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
else:
tag = tag_cache[tag_key]
@ -217,28 +286,31 @@ class Command(BaseCommand):
owner_id=user_id,
position=0,
item_id=item_id,
created_time=created_time)
created_time=created_time,
)
except Exception as e:
print(f'Convert failed for {typ} {entity.id}: {e}')
if options['failstop']:
print(f"Convert failed for {typ} {entity.id}: {e}")
if options["failstop"]:
raise (e)
def handle(self, *args, **options):
if options['initshelf']:
if options["initshelf"]:
self.initshelf()
elif options['collection']:
if options['clear']:
elif options["collection"]:
if options["clear"]:
self.clear([Collection, Like])
else:
self.collection(options)
elif options['review']:
if options['clear']:
elif options["review"]:
if options["clear"]:
self.clear([Review])
else:
self.review(options)
elif options['mark']:
if options['clear']:
self.clear([Comment, Rating, TagMember, Tag, ShelfLogEntry, ShelfMember])
elif options["mark"]:
if options["clear"]:
self.clear(
[Comment, Rating, TagMember, Tag, ShelfLogEntry, ShelfMember]
)
else:
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
# retruns the same info as verify account credentials
# GET
API_GET_ACCOUNT = '/api/v1/accounts/:id'
API_GET_ACCOUNT = "/api/v1/accounts/:id"
# returns user info if valid, 401 if invalid
# GET
API_VERIFY_ACCOUNT = '/api/v1/accounts/verify_credentials'
API_VERIFY_ACCOUNT = "/api/v1/accounts/verify_credentials"
# obtain token
# GET
API_OBTAIN_TOKEN = '/oauth/token'
API_OBTAIN_TOKEN = "/oauth/token"
# obatin auth code
# GET
API_OAUTH_AUTHORIZE = '/oauth/authorize'
API_OAUTH_AUTHORIZE = "/oauth/authorize"
# revoke token
# POST
API_REVOKE_TOKEN = '/oauth/revoke'
API_REVOKE_TOKEN = "/oauth/revoke"
# relationships
# GET
API_GET_RELATIONSHIPS = '/api/v1/accounts/relationships'
API_GET_RELATIONSHIPS = "/api/v1/accounts/relationships"
# toot
# POST
API_PUBLISH_TOOT = '/api/v1/statuses'
API_PUBLISH_TOOT = "/api/v1/statuses"
# create new app
# POST
API_CREATE_APP = '/api/v1/apps'
API_CREATE_APP = "/api/v1/apps"
# search
# 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"
@ -70,43 +70,38 @@ post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT)
# low level api below
def get_relationships(site, id_list, token): # no longer in use
url = 'https://' + site + API_GET_RELATIONSHIPS
payload = {'id[]': id_list}
headers = {
'User-Agent': USER_AGENT,
'Authorization': f'Bearer {token}'
}
url = "https://" + site + API_GET_RELATIONSHIPS
payload = {"id[]": id_list}
headers = {"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
response = get(url, headers=headers, params=payload)
return response.json()
def post_toot(site, content, visibility, token, local_only=False, update_id=None):
headers = {
'User-Agent': USER_AGENT,
'Authorization': f'Bearer {token}',
'Idempotency-Key': random_string_generator(16)
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {token}",
"Idempotency-Key": random_string_generator(16),
}
if site == TWITTER_DOMAIN:
url = TWITTER_API_POST
payload = {
'text': content if len(content) <= 150 else content[0:150] + '...'
}
payload = {"text": content if len(content) <= 150 else content[0:150] + "..."}
response = post(url, headers=headers, json=payload)
if response.status_code == 201:
response.status_code = 200
if response.status_code != 200:
logger.error(f"Error {url} {response.status_code}")
else:
url = 'https://' + site + API_PUBLISH_TOOT
url = "https://" + site + API_PUBLISH_TOOT
payload = {
'status': content,
'visibility': visibility,
"status": content,
"visibility": visibility,
}
if local_only:
payload['local_only'] = True
payload["local_only"] = True
try:
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:
response = post(url, headers=headers, data=payload)
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):
if domain_name.lower().strip() == TWITTER_DOMAIN:
return TWITTER_DOMAIN, ''
return TWITTER_DOMAIN, ""
try:
url = f'https://{domain_name}/api/v1/instance'
response = get(url, headers={'User-Agent': USER_AGENT})
url = f"https://{domain_name}/api/v1/instance"
response = get(url, headers={"User-Agent": USER_AGENT})
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:
logger.error(f"Error {url}")
return domain_name, ''
return domain_name, ""
def create_app(domain_name):
# naive protocal strip
is_http = False
if domain_name.startswith("https://"):
domain_name = domain_name.replace("https://", '')
domain_name = domain_name.replace("https://", "")
elif domain_name.startswith("http://"):
is_http = True
domain_name = domain_name.replace("http://", '')
if domain_name.endswith('/'):
domain_name = domain_name.replace("http://", "")
if domain_name.endswith("/"):
domain_name = domain_name[0:-1]
if not is_http:
url = 'https://' + domain_name + API_CREATE_APP
url = "https://" + domain_name + API_CREATE_APP
else:
url = 'http://' + domain_name + API_CREATE_APP
url = "http://" + domain_name + API_CREATE_APP
payload = {
'client_name': settings.CLIENT_NAME,
'scopes': settings.MASTODON_CLIENT_SCOPE,
'redirect_uris': settings.REDIRECT_URIS,
'website': settings.APP_WEBSITE
"client_name": settings.CLIENT_NAME,
"scopes": settings.MASTODON_CLIENT_SCOPE,
"redirect_uris": settings.REDIRECT_URIS,
"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
def get_site_id(username, user_site, target_site, token):
url = 'https://' + target_site + API_SEARCH
url = "https://" + target_site + API_SEARCH
payload = {
'limit': 1,
'type': 'accounts',
'resolve': True,
'q': f"{username}@{user_site}"
}
headers = {
'User-Agent': USER_AGENT,
'Authorization': f'Bearer {token}'
"limit": 1,
"type": "accounts",
"resolve": True,
"q": f"{username}@{user_site}",
}
headers = {"User-Agent": USER_AGENT, "Authorization": f"Bearer {token}"}
response = get(url, params=payload, headers=headers)
try:
data = response.json()
except Exception:
logger.error(f"Error parsing JSON from {url}")
return None
if 'accounts' not in data:
if "accounts" not in data:
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
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
else:
return data['accounts'][0]['id']
return data["accounts"][0]["id"]
# high level api below
def get_relationship(request_user, target_user, useless_token=None):
return [{
'blocked_by': target_user.is_blocking(request_user),
'following': request_user.is_following(target_user),
}]
return [
{
"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):
@ -209,19 +207,22 @@ def get_cross_site_id(target_user, target_site, token):
try:
cross_site_info = CrossSiteUserInfo.objects.get(
uid=f"{target_user.username}@{target_user.mastodon_site}",
target_site=target_site
target_site=target_site,
)
except ObjectDoesNotExist:
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:
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
cross_site_info = CrossSiteUserInfo.objects.create(
uid=f"{target_user.username}@{target_user.mastodon_site}",
target_site=target_site,
site_id=cross_site_id,
local_id=target_user.id
local_id=target_user.id,
)
return cross_site_info.site_id
@ -229,31 +230,41 @@ def get_cross_site_id(target_user, target_site, token):
# utils below
def random_string_generator(n):
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):
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:
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:
logger.error(f"Error {url} {response.status_code}")
return response.status_code, None
r = response.json()['data']
r['display_name'] = r['name']
r['note'] = r['description']
r['avatar'] = r['profile_image_url']
r['avatar_static'] = r['profile_image_url']
r['locked'] = r['protected']
r['url'] = f'https://{TWITTER_DOMAIN}/{r["username"]}'
r = response.json()["data"]
r["display_name"] = r["name"]
r["note"] = r["description"]
r["avatar"] = r["profile_image_url"]
r["avatar_static"] = r["profile_image_url"]
r["locked"] = r["protected"]
r["url"] = f'https://{TWITTER_DOMAIN}/{r["username"]}'
return 200, r
except Exception:
return -1, None
url = 'https://' + site + API_VERIFY_ACCOUNT
url = "https://" + site + API_VERIFY_ACCOUNT
try:
response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'})
return response.status_code, (response.json() if response.status_code == 200 else None)
response = get(
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:
return -1, None
@ -261,94 +272,130 @@ def verify_account(site, token):
def get_related_acct_list(site, token, api):
if site == TWITTER_DOMAIN:
return []
url = 'https://' + site + api
url = "https://" + site + api
results = []
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
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()))
if 'Link' in response.headers:
for ls in response.headers['Link'].split(','):
li = ls.strip().split(';')
results.extend(
map(
lambda u: (
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"':
url = li[0].strip().replace('>', '').replace('<', '')
url = li[0].strip().replace(">", "").replace("<", "")
return results
class TootVisibilityEnum:
PUBLIC = 'public'
PRIVATE = 'private'
DIRECT = 'direct'
UNLISTED = 'unlisted'
PUBLIC = "public"
PRIVATE = "private"
DIRECT = "direct"
UNLISTED = "unlisted"
def get_mastodon_application(domain):
app = MastodonApplication.objects.filter(domain_name=domain).first()
if app is not None:
return app, ''
return app, ""
if domain == TWITTER_DOMAIN:
return None, 'Twitter未配置'
return None, "Twitter未配置"
error_msg = None
try:
response = create_app(domain)
except (requests.exceptions.Timeout, ConnectionError):
error_msg = "联邦网络请求超时。"
logger.error(f'Error creating app for {domain}: Timeout')
logger.error(f"Error creating app for {domain}: Timeout")
except Exception as e:
error_msg = "联邦网络请求失败 " + str(e)
logger.error(f'Error creating app for {domain}: {e}')
logger.error(f"Error creating app for {domain}: {e}")
else:
# fill the form with returned data
if response.status_code != 200:
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:
try:
data = response.json()
except Exception:
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:
if settings.MASTODON_ALLOW_ANY_SITE:
app = MastodonApplication.objects.create(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 '')
app = MastodonApplication.objects.create(
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:
error_msg = "不支持其它实例登录"
logger.error(f'Disallowed to create app for {domain}')
logger.error(f"Disallowed to create app for {domain}")
return app, error_msg
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:
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
return "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(scope) + "&redirect_uri=" + url + "&response_type=code"
scope = (
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):
"""Returns token if success else None."""
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 = {
'client_id': mast_app.client_id,
'client_secret': mast_app.client_secret,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code',
'code': code
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
"code": code,
}
headers = {'User-Agent': USER_AGENT}
headers = {"User-Agent": USER_AGENT}
auth = None
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:
url = TWITTER_API_TOKEN
auth = (mast_app.client_id, mast_app.client_secret)
del payload['client_secret']
payload['code_verifier'] = 'challenge'
del payload["client_secret"]
payload["code_verifier"] = "challenge"
else:
url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN
url = "https://" + mast_app.domain_name + API_OBTAIN_TOKEN
try:
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"}
@ -359,7 +406,7 @@ def obtain_token(site, request, code):
logger.error(f"Error {url} {e}")
return None, None
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):
@ -368,34 +415,34 @@ def refresh_access_token(site, refresh_token):
mast_app = MastodonApplication.objects.get(domain_name=site)
url = TWITTER_API_TOKEN
payload = {
'client_id': mast_app.client_id,
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
"client_id": mast_app.client_id,
"refresh_token": 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)
response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200:
logger.error(f"Error {url} {response.status_code}")
return None
data = response.json()
return data.get('access_token')
return data.get("access_token")
def revoke_token(site, token):
mast_app = MastodonApplication.objects.get(domain_name=site)
payload = {
'client_id': mast_app.client_id,
'client_secret': mast_app.client_secret,
'token': token
"client_id": mast_app.client_id,
"client_secret": mast_app.client_secret,
"token": token,
}
if mast_app.is_proxy:
url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN
url = "https://" + mast_app.proxy_to + API_REVOKE_TOKEN
else:
url = 'https://' + site + API_REVOKE_TOKEN
post(url, data=payload, headers={'User-Agent': USER_AGENT})
url = "https://" + site + API_REVOKE_TOKEN
post(url, data=payload, headers={"User-Agent": USER_AGENT})
def share_mark(mark):
@ -408,22 +455,38 @@ def share_mark(mark):
visibility = TootVisibilityEnum.PUBLIC
else:
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 ''
stars = rating_to_emoji(mark.rating, MastodonApplication.objects.get(domain_name=user.mastodon_site).star_mode)
tags = (
"\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}"
update_id = None
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
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]:
j = response.json()
if 'url' in j:
mark.shared_link = j['url']
elif 'data' in j:
mark.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}"
if "url" in j:
mark.shared_link = j["url"]
elif "data" in j:
mark.shared_link = (
f"https://twitter.com/{user.username}/status/{j['data']['id']}"
)
if mark.shared_link:
mark.save(update_fields=['shared_link'])
mark.save(update_fields=["shared_link"])
return True
else:
return False
@ -439,21 +502,34 @@ def share_review(review):
visibility = TootVisibilityEnum.PUBLIC
else:
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}"
update_id = None
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
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]:
j = response.json()
if 'url' in j:
review.shared_link = j['url']
elif 'data' in j:
review.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}"
if "url" in j:
review.shared_link = j["url"]
elif "data" in j:
review.shared_link = (
f"https://twitter.com/{user.username}/status/{j['data']['id']}"
)
if review.shared_link:
review.save(update_fields=['shared_link'])
review.save(update_fields=["shared_link"])
return True
else:
return False
@ -468,15 +544,21 @@ def share_collection(collection, comment, user, visibility_no):
visibility = TootVisibilityEnum.PUBLIC
else:
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}"
response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token)
if response and response.status_code in [200, 201]:
j = response.json()
if 'url' in j:
shared_link = j['url']
elif 'data' in j:
shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}"
if "url" in j:
shared_link = j["url"]
elif "data" in j:
shared_link = (
f"https://twitter.com/{user.username}/status/{j['data']['id']}"
)
if shared_link:
pass
return True

2
pyproject.toml Normal file
View file

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