From c7b25744f6ddc13505df20b45376a004ab5e2571 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Dec 2021 21:02:50 -0500 Subject: [PATCH] wip: collection --- boofilsic/urls.py | 1 + collection/__init__.py | 0 collection/admin.py | 3 + collection/apps.py | 6 + collection/forms.py | 29 +++++ collection/models.py | 47 ++++++++ collection/tests.py | 3 + collection/urls.py | 15 +++ collection/views.py | 266 +++++++++++++++++++++++++++++++++++++++++ common/models.py | 3 +- 10 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 collection/__init__.py create mode 100644 collection/admin.py create mode 100644 collection/apps.py create mode 100644 collection/forms.py create mode 100644 collection/models.py create mode 100644 collection/tests.py create mode 100644 collection/urls.py create mode 100644 collection/views.py diff --git a/boofilsic/urls.py b/boofilsic/urls.py index e6025f89..c164046b 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ path('movies/', include('movies.urls')), path('music/', include('music.urls')), path('games/', include('games.urls')), + path('collections/', include('collection.urls')), path('sync/', include('sync.urls')), path('announcement/', include('management.urls')), path('', include('common.urls')), diff --git a/collection/__init__.py b/collection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/collection/admin.py b/collection/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/collection/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/collection/apps.py b/collection/apps.py new file mode 100644 index 00000000..7edc77d1 --- /dev/null +++ b/collection/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CollectionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'collection' diff --git a/collection/forms.py b/collection/forms.py new file mode 100644 index 00000000..57bad4f6 --- /dev/null +++ b/collection/forms.py @@ -0,0 +1,29 @@ +from django import forms +from django.contrib.postgres.forms import SimpleArrayField +from django.utils.translation import gettext_lazy as _ +from .models import Collection +from common.models import MarkStatusEnum +from common.forms import * + + +class CollectionForm(forms.ModelForm): + # id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + name = forms.CharField(label=_("标题")) + description = MarkdownxFormField(label=_("正文 (Markdown)")) + + class Meta: + model = Collection + fields = [ + 'name', + 'description', + 'cover', + ] + + widgets = { + 'name': forms.TextInput(attrs={'placeholder': _("收藏单名称")}), + 'developer': forms.TextInput(attrs={'placeholder': _("多个开发商使用英文逗号分隔")}), + 'publisher': forms.TextInput(attrs={'placeholder': _("多个发行商使用英文逗号分隔")}), + 'genre': forms.TextInput(attrs={'placeholder': _("多个类型使用英文逗号分隔")}), + 'platform': forms.TextInput(attrs={'placeholder': _("多个平台使用英文逗号分隔")}), + 'cover': PreviewImageInput(), + } diff --git a/collection/models.py b/collection/models.py new file mode 100644 index 00000000..4617db10 --- /dev/null +++ b/collection/models.py @@ -0,0 +1,47 @@ +from django.db import models +from common.models import UserOwnedEntity +from movies.models import Movie +from books.models import Book +from music.models import Song, Album +from games.models import Game +from markdownx.models import MarkdownxField +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + + +def collection_cover_path(instance, filename): + return GenerateDateUUIDMediaFilePath(instance, filename, settings.COLLECTION_MEDIA_PATH_ROOT) + + +class Collection(UserOwnedEntity): + name = models.CharField(max_length=200) + description = MarkdownxField() + cover = models.ImageField(_("封面"), upload_to=collection_cover_path, default=settings.DEFAULT_COLLECTION_IMAGE, blank=True) + + def __str__(self): + return str(self.owner) + ': ' + self.name + + @property + def item_list(self): + return list(self.collectionitem_set.objects.all()).sort(lambda i: i.position) + + @property + def plain_description(self): + html = markdown(self.description) + return RE_HTML_TAG.sub(' ', html) + + +class CollectionItem(models.Model): + movie = models.ForeignKey(Movie, on_delete=models.CASCADE, null=True) + album = models.ForeignKey(Album, on_delete=models.CASCADE, null=True) + song = models.ForeignKey(Song, on_delete=models.CASCADE, null=True) + book = models.ForeignKey(Book, on_delete=models.CASCADE, null=True) + game = models.ForeignKey(Game, on_delete=models.CASCADE, null=True) + collection = models.ForeignKey(Collection, on_delete=models.CASCADE) + position = models.PositiveIntegerField() + comment = models.TextField(_("备注"), default='') + + @property + def item(self): + items = list(filter(lambda i: i is not None, [self.movie, self.book, self.album, self.song, self.game])) + return items[0] if len(items) > 0 else None diff --git a/collection/tests.py b/collection/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/collection/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/collection/urls.py b/collection/urls.py new file mode 100644 index 00000000..99b8d9af --- /dev/null +++ b/collection/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, re_path +from .views import * + + +app_name = 'collection' +urlpatterns = [ + path('create/', create, name='create'), + path('/', retrieve, name='retrieve'), + path('update//', update, name='update'), + path('delete//', delete, name='delete'), + path('follow//', follow, name='follow'), + path('unfollow//', follow, name='unfollow'), + path('with///', list_with, name='list_with'), + # TODO: tag +] diff --git a/collection/views.py b/collection/views.py new file mode 100644 index 00000000..89742b49 --- /dev/null +++ b/collection/views.py @@ -0,0 +1,266 @@ +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 mastodon.utils import rating_to_emoji +from common.utils import PageLinksGenerator +from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.models import SourceSiteEnum +from .models import * +from .forms import * +from django.conf import settings + + +logger = logging.getLogger(__name__) +mastodon_logger = logging.getLogger("django.mastodon") + + +# how many marks showed on the detail page +MARK_NUMBER = 5 +# how many marks at the mark page +MARK_PER_PAGE = 20 +# how many reviews showed on the detail page +REVIEW_NUMBER = 5 +# how many reviews at the mark page +REVIEW_PER_PAGE = 20 +# max tags on detail page +TAG_NUMBER = 10 + + +# public data +########################### +@login_required +def create(request): + if request.method == 'GET': + form = GameForm() + return render( + request, + 'games/create_update.html', + { + 'form': form, + 'title': _('添加游戏'), + 'submit_url': reverse("games:create"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + if request.user.is_authenticated: + # only local user can alter public data + form = GameForm(request.POST, request.FILES) + if form.is_valid(): + form.instance.last_editor = request.user + try: + with transaction.atomic(): + form.save() + if form.instance.source_site == SourceSiteEnum.IN_SITE.value: + real_url = form.instance.get_absolute_url() + form.instance.source_url = real_url + form.instance.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + return redirect(reverse("games:retrieve", args=[form.instance.id])) + else: + return render( + request, + 'games/create_update.html', + { + 'form': form, + 'title': _('添加游戏'), + 'submit_url': reverse("games:create"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + else: + return redirect(reverse("users:login")) + else: + return HttpResponseBadRequest() + + +@login_required +def update(request, id): + if request.method == 'GET': + game = get_object_or_404(Game, pk=id) + form = GameForm(instance=game) + page_title = _('修改游戏') + return render( + request, + 'games/create_update.html', + { + 'form': form, + 'title': page_title, + 'submit_url': reverse("games:update", args=[game.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + game = get_object_or_404(Game, pk=id) + form = GameForm(request.POST, request.FILES, instance=game) + page_title = _("修改游戏") + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + try: + with transaction.atomic(): + form.save() + if form.instance.source_site == SourceSiteEnum.IN_SITE.value: + real_url = form.instance.get_absolute_url() + form.instance.source_url = real_url + form.instance.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + else: + return render( + request, + 'games/create_update.html', + { + 'form': form, + 'title': page_title, + 'submit_url': reverse("games:update", args=[game.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + return redirect(reverse("games:retrieve", args=[form.instance.id])) + + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +# @login_required +def retrieve(request, id): + if request.method == 'GET': + game = get_object_or_404(Game, pk=id) + mark = None + mark_tags = None + review = None + + # retreive tags + game_tag_list = game.game_tags.values('content').annotate( + tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER] + + # retrieve user mark and initialize mark form + try: + if request.user.is_authenticated: + mark = GameMark.objects.get(owner=request.user, game=game) + except ObjectDoesNotExist: + mark = None + if mark: + mark_tags = mark.gamemark_tags.all() + mark.get_status_display = GameMarkStatusTranslator(mark.status) + mark_form = GameMarkForm(instance=mark, initial={ + 'tags': mark_tags + }) + else: + mark_form = GameMarkForm(initial={ + 'game': game, + 'tags': mark_tags + }) + + # retrieve user review + try: + if request.user.is_authenticated: + review = GameReview.objects.get( + owner=request.user, game=game) + except ObjectDoesNotExist: + review = None + + # retrieve other related reviews and marks + if request.user.is_anonymous: + # hide all marks and reviews for anonymous user + mark_list = None + review_list = None + mark_list_more = None + review_list_more = None + else: + mark_list = GameMark.get_available(game, request.user) + review_list = GameReview.get_available(game, request.user) + mark_list_more = True if len(mark_list) > MARK_NUMBER else False + mark_list = mark_list[:MARK_NUMBER] + for m in mark_list: + m.get_status_display = GameMarkStatusTranslator(m.status) + review_list_more = True if len( + review_list) > REVIEW_NUMBER else False + review_list = review_list[:REVIEW_NUMBER] + + # def strip_html_tags(text): + # import re + # regex = re.compile('<.*?>') + # return re.sub(regex, '', text) + + # for r in review_list: + # r.content = strip_html_tags(r.content) + + return render( + request, + 'games/detail.html', + { + 'game': game, + 'mark': mark, + 'review': review, + 'status_enum': MarkStatusEnum, + 'mark_form': mark_form, + 'mark_list': mark_list, + 'mark_list_more': mark_list_more, + 'review_list': review_list, + 'review_list_more': review_list_more, + 'game_tag_list': game_tag_list, + 'mark_tags': mark_tags, + } + ) + else: + logger.warning('non-GET method at /games/') + return HttpResponseBadRequest() + + +@permission_required("games.delete_game") +@login_required +def delete(request, id): + if request.method == 'GET': + game = get_object_or_404(Game, pk=id) + return render( + request, + 'games/delete.html', + { + 'game': game, + } + ) + elif request.method == 'POST': + if request.user.is_staff: + # only staff has right to delete + game = get_object_or_404(Game, pk=id) + game.delete() + return redirect(reverse("common:home")) + else: + raise PermissionDenied() + else: + return HttpResponseBadRequest() + + +@login_required +def follow(request, id): + pass + + +@login_required +def unfollow(request, id): + pass + + +@login_required +def list_with(request, type, id): + pass diff --git a/common/models.py b/common/models.py index b69a9868..77d67704 100644 --- a/common/models.py +++ b/common/models.py @@ -152,8 +152,7 @@ class Entity(models.Model): class UserOwnedEntity(models.Model): is_private = models.BooleanField(default=False, null=True) # first set allow null, then migration, finally (in a few days) remove for good visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only - owner = models.ForeignKey( - User, on_delete=models.CASCADE, related_name='user_%(class)ss') + owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_%(class)ss') created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(default=timezone.now)