add timeline, etc

This commit is contained in:
Your Name 2022-05-30 17:54:35 -04:00
parent eb602ce2b1
commit cd4d9f64cd
22 changed files with 457 additions and 0 deletions

View file

@ -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')),

View file

@ -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
})

View file

@ -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 '关注了收藏单'

View file

@ -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)

View file

@ -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)

View file

@ -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
})

View file

@ -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
})

View file

@ -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
})

0
timeline/__init__.py Normal file
View file

3
timeline/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

15
timeline/apps.py Normal file
View file

@ -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)

View file

@ -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)

63
timeline/models.py Normal file
View file

@ -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

View file

@ -0,0 +1,83 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% 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 }} {{ list_title }}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.6.1"></script>
<script src="{% static 'lib/js/rating-star.js' %}"></script>
<script>
$(document).ready( function() {
let render = function() {
let ratingLabels = $(".rating-star");
$(ratingLabels).each( function(index, value) {
let ratingScore = $(this).data("rating-score") / 2;
$(this).starRating({
initialRating: ratingScore,
readOnly: true,
starSize: 16,
});
});
};
document.body.addEventListener('htmx:load', function(evt) {
render();
});
render();
});
</script>
<script src="{% static 'js/mastodon.js' %}"></script>
<script src="{% static 'js/home.js' %}"></script>
<link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}">
<link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}">
<link rel="stylesheet" href="{% static 'lib/css/neo.css' %}">
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
{% include "partial/_navbar.html" %}
<section id="content" class="container">
<div class="grid grid--reverse-order">
<div class="grid__main grid__main--reverse-order">
<div class="main-section-wrapper">
<div class="entity-list">
<!-- <div class="set">
<h5 class="entity-list__title">
我的时间轴
</h5>
</div> -->
<ul class="entity-list__entities" hx-indicator=".htmx-indicator">
<div hx-get="{% url 'timeline:data' %}" hx-trigger="revealed" hx-swap="outerHTML"></div>
</ul>
</div>
</div>
</div>
{% include "partial/_sidebar.html" %}
</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

@ -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 %}
<li class="entity-list__entity">
<div class="entity-list__entity-img-wrapper">
<a href="{{ activity.target.item.url }}">
<img src="{{ activity.target.item.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img" style="min-width:80px;max-width:80px">
</a>
</div>
<div class="entity-list__entity-text">
<div class="collection-item-position-edit">
<span class="entity-marks__mark-time">
{% if activity.target.shared_link %}
<a href="{{ activity.target.shared_link }}" target="_blank"><span class="entity-marks__mark-time">{{ activity.target.created_time|prettydate }}</span></a>
{% else %}
<a><span class="entity-marks__mark-time">{{ activity.target.created_time|prettydate }}</span></a>
{% endif %}
</span>
</div>
<span class="entity-list__entity-info" style="top:0px;">
<a href="{% url 'users:home' activity.owner.mastodon_username %}">{{ activity.owner.mastodon_account.display_name }}</a> {{ activity.target.translated_status }}
</span>
<div class="entity-list__entity-title">
<a href="{{ activity.target.item.url }}" class="entity-list__entity-link" style="font-weight:bold;">{{ activity.target.item.title }}</a>
{% if activity.target.item.source_url %}
<a href="{{ activity.target.item.source_url }}">
<span class="source-label source-label__{{ activity.target.item.source_site }}" style="font-size:xx-small;">{{ activity.target.item.get_source_site_display }}</span>
</a>
{% endif %}
</div>
<p class="entity-list__entity-brief">
{% if activity.review %}
<a href="{{ activity.review.url }}">{{ activity.review.title }}</a>
{% endif %}
{% if activity.mark %}
{% if activity.mark.rating %}
<span class="entity-marks__rating-star rating-star" data-rating-score="{{ activity.mark.rating | floatformat:"0" }}" style=""></span>
{% endif %}
{% if activity.mark.text %}
<p class="entity-marks__mark-content">{{ activity.mark.text }}</p>
{% endif %}
{% endif %}
</p>
</div>
</li>
{% if forloop.last %}
<div class="htmx-indicator" style="margin-left: 60px;"
hx-get="{% url 'timeline:data' %}?last={{ activity.created_time|date:'Y-m-d H:i:s.uO'|urlencode }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<svg width="16" height="16" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#ccc">
<rect y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.5s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="y"
begin="0.5s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite" />
</rect>
<rect x="30" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.25s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="y"
begin="0.25s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite" />
</rect>
<rect x="60" width="15" height="140" rx="6">
<animate attributeName="height"
begin="0s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="y"
begin="0s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite" />
</rect>
<rect x="90" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.25s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="y"
begin="0.25s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite" />
</rect>
<rect x="120" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.5s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="y"
begin="0.5s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite" />
</rect>
</svg>
</div>
{% endif %}
{% empty %}
<div>{% trans '目前没有更多内容了' %}</div>
{% endfor %}

3
timeline/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
timeline/urls.py Normal file
View file

@ -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'),
]

56
timeline/views.py Normal file
View file

@ -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,
}
)

View file

@ -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"))

View file

@ -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')

View file

@ -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='')

View file

@ -33,6 +33,17 @@
<div class="import-panel__body">
<form action="{% url 'users:preferences' %}" method="POST">
{% csrf_token %}
<span>{% trans '新标记默认可见性:' %}</span>
<div class="import-panel__checkbox import-panel__checkbox--last">
可见性:
<label for="id_visibility_0"><input type="radio" name="default_visibility" value="0" required="" id="id_visibility_0" {%if request.user.preference.default_visibility == 0 %}checked{% endif %}>
公开</label>
<label for="id_visibility_1"><input type="radio" name="default_visibility" value="1" required="" id="id_visibility_1" {%if request.user.preference.default_visibility == 1 %}checked{% endif %}>
仅关注者</label>
<label for="id_visibility_2"><input type="radio" name="default_visibility" value="2" required="" id="id_visibility_2" {%if request.user.preference.default_visibility == 2 %}checked{% endif %}>
仅自己</label>
</div>
<br>
<span>{% trans '在联邦网络上以公开方式分享的帖文是否发布到公共时间轴上:' %}</span>
<div class="import-panel__checkbox import-panel__checkbox--last">
<input type="checkbox" name="mastodon_publish_public" id="visibility" {%if request.user.preference.mastodon_publish_public%}checked{% endif %}>