From 8ddbb9d25788068abfa50166d9c848da9d6d0537 Mon Sep 17 00:00:00 2001 From: doubaniux Date: Tue, 5 May 2020 23:50:48 +0800 Subject: [PATCH] second commit --- boofilsic/settings.py | 7 +- books/admin.py | 6 +- books/forms.py | 66 ++- books/models.py | 15 +- books/static/js/scrape.js | 7 + books/templates/books/create_update.html | 8 +- .../templates/books/create_update_review.html | 128 ++++++ books/templates/books/delete.html | 107 +++++ books/templates/books/delete_review.html | 109 +++++ books/templates/books/detail.html | 216 ++++++++- books/templates/books/list.html | 0 books/templates/books/mark_list.html | 177 ++++++++ books/templates/books/review_detail.html | 142 ++++++ books/templates/books/review_list.html | 172 ++++++++ books/templates/books/scrape.html | 91 ++++ books/urls.py | 11 + books/views.py | 412 +++++++++++++++++- common/forms.py | 37 +- common/mastodon/__init__.py | 1 + common/mastodon/api.py | 79 +++- common/mastodon/auth.py | 87 ++++ common/mastodon/decorators.py | 34 ++ common/models.py | 101 ++++- common/static/css/boofilsic_box.css | 29 ++ common/static/css/boofilsic_browse.css | 337 +++++++++++++- common/static/css/boofilsic_edit.css | 164 ++++--- common/static/css/boofilsic_modal.css | 89 ++++ common/static/js/create_update.js | 2 +- common/static/js/create_update_review.js | 25 ++ common/static/js/detail.js | 98 ++++- common/static/js/home.js | 22 +- common/static/js/mastodon.js | 20 +- ...arch_result.js => rating-star-readonly.js} | 0 common/static/lib/css/milligram.css | 5 +- common/templates/common/error.html | 30 ++ common/templates/common/home.html | 82 ++-- common/templates/common/search_result.html | 22 +- common/templates/widgets/key_value.html | 10 +- common/views.py | 24 +- users/admin.py | 5 +- users/auth.py | 88 ---- users/forms.py | 16 + users/models.py | 11 +- users/static/js/followers_list.js | 187 ++++++++ users/static/js/following_list.js | 188 ++++++++ users/templates/users/list.html | 234 ++++++++++ users/templates/users/login.html | 19 +- users/templates/users/manage_report.html | 89 ++++ users/templates/users/register.html | 39 +- users/templates/users/report.html | 78 ++++ users/urls.py | 6 + users/views.py | 263 ++++++++++- 52 files changed, 3889 insertions(+), 306 deletions(-) create mode 100644 books/static/js/scrape.js create mode 100644 books/templates/books/create_update_review.html create mode 100644 books/templates/books/delete_review.html create mode 100644 books/templates/books/list.html create mode 100644 books/templates/books/mark_list.html create mode 100644 books/templates/books/review_detail.html create mode 100644 books/templates/books/review_list.html create mode 100644 books/templates/books/scrape.html create mode 100644 common/mastodon/auth.py create mode 100644 common/mastodon/decorators.py create mode 100644 common/static/css/boofilsic_box.css create mode 100644 common/static/css/boofilsic_modal.css create mode 100644 common/static/js/create_update_review.js rename common/static/js/{search_result.js => rating-star-readonly.js} (100%) create mode 100644 common/templates/common/error.html create mode 100644 users/forms.py create mode 100644 users/static/js/followers_list.js create mode 100644 users/static/js/following_list.js create mode 100644 users/templates/users/list.html create mode 100644 users/templates/users/manage_report.html create mode 100644 users/templates/users/report.html diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 2708bbbe..a72782ce 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -86,7 +86,7 @@ if DEBUG: 'NAME': 'test', 'USER': 'donotban', 'PASSWORD': 'donotbansilvousplait', - 'HOST': '192.168.136.5', + 'HOST': '192.168.144.2', 'OPTIONS': { 'client_encoding': 'UTF8', # 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_DEFAULT, @@ -112,7 +112,7 @@ else: # https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#authentication-backends AUTHENTICATION_BACKENDS = [ - 'users.auth.OAuth2Backend', + 'common.mastodon.auth.OAuth2Backend', # for admin to login admin site # 'django.contrib.auth.backends.ModelBackend' ] @@ -155,6 +155,9 @@ DEFAULT_BOOK_IMAGE = os.path.join(MEDIA_ROOT, BOOK_MEDIA_PATH_ROOT, 'default.jpg # Mastodon domain name MASTODON_DOMAIN_NAME = 'cmx-im.work' +# Timeout of requests to Mastodon, in seconds +MASTODON_TIMEOUT = 30 + # Default password for each user. since assword is not used any way, # any string that is not empty is ok DEFAULT_PASSWORD = 'eBRM1DETkYgiqPgq' diff --git a/books/admin.py b/books/admin.py index 8c38f3f3..9b58e6bb 100644 --- a/books/admin.py +++ b/books/admin.py @@ -1,3 +1,7 @@ from django.contrib import admin +from .models import * + +admin.site.register(Book) +admin.site.register(BookMark) +admin.site.register(BookReview) -# Register your models here. diff --git a/books/forms.py b/books/forms.py index 2e07a45e..b605924c 100644 --- a/books/forms.py +++ b/books/forms.py @@ -3,14 +3,27 @@ from common.forms import KeyValueInput from django.contrib.postgres.forms import SimpleArrayField from django.utils.translation import gettext_lazy as _ from .models import Book, BookMark, BookReview +from common.models import MarkStatusEnum +from common.forms import RadioBooleanField, RatingValidator + + +def BookMarkStatusTranslator(status): + trans_dict = { + MarkStatusEnum.DO.value: _("在看"), + MarkStatusEnum.WISH.value: _("想看"), + MarkStatusEnum.COLLECT.value: _("看过") + } + return trans_dict[status] class BookForm(forms.ModelForm): pub_year = forms.IntegerField(required=False, max_value=9999, min_value=0, label=_("出版年份")) pub_month = forms.IntegerField(required=False, max_value=12, min_value=1, label=_("出版月份")) + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) class Meta: model = Book fields = [ + 'id', 'title', 'isbn', 'author', @@ -50,27 +63,78 @@ class BookForm(forms.ModelForm): 'author': forms.TextInput(attrs={'placeholder': _("多个作者使用英文逗号分隔")}), 'translator': forms.TextInput(attrs={'placeholder': _("多个译者使用英文逗号分隔")}), 'other_info': KeyValueInput(), + # 'cover': forms.FileInput(), } class BookMarkForm(forms.ModelForm): + IS_PRIVATE_CHOICES = [ + (True, _("仅关注者")), + (False, _("公开")), + ] + STATUS_CHOICES = [(v, BookMarkStatusTranslator(v)) for v in MarkStatusEnum.values] + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + share_to_mastodon = forms.BooleanField(label=_("分享到长毛象"), initial=True, required=False) + rating = forms.IntegerField(validators=[RatingValidator()], widget=forms.HiddenInput(), required=False) + status = forms.ChoiceField( + label=_(""), + widget=forms.RadioSelect(), + choices=STATUS_CHOICES + ) + is_private = RadioBooleanField( + label=_("可见性"), + initial=True, + choices=IS_PRIVATE_CHOICES + ) class Meta: model = BookMark fields = [ + 'id', 'book', 'status', 'rating', 'text', 'is_private', ] + labels = { + 'rating': _("评分"), + 'text': _("短评"), + } + widgets = { + 'book': forms.Select(attrs={"hidden": ""}), + 'text': forms.Textarea(attrs={"placeholder": _("最多只能写500字哦~")}), + } class BookReviewForm(forms.ModelForm): + IS_PRIVATE_CHOICES = [ + (True, _("仅关注者")), + (False, _("公开")), + ] + share_to_mastodon = forms.BooleanField(label=_("分享到长毛象"), initial=True, required=False) + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + is_private = RadioBooleanField( + label=_("可见性"), + initial=True, + choices=IS_PRIVATE_CHOICES + ) class Meta: model = BookReview fields = [ + 'id', 'book', 'title', 'content', 'is_private' - ] \ No newline at end of file + ] + labels = { + 'book': "", + 'title': _("标题"), + 'content': _("正文"), + 'share_to_mastodon': _("分享到长毛象") + } + widgets = { + 'book': forms.Select(attrs={"hidden": ""}), + } + + diff --git a/books/models.py b/books/models.py index ab699bd4..f3b0f721 100644 --- a/books/models.py +++ b/books/models.py @@ -1,20 +1,22 @@ +import uuid import django.contrib.postgres.fields as postgres from django.utils.translation import ugettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from common.models import Resource, Mark, Review from boofilsic.settings import BOOK_MEDIA_PATH_ROOT, DEFAULT_BOOK_IMAGE -from datetime import datetime +from django.utils import timezone def book_cover_path(instance, filename): - raise NotImplementedError("UUID!!!!!!!!!!!") + ext = filename.split('.')[-1] + filename = "%s.%s" % (uuid.uuid4(), ext) root = '' if BOOK_MEDIA_PATH_ROOT.endswith('/'): root = BOOK_MEDIA_PATH_ROOT else: root = BOOK_MEDIA_PATH_ROOT + '/' - return root + datetime.now().strftime('%Y/%m/%d') + f'{filename}' + return root + timezone.now().strftime('%Y/%m/%d') + f'{filename}' class Book(Resource): @@ -44,9 +46,8 @@ class Book(Resource): # since data origin is not formatted and might be CNY USD or other currency, use char instead price = models.CharField(_("pricing"), blank=True, default='', max_length=50) pages = models.PositiveIntegerField(_("pages"), null=True, blank=True) - isbn = models.CharField(_("ISBN"), blank=True, max_length=20, unique=True, db_index=True) + isbn = models.CharField(_("ISBN"), blank=True, null=True, max_length=20, unique=True, db_index=True) # to store previously scrapped data - img_url = models.URLField(max_length=300) cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, default=DEFAULT_BOOK_IMAGE, blank=True) class Meta: @@ -67,7 +68,7 @@ class Book(Resource): class BookMark(Mark): - book = models.ForeignKey(Book, on_delete=models.SET_NULL, related_name='book_marks', null=True) + book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_marks', null=True) class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_mark") @@ -75,7 +76,7 @@ class BookMark(Mark): class BookReview(Review): - book = models.ForeignKey(Book, on_delete=models.SET_NULL, related_name='book_reviews', null=True) + book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_reviews', null=True) class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_review") diff --git a/books/static/js/scrape.js b/books/static/js/scrape.js new file mode 100644 index 00000000..b36d2468 --- /dev/null +++ b/books/static/js/scrape.js @@ -0,0 +1,7 @@ +$(document).ready( function() { + $("#submit").click(function(e) { + e.preventDefault(); + $("#scrapeForm form").submit(); + }); + +}); \ No newline at end of file diff --git a/books/templates/books/create_update.html b/books/templates/books/create_update.html index 776e0663..b9d0882c 100644 --- a/books/templates/books/create_update.html +++ b/books/templates/books/create_update.html @@ -10,7 +10,7 @@ - {% trans 'Boofilsic - 添加图书' %} + {% trans 'Boofilsic - ' %}{{ title }} @@ -30,7 +30,9 @@ {% trans '登出' %} {% trans '主页' %} + {% if user.is_staff %} {% trans '后台' %} + {% endif %} @@ -38,10 +40,10 @@
-
+ {% csrf_token %} {{ form }} - +
diff --git a/books/templates/books/create_update_review.html b/books/templates/books/create_update_review.html new file mode 100644 index 00000000..c19e72db --- /dev/null +++ b/books/templates/books/create_update_review.html @@ -0,0 +1,128 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - ' %}{{ title }} + + + + + + + + + +
+
+ + +
+
+
+
+ +
+ + +
{% if book.isbn %}{% trans 'ISBN:' %}{{ book.isbn }}{% endif %}
+
{% if book.author %}{% trans '作者:' %} + {% for author in book.author %} + {{ author }} + {% endfor %} + {% endif %}
+
{% if book.pub_house %}{% trans '出版社:' %}{{ book.pub_house }}{% endif %}
+
{%if book.pub_year %}{% trans '出版时间:' %}{{ book.pub_year }}{% trans '年' %}{% if book.pub_month %}{{ book.pub_month }}{% trans '月' %}{% endif %}{% endif %}
+ + {% if book.rating %} + + {{ book.rating }} + {% endif %} +
+
+ +
+
+ {% csrf_token %} + {{ form.book }} + {{ form.title.label }}{{ form.title }} +
+ + {{ form.content.label }} + + + {% trans '预览' %} + +
+
+ {{ form.content }} +
+
{% trans '不知道什么是Markdown?可以参考' %}{% trans '这里' %}
+
+
+ + {{ form.is_private.label }}{{ form.is_private }} +
+
+ {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} +
+
+
+ +
+ {{ form.media }} +
+
+
+ +
+
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/delete.html b/books/templates/books/delete.html index e69de29b..f5ac6bfb 100644 --- a/books/templates/books/delete.html +++ b/books/templates/books/delete.html @@ -0,0 +1,107 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 删除图书' %} + + + + + + + + + +
+
+ + +
+
+
+

{% trans '确认删除这本书吗?相关评论和标记将一并删除。' %}

+ +
+
+ +
+ +
+ {{ book.title }} +
+
+ {% if book.rating %} + {% trans '评分:' %} + + + {{ book.rating }} + {% else %} + {% trans '评分:暂无评分' %} + {% endif %} +
{% trans '最近编辑者:' %}{{ book.last_editor | default:"" }}
+
{% trans '上次编辑时间:' %}{{ book.edited_time }}
+
+
+
+
+
+
+ {% csrf_token %} + +
+ +
+
+
+ +
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/delete_review.html b/books/templates/books/delete_review.html new file mode 100644 index 00000000..e3d8404b --- /dev/null +++ b/books/templates/books/delete_review.html @@ -0,0 +1,109 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 删除评论' %} + + + + + + + + +
+
+ + +
+
+
+

{% trans '确认删除这篇评论吗?' %}

+ +
+
+
+
+

+ {{ review.title }} + {% if review.is_private %} + + {% endif %} +

+ {{ review.owner.username }} + + {{ review.edited_time }} +
+ +
+ +
+ {{ form.content }} +
+ {{ form.media }} +
+ +
+
+
+ {% csrf_token %} + +
+ +
+
+
+ +
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/detail.html b/books/templates/books/detail.html index a26a814c..ce64005b 100644 --- a/books/templates/books/detail.html +++ b/books/templates/books/detail.html @@ -15,6 +15,7 @@ + @@ -31,7 +32,9 @@ {% trans '登出' %} {% trans '主页' %} + {% if user.is_staff %} {% trans '后台' %} + {% endif %}
@@ -40,7 +43,9 @@
- +
+ +
{{ book.title }} @@ -58,7 +63,7 @@
{% if book.translator %}{% trans '译者:' %} {% for translator in book.translator %} {{ translator }} - {% endfor %} + {% endfor %} {% endif %}
{% if book.orig_title %}{% trans '原作名:' %}{{ book.orig_title }}{% endif %}
{% if book.language %}{% trans '语言:' %}{{ book.language }}{% endif %}
@@ -88,7 +93,13 @@ {% url 'users:home' book.last_editor %} {% endcomment %} -
{% trans '最近编辑者:' %}someone
+
{% trans '最近编辑者:' %}{{ book.last_editor | default:"" }}
+
+ {% trans '编辑这本书' %} + {% if user.is_staff %} + / {% trans '删除' %} + {% endif %} +
@@ -104,16 +115,136 @@ {% endif %}
+
+
{% trans '这本书的标记' %}
+ {% if mark_list_more %} + {% trans '更多' %} + {% endif %} + {% if mark_list %} + {% for others_mark in mark_list %} +
+
+ {{ others_mark.owner.username }} + {{ others_mark.get_status_display }} + {% if others_mark.rating %} + + {% endif %} + {% if others_mark.is_private %} + + {% endif %} + {{ others_mark.edited_time }} +
+ + {% if others_mark.text %} +

{{ others_mark.text }}

+ {% endif %} + +
+ {% endfor %} + {% else %} +

{% trans '暂无标记' %}

+ {% endif %} +
+
+
{% trans '这本书的评论' %}
+ {% if review_list_more %} + {% trans '更多' %} + {% endif %} + {% if review_list %} + {% for others_review in review_list %} +
+
+ {{ others_review.owner.username }} + {% if others_review.is_private %} + + {% endif %} + {{ others_review.edited_time }} + {{ others_review.title }} +
+ +
+ {% endfor %} + {% else %} +

{% trans '暂无评论' %}

+ {% endif %} +
- +
-
-

- - +
+ + {% if mark %} +
+
+ {% trans '我' %}{{ mark.get_status_display }} + {% if mark.status == status_enum.DO.value or mark.status == status_enum.COLLECT.value%} + {% if mark.rating %} + + {% endif %} + {% endif %} + {% if mark.is_private %} + + {% endif %} + + {% trans '修改' %} +
+ {% csrf_token %} + {% trans '删除' %} +
+
+
+
+ {{ mark.edited_time }} +
+ {% if mark.text %} +

{{ mark.text }}

+ {% endif %} +
+ {% else %} +
+
+ {% trans '标记这本书' %} +
+ +
+ {% endif %} +
- +
+ {% if review %} +
+
+ {% trans '我的评论' %} + {% if review.is_private %} + + {% endif %} + + + {% trans '编辑' %} + {% trans '删除' %} + + +
+
+ {{ review.edited_time }} +
+ + {{ review.title }} + +
+ {% else %} +
+ {% trans '我的评论' %} +
+ {% trans '去写评论' %} + {% endif %} +
+
@@ -124,6 +255,73 @@
+
+ + + +
+
+ + + + + + + + +
+
+ + +
+
+
+
+ {{ book.title }}{% trans ' 的标记' %} +
+
    + + {% for mark in marks %} + +
    +
    + {{ mark.owner.username }} + {{ mark.get_status_display }} + {% if mark.rating %} + + {% endif %} + {% if mark.is_private %} + + {% endif %} + {{ mark.edited_time }} +
    + + {% if mark.text %} +

    {{ mark.text }}

    + {% endif %} + +
    + {% empty %} + {% trans '无结果' %} + {% endfor %} + +
+ +
+ +
+
+
+ +
{{ book.title }}
+ + {% if book.isbn %} +
ISBN: {{ book.isbn }}
+ {% endif %} + +
{% if book.pub_house %}{% trans '出版社:' %}{{ book.pub_house }}{% endif %}
+ {% if book.rating %} + {% trans '评分: ' %} + {% endif %} + +
+
+
+
+
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/review_detail.html b/books/templates/books/review_detail.html new file mode 100644 index 00000000..a1ebbf4b --- /dev/null +++ b/books/templates/books/review_detail.html @@ -0,0 +1,142 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 查看评论' %} + + + + + + + + +
+
+ + +
+
+
+ + +
+
+
+

+ {{ review.title }} + {% if review.is_private %} + + {% endif %} +

+ {{ review.owner.username }} + + {% if mark %} + + {% if mark.rating %} + + {% endif %} + + {% endif %} + + {{ review.edited_time }} +
+ + {% if request.user == review.owner %} + + + {% endif %} + +
+
+
+ {{ form.content }} +
+ {{ form.media }} +
+
+
+
+
+ +
{{ book.title }}
+ + {% if book.isbn %} +
ISBN: {{ book.isbn }}
+ {% endif %} +
{% if book.author %}{% trans '作者:' %} + {% for author in book.author %} + {{ author }} + {% endfor %} + {% endif %}
+
{% if book.pub_house %}{% trans '出版社:' %}{{ book.pub_house }}{% endif %}
+
{%if book.pub_year %}{% trans '出版时间:' %}{{ book.pub_year }}{% trans '年' %}{% if book.pub_month %}{{ book.pub_month }}{% trans '月' %}{% endif %}{% endif %}
+ + {% if book.rating %} + {% trans '评分: ' %} + {% endif %} + +
+
+
+
+
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/review_list.html b/books/templates/books/review_list.html new file mode 100644 index 00000000..811b1522 --- /dev/null +++ b/books/templates/books/review_list.html @@ -0,0 +1,172 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'Boofilsic - 搜索结果' %} + + + + + + + + + +
+
+ + +
+
+
+
+ {{ book.title }}{% trans ' 的评论' %} +
+
    + + {% for review in reviews %} + +
    +
    + {{ review.owner.username }} + {% if review.is_private %} + + {% endif %} + {{ review.edited_time }} +
    + + {{ review.title }} + + +
    + {% empty %} + {% trans '无结果' %} + {% endfor %} + +
