From cd4d9f64cd73ad34688f03a2d3571574d2868e13 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 30 May 2022 17:54:35 -0400 Subject: [PATCH] add timeline, etc --- boofilsic/urls.py | 1 + books/views.py | 1 + collection/models.py | 17 +++ common/models.py | 4 + common/templatetags/neo.py | 18 +++ games/views.py | 1 + movies/views.py | 1 + music/views.py | 2 + timeline/__init__.py | 0 timeline/admin.py | 3 + timeline/apps.py | 15 +++ .../management/commands/regen_activity.py | 20 +++ timeline/models.py | 63 ++++++++++ timeline/templates/timeline.html | 83 +++++++++++++ timeline/templates/timeline_data.html | 116 ++++++++++++++++++ timeline/tests.py | 3 + timeline/urls.py | 9 ++ timeline/views.py | 56 +++++++++ users/data.py | 3 + .../management/commands/refresh_following.py | 19 +++ users/models.py | 11 ++ users/templates/users/preferences.html | 11 ++ 22 files changed, 457 insertions(+) create mode 100644 timeline/__init__.py create mode 100644 timeline/admin.py create mode 100644 timeline/apps.py create mode 100644 timeline/management/commands/regen_activity.py create mode 100644 timeline/models.py create mode 100644 timeline/templates/timeline.html create mode 100644 timeline/templates/timeline_data.html create mode 100644 timeline/tests.py create mode 100644 timeline/urls.py create mode 100644 timeline/views.py create mode 100644 users/management/commands/refresh_following.py diff --git a/boofilsic/urls.py b/boofilsic/urls.py index f1bb3add..38d74a5a 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -28,6 +28,7 @@ urlpatterns = [ path('music/', include('music.urls')), path('games/', include('games.urls')), path('collections/', include('collection.urls')), + path('timeline/', include('timeline.urls')), path('sync/', include('sync.urls')), path('announcement/', include('management.urls')), path('hijack/', include('hijack.urls')), diff --git a/books/views.py b/books/views.py index b9e114ef..d6607f15 100644 --- a/books/views.py +++ b/books/views.py @@ -167,6 +167,7 @@ def retrieve(request, id): else: mark_form = BookMarkForm(initial={ 'book': book, + 'visibility': request.user.preference.default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) diff --git a/collection/models.py b/collection/models.py index fa1fd544..2ab0edc3 100644 --- a/collection/models.py +++ b/collection/models.py @@ -8,6 +8,7 @@ from markdownx.models import MarkdownxField from django.utils.translation import gettext_lazy as _ from django.conf import settings from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath +from django.shortcuts import reverse def collection_cover_path(instance, filename): @@ -22,6 +23,10 @@ class Collection(UserOwnedEntity): def __str__(self): return f"Collection({self.id} {self.owner} {self.title})" + @property + def translated_status(self): + return '创建了收藏单' + @property def collectionitem_list(self): return sorted(list(self.collectionitem_set.all()), key=lambda i: i.position) @@ -48,6 +53,14 @@ class Collection(UserOwnedEntity): i.save() return i + @property + def item(self): + return self + + @property + def url(self): + return settings.APP_WEBSITE + reverse("collection:retrieve", args=[self.id]) + class CollectionItem(models.Model): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, null=True) @@ -90,3 +103,7 @@ class CollectionMark(UserOwnedEntity): def __str__(self): return f"CollectionMark({self.id} {self.owner} {self.collection})" + + @property + def translated_status(self): + return '关注了收藏单' diff --git a/common/models.py b/common/models.py index 78fda2ce..a8122ac2 100644 --- a/common/models.py +++ b/common/models.py @@ -323,6 +323,10 @@ class Review(UserOwnedEntity): class Meta: abstract = True + @property + def translated_status(self): + return '评论了' + class Tag(models.Model): content = models.CharField(max_length=50) diff --git a/common/templatetags/neo.py b/common/templatetags/neo.py index ea74fef7..5d8a1efb 100644 --- a/common/templatetags/neo.py +++ b/common/templatetags/neo.py @@ -1,4 +1,6 @@ from django import template +import datetime +from django.utils import timezone register = template.Library() @@ -9,3 +11,19 @@ def current_user_marked_item(context, item): if context['request'].user and context['request'].user.is_authenticated: return context['request'].user.get_mark_for_item(item) return None + + +@register.filter +def prettydate(d): + diff = timezone.now() - d + s = diff.seconds + if diff.days > 14 or diff.days < 0: + return d.strftime('%Y年%m月%d日') + elif diff.days >= 1: + return '{} 天前'.format(diff.days) + elif s < 120: + return '刚刚' + elif s < 3600: + return '{} 分钟前'.format(s / 60) + else: + return '{} 小时前'.format(s / 3600) diff --git a/games/views.py b/games/views.py index 7414bda7..baf46f0f 100644 --- a/games/views.py +++ b/games/views.py @@ -168,6 +168,7 @@ def retrieve(request, id): else: mark_form = GameMarkForm(initial={ 'game': game, + 'visibility': request.user.preference.default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) diff --git a/movies/views.py b/movies/views.py index 49bbbb58..e643d3bd 100644 --- a/movies/views.py +++ b/movies/views.py @@ -168,6 +168,7 @@ def retrieve(request, id): else: mark_form = MovieMarkForm(initial={ 'movie': movie, + 'visibility': request.user.preference.default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) diff --git a/music/views.py b/music/views.py index 8b986d7f..2d151d3b 100644 --- a/music/views.py +++ b/music/views.py @@ -186,6 +186,7 @@ def retrieve_song(request, id): else: mark_form = SongMarkForm(initial={ 'song': song, + 'visibility': request.user.preference.default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -729,6 +730,7 @@ def retrieve_album(request, id): else: mark_form = AlbumMarkForm(initial={ 'album': album, + 'visibility': request.user.preference.default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) diff --git a/timeline/__init__.py b/timeline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timeline/admin.py b/timeline/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/timeline/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/timeline/apps.py b/timeline/apps.py new file mode 100644 index 00000000..00df970d --- /dev/null +++ b/timeline/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + + +class TimelineConfig(AppConfig): + name = 'timeline' + + def ready(self): + from .models import init_post_save_handler + from books.models import BookMark, BookReview + from movies.models import MovieMark, MovieReview + from games.models import GameMark, GameReview + from music.models import AlbumMark, AlbumReview, SongMark, SongReview + from collection.models import Collection, CollectionMark + for m in [BookMark, BookReview, MovieMark, MovieReview, GameMark, GameReview, AlbumMark, AlbumReview, SongMark, SongReview, Collection, CollectionMark]: + init_post_save_handler(m) diff --git a/timeline/management/commands/regen_activity.py b/timeline/management/commands/regen_activity.py new file mode 100644 index 00000000..36d6c1ac --- /dev/null +++ b/timeline/management/commands/regen_activity.py @@ -0,0 +1,20 @@ +from django.core.management.base import BaseCommand +from users.models import User +from datetime import timedelta +from django.utils import timezone +from timeline.models import Activity +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, AlbumReview, SongMark, SongReview +from collection.models import Collection, CollectionMark +from tqdm import tqdm + + +class Command(BaseCommand): + help = 'Re-populating activity for timeline' + + def handle(self, *args, **options): + for cl in [BookMark, BookReview, MovieMark, MovieReview, GameMark, GameReview, AlbumMark, AlbumReview, SongMark, SongReview, Collection, CollectionMark]: + for a in tqdm(cl.objects.all(), desc=f'Populating {cl.__name__}'): + Activity.upsert_item(a) diff --git a/timeline/models.py b/timeline/models.py new file mode 100644 index 00000000..be2e7f6c --- /dev/null +++ b/timeline/models.py @@ -0,0 +1,63 @@ +from django.db import models +from common.models import UserOwnedEntity +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, AlbumReview, SongMark, SongReview +from collection.models import Collection, CollectionMark +from django.db.models.signals import post_save, post_delete + + +class Activity(UserOwnedEntity): + bookmark = models.ForeignKey(BookMark, models.CASCADE, null=True) + bookreview = models.ForeignKey(BookReview, models.CASCADE, null=True) + moviemark = models.ForeignKey(MovieMark, models.CASCADE, null=True) + moviereview = models.ForeignKey(MovieReview, models.CASCADE, null=True) + gamemark = models.ForeignKey(GameMark, models.CASCADE, null=True) + gamereview = models.ForeignKey(GameReview, models.CASCADE, null=True) + albummark = models.ForeignKey(AlbumMark, models.CASCADE, null=True) + albumreview = models.ForeignKey(AlbumReview, models.CASCADE, null=True) + songmark = models.ForeignKey(SongMark, models.CASCADE, null=True) + songreview = models.ForeignKey(SongReview, models.CASCADE, null=True) + collection = models.ForeignKey(Collection, models.CASCADE, null=True) + collectionmark = models.ForeignKey(CollectionMark, models.CASCADE, null=True) + + @property + def target(self): + items = [self.bookmark, self.bookreview, self.moviemark, self.moviereview, self.gamemark, self.gamereview, + self.songmark, self.songreview, self.albummark, self.albumreview, self.collection, self.collectionmark] + return next((x for x in items if x is not None), None) + + @property + def mark(self): + items = [self.bookmark, self.moviemark, self.gamemark, self.songmark, self.albummark] + return next((x for x in items if x is not None), None) + + @property + def review(self): + items = [self.bookreview, self.moviereview, self.gamereview, self.songreview, self.albumreview] + return next((x for x in items if x is not None), None) + + @classmethod + def upsert_item(self, item): + attr = item.__class__.__name__.lower() + f = {'owner': item.owner, attr: item} + activity = Activity.objects.filter(**f).first() + if not activity: + activity = Activity.objects.create(**f) + activity.created_time = item.created_time + activity.visibility = item.visibility + activity.save() + + +def _post_save_handler(sender, instance, created, **kwargs): + Activity.upsert_item(instance) + + +# def activity_post_delete_handler(sender, instance, **kwargs): +# pass + + +def init_post_save_handler(model): + post_save.connect(_post_save_handler, sender=model) + # post_delete.connect(activity_post_delete_handler, sender=model) # delete handled by database diff --git a/timeline/templates/timeline.html b/timeline/templates/timeline.html new file mode 100644 index 00000000..89134864 --- /dev/null +++ b/timeline/templates/timeline.html @@ -0,0 +1,83 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} + + + + + + + {{ site_name }} - {{ user.mastodon_username }} {{ list_title }} + + + + + + + + + + + + +
+
+ {% include "partial/_navbar.html" %} + +
+
+
+
+
+ + +
    +
    +
+
+
+
+ + {% include "partial/_sidebar.html" %} +
+
+
+ {% include "partial/_footer.html" %} +
+ + + + + + diff --git a/timeline/templates/timeline_data.html b/timeline/templates/timeline_data.html new file mode 100644 index 00000000..3c7b49e6 --- /dev/null +++ b/timeline/templates/timeline_data.html @@ -0,0 +1,116 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +{% load neo %} + +{% for activity in activities %} +
  • +
    + + + +
    +
    +
    + + {% if activity.target.shared_link %} + {{ activity.target.created_time|prettydate }} + {% else %} + {{ activity.target.created_time|prettydate }} + {% endif %} + +
    + + {{ activity.owner.mastodon_account.display_name }} {{ activity.target.translated_status }} + +
    + {{ activity.target.item.title }} + {% if activity.target.item.source_url %} + + {{ activity.target.item.get_source_site_display }} + + {% endif %} +
    +

    + {% if activity.review %} + {{ activity.review.title }} + {% endif %} + {% if activity.mark %} + {% if activity.mark.rating %} + + {% endif %} + + {% if activity.mark.text %} +

    {{ activity.mark.text }}

    + {% endif %} + {% endif %} +

    +
    +
  • +{% if forloop.last %} +
    + + + + + + + + + + + + + + + + + + + + + + +
    +{% endif %} +{% empty %} +
    {% trans '目前没有更多内容了' %}
    +{% endfor %} \ No newline at end of file diff --git a/timeline/tests.py b/timeline/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/timeline/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/timeline/urls.py b/timeline/urls.py new file mode 100644 index 00000000..b501f1e8 --- /dev/null +++ b/timeline/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, re_path +from .views import * + + +app_name = 'timeline' +urlpatterns = [ + path('', timeline, name='timeline'), + path('data', data, name='data'), +] diff --git a/timeline/views.py b/timeline/views.py new file mode 100644 index 00000000..40f7d6b6 --- /dev/null +++ b/timeline/views.py @@ -0,0 +1,56 @@ +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 HttpResponseBadRequest, HttpResponseServerError +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import IntegrityError, transaction +from django.db.models import Count +from django.utils import timezone +from django.core.paginator import Paginator +from mastodon import mastodon_request_included +from mastodon.models import MastodonApplication +from mastodon.api import post_toot, TootVisibilityEnum +from common.utils import PageLinksGenerator +from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.models import SourceSiteEnum +from .models import * +from django.conf import settings +import re +from users.models import User +from django.http import HttpResponseRedirect +from django.db.models import Q +import time + + +logger = logging.getLogger(__name__) +mastodon_logger = logging.getLogger("django.mastodon") +PAGE_SIZE = 20 + +@login_required +def timeline(request): + if request.method != 'GET': + return + return render( + request, + 'timeline.html', + { + } + ) + + +def data(request): + if request.method != 'GET': + return + q = Q(owner_id__in=request.user.following, visibility__lt=2) | Q(owner_id=request.user.id) + last = request.GET.get('last') + if last: + q = q & Q(created_time__lt=last) + activities = Activity.objects.filter(q).order_by('-created_time')[:PAGE_SIZE] + return render( + request, + 'timeline_data.html', + { + 'activities': activities, + } + ) diff --git a/users/data.py b/users/data.py index d7fd8521..6ec1ca75 100644 --- a/users/data.py +++ b/users/data.py @@ -38,6 +38,7 @@ from books.models import BookMark, BookReview from movies.models import MovieMark, MovieReview from games.models import GameMark, GameReview from music.models import AlbumMark, SongMark, AlbumReview, SongReview +from timeline.models import Activity from collection.models import Collection from common.importers.goodreads import GoodreadsImporter from common.importers.douban import DoubanImporter @@ -47,6 +48,7 @@ from common.importers.douban import DoubanImporter @login_required def preferences(request): if request.method == 'POST': + request.user.preference.default_visibility = int(request.POST.get('default_visibility')) request.user.preference.mastodon_publish_public = bool(request.POST.get('mastodon_publish_public')) request.user.preference.mastodon_append_tag = request.POST.get('mastodon_append_tag', '').strip() request.user.preference.save() @@ -110,6 +112,7 @@ def reset_visibility(request): GameMark.objects.filter(owner=request.user).update(visibility=visibility) AlbumMark.objects.filter(owner=request.user).update(visibility=visibility) SongMark.objects.filter(owner=request.user).update(visibility=visibility) + Activity.objects.filter(owner=request.user).update(visibility=visibility) messages.add_message(request, messages.INFO, _('已重置。')) return redirect(reverse("users:data")) diff --git a/users/management/commands/refresh_following.py b/users/management/commands/refresh_following.py new file mode 100644 index 00000000..b29f4f0c --- /dev/null +++ b/users/management/commands/refresh_following.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from users.models import User +from datetime import timedelta +from django.utils import timezone +from tqdm import tqdm + + +class Command(BaseCommand): + help = 'Refresh following data for all users' + + def handle(self, *args, **options): + count = 0 + for user in tqdm(User.objects.all()): + user.following = user.get_following_ids() + if user.following: + count += 1 + user.save(update_fields=['following']) + + print(f'{count} users updated') diff --git a/users/models.py b/users/models.py index 6ae3c18f..8c351e93 100644 --- a/users/models.py +++ b/users/models.py @@ -22,6 +22,7 @@ class User(AbstractUser): unique=False, help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'), ) + following = models.JSONField(default=list) mastodon_id = models.CharField(max_length=100, blank=False) # mastodon domain name, eg donotban.com mastodon_site = models.CharField(max_length=100, blank=False) @@ -79,12 +80,21 @@ class User(AbstractUser): self.mastodon_mutes = get_related_acct_list(self.mastodon_site, self.mastodon_token, '/api/v1/mutes') self.mastodon_blocks = get_related_acct_list(self.mastodon_site, self.mastodon_token, '/api/v1/blocks') self.mastodon_domain_blocks = get_related_acct_list(self.mastodon_site, self.mastodon_token, '/api/v1/domain_blocks') + self.following = self.get_following_ids() updated = True elif code == 401: print(f'401 {self}') self.mastodon_token = '' return updated + def get_following_ids(self): + fl = [] + for m in self.mastodon_following: + target = User.get(m) + if target and ((not target.mastodon_locked) or self.mastodon_username in target.mastodon_followers): + fl.append(target.id) + return fl + def is_blocking(self, target): return target.mastodon_username in self.mastodon_blocks or target.mastodon_site in self.mastodon_domain_blocks @@ -142,6 +152,7 @@ class Preference(models.Model): ) export_status = models.JSONField(blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) import_status = models.JSONField(blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) + default_visibility = models.PositiveSmallIntegerField(default=0) mastodon_publish_public = models.BooleanField(null=False, default=False) mastodon_append_tag = models.CharField(max_length=2048, default='') diff --git a/users/templates/users/preferences.html b/users/templates/users/preferences.html index 21226e75..04be8cff 100644 --- a/users/templates/users/preferences.html +++ b/users/templates/users/preferences.html @@ -33,6 +33,17 @@
    {% csrf_token %} + {% trans '新标记默认可见性:' %} +
    + 可见性: + + + +
    +
    {% trans '在联邦网络上以公开方式分享的帖文是否发布到公共时间轴上:' %}