diff --git a/catalog/collection/models.py b/catalog/collection/models.py index e968334b..6664a712 100644 --- a/catalog/collection/models.py +++ b/catalog/collection/models.py @@ -3,4 +3,3 @@ from catalog.common import * class Collection(Item): category = ItemCategory.Collection - url_path = 'collection' diff --git a/catalog/templates/item_base.html b/catalog/templates/item_base.html index d003ebf9..e82aeb9f 100644 --- a/catalog/templates/item_base.html +++ b/catalog/templates/item_base.html @@ -274,7 +274,7 @@ {% if collection_list %} {% for c in collection_list %}

- {{ c.title }} + {{ c.title }}

{% endfor %} {% endif %} diff --git a/journal/forms.py b/journal/forms.py index fdd6ae34..d64ee29b 100644 --- a/journal/forms.py +++ b/journal/forms.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ import json from .models import * +from common.forms import PreviewImageInput class ReviewForm(forms.ModelForm): @@ -33,3 +34,44 @@ class ReviewForm(forms.ModelForm): choices=VisibilityType.choices, widget=forms.RadioSelect ) + + +COLLABORATIVE_CHOICES = [ + (0, _("仅限创建者")), + (1, _("创建者及其互关用户")), +] + + +class CollectionForm(forms.ModelForm): + # id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + title = forms.CharField(label=_("标题")) + brief = MarkdownxFormField(label=_("介绍 (Markdown)")) + # share_to_mastodon = forms.BooleanField(label=_("分享到联邦网络"), initial=True, required=False) + visibility = forms.TypedChoiceField( + label=_("可见性"), + initial=0, + coerce=int, + choices=VisibilityType.choices, + widget=forms.RadioSelect + ) + collaborative = forms.TypedChoiceField( + label=_("协作整理权限"), + initial=0, + coerce=int, + choices=COLLABORATIVE_CHOICES, + widget=forms.RadioSelect + ) + + class Meta: + model = Collection + fields = [ + 'title', + 'cover', + 'visibility', + 'collaborative', + 'brief', + ] + + widgets = { + 'cover': PreviewImageInput(), + } diff --git a/journal/mixins.py b/journal/mixins.py index f0d58ce4..f4d6d529 100644 --- a/journal/mixins.py +++ b/journal/mixins.py @@ -25,7 +25,7 @@ class UserOwnedObjectMixin: return True def is_editable_by(self, viewer): - return True if viewer.is_staff or viewer.is_superuser or viewer == self.owner else False + return viewer.is_authenticated and (viewer.is_staff or viewer.is_superuser or viewer == self.owner) @classmethod def get_available(cls, entity, request_user, following_only=False): diff --git a/journal/models.py b/journal/models.py index e0a53274..1206f450 100644 --- a/journal/models.py +++ b/journal/models.py @@ -23,6 +23,7 @@ from django.db.models import Q from catalog.models import * import mistune from django.contrib.contenttypes.models import ContentType +from markdown import markdown class VisibilityType(models.IntegerChoices): @@ -31,8 +32,19 @@ class VisibilityType(models.IntegerChoices): Private = 2, _('仅自己') +def q_visible_to(viewer, owner): + if viewer == owner: + return Q() + # elif viewer.is_blocked_by(owner): + # return Q(pk__in=[]) + elif viewer.is_following(owner): + return Q(visibility__ne=2) + else: + return Q(visibility=0) + + def query_visible(user): - return Q(visibility=0) | Q(owner_id__in=user.following, visibility__lt=2) | 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): @@ -231,14 +243,22 @@ class List(Piece): # subclass must add this: # items = models.ManyToManyField(Item, through='ListMember') - @ property + @property def ordered_members(self): return self.members.all().order_by('position', 'item_id') - @ property + @property def ordered_items(self): 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') + + @property + def recent_members(self): + return self.members.all().order_by('-created_time') + def has_item(self, item): return self.members.filter(item=item).count() > 0 @@ -356,14 +376,19 @@ class Shelf(List): def __str__(self): return f'{self.id} {self.title}' - @ cached_property + @cached_property + def item_category_label(self): + return ItemCategory(self.item_category).label + + @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) - @ cached_property + @cached_property def title(self): - q = _("{item_category} {shelf_label} list").format(shelf_label=self.shelf_label, item_category=self.item_category) - return _("{user}'s {shelf_name}").format(user=self.owner.mastodon_username, shelf_name=q) + 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): @@ -452,6 +477,10 @@ class ShelfManager: def get_shelf(self, item_category, shelf_type): 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() + return shelf.members.all().order_by + @ staticmethod def get_manager_for_user(user): return ShelfManager(user) @@ -469,8 +498,13 @@ Collection class CollectionMember(ListMember): parent = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE) + @property + def note(self): + return self.metadata.get('comment') + class Collection(List): + url_path = 'collection' MEMBER_CLASS = CollectionMember catalog_item = models.OneToOneField(CatalogCollection, on_delete=models.PROTECT) title = models.CharField(_("title in primary language"), max_length=1000, default="") @@ -479,9 +513,14 @@ class Collection(List): items = models.ManyToManyField(Item, through='CollectionMember', related_name="collections") collaborative = models.PositiveSmallIntegerField(default=0) # 0: Editable by owner only / 1: Editable by bi-direction followers - @ property + @property + def html(self): + html = markdown(self.brief) + return html + + @property def plain_description(self): - html = markdown(self.description) + html = markdown(self.brief) return RE_HTML_TAG.sub(' ', html) def save(self, *args, **kwargs): diff --git a/journal/templates/collection.html b/journal/templates/collection.html new file mode 100644 index 00000000..3533c969 --- /dev/null +++ b/journal/templates/collection.html @@ -0,0 +1,142 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} + + + + + + + + + + + + + + {{ site_name }} {% trans '收藏单' %} - {{ collection.title }} + + {% include "partial/_common_libs.html" with jquery=1 %} + + + +
+
+ {% include "partial/_navbar.html" %} + +
+
+
+
+
+
+ {{ collection.title }} +
+ {% if collection.visibility > 0 %} + + + + {% endif %} +
+
+ + {{ collection.owner.mastodon_username }} + + + {{ collection.edited_time }} + +
+
+ {% if request.user == collection.owner %} + {% trans '编辑' %} + {% trans '删除' %} + {% elif editable %} + 可协作整理 + {% endif %} +
+
+ + {{ collection.html | safe }} + +
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + {{ collection.title }} + +
+ {% if follower_count %} + 被 {{ follower_count }} 人关注 + {% endif %} +
+
+
+ + {% if request.user != collection.owner %} +
+
+
+ {% if following %} +
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+ {% endif %} + +
+
+
+
+ +
+
+
+
+
+
+
+
+ {% include "partial/_footer.html" %} +
+ + + + + + + + diff --git a/journal/templates/collection_edit.html b/journal/templates/collection_edit.html new file mode 100644 index 00000000..a5cd0db7 --- /dev/null +++ b/journal/templates/collection_edit.html @@ -0,0 +1,45 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {{ site_name }} - {{ title }} + {% include "partial/_common_libs.html" with jquery=1 %} + + + + +
+ {% include "partial/_navbar.html" %} +
+
+
+
+
+ {% csrf_token %} + {{ form }} + +
+ {{ form.media }} +
+
+
+
+
+
+
+
+ {% include "partial/_footer.html" %} +
+ + + diff --git a/journal/templates/collection_items.html b/journal/templates/collection_items.html new file mode 100644 index 00000000..e50b0c54 --- /dev/null +++ b/journal/templates/collection_items.html @@ -0,0 +1,22 @@ +{% load thumb %} +{% load i18n %} +{% load l10n %} + diff --git a/journal/templates/list_item_base.html b/journal/templates/list_item_base.html index 387b0f91..26f6fb32 100644 --- a/journal/templates/list_item_base.html +++ b/journal/templates/list_item_base.html @@ -19,12 +19,12 @@ {% if collection_edit %}
{% if not forloop.first %} - + {% endif %} {% if not forloop.last %} - + {% endif %} - +
{% endif %} @@ -119,14 +119,19 @@ {% endif %} - {% if collectionitem %} + {% if collection_member %}
diff --git a/journal/templates/profile.html b/journal/templates/profile.html new file mode 100644 index 00000000..eb19f53b --- /dev/null +++ b/journal/templates/profile.html @@ -0,0 +1,211 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} + + + + + + + {% if user == request.user %} + {{ site_name }} - {% trans '我的个人主页' %} + {% else %} + {{ site_name }} - {{user.display_name}} + {% endif %} + + + {% include "partial/_common_libs.html" with jquery=1 %} + + + + + + +
+
+ {% include "partial/_navbar.html" with current="home" %} + +
+
+
+ +
+ + {% for category, category_shelves in shelf_list.items %} + {% for shelf_type, shelf in category_shelves.items %} + +
+
+ {{ shelf.title }} +
+ + {{ shelf.count }} + + {% if shelf.count > 5 %} + {% trans '更多' %} + {% endif %} + +
+ {% endfor %} + {% endfor %} + + +
+
+ {% trans '创建的收藏单' %} +
+ + {{ collections_count }} + + {% if collections_count > 5 %} + {% trans '更多' %} + {% endif %} + {% if user == request.user %} + {% trans '新建' %} + {% endif %} + + +
+ +
+
+ {% trans '关注的收藏单' %} +
+ + {{ liked_collections_count }} + + {% if liked_collections_count > 5 %} + {% trans '更多' %} + {% endif %} + + +
+ +
+ + {% if user == request.user %} + +
+
+ + {% trans '编辑布局' %} + + + + + + + + + +
+ +
+ +
+ {% csrf_token %} + +
+ + + {% endif %} + + +
+ + {% include "partial/_sidebar.html" %} +
+
+
+ {% include "partial/_footer.html" %} +
+ + {% if unread_announcements %} + {% include "partial/_announcement.html" %} + {% endif %} + + \ No newline at end of file diff --git a/journal/templates/review_edit.html b/journal/templates/review_edit.html index f4e554e7..f6b1d20c 100644 --- a/journal/templates/review_edit.html +++ b/journal/templates/review_edit.html @@ -26,7 +26,7 @@
-
+ {% csrf_token %} {{ form.item }}
diff --git a/journal/tests.py b/journal/tests.py index 013239da..91d626af 100644 --- a/journal/tests.py +++ b/journal/tests.py @@ -110,7 +110,7 @@ class TagTest(TestCase): self.assertEqual(self.user2.tags, [t1, t3]) TagManager.add_tag_by_user(self.book2, t3, self.user2) TagManager.add_tag_by_user(self.movie1, t3, self.user2) - self.assertEqual(self.user2.tags, [t1, t3]) + self.assertEqual(sorted(self.user2.tags), [t1, t3]) class MarkTest(TestCase): diff --git a/journal/urls.py b/journal/urls.py index af4c5d68..10f01640 100644 --- a/journal/urls.py +++ b/journal/urls.py @@ -26,11 +26,24 @@ urlpatterns = [ path('review/edit//', review_edit, name='review_edit'), path('review/delete/', review_delete, name='review_delete'), - re_path(r'^user/(?P[A-Za-z0-0_\-.@]+)/(?P' + _get_all_shelf_types() + ')/(?P' + _get_all_categories() + ')/$', user_mark_list, name='user_mark_list'), - re_path(r'^user/(?P[A-Za-z0-0_\-.@]+)/reviews/(?P' + _get_all_categories() + ')/$', user_review_list, name='user_review_list'), - re_path(r'^user/(?P[A-Za-z0-0_\-.@]+)/tags/(?P[^/]+)/$', user_tag_member_list, name='user_tag_member_list'), - re_path(r'^user/(?P[A-Za-z0-0_\-.@]+)/collections/$', user_collection_list, name='user_collection_list'), - re_path(r'^user/(?P[A-Za-z0-0_\-.@]+)/like/collections/$', user_liked_collection_list, name='user_liked_collection_list'), - re_path(r'^user/(?P[A-Za-z0-0_\-.@]+)/tags/$', user_tag_list, name='user_tag_list'), + path('collection/', collection_retrieve, name='collection_retrieve'), + path('collection/create/', collection_edit, name='collection_create'), + path('collection/edit/', collection_edit, name='collection_edit'), + path('collection/delete/', collection_delete, name='collection_delete'), + path('collection//items', collection_retrieve_items, name='collection_retrieve_items'), + path('collection//append_item', collection_append_item, name='collection_append_item'), + path('collection//delete_item/', collection_delete_item, name='collection_delete_item'), + path('collection//move_up_item/', collection_move_up_item, name='collection_move_up_item'), + path('collection//move_down_item/', collection_move_down_item, name='collection_move_down_item'), + path('collection//update_item_note/', collection_update_item_note, name='collection_update_item_note'), + + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/(?P' + _get_all_shelf_types() + ')/(?P' + _get_all_categories() + ')/$', user_mark_list, name='user_mark_list'), + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/reviews/(?P' + _get_all_categories() + ')/$', user_review_list, name='user_review_list'), + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/tags/(?P[^/]+)/$', user_tag_member_list, name='user_tag_member_list'), + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/collections/$', user_collection_list, name='user_collection_list'), + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/like/collections/$', user_liked_collection_list, name='user_liked_collection_list'), + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/tags/$', user_tag_list, name='user_tag_list'), + + re_path(r'^user/(?P[A-Za-z0-9_\-.@]+)/$', home, name='user_profile'), ] diff --git a/journal/views.py b/journal/views.py index 08daefdb..edebd230 100644 --- a/journal/views.py +++ b/journal/views.py @@ -11,7 +11,6 @@ from django.core.paginator import Paginator 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 @@ -20,7 +19,7 @@ from django.utils.baseconv import base62 from .forms import * from mastodon.api import share_review from users.views import render_user_blocked, render_user_not_found - +from users.models import User, Report, Preference _logger = logging.getLogger(__name__) PAGE_SIZE = 10 @@ -115,6 +114,101 @@ def mark(request, item_uuid): 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}) + + +def collection_retrieve_items(request, collection_uuid): + 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': collection, + 'form': form, + 'collection_edit': request.GET.get('edit'), # collection.is_editable_by(request.user), + } + ) + + +@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): + 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_delete_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_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])) + else: + return HttpResponseBadRequest(form.errors) + 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): + 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")) + else: + return HttpResponseBadRequest() + + 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): @@ -198,11 +292,7 @@ def _render_list(request, user_name, type, shelf_type=None, item_category=None, queryset = queryset.filter(query_item_category(item_category)) else: return HttpResponseBadRequest() - if user != request.user: - if request.user.is_following(user): - queryset = queryset.filter(visibility__ne=2) - else: - queryset = queryset.filter(visibility=0) + queryset = queryset.filter(q_visible_to(request.user, user)) paginator = Paginator(queryset, PAGE_SIZE) page_number = request.GET.get('page', default=1) members = paginator.get_page(page_number) @@ -239,7 +329,7 @@ def user_tag_list(request, user_name): if user != request.user: tags = tags.filter(visibility=0) tags = tags.values('title').annotate(total=Count('members')).order_by('-total') - return render(request, f'user_tag_list.html', { + return render(request, 'user_tag_list.html', { 'user': user, 'tags': tags, }) @@ -258,7 +348,7 @@ def user_collection_list(request, user_name): collections = collections.filter(visibility__ne=2) else: collections = collections.filter(visibility=0) - return render(request, f'user_collection_list.html', { + return render(request, 'user_collection_list.html', { 'user': user, 'collections': collections, }) @@ -274,7 +364,94 @@ def user_liked_collection_list(request, user_name): collections = Collection.objects.filter(likes__owner=user) if user != request.user: collections = collections.filter(query_visible(request.user)) - return render(request, f'user_collection_list.html', { + 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, + }) + except Exception: + return redirect(login_url) + + +def home(request, user_name): + if not request.user.is_authenticated: + return home_anonymous(request, user_name) + if request.method != 'GET': + return HttpResponseBadRequest() + user = User.get(user_name) + if user is None: + return render_user_not_found(request) + + # access one's own home page + if user == request.user: + 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') + try: + 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 + # visit other's home page + else: + if request.user.is_blocked_by(user) or request.user.is_blocking(user): + return render_user_blocked(request) + # no these value on other's home page + reports = None + unread_announcements = None + + qv = q_visible_to(request.user, user) + shelf_list = {} + 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'), + } + reviews = Review.objects.filter(owner=user).filter(qv) + 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") + 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', + { + '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, + } + ) diff --git a/social/models.py b/social/models.py index 257d9a4c..f47be08c 100644 --- a/social/models.py +++ b/social/models.py @@ -48,7 +48,7 @@ class ActivityManager: q = Q(owner_id__in=self.owner.following, visibility__lt=2) | Q(owner=self.owner) if before_time: q = q & Q(created_time__lt=before_time) - return LocalActivity.objects.filter(q).order_by('-created_time') # .select_related() https://github.com/django-polymorphic/django-polymorphic/pull/531 + return LocalActivity.objects.filter(q).order_by('-created_time').prefetch_related('action_object') # .select_related() https://github.com/django-polymorphic/django-polymorphic/pull/531 @staticmethod def get_manager_for_user(user):