+ +
+ +
+
+
+ +
{{ book.title }}
+ + {% if book.isbn %} +
ISBN: {{ book.isbn }}
+ {% endif %} + +
{% if book.pub_house %}{% trans '出版社:' %}{{ book.pub_house }}{% endif %}
+ {% if book.rating %} + {% trans '评分: ' %} + {% endif %} + +
+
+
+
+
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/scrape.html b/books/templates/books/scrape.html new file mode 100644 index 00000000..67f0e9f3 --- /dev/null +++ b/books/templates/books/scrape.html @@ -0,0 +1,91 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 主页' %} + + + + + + + + +
+
+ + +
+
+
+ +
+
+
+ {% csrf_token %} + {{ form }} +
+
+
+ +
+
+
+ +
+ + {% trans '根据豆瓣内容填写!' %} +
+ {% trans '剽取!' %} +
+
+ +
+
+ +
+ + +
+ + + + + diff --git a/books/urls.py b/books/urls.py index 620c3989..d768014f 100644 --- a/books/urls.py +++ b/books/urls.py @@ -6,4 +6,15 @@ app_name = 'books' urlpatterns = [ path('create/', create, name='create'), path('/', retrieve, name='retrieve'), + path('update//', update, name='update'), + path('delete//', delete, name='delete'), + path('mark/', create_update_mark, name='create_update_mark'), + path('/mark/list/', retrieve_mark_list, name='retrieve_mark_list'), + path('mark/delete//', delete_mark, name='delete_mark'), + path('/review/create/', create_review, name='create_review'), + path('review/update//', update_review, name='update_review'), + path('review/delete//', delete_review, name='delete_review'), + path('review//', retrieve_review, name='retrieve_review'), + path('/review/list/', retrieve_review_list, name='retrieve_review_list'), + path('scrape/', scrape, name='scrape'), ] diff --git a/books/views.py b/books/views.py index 574d21d2..098e9331 100644 --- a/books/views.py +++ b/books/views.py @@ -1,11 +1,30 @@ from django.shortcuts import render, get_object_or_404, redirect, reverse from django.contrib.auth.decorators import login_required from django.utils.translation import gettext_lazy as _ -from django.http import HttpResponseBadRequest +from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import IntegrityError, transaction +from django.utils import timezone +from django.core.paginator import Paginator +from common.mastodon import mastodon_request_included +from common.mastodon.api import check_visibility, post_toot, TootVisibilityEnum from .models import * from .forms import * +from .forms import BookMarkStatusTranslator +# 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 + + +# public data +########################### @login_required def create(request): if request.method == 'GET': @@ -15,31 +34,404 @@ def create(request): 'books/create_update.html', { 'form': form, - 'title': _('添加书籍') + 'title': _('添加书籍'), + 'submit_url': reverse("books:create") } ) elif request.method == 'POST': - # check user credential in post data, must be the login user - pass - form = BookForm(request.POST) - if form.is_valid(): - form.instance.last_editor = request.user - form.save() - - return redirect(reverse("books:retrieve", args=[form.instance.id])) + if request.user.is_authenticated: + # only local user can alter public data + form = BookForm(request.POST, request.FILES) + if form.is_valid(): + form.instance.last_editor = request.user + form.save() + return redirect(reverse("books:retrieve", args=[form.instance.id])) + else: + return render( + request, + 'books/create_update.html', + { + 'form': form, + 'title': _('添加书籍'), + 'submit_url': reverse("books:create") + } + ) + else: + return redirect(reverse("users:login")) else: return HttpResponseBadRequest() +@login_required +def update(request, id): + if request.method == 'GET': + book = get_object_or_404(Book, pk=id) + form = BookForm(instance=book) + return render( + request, + 'books/create_update.html', + { + 'form': form, + 'title': _('修改书籍'), + 'submit_url': reverse("books:update", args=[book.id]) + } + ) + elif request.method == 'POST': + book = get_object_or_404(Book, pk=id) + form = BookForm(request.POST, instance=book) + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + form.save() + return redirect(reverse("books:retrieve", args=[form.instance.id])) + + else: + return HttpResponseBadRequest() + + +@mastodon_request_included @login_required def retrieve(request, id): if request.method == 'GET': book = get_object_or_404(Book, pk=id) + mark = None + review = None + try: + mark = BookMark.objects.get(owner=request.user, book=book) + except ObjectDoesNotExist: + mark = None + if mark: + mark.get_status_display = BookMarkStatusTranslator(mark.status) + mark_form = BookMarkForm(instance=mark) + else: + mark_form = BookMarkForm(initial={ + 'book': book + }) + + try: + review = BookReview.objects.get(owner=request.user, book=book) + except ObjectDoesNotExist: + review = None + + mark_list = BookMark.get_available(book, request.user, request.session['oauth_token']) + 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 = BookMarkStatusTranslator(m.status) + review_list = BookReview.get_available(book, request.user, request.session['oauth_token']) + 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, 'books/detail.html', { 'book': book, + '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, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def delete(request, id): + if request.method == 'GET': + book = get_object_or_404(Book, pk=id) + return render( + request, + 'books/delete.html', + { + 'book': book, + } + ) + elif request.method == 'POST': + if request.user.is_staff: + # only staff has right to delete + book = get_object_or_404(Book, pk=id) + book.delete() + return redirect(reverse("common:search")) + else: + raise PermissionDenied() + else: + return HttpResponseBadRequest() + + +# user owned entites +########################### +@mastodon_request_included +@login_required +def create_update_mark(request): + # check list: + # clean rating if is wish + # transaction on updating book rating + # owner check(guarantee) + if request.method == 'POST': + pk = request.POST.get('id') + old_rating = None + if pk: + mark = get_object_or_404(BookMark, pk=pk) + old_rating = mark.rating + # update + form = BookMarkForm(request.POST, instance=mark) + else: + # create + form = BookMarkForm(request.POST) + + if form.is_valid(): + if form.instance.status == MarkStatusEnum.WISH.value: + form.instance.rating = None + form.instance.owner = request.user + form.instance.edited_time = timezone.now() + book = form.instance.book + try: + with transaction.atomic(): + # update book rating + book.update_rating(old_rating, form.instance.rating) + form.save() + except IntegrityError as e: + return HttpResponseServerError() + + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.PUBLIC + url = "https://" + request.get_host() + reverse("books:retrieve", args=[book.id]) + words = BookMarkStatusTranslator(int(form.cleaned_data['status'])) + f"《{book.title}》" + content = words + '\n' + url + '\n' + form.cleaned_data['text'] + post_toot(content, visibility, request.session['oauth_token']) + else: + return HttpResponseBadRequest() + + return redirect(reverse("books:retrieve", args=[form.instance.book.id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_mark_list(request, book_id): + if request.method == 'GET': + book = get_object_or_404(Book, pk=book_id) + queryset = BookMark.get_available(book, request.user, request.session['oauth_token']) + paginator = Paginator(queryset, MARK_PER_PAGE) + page_number = request.GET.get('page', default=1) + marks = paginator.get_page(page_number) + for m in marks: + m.get_status_display = BookMarkStatusTranslator(m.status) + return render( + request, + 'books/mark_list.html', + { + 'marks': marks, + 'book': book, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def delete_mark(request, id): + if request.method == 'POST': + mark = get_object_or_404(BookMark, pk=id) + book_id = mark.book.id + try: + with transaction.atomic(): + # update book rating + mark.book.update_rating(mark.rating, None) + mark.delete() + except IntegrityError as e: + return HttpResponseServerError() + return redirect(reverse("books:retrieve", args=[book_id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def create_review(request, book_id): + if request.method == 'GET': + form = BookReviewForm(initial={'book': book_id}) + book = get_object_or_404(Book, pk=book_id) + return render( + request, + 'books/create_update_review.html', + { + 'form': form, + 'title': _("添加评论"), + 'book': book, + 'submit_url': reverse("books:create_review", args=[book_id]), + } + ) + elif request.method == 'POST': + form = BookReviewForm(request.POST) + if form.is_valid(): + form.instance.owner = request.user + form.save() + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.PUBLIC + url = "https://" + request.get_host() + reverse("books:retrieve_review", args=[form.instance.id]) + words = "发布了关于" + f"《{form.instance.book.title}》" + "的评论" + content = words + '\n' + url + '\n' + form.cleaned_data['title'] + post_toot(content, visibility, request.session['oauth_token']) + return redirect(reverse("books:retrieve_review", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def update_review(request, id): + # owner check + # edited time + if request.method == 'GET': + review = get_object_or_404(BookReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + form = BookReviewForm(instance=review) + book = review.book + return render( + request, + 'books/create_update_review.html', + { + 'form': form, + 'title': _("编辑评论"), + 'book': book, + 'submit_url': reverse("books:update_review", args=[review.id]), + } + ) + elif request.method == 'POST': + review = get_object_or_404(BookReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + form = BookReviewForm(request.POST, instance=review) + if form.is_valid(): + form.instance.edited_time = timezone.now() + form.save() + if form.cleaned_data['share_to_mastodon']: + if form.cleaned_data['is_private']: + visibility = TootVisibilityEnum.PRIVATE + else: + visibility = TootVisibilityEnum.PUBLIC + url = "https://" + request.get_host() + reverse("books:retrieve_review", args=[form.instance.id]) + words = "发布了关于" + f"《{form.instance.book.title}》" + "的评论" + content = words + '\n' + url + '\n' + form.cleaned_data['title'] + post_toot(content, visibility, request.session['oauth_token']) + return redirect(reverse("books:retrieve_review", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + else: + return HttpResponseBadRequest() + + +@login_required +def delete_review(request, id): + if request.method == 'GET': + review = get_object_or_404(BookReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + review_form = BookReviewForm(instance=review) + return render( + request, + 'books/delete_review.html', + { + 'form': review_form, + 'review': review, + } + ) + elif request.method == 'POST': + review = get_object_or_404(BookReview, pk=id) + if request.user != review.owner: + return HttpResponseBadRequest() + book_id = review.book.id + review.delete() + return redirect(reverse("books:retrieve", args=[book_id])) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_review(request, id): + if request.method == 'GET': + review = get_object_or_404(BookReview, pk=id) + if not check_visibility(review, request.session['oauth_token'], request.user): + return HttpResponseBadRequest() + review_form = BookReviewForm(instance=review) + book = review.book + try: + mark = BookMark.objects.get(owner=review.owner, book=book) + mark.get_status_display = BookMarkStatusTranslator(mark.status) + except ObjectDoesNotExist: + mark = None + return render( + request, + 'books/review_detail.html', + { + 'form': review_form, + 'review': review, + 'book': book, + 'mark': mark, + } + ) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def retrieve_review_list(request, book_id): + if request.method == 'GET': + book = get_object_or_404(Book, pk=book_id) + queryset = BookReview.get_available(book, request.user, request.session['oauth_token']) + paginator = Paginator(queryset, REVIEW_PER_PAGE) + page_number = request.GET.get('page', default=1) + reviews = paginator.get_page(page_number) + return render( + request, + 'books/review_list.html', + { + 'reviews': reviews, + 'book': book, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def scrape(request): + if request.method == 'GET': + keywords = request.GET.get('keywords') + form = BookForm() + return render( + request, + 'books/scrape.html', + { + 'keywords': keywords, + 'form': form, } ) else: diff --git a/common/forms.py b/common/forms.py index e19899c0..6b58bba9 100644 --- a/common/forms.py +++ b/common/forms.py @@ -1,5 +1,7 @@ from django import forms from django.contrib.postgres.forms import JSONField +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ import json @@ -22,9 +24,36 @@ class KeyValueInput(forms.Widget): if context['widget']['value']: key_value_pairs = json.loads(context['widget']['value']) # for kv in key_value_pairs: - - context['widget']['keyvalue_pairs'] = { - - } + context['widget']['keyvalue_pairs'] = key_value_pairs return context + +class RadioBooleanField(forms.ChoiceField): + widget = forms.RadioSelect + + def to_python(self, value): + """Return a Python boolean object.""" + # Explicitly check for the string 'False', which is what a hidden field + # will submit for False. Also check for '0', since this is what + # RadioSelect will provide. Because bool("True") == bool('1') == True, + # we don't need to handle that explicitly. + if isinstance(value, str) and value.lower() in ('false', '0'): + value = False + else: + value = bool(value) + return super().to_python(value) + + +class RatingValidator: + """ empty value is not validated """ + def __call__(self, value): + if not isinstance(value, int): + raise ValidationError( + _('%(value)s is not an integer'), + params={'value': value}, + ) + if not str(value) in [str(i) for i in range(1, 11)]: + raise ValidationError( + _('%(value)s is not an integer in range 1-10'), + params={'value': value}, + ) \ No newline at end of file diff --git a/common/mastodon/__init__.py b/common/mastodon/__init__.py index e69de29b..42be4720 100644 --- a/common/mastodon/__init__.py +++ b/common/mastodon/__init__.py @@ -0,0 +1 @@ +from .decorators import * \ No newline at end of file diff --git a/common/mastodon/api.py b/common/mastodon/api.py index dc5f5e5e..db9196b0 100644 --- a/common/mastodon/api.py +++ b/common/mastodon/api.py @@ -1,22 +1,91 @@ +import requests +import string +import random +import functools +from boofilsic.settings import MASTODON_TIMEOUT, MASTODON_DOMAIN_NAME + # See https://docs.joinmastodon.org/methods/accounts/ # returns user info # retruns the same info as verify account credentials # GET -ACCOUNT = '/api/v1/accounts/:id' +API_GET_ACCOUNT = '/api/v1/accounts/:id' # returns user info if valid, 401 if invalid # GET -VERIFY_ACCOUNT_CREDENTIALS = '/api/v1/accounts/verify_credentials' +API_VERIFY_ACCOUNT = '/api/v1/accounts/verify_credentials' # obtain token # GET -OAUTH_TOKEN = '/oauth/token' +API_OBTAIN_TOKEN = '/oauth/token' # obatin auth code # GET -OAUTH_AUTHORIZE = '/oauth/authorize' +API_OAUTH_AUTHORIZE = '/oauth/authorize' # revoke token # POST -REVOKE_TOKEN = '/oauth/revoke' \ No newline at end of file +API_REVOKE_TOKEN = '/oauth/revoke' + +# relationships +# GET +API_GET_RELATIONSHIPS = '/api/v1/accounts/relationships' + +# toot +# POST +API_PUBLISH_TOOT = '/api/v1/statuses' + + +get = functools.partial(requests.get, timeout=MASTODON_TIMEOUT) +post = functools.partial(requests.post, timeout=MASTODON_TIMEOUT) + + +def get_relationships(id_list, token): + url = 'https://' + MASTODON_DOMAIN_NAME + API_GET_RELATIONSHIPS + payload = {'id[]': id_list} + headers = { + 'Authorization': f'Bearer {token}' + } + response = get(url, headers=headers, data=payload) + return response.json() + + +def check_visibility(user_owned_entity, token, visitor): + """ + check if given user can see the user owned entity + """ + if not visitor == user_owned_entity.owner: + # mastodon request + relationship = get_relationships([visitor.mastodon_id], token)[0] + if relationship['blocked_by']: + return False + if not relationship['following'] and user_owned_entity.is_private: + return False + return True + else: + return True + + +def post_toot(content, visibility, token): + url = 'https://' + MASTODON_DOMAIN_NAME + API_PUBLISH_TOOT + headers = { + 'Authorization': f'Bearer {token}', + 'Idempotency-Key': random_string_generator(16) + } + payload = { + 'status': content, + 'visibility': visibility + } + response = post(url, headers=headers, data=payload) + return response + +def random_string_generator(n): + s = string.ascii_letters + string.punctuation + string.digits + return ''.join(random.choice(s) for i in range(n)) + + +class TootVisibilityEnum: + PUBLIC = 'public' + PRIVATE = 'private' + DIRECT = 'direct' + UNLISTED = 'unlisted' \ No newline at end of file diff --git a/common/mastodon/auth.py b/common/mastodon/auth.py new file mode 100644 index 00000000..1ebe99b8 --- /dev/null +++ b/common/mastodon/auth.py @@ -0,0 +1,87 @@ +from django.contrib.auth.backends import ModelBackend, UserModel +from django.shortcuts import reverse +from boofilsic.settings import MASTODON_DOMAIN_NAME, CLIENT_ID, CLIENT_SECRET +from .api import * + + +def obtain_token(request, code): + """ Returns token if success else None. """ + payload = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'redirect_uri': f"http://{request.get_host()}{reverse('users:OAuth2_login')}", + 'grant_type': 'authorization_code', + 'code': code, + 'scope': 'read write' + } + url = 'https://' + MASTODON_DOMAIN_NAME + API_OBTAIN_TOKEN + response = post(url, data=payload) + if response.status_code != 200: + return + data = response.json() + return data.get('access_token') + + +def get_user_data(token): + url = 'https://' + MASTODON_DOMAIN_NAME + API_VERIFY_ACCOUNT + headers = { + 'Authorization': f'Bearer {token}' + } + response = get(url, headers=headers) + if response.status_code != 200: + return None + return response.json() + + +def revoke_token(token): + payload = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'scope': token + } + url = 'https://' + MASTODON_DOMAIN_NAME + API_REVOKE_TOKEN + response = post(url, data=payload) + + +def verify_token(token): + """ Check if the token is valid and is of local instance. """ + url = 'https://' + MASTODON_DOMAIN_NAME + API_VERIFY_ACCOUNT + headers = { + 'Authorization': f'Bearer {token}' + } + response = get(url, headers=headers) + if response.status_code == 200: + res_data = response.json() + # check if is local instance user + if res_data['acct'] == res_data['username']: + return True + return False + + +class OAuth2Backend(ModelBackend): + """ Used to glue OAuth2 and Django User model """ + # "authenticate() should check the credentials it gets and returns + # a user object that matches those credentials." + # arg request is an interface specification, not used in this implementation + def authenticate(self, request, token=None, username=None, **kwargs): + """ when username is provided, assume that token is newly obtained and valid """ + if token is None: + return + + if username is None: + user_data = get_user_data(token) + if user_data: + username = user_data['username'] + else: + # aquiring user data fail means token is invalid thus auth fail + return None + + # when username is provided, assume that token is newly obtained and valid + try: + user = UserModel._default_manager.get_by_natural_key(user_data['username']) + except UserModel.DoesNotExist: + return None + else: + if self.user_can_authenticate(user): + return user + return None diff --git a/common/mastodon/decorators.py b/common/mastodon/decorators.py new file mode 100644 index 00000000..3d9cd433 --- /dev/null +++ b/common/mastodon/decorators.py @@ -0,0 +1,34 @@ +from django.http import HttpResponse +import functools +from django.shortcuts import render +from django.utils.translation import gettext_lazy as _ +from requests.exceptions import Timeout + + +def mastodon_request_included(func): + """ Handles timeout exception of requests to mastodon, returns http 500 """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Timeout: + return render( + args[0], + 'common/error.html', + { + 'msg': _("长毛象请求超时叻_(´ཀ`」 ∠)__ ") + } + ) + except ConnectionError: + return render( + args[0], + 'common/error.html', + { + 'msg': _("长毛象请求超时叻_(´ཀ`」 ∠)__ ") + } + ) + return wrapper + + +class HttpResponseInternalServerError(HttpResponse): + status_code = 500 diff --git a/common/models.py b/common/models.py index 833d4479..a9df779f 100644 --- a/common/models.py +++ b/common/models.py @@ -1,10 +1,12 @@ import django.contrib.postgres.fields as postgres from decimal import * from django.utils.translation import ugettext_lazy as _ -from django.db import models +from django.db import models, IntegrityError from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Q from markdownx.models import MarkdownxField from users.models import User +from common.mastodon.api import get_relationships # abstract base classes @@ -13,7 +15,7 @@ class Resource(models.Model): rating_total_score = models.PositiveIntegerField(null=True, blank=True) rating_number = models.PositiveIntegerField(null=True, blank=True) - rating = models.DecimalField(null=True, blank=True, max_digits=2, decimal_places=1) + rating = models.DecimalField(null=True, blank=True, max_digits=3, decimal_places=1) created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now_add=True) last_editor = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='%(class)s_last_editor', null=True, blank=False) @@ -30,32 +32,115 @@ class Resource(models.Model): def save(self, *args, **kwargs): """ update rating before save to db """ if self.rating_number and self.rating_total_score: - self.rating = Decimal(str(round(self.rating_total_score / self.rating_number ), 1)) + self.rating = Decimal(str(round(self.rating_total_score / self.rating_number, 1))) + elif self.rating_number is None and self.rating_total_score is None: + self.rating = None + else: + raise IntegrityError() super().save(*args, **kwargs) + def calculate_rating(self, old_rating, new_rating): + if (not (self.rating and self.rating_total_score and self.rating_number)\ + and (self.rating or self.rating_total_score or self.rating_number))\ + or (not (self.rating or self.rating_number or self.rating_total_score) and old_rating is not None): + raise IntegrityError("Rating integiry error.") + if old_rating: + if new_rating: + # old -> new + self.rating_total_score += (new_rating - old_rating) + else: + # old -> none + if self.rating_number >= 2: + self.rating_total_score -= old_rating + self.rating_number -= 1 + else: + # only one rating record + self.rating_number = None + self.rating_total_score = None + pass + else: + if new_rating: + # none -> new + if self.rating_number and self.rating_number >= 1: + self.rating_total_score += new_rating + self.rating_number += 1 + else: + # no rating record before + self.rating_number = 1 + self.rating_total_score = new_rating + else: + # none -> none + pass + + def update_rating(self, old_rating, new_rating): + self.calculate_rating(old_rating, new_rating) + self.save() + class UserOwnedEntity(models.Model): is_private = models.BooleanField() - owner = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='user_%(class)ss', null=True) + owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_%(class)ss') created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now_add=True) class Meta: abstract = True + @classmethod + def get_available(cls, resource, user, token): + """ + Returns all avaliable user-owned entities related to given resource. + This method handls mute/block relationships and private/public visibilities. + """ + # the foreign key field that points to resource + # has to be named as the lower case name of that resource + query_kwargs = {resource.__class__.__name__.lower(): resource} + user_owned_entities = cls.objects.filter(**query_kwargs).order_by("-edited_time") + # every user should only be abled to have one user owned entity for each resource + # this is guaranteed by models + id_list = [e.owner.mastodon_id for e in user_owned_entities] + # Mastodon request + # relationships = get_relationships(id_list, token) + # mute_block_blocked = [] + # following = [] + # for r in relationships: + # # check json data type + # if r['blocking'] or r['blocked_by'] or r['muting']: + # mute_block_blocked.append(r['id']) + # if r['following']: + # following.append(r['id']) + # user_owned_entities = user_owned_entities.exclude(owner__mastodon_id__in=mute_block_blocked) + # following.append(str(user.mastodon_id)) + # user_owned_entities = user_owned_entities.exclude(Q(is_private=True) & ~Q(owner__mastodon_id__in=following)) + return user_owned_entities + + + @classmethod + def get_available_user_data(cls, owner, is_following): + """ + Returns all avaliable owner's entities. + + :param owner: visited user + :param is_following: if the current user is following the owner + """ + user_owned_entities = cls.objects.filter(owner=owner) + if not is_following: + user_owned_entities = user_owned_entities.exclude(is_private=True) + return user_owned_entities + # commonly used entity classes ################################### class MarkStatusEnum(models.IntegerChoices): - DO = 1, _('Do') - WISH = 2, _('Wish') + WISH = 1, _('Wish') + DO = 2, _('Do') COLLECT = 3, _('Collect') class Mark(UserOwnedEntity): status = models.SmallIntegerField(choices=MarkStatusEnum.choices) rating = models.PositiveSmallIntegerField(blank=True, null=True) - text = models.CharField(max_length=150, blank=True, default='') + text = models.CharField(max_length=500, blank=True, default='') class Meta: abstract = True @@ -73,4 +158,4 @@ class Review(UserOwnedEntity): return self.title class Meta: - abstract = True \ No newline at end of file + abstract = True diff --git a/common/static/css/boofilsic_box.css b/common/static/css/boofilsic_box.css new file mode 100644 index 00000000..5acdbc01 --- /dev/null +++ b/common/static/css/boofilsic_box.css @@ -0,0 +1,29 @@ +.box { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + padding: 80px 100px; + padding-bottom: 60px; + background-color: var(--bright); + text-align: center; + min-width: 400px; +} + +.box .sec-msg { + color: var(--light); + font-size: smaller; +} + +.box .main-msg { + margin-bottom: 5px; +} + +.box .logo { + width: 140px; + margin-bottom: 60px; +} + +.box p { + text-align: justify; +} \ No newline at end of file diff --git a/common/static/css/boofilsic_browse.css b/common/static/css/boofilsic_browse.css index 0630379e..c975143f 100644 --- a/common/static/css/boofilsic_browse.css +++ b/common/static/css/boofilsic_browse.css @@ -18,9 +18,17 @@ body { margin: 0; } +.errorlist { + color: var(--primary); + font-size: small; + font-weight: bold; + list-style-type: square; +} + #page-wrapper { position: relative; min-height: 100vh; + z-index: 0; } #content-wrapper { @@ -78,6 +86,126 @@ div.dividing-line { border-top: solid 1px var(--light); } +.icon-lock svg{ + fill: var(--light); + height: 12px; + position: relative; + top: 1px; + margin-left: 3px; +} + +textarea::placeholder { + color: var(--light); +} + +input::placeholder { + color: var(--light); +} + +.spinner { + transform: scale(0.4); + margin: auto; +} + +.lds-spinner { + color: official; + display: inline-block; + position: relative; + width: 80px; + height: 80px; + } + + .lds-spinner div { + transform-origin: 40px 40px; + animation: lds-spinner 1.2s linear infinite; + } + + .lds-spinner div:after { + content: " "; + display: block; + position: absolute; + top: 3px; + left: 37px; + width: 6px; + height: 18px; + border-radius: 20%; + background: var(--secondary); + } + + .lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; + } + + .lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; + } + + .lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; + } + + .lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; + } + + .lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; + } + + .lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; + } + + .lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; + } + + .lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; + } + + .lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; + } + + .lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; + } + + .lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; + } + + .lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; + } + + @keyframes lds-spinner { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } + } + +/* overwrite rating-star plugin makes it readonly */ +.rating-star .jq-star { + cursor: unset; +} /* Nav Bar */ @@ -126,6 +254,7 @@ section#content div#main { background-color: var(--bright); margin-right: 40px; width: 75%; + position: relative; } /* Aside Content Section */ @@ -134,8 +263,13 @@ section#content div#aside { width: 25%; } +.set { + margin-bottom: 20px; +} + .set .set-title { display: inline-block; + margin-bottom: 7px; } .set .set-empty { font-size: small; @@ -144,11 +278,12 @@ section#content div#aside { } .set .set-item-list { - padding: 0 25px 0 10px; + padding: 8px 25px 0 10px; } .set .set-item { text-align: center; + margin-bottom: 0 !important; } .set .set-item-image { @@ -158,7 +293,7 @@ section#content div#aside { } .set .set-item-title, .set .set-item-title:visited { - width: 80%; + /* width: 80%; */ margin: auto; line-height: 1.3em; font-size: small; @@ -250,6 +385,10 @@ section#content div#aside { overflow: hidden; } +.relation-user .relation-name .emoji { + transform: scale(0.8); +} + .relation-card .relation-user-list { margin-bottom: 15px; } @@ -283,6 +422,10 @@ section#content div#aside { /* Search Result */ +.result-items .result-item:last-of-type { + margin-bottom: 50px; +} + .result-item { list-style: none; margin-bottom: 30px; @@ -313,11 +456,6 @@ section#content div#aside { left: -3px; } -/* overwrite rating-star plugin */ -.result-item .result-info .rating-star .jq-star { - cursor: unset; -} - .result-item .result-info .result-book-info { font-size: 80%; overflow: hidden; @@ -335,7 +473,7 @@ section#content div#aside { margin-right: 20px; } -.result-item .result-info .result-book-title { +.result-item .result-info .result-item-title { white-space: nowrap; text-overflow: ellipsis; max-width: 500px; @@ -343,7 +481,7 @@ section#content div#aside { overflow: hidden } -.result-item .result-info .result-book-brief { +.result-item .result-info .result-item-brief { margin-top: 10px; margin-bottom: 0; font-size: small; @@ -355,9 +493,16 @@ section#content div#aside { height: 90px; } +.aside-card .add-nav .clearfix { + margin-bottom: 5px; +} + /* Pagination */ .pagination { - margin-top: 50px; + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); text-align: center; } @@ -413,4 +558,176 @@ img.display-image { .display-info-detail .rating-score { position: relative; top: -2.5px; + font-weight: bold; +} + +.display-info-detail form, .display-info-detail input[type='submit'] { + display: inline; +} + +.mark { + font-size: small; + margin-bottom: 5px; +} + +.mark form { + display: inline; + /* margin: 0; */ +} + +.mark .mark-status-label { + font-weight: bold; +} + +.mark .rating-star { + position: relative; + top: 1.5px; +} + +.mark .icon-lock { + position: relative; + top: -0.5px; +} + +.mark .clearfix { + margin-bottom: 5px; +} + +.list-item .item-time { + color: var(--light); + margin-left: 5px; + font-size: small; +} + +.list-item .mark-text { + margin: 20px 0 15px 0; +} + + +.mark .button-group a.button{ + width: 33%; + box-sizing: border-box; + display: initial; + padding: 7px 8%; + margin: 0 4px; + font-weight: normal; +} + +.review { + font-size: small; + margin-bottom: 5px; +} + +.review .clearfix { + margin-bottom: 5px; +} + +.review .review-label { + font-weight: bold; +} + +.review .review-time { + color: var(--light); +} + +.review .review-title { + font-size: small; + font-weight: bold; + display: block; + margin-top: 10px; + margin-bottom: 5px; +} + +.review .review-head .edit { + margin-left: 10px; +} + +.set .mark { + font-size: small; + /* margin-bottom: 3px; */ + padding-left: 20px; + padding-right: 20px; +} + +.list-label .rating-star { + position: relative; + top: 3px; +} + +.list-label .list-owner-name { + position: relative; + top: -1px; +} + +.list-label { + font-size: small; +} + +.set .mark-text { + margin: 0; +} + +.set .review-title { + font-weight: bold; + font-size: small; +} + +.set.result-items .list-item { + margin-bottom: 10px; +}s + +.aside-card .aside-item { + overflow: hidden; +} + +.aside-item .item-image { + height: 150px; +} + +.aside-item div { + font-size: small; +} + +.aside-item .item-title { + font-weight: bold; + font-size: small; + margin-bottom: 5px; + margin-top: 10px; +} + +.aside-item .rating-star { + position: relative; + top: 4px; +} + +.list-head { + margin-bottom: 30px; +} + +.user { + font-size: small; + margin-bottom: 24px; +} + +.user .user-info { + margin-left: 15px; + max-width: 88%; +} + +.user .user-name { + font-size: small; + margin-bottom: 5px; +} + +.user .user-brief { + margin: 0; +} + +.user .avatar { + height: 70px; +} + +iframe { + width: 100%; + height: 500px; } \ No newline at end of file diff --git a/common/static/css/boofilsic_edit.css b/common/static/css/boofilsic_edit.css index 3a0353e7..56d81ef2 100644 --- a/common/static/css/boofilsic_edit.css +++ b/common/static/css/boofilsic_edit.css @@ -18,9 +18,33 @@ body { margin: 0; } +.rating-star { + cursor: unset; +} + +.rating-star .jq-star { + cursor: unset; +} + +.icon-lock svg{ + fill: var(--light); + height: 12px; + position: relative; + top: 1px; + margin-left: 3px; +} + +.errorlist { + color: var(--primary); + font-size: small; + font-weight: bold; + list-style-type: square; +} + #page-wrapper { position: relative; min-height: 100vh; + z-index: 0; } #content-wrapper { @@ -53,6 +77,11 @@ footer a { font-size: small; } +textarea { + resize: vertical; + height: 180px; +} + img.emoji { height: 20px !important; width: 20px !important; @@ -67,6 +96,12 @@ img.emoji { margin-left: 5px; } +div.dividing-line { + height: 0; + width: 100%; + margin: 40px 0 15px 0; + border-top: solid 1px var(--light); +} /* Nav Bar */ @@ -92,7 +127,7 @@ section#navbar { } .navbar a.nav-link, .navbar a.nav-link:visited { - font-size: 80%; + float: right; margin-top: 10px; color: var(--secondary); @@ -103,6 +138,10 @@ section#navbar { color: var(--primary); } +textarea::placeholder { + color: var(--light); +} + /* Main Content Section */ section#content div#main { @@ -235,95 +274,88 @@ div#main form input[type="file"] { list-style-type: none; } -/* Search Result */ - -.result-item { - list-style: none; - margin-bottom: 30px; -} - - -.result-item .result-info { - margin-left: 20px; - float: left; -} - -.result-item img.result-book-cover { - float: left; - object-fit: contain; +.item-card img.item-image { height: 150px; - width: 100px; + display: inline-block; } -.result-item .result-info .rating-empty { - font-size: small; +.item-card .item-info { + display: inline-block; + margin-left: 20px; } -.result-item .result-info .rating-star{ - cursor: unset; +.item-card .item-info .item-title { + font-weight: bold; +} + +.item-card .item-info .rating-star { display: inline; position: relative; top: 3px; - left: -3px; + left: -2px; } -/* overwrite rating-star plugin */ -.result-item .result-info .rating-star .jq-star { - cursor: unset; +.prompt { + font-weight: bold; + margin-bottom: 40px; } -.result-item .result-info .result-book-info { - font-size: 80%; - overflow: hidden; - width: 420px; - display: inline-block; - white-space: nowrap; - text-overflow: ellipsis; - position: relative; - top: .55rem; -} - -.result-item .result-info .rating-score { - font-size: 80%; +.review .option .selection ul { + list-style-type: none; display: inline; - margin-right: 20px; } -.result-item .result-info .result-book-title { - white-space: nowrap; - text-overflow: ellipsis; - max-width: 500px; - display: block; - overflow: hidden +.review .option .selection ul li, .review .option .selection ul label { + display: inline; } -.result-item .result-info .result-book-brief { - margin-top: 10px; - margin-bottom: 0; +.review .option .selection ul li { + position: relative; + top: 1px; +} + +.review .option input[type="checkbox"], .review .option input[type="radio"] { + position: relative; + top: 2px; +} + +.review input[type="submit"] { + margin-top: 16px; +} + +.review textarea { + height: 400px; + margin: 0; +} + +.review .preview-button { font-size: small; - width: 565px; - display: block; - text-overflow: ellipsis; - overflow: hidden; - /* white-space: nowrap; */ - height: 90px; + font-weight: bold; + color: var(--primary); + cursor: pointer; } -/* Pagination */ -.pagination { - margin-top: 50px; - text-align: center; +.review .markdownx-preview { + display: none; + min-height: 400px; + overflow: auto; + border-top: 0.1rem solid #d1d1d1; + border-bottom: 0.1rem solid #d1d1d1; + padding: 10px; + margin: 20px 0; } -.pagination a { - display: inline-block; +.review .fyi { + margin-bottom: 15px; + color: var(--light); } -.pagination .button { - padding: 0 5px; - height: 2rem; +.review { + margin-top: 30px; } -.pagination .page-index { - font-size: small; +.review .delete-preview .markdownx-preview { + padding-left: 0; + padding-right: 0; + margin: 20px 0; } \ No newline at end of file diff --git a/common/static/css/boofilsic_modal.css b/common/static/css/boofilsic_modal.css new file mode 100644 index 00000000..f8c0530f --- /dev/null +++ b/common/static/css/boofilsic_modal.css @@ -0,0 +1,89 @@ +.bg-mask { + background-color: black; + z-index: 1; + filter: opacity(20%); + position: fixed; + width: 100%; + height: 100%; + left: 0; + top: 0; + display: none; +} +.modal { + z-index: 2; + display: none; + position: fixed; + width: 500px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + background-color: var(--bright); + /* border: solid 2px var(--light); */ + padding: 20px 20px 0px 20px; + color: var(--secondary); +} + +.modal-head { + /* border-bottom: 1px solid var(--light); */ + padding-bottom: 30px; +} + +.modal-head .modal-title { + font-weight: bold; +} + +.modal-head .modal-close svg { + height: 10px; + cursor: pointer; + fill: var(--light); +} + +.modal-body form { + margin: 0; +} + +.modal-body, .modal-body label { + font-size: small; + font-weight: normal; +} + +.modal-body .modal-selection ul { + list-style-type: none; + display: inline; +} + +.modal-body .modal-selection ul li, .modal-body .modal-selection ul label { + display: inline; +} + +.modal-body .modal-selection ul li { + position: relative; + top: 1px; +} + +.modal-body .modal-checkbox { + position: relative; + top: 1px; +} + +.modal-body .modal-option { + +} + +.modal-body .modal-button { + margin: 15px 0 15px 0; + padding: 0; +} + +.modal-body textarea { + height: 160px; + margin-top: 5px; + margin-bottom: 5px; + resize: vertical; +} + +.modal-body input[type="checkbox"], .modal-body input[type="radio"] { + position: relative; + top: 2px; +} \ No newline at end of file diff --git a/common/static/js/create_update.js b/common/static/js/create_update.js index fe407674..db88a427 100644 --- a/common/static/js/create_update.js +++ b/common/static/js/create_update.js @@ -1,7 +1,7 @@ $(document).ready( function() { // assume there is only one input[file] on page $("input[type='file']").each(function() { - $(this).after(''); + $(this).after(''); }) // mark required diff --git a/common/static/js/create_update_review.js b/common/static/js/create_update_review.js new file mode 100644 index 00000000..574f0f23 --- /dev/null +++ b/common/static/js/create_update_review.js @@ -0,0 +1,25 @@ +$(document).ready( function() { + + $(".markdownx textarea").attr("placeholder", "拖拽图片至编辑框即可插入哦~"); + + $(".preview-button").click(function() { + if ($(".markdownx-preview").is(":visible")) { + $(".preview-button").text("预览"); + $(".markdownx-preview").hide(); + $(".markdownx textarea").show(); + } else { + $(".preview-button").text("编辑"); + $(".markdownx-preview").show(); + $(".markdownx textarea").hide(); + } + }); + + let ratingLabels = $("#main .rating-star"); + $(ratingLabels).each( function(index, value) { + let ratingScore = $(this).data("rating-score") / 2; + $(this).starRating({ + initialRating: ratingScore, + readOnly: true, + }); + }); +}); \ No newline at end of file diff --git a/common/static/js/detail.js b/common/static/js/detail.js index 0beaa427..29254d21 100644 --- a/common/static/js/detail.js +++ b/common/static/js/detail.js @@ -1,12 +1,104 @@ $(document).ready( function() { - // readonly star rating - let ratingLabels = $(".rating-star"); + + + $(".modal-close").on('click', function() { + $(this).parents(".modal").hide(); + $(".bg-mask").hide(); + }); + + $("#aside .mark .button-group .button").each(function() { + $(this).click(function(e) { + e.preventDefault(); + let title = $(this).text().trim(); + $(".mark-modal .modal-title").text(title); + $(".mark-modal .modal-body textarea").val(""); + let status = $(this).data('status') + $("input[name='status'][value='"+status+"']").prop("checked", true) + $(".bg-mask").show(); + $(".mark-modal").show(); + + // if wish, hide rating widget in modal + if ($(this).attr("id") == "wishButton") { + console.log($(this).attr("id")) + $(".mark-modal .rating-star-edit").hide(); + } else { + $(".mark-modal .rating-star-edit").show(); + } + + }); + }) + + $(".mark a.edit").click(function(e) { + e.preventDefault(); + let title = $(".mark-status-label").text().trim(); + $(".mark-modal .modal-title").text(title); + $(".bg-mask").show(); + $(".mark-modal").show(); + }); + + // readonly star rating of detail display section + let ratingLabels = $("#main .rating-star"); $(ratingLabels).each( function(index, value) { let ratingScore = $(this).data("rating-score") / 2; $(this).starRating({ initialRating: ratingScore, - readOnly: true + readOnly: true, + }); + }); + // readonly star rating at aside section + ratingLabels = $("#aside .rating-star"); + $(ratingLabels).each( function(index, value) { + let ratingScore = $(this).data("rating-score") / 2; + $(this).starRating({ + initialRating: ratingScore, + readOnly: true, + starSize: 15, + }); + }); + + // editable rating star in modal + ratingLabels = $("#modals .rating-star-edit"); + $(ratingLabels).each( function(index, value) { + let ratingScore = $("input[type='hidden'][name='rating']").val() / 2; + let label = $(this); + label.starRating({ + initialRating: ratingScore, + starSize: 20, + onHover: function(currentIndex, currentRating, $el){ + $("input[type='hidden'][name='rating']").val(currentIndex); + }, + onLeave: function(currentIndex, currentRating, $el){ + $("input[type='hidden'][name='rating']").val(currentRating * 2); + } }); }); + // hide rating star when select wish + const WISH_CODE = 1; + if ($(".modal-selection input[type='radio']:checked").val() == WISH_CODE) { + $(".mark-modal .rating-star-edit").hide(); + } + $(".modal-selection input[type='radio']").click(function() { + // 2 is the status code of wish + if ($(this).val() == WISH_CODE) { + $(".mark-modal .rating-star-edit").hide(); + } else { + $(".mark-modal .rating-star-edit").show(); + } + + }); + + // show confirm modal + $(".mark form a").click(function(e) { + e.preventDefault(); + $(".modal.confirm-modal").show(); + $(".bg-mask").show(); + }); + + // confirm modal + $(".confirm-modal input[type='submit']").click(function(e) { + e.preventDefault(); + $(".mark form").submit(); + }); + }); \ No newline at end of file diff --git a/common/static/js/home.js b/common/static/js/home.js index 7606e621..6ae4cdf9 100644 --- a/common/static/js/home.js +++ b/common/static/js/home.js @@ -4,6 +4,12 @@ $(document).ready( function() { let mast_uri = $("#mastodonURI").text(); let id = $("#userMastodonID").text(); + let userInfoSpinner = $("#spinner").clone().removeAttr("hidden"); + let followersSpinner = $("#spinner").clone().removeAttr("hidden"); + let followingSpinner = $("#spinner").clone().removeAttr("hidden"); + $("#userInfoCard").append(userInfoSpinner); + $("#userRelationCard h5:first").append(followingSpinner); + $("#userRelationCard h5:last").append(followersSpinner); $(".mast-following-more").hide(); $(".mast-followers-more").hide(); @@ -21,6 +27,7 @@ $(document).ready( function() { $(".mast-user .mast-avatar").attr("src", userData.avatar); $(".mast-user .mast-displayname").html(userName); $(".mast-user .mast-brief").text($(userData.note).text()); + $(userInfoSpinner).remove(); } ); @@ -28,7 +35,7 @@ $(document).ready( function() { id, mast_uri, token, - function(userList) { + function(userList, request) { if (userList.length == 0) { $(".mast-followers").hide(); } else { @@ -42,13 +49,16 @@ $(document).ready( function() { temp = $(template).clone(); temp.find("img").attr("src", data.avatar); if (data.display_name) { - temp.find("a").text(data.display_name); + temp.find("a").html(translateEmojis(data.display_name, data.emojis)); } else { temp.find("a").text(data.username); } + let url = $("#userPageURL").text().replace('0', data.id) + "?is_mastodon_id=true"; + temp.find("a").attr('href', url); $(".mast-followers").append(temp); }); } + $(followersSpinner).remove(); } ); @@ -56,7 +66,7 @@ $(document).ready( function() { id, mast_uri, token, - function(userList) { + function(userList, request) { if (userList.length == 0) { $(".mast-following").hide(); } else { @@ -70,13 +80,17 @@ $(document).ready( function() { temp = $(template).clone() temp.find("img").attr("src", data.avatar); if (data.display_name) { - temp.find("a").text(data.display_name); + temp.find("a").html(translateEmojis(data.display_name, data.emojis)); } else { temp.find("a").text(data.username); } + let url = $("#userPageURL").text().replace('0', data.id) + "?is_mastodon_id=true"; + temp.find("a").attr('href', url); $(".mast-following").append(temp); }); } + $(followingSpinner).remove(); + } ); diff --git a/common/static/js/mastodon.js b/common/static/js/mastodon.js index 24cdef70..115d5a38 100644 --- a/common/static/js/mastodon.js +++ b/common/static/js/mastodon.js @@ -5,7 +5,9 @@ const API_FOLLOWERS = "/api/v1/accounts/:id/followers" // GET const API_FOLLOWING = "/api/v1/accounts/:id/following" // GET -const API_ACCOUNT = '/api/v1/accounts/:id' +const API_GET_ACCOUNT = '/api/v1/accounts/:id' + +const NUMBER_PER_REQUEST = 20 // [ @@ -60,8 +62,11 @@ function getFollowers(id, mastodonURI, token, callback) { headers: { 'Authorization': 'Bearer ' + token, }, - success: function(data){ - callback(data); + data: { + 'limit': NUMBER_PER_REQUEST + }, + success: function(data, status, request){ + callback(data, request); }, }); } @@ -73,9 +78,12 @@ function getFollowing(id, mastodonURI, token, callback) { method: 'GET', headers: { 'Authorization': 'Bearer ' + token, + }, + data: { + 'limit': NUMBER_PER_REQUEST }, - success: function(data){ - callback(data); + success: function(data, status, request){ + callback(data, request); }, }); } @@ -113,7 +121,7 @@ function getFollowing(id, mastodonURI, token, callback) { // ] // } function getUserInfo(id, mastodonURI, token, callback) { - let url = mastodonURI + API_ACCOUNT.replace(":id", id); + let url = mastodonURI + API_GET_ACCOUNT.replace(":id", id); $.ajax({ url: url, method: 'GET', diff --git a/common/static/js/search_result.js b/common/static/js/rating-star-readonly.js similarity index 100% rename from common/static/js/search_result.js rename to common/static/js/rating-star-readonly.js diff --git a/common/static/lib/css/milligram.css b/common/static/lib/css/milligram.css index d253355e..13fbaabd 100644 --- a/common/static/lib/css/milligram.css +++ b/common/static/lib/css/milligram.css @@ -26,6 +26,10 @@ body { line-height: 1.6; } +textarea { + font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; +} + blockquote { border-left: 0.3rem solid #d1d1d1; margin-left: 0; @@ -599,4 +603,3 @@ img { float: right; } -/*# sourceMappingURL=milligram.css.map */ \ No newline at end of file diff --git a/common/templates/common/error.html b/common/templates/common/error.html new file mode 100644 index 00000000..e627d2ff --- /dev/null +++ b/common/templates/common/error.html @@ -0,0 +1,30 @@ +{% load i18n %} +{% load static %} + + + + + + + + + + {% trans '错误' %} + + +
+ + + +
+ {{ msg }} +
+
+ {% if secondary_msg %} + {{ secondary_msg }} + {% endif %} +
+
+ + + \ No newline at end of file diff --git a/common/templates/common/home.html b/common/templates/common/home.html index 00a8fa92..a4b97ad3 100644 --- a/common/templates/common/home.html +++ b/common/templates/common/home.html @@ -30,7 +30,9 @@ {% trans '登出' %} {% trans '主页' %} + {% if request.user.is_staff %} {% trans '后台' %} + {% endif %} @@ -41,19 +43,19 @@
- {% trans '我想看的书' %} + {% trans '想看的书' %}
{% if wish_books_more %} - {% trans '更多' %} + {% trans '更多' %} {% endif %}
- {% trans '我在看的书' %} + {% trans '在看的书' %}
{% if do_books_more %} - {% trans '更多' %} + {% trans '更多' %} {% endif %}
- {% trans '我看过的书' %} + {% trans '看过的书' %}
{% if collect_books_more %} - {% trans '更多' %} + {% trans '更多' %} {% endif %}
    - {% for collect_book in collect_books %} + {% for collect_book_mark in collect_book_marks %}
  • - - - - {{ collect_book.title | truncate:9 }} + + + + {{ collect_book_mark.book.title | truncate:15 }}
  • {% empty %} @@ -111,21 +113,23 @@
    - -

    - + + {% if request.user != user %} + {% trans '举报用户' %} + {% endif %} +
    - {% trans '我关注的人' %} + {% trans '关注的人' %}
    - {% trans '更多' %} + {% trans '更多' %}
    • @@ -133,9 +137,9 @@
    - {% trans '关注我的人' %} + {% trans '被他们关注' %}
    - {% trans '更多' %} + {% trans '更多' %}
    • @@ -144,7 +148,7 @@
    - {% if user.is_staff %} + {% if request.user.is_staff %}
    {% trans '举报信息' %}
      @@ -155,6 +159,7 @@ {% endfor %}
    + 全部举报
    {% endif %} @@ -172,6 +177,23 @@ + + - + @@ -33,7 +33,9 @@ value="{% if request.GET.keywords %}{{ request.GET.keywords }}{% endif %}" id="searchInput" required="true" placeholder="{% trans '搜索书影音,多个关键字以空格分割' %}"> {% trans '登出' %} {% trans '主页' %} + {% if user.is_staff %} {% trans '后台' %} + {% endif %}
@@ -46,10 +48,12 @@ {% for book in items %}
  • - + + + @@ -150,12 +154,12 @@
    diff --git a/common/templates/widgets/key_value.html b/common/templates/widgets/key_value.html index 512102bb..2ca666f2 100644 --- a/common/templates/widgets/key_value.html +++ b/common/templates/widgets/key_value.html @@ -8,7 +8,15 @@ margin-left: 1%; } -
    +
    + {% if widget.value != None %} + + {% for k, v in widget.keyvalue_pairs.items %} + + {% endfor %} + + {% endif %} +
    + + + + + + + + + + +
    +
    + + +
    +
    +
    +
      + + {% for mark in marks %} + +
    • + + + +
      + + + {{ mark.book.title }} + + {% if mark.book.rating %} + +
      + + {{ mark.book.rating }} + + {% else %} + {% trans '暂无评分' %} + {% endif %} + + {% if mark.book.pub_year %} + {{ mark.book.pub_year }}{% trans '年' %} / + {% if mark.book.pub_month %} + {{ mark.book.pub_month }}{% trans '月' %} / + {% endif %} + {% endif %} + + {% if mark.book.author %} + {% trans '作者' %} + {% for author in mark.book.author %} + {{ author }}{% if not forloop.last %},{% endif %} + {% endfor %}/ + {% endif %} + + {% if mark.book.translator %} + {% trans '译者' %} + {% for translator in mark.book.translator %} + {{ translator }}{% if not forloop.last %},{% endif %} + {% endfor %}/ + {% endif %} + + {% if mark.book.orig_title %} +  {% trans '原名' %} + {{ mark.book.orig_title }} + {% endif %} + +

      + {{ mark.book.brief | truncate:170 }} +

      +
      +
    • + {% empty %} + {% trans '无结果' %} + {% endfor %} + +
    + +
    + +
    +
    +
    + {{ user.username }} + +
    +
    +
    +

    + + + {% if request.user != user %} + {% trans '举报用户' %} + {% endif %} + +
    +
    +
    + {% trans '关注的人' %} +
    + {% trans '更多' %} +
      +
    • + + +
    • +
    +
    + {% trans '被他们关注' %} +
    + {% trans '更多' %} +
      +
    • + + +
    • +
    +
    +
    +
    +
    + +
    + + + + + + + + + + + + + diff --git a/users/templates/users/login.html b/users/templates/users/login.html index d7bebb9c..5855da56 100644 --- a/users/templates/users/login.html +++ b/users/templates/users/login.html @@ -1,24 +1,27 @@ {% load i18n %} +{% load static %} - - Document + + + + {% trans 'Boofilsic - 登录' %} -
    - +
    + + + diff --git a/users/templates/users/manage_report.html b/users/templates/users/manage_report.html new file mode 100644 index 00000000..e7117423 --- /dev/null +++ b/users/templates/users/manage_report.html @@ -0,0 +1,89 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 管理举报' %} + + + + + + + +
    +
    + + +
    +
    +
    + + {% for report in reports %} +
    + {{ report.submit_user.username }} + {% trans '举报了' %} + {{ report.reported_user.username }} + @{{ report.submitted_time }} + + {% if report.image %} + + + {% endif %} + +
    + {% endfor %} + +
    + +
    +
    + +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/users/templates/users/register.html b/users/templates/users/register.html index d4c9a4b9..c1ca096a 100644 --- a/users/templates/users/register.html +++ b/users/templates/users/register.html @@ -1,16 +1,41 @@ +{% load i18n %} +{% load static %} + - Register + + + + {% trans 'Boofilsic - 注册' %} + - Are you sure you wanna join? -
    - {% csrf_token %} - - -
    +
    + + + +
    +

    欢迎来到里瓣书影音(其实现在只有书)!

    +

    + 里瓣书影音继承了长毛象的用户关系,比如您在里瓣屏蔽了某人,那您将不会在书影音的公共区域看到TA的痕迹。 + 这里仍是一片处女地,丰富的内容需要大家共同创造! + 请注意虽然您可以随意发表任何言论,但试图添加垃圾数据到公共数据领域(如添加不存在的乱码的书籍)将会受到制裁! +

    +

    + 此外里瓣书影音现处于“公开阿尔法测试”阶段,您的数据存在丢失的可能,现阶段将不对用户数据负责,请您理解风险后再决定继续使用! +

    + +
    + {% csrf_token %} + +
    + +
    +
    + + \ No newline at end of file diff --git a/users/templates/users/report.html b/users/templates/users/report.html new file mode 100644 index 00000000..03d3c2fd --- /dev/null +++ b/users/templates/users/report.html @@ -0,0 +1,78 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 举报用户' %} + + + + + + + +
    +
    + + +
    +
    +
    +
    + {% csrf_token %} + {{ form }} + +
    +
    + +
    +
    + +
    + + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/users/urls.py b/users/urls.py index 04f2891a..2d4f1980 100644 --- a/users/urls.py +++ b/users/urls.py @@ -8,4 +8,10 @@ urlpatterns = [ path('logout/', logout, name='logout'), path('delete/', delete, name='delete'), path('OAuth2_login/', OAuth2_login, name='OAuth2_login'), + path('/', home, name='home'), + path('/followers/', followers, name='followers'), + path('/following/', following, name='following'), + path('/book//', book_list, name='book_list'), + path('report/', report, name='report'), + path('manage_report/', manage_report, name='manage_report'), ] diff --git a/users/views.py b/users/views.py index 27746a87..0f14a0ad 100644 --- a/users/views.py +++ b/users/views.py @@ -1,17 +1,27 @@ -from django.shortcuts import reverse, redirect, render +from django.shortcuts import reverse, redirect, render, get_object_or_404 from django.http import HttpResponseBadRequest, HttpResponse +from django.contrib.auth.decorators import login_required from django.contrib import auth from django.contrib.auth import authenticate -from .models import User -from .auth import * -from boofilsic.settings import MASTODON_DOMAIN_NAME, CLIENT_ID, CLIENT_SECRET +from django.core.paginator import Paginator +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ObjectDoesNotExist +from .models import User, Report +from .forms import ReportForm +from common.mastodon.auth import * from common.mastodon.api import * +from common.mastodon import mastodon_request_included +from common.views import BOOKS_PER_SET, ITEMS_PER_PAGE +from common.models import MarkStatusEnum +from books.models import * +from boofilsic.settings import MASTODON_DOMAIN_NAME, CLIENT_ID, CLIENT_SECRET # Views ######################################## # no page rendered +@mastodon_request_included def OAuth2_login(request): """ oauth authentication and logging user into django system """ if request.method == 'GET': @@ -39,7 +49,7 @@ def OAuth2_login(request): def login(request): if request.method == 'GET': # TODO NOTE replace http with https!!!! - auth_url = f"https://{MASTODON_DOMAIN_NAME}{OAUTH_AUTHORIZE}?" +\ + auth_url = f"https://{MASTODON_DOMAIN_NAME}{API_OAUTH_AUTHORIZE}?" +\ f"client_id={CLIENT_ID}&scope=read+write&" +\ f"redirect_uri=http://{request.get_host()}{reverse('users:OAuth2_login')}" +\ "&response_type=code" @@ -55,6 +65,8 @@ def login(request): return HttpResponseBadRequest() +@mastodon_request_included +@login_required def logout(request): if request.method == 'GET': revoke_token(request.session['oauth_token']) @@ -64,6 +76,7 @@ def logout(request): return HttpResponseBadRequest() +@mastodon_request_included def register(request): """ register confirm page """ if request.method == 'GET': @@ -95,9 +108,247 @@ def delete(request): raise NotImplementedError +@mastodon_request_included +@login_required +def home(request, id): + if request.method == 'GET': + if request.GET.get('is_mastodon_id') in ['true', 'True']: + query_kwargs = {'mastodon_id': id} + else: + query_kwargs = {'pk': id} + try: + user = User.objects.get(**query_kwargs) + except ObjectDoesNotExist: + msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") + sec_msg = _("目前只开放本站用户注册") + return render( + request, + 'common/error.html', + { + 'msg': msg, + 'secondary_msg': sec_msg, + } + ) + if user == request.user: + return redirect("common:home") + else: + # mastodon request + relation = get_relationships([user.mastodon_id], request.session['oauth_token'])[0] + if relation['blocked_by']: + msg = _("你没有访问TA主页的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + book_marks = BookMark.get_available_user_data(user, relation['following']) + do_book_marks = book_marks.filter(status=MarkStatusEnum.DO) + do_books_more = True if do_book_marks.count() > BOOKS_PER_SET else False + + wish_book_marks = book_marks.filter(status=MarkStatusEnum.WISH) + wish_books_more = True if wish_book_marks.count() > BOOKS_PER_SET else False + + collect_book_marks = book_marks.filter(status=MarkStatusEnum.COLLECT) + collect_books_more = True if collect_book_marks.count() > BOOKS_PER_SET else False + return render( + request, + 'common/home.html', + { + 'user': user, + 'do_book_marks': do_book_marks[:BOOKS_PER_SET], + 'wish_book_marks': wish_book_marks[:BOOKS_PER_SET], + 'collect_book_marks': collect_book_marks[:BOOKS_PER_SET], + 'do_books_more': do_books_more, + 'wish_books_more': wish_books_more, + 'collect_books_more': collect_books_more, + } + ) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def followers(request, id): + if request.method == 'GET': + try: + user = User.objects.get(pk=id) + except ObjectDoesNotExist: + msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") + sec_msg = _("目前只开放本站用户注册") + return render( + request, + 'common/error.html', + { + 'msg': msg, + 'secondary_msg': sec_msg, + } + ) + # mastodon request + if not user == request.user: + relation = get_relationships([user.mastodon_id], request.session['oauth_token'])[0] + if relation['blocked_by']: + msg = _("你没有访问TA主页的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + return render( + request, + 'users/list.html', + { + 'user': user, + 'is_followers_page': True, + } + ) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def following(request, id): + if request.method == 'GET': + try: + user = User.objects.get(pk=id) + except ObjectDoesNotExist: + msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") + sec_msg = _("目前只开放本站用户注册") + return render( + request, + 'common/error.html', + { + 'msg': msg, + 'secondary_msg': sec_msg, + } + ) + # mastodon request + if not user == request.user: + relation = get_relationships([user.mastodon_id], request.session['oauth_token'])[0] + if relation['blocked_by']: + msg = _("你没有访问TA主页的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + return render( + request, + 'users/list.html', + { + 'user': user, + 'page_type': 'followers', + } + ) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def book_list(request, id, status): + if request.method == 'GET': + if not status.upper() in MarkStatusEnum.names: + return HttpResponseBadRequest() + try: + user = User.objects.get(pk=id) + except ObjectDoesNotExist: + msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") + sec_msg = _("目前只开放本站用户注册") + return render( + request, + 'common/error.html', + { + 'msg': msg, + 'secondary_msg': sec_msg, + } + ) + # mastodon request + if not user == request.user: + relation = get_relationships([user.mastodon_id], request.session['oauth_token'])[0] + if relation['blocked_by']: + msg = _("你没有访问TA主页的权限😥") + return render( + request, + 'common/error.html', + { + 'msg': msg, + } + ) + queryset = BookMark.get_available_user_data(user, relation['is_following']).filter(status=MarkStatusEnum[status.upper()]) + else: + queryset = BookMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()]) + paginator = Paginator(queryset, ITEMS_PER_PAGE) + page_number = request.GET.get('page', default=1) + marks = paginator.get_page(page_number) + return render( + request, + 'users/list.html', + { + 'marks': marks, + 'user': user, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def report(request): + if request.method == 'GET': + form = ReportForm() + return render( + request, + 'users/report.html', + { + 'form': form, + } + ) + elif request.method == 'POST': + form = ReportForm(request.POST) + if form.is_valid(): + form.instance.is_read = False + form.instance.submit_user = request.user + form.save() + return redirect(reverse("users:home", args=[form.instance.reported_user.id])) + else: + return render( + request, + 'users/report.html', + { + 'form': form, + } + ) + else: + return HttpResponseBadRequest() + + +@login_required +def manage_report(request): + if request.method == 'GET': + reports = Report.objects.all() + for r in reports.filter(is_read=False): + r.save() + return render( + request, + 'users/manage_report.html', + { + 'reports': reports, + } + ) + else: + return HttpResponseBadRequest() + + # Utils ######################################## - def auth_login(request, user, token): """ Decorates django ``login()``. Attach token to session.""" request.session['oauth_token'] = token