diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..514df728 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,74 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "neo" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "neo" ] + schedule: + - cron: '35 0 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/pysa.yml b/.github/workflows/pysa.yml new file mode 100644 index 00000000..e4e20af3 --- /dev/null +++ b/.github/workflows/pysa.yml @@ -0,0 +1,50 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow integrates Python Static Analyzer (Pysa) with +# GitHub's Code Scanning feature. +# +# Python Static Analyzer (Pysa) is a security-focused static +# analysis tool that tracks flows of data from where they +# originate to where they terminate in a dangerous location. +# +# See https://pyre-check.org/docs/pysa-basics/ + +name: Pysa + +on: + workflow_dispatch: + push: + branches: [ "neo" ] + pull_request: + branches: [ "neo" ] + schedule: + - cron: '45 12 * * 4' + +permissions: + contents: read + +jobs: + pysa: + permissions: + actions: read + contents: read + security-events: write + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: true + + - name: Run Pysa + uses: facebook/pysa-action@f46a63777e59268613bd6e2ff4e29f144ca9e88b + with: + # To customize these inputs: + # See https://github.com/facebook/pysa-action#inputs + repo-directory: './' + requirements-path: 'requirements.txt' + infer-types: true + include-default-sapp-filters: true diff --git a/.gitignore b/.gitignore index 086f9d04..d1edae82 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ migrations/ # debug log file /log -log \ No newline at end of file +log + +# conf folder for neodb +/neodb diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..cc9bf6e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +FROM python:3.8-slim +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev git \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && rm -rf /tmp/requirements.txt \ + && useradd -U app_user \ + && install -d -m 0755 -o app_user -g app_user /app/static + +ENV DJANGO_SETTINGS_MODULE=neodb.dev +WORKDIR /app +USER app_user:app_user +COPY --chown=app_user:app_user . . +RUN chmod +x docker/*.sh + +# Section 6- Docker Run Checks and Configurations +ENTRYPOINT [ "docker/entrypoint.sh" ] + +CMD [ "docker/start.sh", "server" ] \ No newline at end of file diff --git a/README.md b/README.md index 2cfde8ea..d9f46dbc 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,13 @@ An application allows you to mark any books, movies and more things you love. Depends on Mastodon. +## Install +Please see [doc/GUIDE.md](doc/GUIDE.md) + +## Bug Report + - to file a bug for NiceDB, please create an issue [here](https://github.com/doubaniux/boofilsic/issues/new) + - to file a bug or request new features for NeoDB, please contact NeoDB on [Fediverse](https://mastodon.social/@neodb) or [Twitter](https://twitter.com/NeoDBsocial) + ## Contribution The project is based on Django. If you are familiar with this technique and willing to read through the terrible code😝, your contribution would be the most welcome! @@ -11,8 +18,6 @@ Currently looking for someone to help with: - Explaining the structure of code - Refactoring (this is something big) -This project is still in its early stage, so you are not encouraged to deploy it on your own. If you do want to give it a try, please check the [fork of *alphatownsman*](https://github.com/alphatownsman/boofilsic), which is more friendly. - ## Sponsor -If you like this project, please consider sponsoring us on [Patreon](https://patreon.com/tertius). +If you like this project, please consider sponsoring NiceDB on [Patreon](https://patreon.com/tertius). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..cafb3b8b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please DM [us on Fediverse](https://mastodon.social/@neodb) or send email to `dev`@`neodb.social` to report a vulnerability. Please do not post publicly or create pr/issues directly. Thank you. diff --git a/boofilsic/context_processors.py b/boofilsic/context_processors.py new file mode 100644 index 00000000..6fd333b3 --- /dev/null +++ b/boofilsic/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def site_info(request): + return settings.SITE_INFO diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 86d4abf2..6393f4a0 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -12,10 +12,13 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ import os import psycopg2.extensions +from git import Repo # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ @@ -38,6 +41,8 @@ INTERNAL_IPS = [ INSTALLED_APPS = [ 'django.contrib.admin', + 'hijack', + 'hijack.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -45,6 +50,9 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.humanize', 'django.contrib.postgres', + 'django_sass', + 'django_rq', + 'simple_history', 'markdownx', 'management.apps.ManagementConfig', 'mastodon.apps.MastodonConfig', @@ -54,7 +62,12 @@ INSTALLED_APPS = [ 'movies.apps.MoviesConfig', 'music.apps.MusicConfig', 'games.apps.GamesConfig', + 'sync.apps.SyncConfig', + 'collection.apps.CollectionConfig', + 'timeline.apps.TimelineConfig', 'easy_thumbnails', + 'user_messages', + 'django_slack', ] MIDDLEWARE = [ @@ -65,6 +78,8 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'hijack.middleware.HijackUserMiddleware', + 'simple_history.middleware.HistoryRequestMiddleware', ] ROOT_URLCONF = 'boofilsic.urls' @@ -79,7 +94,9 @@ TEMPLATES = [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + # 'django.contrib.messages.context_processors.messages', + "user_messages.context_processors.messages", + 'boofilsic.context_processors.site_info', ], }, }, @@ -95,10 +112,10 @@ if DEBUG: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'test', - 'USER': 'donotban', - 'PASSWORD': 'donotbansilvousplait', - 'HOST': '172.18.116.29', + 'NAME': os.environ.get('DB_NAME', 'test'), + 'USER': os.environ.get('DB_USER', 'donotban'), + 'PASSWORD': os.environ.get('DB_PASSWORD', 'donotbansilvousplait'), + 'HOST': os.environ.get('DB_HOST', '172.18.116.29'), 'OPTIONS': { 'client_encoding': 'UTF8', # 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_DEFAULT, @@ -184,13 +201,29 @@ STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesSto AUTH_USER_MODEL = 'users.User' +SILENCED_SYSTEM_CHECKS = [ + "auth.W004", # User.username is non-unique + "admin.E404" # Required by django-user-messages +] + MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') +PROJECT_ROOT = os.path.abspath(os.path.dirname(__name__)) +SITE_INFO = { + 'site_name': 'NiceDB', + 'support_link': 'https://github.com/doubaniux/boofilsic/issues', + 'version_hash': None, + 'settings_module': os.getenv('DJANGO_SETTINGS_MODULE'), + 'sentry_dsn': None, +} + # Mastodon configs -CLIENT_NAME = 'NiceDB' -APP_WEBSITE = 'https://nicedb.org' -REDIRECT_URIS = "https://nicedb.org/users/OAuth2_login/\nhttps://www.nicedb.org/users/OAuth2_login/" +CLIENT_NAME = os.environ.get('APP_NAME', 'NiceDB') +SITE_INFO['site_name'] = os.environ.get('APP_NAME', 'NiceDB') +APP_WEBSITE = os.environ.get('APP_URL', 'https://nicedb.org') +REDIRECT_URIS = APP_WEBSITE + "/users/OAuth2_login/" + # Path to save report related images, ends with slash REPORT_MEDIA_PATH_ROOT = 'report/' @@ -205,10 +238,23 @@ ALBUM_MEDIA_PATH_ROOT = 'album/' DEFAULT_ALBUM_IMAGE = os.path.join(ALBUM_MEDIA_PATH_ROOT, 'default.svg') GAME_MEDIA_PATH_ROOT = 'game/' DEFAULT_GAME_IMAGE = os.path.join(GAME_MEDIA_PATH_ROOT, 'default.svg') +COLLECTION_MEDIA_PATH_ROOT = 'collection/' +DEFAULT_COLLECTION_IMAGE = os.path.join(COLLECTION_MEDIA_PATH_ROOT, 'default.svg') +SYNC_FILE_PATH_ROOT = 'sync/' +EXPORT_FILE_PATH_ROOT = 'export/' + +# Allow user to login via any Mastodon/Pleroma sites +MASTODON_ALLOW_ANY_SITE = False # Timeout of requests to Mastodon, in seconds MASTODON_TIMEOUT = 30 +MASTODON_CLIENT_SCOPE = 'read write follow' +#use the following if it's a new site +#MASTODON_CLIENT_SCOPE = 'read:accounts read:follows read:search read:blocks read:mutes write:statuses write:media' + +MASTODON_LEGACY_CLIENT_SCOPE = 'read write follow' + # Tags for toots posted from this site MASTODON_TAGS = '#NiceDB #NiceDB%(category)s #NiceDB%(category)s%(type)s' @@ -217,7 +263,7 @@ STAR_SOLID = ':star_solid:' STAR_HALF = ':star_half:' STAR_EMPTY = ':star_empty:' -# Default password for each user. since assword is not used any way, +# Default password for each user. since password is not used any way, # any string that is not empty is ok DEFAULT_PASSWORD = 'ab7nsm8didusbaqPgq' @@ -231,8 +277,12 @@ ADMIN_URL = 'tertqX7256n7ej8nbv5cwvsegdse6w7ne5rHd' LUMINATI_USERNAME = 'lum-customer-hl_nw4tbv78-zone-static' LUMINATI_PASSWORD = 'nsb7te9bw0ney' +SCRAPING_TIMEOUT = 90 + # ScraperAPI api key SCRAPERAPI_KEY = 'wnb3794v675b8w475h0e8hr7tyge' +PROXYCRAWL_KEY = None +SCRAPESTACK_KEY = None # Spotify credentials SPOTIFY_CREDENTIAL = "NzYzNkYTE6MGQ0ODY0NTY2Y2b3n645sdfgAyY2I1ljYjg3Nzc0MjIwODQ0ZWE=" @@ -240,6 +290,17 @@ SPOTIFY_CREDENTIAL = "NzYzNkYTE6MGQ0ODY0NTY2Y2b3n645sdfgAyY2I1ljYjg3Nzc0MjIwODQ0 # IMDb API service https://imdb-api.com/ IMDB_API_KEY = "k23fwewff23" +# The Movie Database (TMDB) API Keys +TMDB_API3_KEY = "deadbeef" +TMDB_API4_KEY = "deadbeef.deadbeef.deadbeef" + +# Google Books API Key +GOOGLE_API_KEY = 'deadbeef-deadbeef-deadbeef' + +# IGDB +IGDB_CLIENT_ID = 'deadbeef' +IGDB_ACCESS_TOKEN = 'deadbeef' + # Thumbnail setting # It is possible to optimize the image size even more: https://easy-thumbnails.readthedocs.io/en/latest/ref/optimize/ THUMBNAIL_ALIASES = { @@ -257,3 +318,47 @@ if DEBUG: # https://django-debug-toolbar.readthedocs.io/en/latest/ # maybe benchmarking before deployment + +REDIS_HOST = os.environ.get('REDIS_HOST', '127.0.0.1') + +RQ_QUEUES = { + 'mastodon': { + 'HOST': REDIS_HOST, + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': -1, + }, + 'export': { + 'HOST': REDIS_HOST, + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': -1, + }, + 'doufen': { + 'HOST': REDIS_HOST, + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': -1, + } +} + +RQ_SHOW_ADMIN_LINK = True + +SEARCH_INDEX_NEW_ONLY = False + +SEARCH_BACKEND = None + +# SEARCH_BACKEND = 'MEILISEARCH' +# MEILISEARCH_SERVER = 'http://127.0.0.1:7700' +# MEILISEARCH_KEY = 'deadbeef' + +# SEARCH_BACKEND = 'TYPESENSE' +# TYPESENSE_CONNECTION = { +# 'api_key': 'deadbeef', +# 'nodes': [{ +# 'host': 'localhost', +# 'port': '8108', +# 'protocol': 'http' +# }], +# 'connection_timeout_seconds': 2 +# } diff --git a/boofilsic/urls.py b/boofilsic/urls.py index dd52087a..38d74a5a 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -27,10 +27,16 @@ urlpatterns = [ path('movies/', include('movies.urls')), path('music/', include('music.urls')), path('games/', include('games.urls')), + path('collections/', include('collection.urls')), + path('timeline/', include('timeline.urls')), path('sync/', include('sync.urls')), path('announcement/', include('management.urls')), + path('hijack/', include('hijack.urls')), path('', include('common.urls')), +] +urlpatterns += [ + path(settings.ADMIN_URL + '-rq/', include('django_rq.urls')) ] if settings.DEBUG: diff --git a/books/admin.py b/books/admin.py index 942dccb4..75df663b 100644 --- a/books/admin.py +++ b/books/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Book) +admin.site.register(Book, SimpleHistoryAdmin) admin.site.register(BookMark) admin.site.register(BookReview) admin.site.register(BookTag) diff --git a/books/apps.py b/books/apps.py index f716137a..b03e2d23 100644 --- a/books/apps.py +++ b/books/apps.py @@ -3,3 +3,8 @@ from django.apps import AppConfig class BooksConfig(AppConfig): name = 'books' + + def ready(self): + from common.index import Indexer + from .models import Book + Indexer.update_model_indexable(Book) diff --git a/books/forms.py b/books/forms.py index da7ecee6..27abda07 100644 --- a/books/forms.py +++ b/books/forms.py @@ -1,17 +1,12 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from .models import Book, BookMark, BookReview +from .models import Book, BookMark, BookReview, BookMarkStatusTranslation from common.models import MarkStatusEnum from common.forms import * def BookMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在读"), - MarkStatusEnum.WISH.value: _("想读"), - MarkStatusEnum.COLLECT.value: _("读过") - } - return trans_dict[status] + return BookMarkStatusTranslation[status] class BookForm(forms.ModelForm): @@ -96,11 +91,8 @@ class BookMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', - ] - labels = { - 'rating': _("评分"), - } + 'visibility', + ] widgets = { 'book': forms.TextInput(attrs={"hidden": ""}), } @@ -115,14 +107,8 @@ class BookReviewForm(ReviewForm): 'book', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'book': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'book': forms.TextInput(attrs={"hidden": ""}), } diff --git a/books/management/commands/fix-book-cover.py b/books/management/commands/fix-book-cover.py new file mode 100644 index 00000000..ae9227b5 --- /dev/null +++ b/books/management/commands/fix-book-cover.py @@ -0,0 +1,200 @@ +from django.core.management.base import BaseCommand +from django.core.files.uploadedfile import SimpleUploadedFile +from django.conf import settings +from common.scraper import * +from books.models import Book +from books.forms import BookForm +import requests +import re +import filetype +from lxml import html +from PIL import Image +from io import BytesIO + + +class DoubanPatcherMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + + def get(url, timeout): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=timeout) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content + content = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + # with open('/tmp/temp.html', 'w', encoding='utf-8') as fp: + # fp.write(content) + content = None + error = error + 'Content not authentic' # response is garbage + elif re.search('不存在[^<]+', content, re.MULTILINE): + content = None + error = error + 'Not found or hidden by Douban' + else: + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url, 10) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url'], 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url, 10) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url, 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is None: + error = error + '\nDirect: ' + get(url, 60) + else: + error = error + '\nScrapeStack: ' + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}', 60) + check_content() + + wayback_cdx() + if content is None: + latest() + + if content is None: + logger.error(error) + content = '' + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + if url is None: + logger.error(f"Douban: no image url for {item_url}") + return None, None + raw_img = None + ext = None + + dl_url = url + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + if raw_img is None and settings.SCRAPESTACK_KEY is not None: + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanBookPatcher(DoubanPatcherMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'book.douban.com' + data_class = Book + form_class = BookForm + + regex = re.compile(r"https://book\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + img_url_elem = content.xpath("//*[@id='mainpic']/a/img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + return raw_img, ext + + +class Command(BaseCommand): + help = 'fix cover image' + + def add_arguments(self, parser): + parser.add_argument('threadId', type=int, help='% 8') + + def handle(self, *args, **options): + t = int(options['threadId']) + for m in Book.objects.filter(cover='book/default.svg', source_site='douban'): + if m.id % 8 == t: + self.stdout.write(f'Re-fetching {m.source_url}') + try: + raw_img, img_ext = DoubanBookPatcher.scrape(m.source_url) + if img_ext is not None: + m.cover = SimpleUploadedFile('temp.' + img_ext, raw_img) + m.save() + self.stdout.write(self.style.SUCCESS(f'Saved {m.source_url}')) + else: + self.stdout.write(self.style.ERROR(f'Skipped {m.source_url}')) + except Exception as e: + print(e) diff --git a/books/models.py b/books/models.py index 4f898709..8b23e9a6 100644 --- a/books/models.py +++ b/books/models.py @@ -1,98 +1,184 @@ -import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models -from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, MarkStatusEnum from common.utils import GenerateDateUUIDMediaFilePath -from boofilsic.settings import BOOK_MEDIA_PATH_ROOT, DEFAULT_BOOK_IMAGE -from django.utils import timezone +from django.conf import settings +from django.db.models import Q +from simple_history.models import HistoricalRecords + + +BookMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在读"), + MarkStatusEnum.WISH.value: _("想读"), + MarkStatusEnum.COLLECT.value: _("读过") +} def book_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, BOOK_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.BOOK_MEDIA_PATH_ROOT) class Book(Entity): # widely recognized name, usually in Chinese - title = models.CharField(_("title"), max_length=200) - subtitle = models.CharField(_("subtitle"), blank=True, default='', max_length=200) + title = models.CharField(_("title"), max_length=500) + subtitle = models.CharField( + _("subtitle"), blank=True, default='', max_length=500) # original name, for books in foreign language - orig_title = models.CharField(_("original title"), blank=True, default='', max_length=200) + orig_title = models.CharField( + _("original title"), blank=True, default='', max_length=500) author = postgres.ArrayField( - models.CharField(_("author"), blank=True, default='', max_length=100), + models.CharField(_("author"), blank=True, default='', max_length=200), null=True, blank=True, default=list, ) translator = postgres.ArrayField( - models.CharField(_("translator"), blank=True, default='', max_length=100), + models.CharField(_("translator"), blank=True, + default='', max_length=200), null=True, blank=True, default=list, ) - language = models.CharField(_("language"), blank=True, default='', max_length=10) - pub_house = models.CharField(_("publishing house"), blank=True, default='', max_length=200) + language = models.CharField( + _("language"), blank=True, default='', max_length=50) + pub_house = models.CharField( + _("publishing house"), blank=True, default='', max_length=200) pub_year = models.IntegerField(_("published year"), null=True, blank=True) - pub_month = models.IntegerField(_("published month"), null=True, blank=True) - binding = models.CharField(_("binding"), blank=True, default='', max_length=50) + pub_month = models.IntegerField( + _("published month"), null=True, blank=True) + binding = models.CharField( + _("binding"), blank=True, default='', max_length=200) # 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) + price = models.CharField(_("pricing"), blank=True, + default='', max_length=50) pages = models.PositiveIntegerField(_("pages"), null=True, blank=True) - isbn = models.CharField(_("ISBN"), blank=True, null=False, max_length=20, db_index=True, default='') - # to store previously scrapped data - cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, default=DEFAULT_BOOK_IMAGE, blank=True) + isbn = models.CharField(_("ISBN"), blank=True, null=False, + max_length=20, db_index=True, default='') + # to store previously scrapped data + cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, + default=settings.DEFAULT_BOOK_IMAGE, blank=True) contents = models.TextField(blank=True, default="") + history = HistoricalRecords() class Meta: - # more info: https://docs.djangoproject.com/en/2.2/ref/models/options/ - # set managed=False if the model represents an existing table or - # a database view that has been created by some other means. - # check the link above for further info - # managed = True - # db_table = 'book' constraints = [ - models.CheckConstraint(check=models.Q(pub_year__gte=0), name='pub_year_lowerbound'), - models.CheckConstraint(check=models.Q(pub_month__lte=12), name='pub_month_upperbound'), - models.CheckConstraint(check=models.Q(pub_month__gte=1), name='pub_month_lowerbound'), + models.CheckConstraint(check=models.Q( + pub_year__gte=0), name='pub_year_lowerbound'), + models.CheckConstraint(check=models.Q( + pub_month__lte=12), name='pub_month_upperbound'), + models.CheckConstraint(check=models.Q( + pub_month__gte=1), name='pub_month_lowerbound'), ] def __str__(self): return self.title - + + def get_json(self): + r = { + 'subtitle': self.subtitle, + 'original_title': self.orig_title, + 'author': self.author, + 'translator': self.translator, + 'publisher': self.pub_house, + 'publish_year': self.pub_year, + 'publish_month': self.pub_month, + 'language': self.language, + 'isbn': self.isbn, + } + r.update(super().get_json()) + return r + def get_absolute_url(self): return reverse("books:retrieve", args=[self.id]) + @property + def wish_url(self): + return reverse("books:wish", args=[self.id]) + def get_tags_manager(self): return self.book_tags + def get_related_books(self): + qs = Q(orig_title=self.title) + if self.isbn: + qs = qs | Q(isbn=self.isbn) + if self.orig_title: + qs = qs | Q(title=self.orig_title) + qs = qs | Q(orig_title=self.orig_title) + qs = qs & ~Q(id=self.id) + return Book.objects.filter(qs) + + def get_identicals(self): + qs = Q(orig_title=self.title) + if self.isbn: + qs = Q(isbn=self.isbn) + # qs = qs & ~Q(id=self.id) + return Book.objects.filter(qs) + else: + return [self] # Book.objects.filter(id=self.id) + @property def verbose_category_name(self): return _("书籍") + @property + def mark_class(self): + return BookMark + + @property + def tag_class(self): + return BookTag + class BookMark(Mark): - book = models.ForeignKey(Book, on_delete=models.CASCADE, 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") + models.UniqueConstraint( + fields=['owner', 'book'], name="unique_book_mark") ] + @property + def translated_status(self): + return BookMarkStatusTranslation[self.status] + class BookReview(Review): - book = models.ForeignKey(Book, on_delete=models.CASCADE, 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") - ] + models.UniqueConstraint( + fields=['owner', 'book'], name="unique_book_review") + ] + + @property + def url(self): + return settings.APP_WEBSITE + reverse("books:retrieve_review", args=[self.id]) + + @property + def item(self): + return self.book class BookTag(Tag): - book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_tags', null=True) - mark = models.ForeignKey(BookMark, on_delete=models.CASCADE, related_name='bookmark_tags', null=True) + book = models.ForeignKey( + Book, on_delete=models.CASCADE, related_name='book_tags', null=True) + mark = models.ForeignKey( + BookMark, on_delete=models.CASCADE, related_name='bookmark_tags', null=True) + class Meta: constraints = [ - models.UniqueConstraint(fields=['content', 'mark'], name="unique_bookmark_tag") + models.UniqueConstraint( + fields=['content', 'mark'], name="unique_bookmark_tag") ] + + @property + def item(self): + return self.book diff --git a/books/templates/books/create_update.html b/books/templates/books/create_update.html index fdd7ec61..de4b8ca7 100644 --- a/books/templates/books/create_update.html +++ b/books/templates/books/create_update.html @@ -10,8 +10,8 @@ - {% trans 'NiceDB - ' %}{{ title }} - + {{ site_name }} - {{ title }} + @@ -22,8 +22,24 @@
+ {% if is_update and form.source_site.value != 'in-site' %} +
+
+
+
{% trans '源网站' %}: {{ form.source_site.value }}
+
+
+ {% csrf_token %} + +
+
+
+
+
+ {% endif %} +
- {% trans '>>> 试试一键剽取~ <<<' %} + {% comment %} {% trans '>>> 试试一键剽取~ <<<' %} {% endcomment %}
{% csrf_token %} {{ form.media }} @@ -38,12 +54,6 @@
- {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {{ title }} + @@ -80,7 +80,7 @@
- {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }}
- {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除图书' %} + @@ -55,7 +55,7 @@ {% if book.last_editor %}
{% trans '最近编辑者:' %} - + {{ book.last_editor | default:"" }}
@@ -89,12 +89,6 @@
- {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除评论' %} + @@ -35,7 +35,7 @@
{{ review.title }}
- {% if review.is_private %} + {% if review.visibility > 0 %} @@ -47,7 +47,7 @@
- {{ review.owner.username }} {% if mark %} @@ -90,12 +90,6 @@
- {% comment %} - - - - - {% endcomment %} - - @@ -57,11 +60,12 @@
- {% if book.rating %} + {% if book.rating and book.rating_number >= 5 %} {{ book.rating }} + ({{ book.rating_number }}人评分) {% else %} - {% trans '评分:暂无评分' %} + {% trans '评分:评分人数不足' %} {% endif %}
{% if book.isbn %}{% trans 'ISBN:' %}{{ book.isbn }}{% endif %}
@@ -96,7 +100,7 @@ {% if book.last_editor %} -
{% trans '最近编辑者:' %}{{ book.last_editor | default:"" }}
+
{% trans '最近编辑者:' %}{{ book.last_editor | default:"" }}
{% endif %}
@@ -148,46 +152,27 @@
{% 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 '全部标记' %} + 关注的人的标记 + {% include "partial/mark_list.html" with mark_list=mark_list current_item=book %}
{% trans '这本书的评论' %}
{% if review_list_more %} - {% trans '更多' %} + {% trans '全部评论' %} {% endif %} {% if review_list %}
    {% for others_review in review_list %}
  • - {{ others_review.owner.username }} - {% if others_review.is_private %} + {{ others_review.owner.username }} + {% if others_review.visibility > 0 %} {% endif %} {{ others_review.edited_time }} + {% if others_review.book != book %} + {{ others_review.book.get_source_site_display }} + {% endif %} {{ others_review.title }} {{ others_review.get_plain_content | truncate:100 }}
  • @@ -202,7 +187,6 @@
    - {% if mark %}
    @@ -212,7 +196,7 @@ {% endif %} {% endif %} - {% if mark.is_private %} + {% if mark.visibility > 0 %} {% endif %} @@ -224,7 +208,7 @@
    -
    {{ mark.edited_time }}
    +
    {{ mark.created_time }}
    {% if mark.text %}

    {{ mark.text }}

    @@ -245,9 +229,8 @@
    -
    +
    {% endif %} -
@@ -255,7 +238,7 @@
{% trans '我的评论' %} - {% if review.is_private %} + {% if review.visibility > 0 %} {% endif %} @@ -284,7 +267,53 @@ {% endif %}
- + + {% if book.get_related_books.count > 0 %} +
+
+
{% trans '相关书目' %}
+
+ {% for b in book.get_related_books %} +

+ {{ b.title }} + ({{ b.pub_house }} {{ b.pub_year }}) + {{ b.get_source_site_display }} +

+ {% endfor %} +
+
+
+ {% endif %} + + {% if book.isbn %} +
+
+
{% trans '借阅或购买' %}
+ +
+
+ {% endif %} + + {% if collection_list %} +
+
+
{% trans '相关收藏单' %}
+
+ {% for c in collection_list %} +

+ {{ c.title }} +

+ {% endfor %} +
+ +
+
+
+
+ {% endif %}
@@ -296,7 +325,6 @@
- {% comment %} - - - - - {% endcomment %} diff --git a/common/templates/partial/_announcement.html b/common/templates/partial/_announcement.html new file mode 100644 index 00000000..166d2856 --- /dev/null +++ b/common/templates/partial/_announcement.html @@ -0,0 +1,61 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +
+ + +
+
+ diff --git a/common/templates/partial/_common_libs.html b/common/templates/partial/_common_libs.html new file mode 100644 index 00000000..9460e2da --- /dev/null +++ b/common/templates/partial/_common_libs.html @@ -0,0 +1,23 @@ +{% load static %} +{% if sentry_dsn %} + + +{% endif %} +{% if jquery %} + +{% else %} + +{% endif %} + + + + + + diff --git a/common/templates/partial/_footer.html b/common/templates/partial/_footer.html index e5710473..412f00ad 100644 --- a/common/templates/partial/_footer.html +++ b/common/templates/partial/_footer.html @@ -1,13 +1,12 @@ \ No newline at end of file diff --git a/common/templates/partial/_navbar.html b/common/templates/partial/_navbar.html index 171eda8d..90d7a459 100644 --- a/common/templates/partial/_navbar.html +++ b/common/templates/partial/_navbar.html @@ -1,24 +1,24 @@ {% load static %} {% load i18n %} {% load admin_url %} +
\ No newline at end of file + +
diff --git a/common/templates/partial/_sidebar.html b/common/templates/partial/_sidebar.html new file mode 100644 index 00000000..90bc2c0d --- /dev/null +++ b/common/templates/partial/_sidebar.html @@ -0,0 +1,186 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +{% load neo %} +
+
+ +
+ +
+
+ + + + + +
+ {% if user == request.user %} +
+
+ +
+
+ {% trans '关注的人' %} +
+ {% trans '更多' %} + +
+ +
+
+ {% trans '被他们关注' %} +
+ {% trans '更多' %} + +
+ +
+
+ {% trans '常用标签' %} +
+ {% trans '更多' %} +
+ {% if book_tags %} +
{% trans '书籍' %}
+ {% for v in book_tags %} + + {{ v.content }} + + {% endfor %} +
+ {% endif %} + + {% if movie_tags %} +
{% trans '电影和剧集' %}
+ {% for v in movie_tags %} + + {{ v.content }} + + {% endfor %} +
+ {% endif %} + + {% if music_tags %} +
{% trans '音乐' %}
+ {% for v in music_tags %} + + {{ v.content }} + + {% endfor %} +
+ {% endif %} + + {% if game_tags %} +
{% trans '游戏' %}
+ {% for v in game_tags %} + + {{ v.content }} + + {% endfor %} +
+ {% endif %} +
+
+ +
+ +
+ {% if request.user.is_staff and request.user == user%} +
+
{% trans '投诉信息' %}
+ 全部投诉 +
+ +
+
+ {% endif %} +
+
+ {% endif %} +
+
+ +{% if user == request.user %} + + + + + + +{% endif %} diff --git a/common/templates/partial/list_item.html b/common/templates/partial/list_item.html new file mode 100644 index 00000000..9b4eb9ad --- /dev/null +++ b/common/templates/partial/list_item.html @@ -0,0 +1,9 @@ +{% if item.category_name|lower == 'book' %} +{% include "partial/list_item_book.html" with book=item %} +{% elif item.category_name|lower == 'movie' %} +{% include "partial/list_item_movie.html" with movie=item %} +{% elif item.category_name|lower == 'game' %} +{% include "partial/list_item_game.html" with game=item %} +{% elif item.category_name|lower == 'album' or item.category_name|lower == 'song' %} +{% include "partial/list_item_music.html" with music=item %} +{% endif %} \ No newline at end of file diff --git a/common/templates/partial/list_item_book.html b/common/templates/partial/list_item_book.html new file mode 100644 index 00000000..3c5dc9eb --- /dev/null +++ b/common/templates/partial/list_item_book.html @@ -0,0 +1,159 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load neo %} +{% current_user_marked_item book as marked %} +
  • +
    + + + + {% if not marked %} + + {% endif %} +
    + +
    + {% if editable %} +
    + {% if not forloop.first %} + + {% endif %} + {% if not forloop.last %} + + {% endif %} + +
    + {% endif %} + + + + {% if book.rating %} +
    + {{ book.rating }} + {% else %} +
    {% trans '暂无评分' %}
    + {% endif %} + + + {% if book.pub_year %} / + {{ book.pub_year }}{% trans '年' %}{% if book.pub_month %}{{book.pub_month }}{% trans '月' %}{% endif %} + {% endif %} + + {% if book.author %} / + {% for author in book.author %} + {% if request.GET.q %} + {{ author | highlight:request.GET.q }} + {% else %} + {{ author }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% endif %} + + {% if book.translator %} / + {% trans '翻译' %}: + {% for translator in book.translator %} + {% if request.GET.q %} + {{ translator | highlight:request.GET.q }} + {% else %} + {{ translator }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% endif %} + + {% if book.subtitle %} / + {% trans '副标题' %}: + {% if request.GET.q %} + {{ book.subtitle | highlight:request.GET.q }} + {% else %} + {{ book.subtitle }} + {% endif %} + {% endif %} + + {% if book.orig_title %} / + {% trans '原名' %}: + {% if request.GET.q %} + {{ book.orig_title | highlight:request.GET.q }} + {% else %} + {{ book.orig_title }} + {% endif %} + {% endif %} + +

    + {{ book.brief }} +

    + +
    + {% for tag_dict in book.top_tags %} + + {{ tag_dict.content }} + + {% endfor %} +
    + + {% if mark %} +
    +
    +
    +
      +
    • + {% if mark.rating %} + + {% endif %} + {% if mark.visibility > 0 %} + + + + {% endif %} + + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: {{ mark.title }} + {% else %} + {% trans '标记' %} + {% endif %} + + {% if mark.text %} +

      {{ mark.text }}

      + {% endif %} +
    • +
    +
    + {% endif %} + + {% if collectionitem %} +
    +
    +
    +
      +
    • +

      + {% include "show_item_comment.html" %} +

      +
    • +
    +
    + {% endif %} +
    +
  • \ No newline at end of file diff --git a/common/templates/partial/list_item_game.html b/common/templates/partial/list_item_game.html new file mode 100644 index 00000000..42346910 --- /dev/null +++ b/common/templates/partial/list_item_game.html @@ -0,0 +1,139 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load neo %} +{% current_user_marked_item game as marked %} +
  • +
    + + + + {% if not marked %} + + {% endif %} +
    +
    + {% if editable %} +
    + {% if not forloop.first %} + + {% endif %} + {% if not forloop.last %} + + {% endif %} + +
    + {% endif %} + +
    + + {% if request.GET.q %} + {{ game.title | highlight:request.GET.q }} + {% else %} + {{ game.title }} + {% endif %} + + + {% if not request.GET.c and not hide_category %} + [{{item.verbose_category_name}}] + {% endif %} + + {{ game.get_source_site_display }} + +
    + + {% if game.rating %} +
    + {{ game.rating }} + {% else %} +
    {% trans '暂无评分' %}
    + {% endif %} + + + + {% if game.other_title %}{% trans '别名' %}: + {% for other_title in game.other_title %} + {{ other_title }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + {% if game.developer %}{% trans '开发商' %}: + {% for developer in game.developer %} + {{ developer }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + {% if game.genre %}{% trans '类型' %}: + {% for genre in game.genre %} + {{ genre }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + {% if game.platform %}{% trans '平台' %}: + {% for platform in game.platform %} + {{ platform }}{% if not forloop.last %} {% endif %} + {% endfor %} + {% endif %} + + +

    + {{ game.brief }} +

    + +
    + {% for tag_dict in game.top_tags %} + + {{ tag_dict.content }} + + {% endfor %} +
    + + {% if mark %} +
    +
    +
    +
      +
    • + {% if mark.rating %} + + {% endif %} + {% if mark.visibility > 0 %} + + + + {% endif %} + + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: {{ mark.title }} + {% else %} + {% trans '标记' %} + {% endif %} + + {% if mark.text %} +

      {{ mark.text }}

      + {% endif %} +
    • +
    +
    + {% endif %} + + {% if collectionitem %} +
    +
    +
    +
      +
    • +

      + {% include "show_item_comment.html" %} +

      +
    • +
    +
    + {% endif %} +
    + +
  • \ No newline at end of file diff --git a/common/templates/partial/list_item_movie.html b/common/templates/partial/list_item_movie.html new file mode 100644 index 00000000..f20f40f5 --- /dev/null +++ b/common/templates/partial/list_item_movie.html @@ -0,0 +1,164 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load neo %} +{% current_user_marked_item movie as marked %} +
  • +
    + + + + {% if not marked %} + + {% endif %} +
    +
    + {% if editable %} +
    + {% if not forloop.first %} + + {% endif %} + {% if not forloop.last %} + + {% endif %} + +
    + {% endif %} + + + + {% if movie.rating %} +
    + {{ movie.rating }} + {% else %} +
    {% trans '暂无评分' %}
    + {% endif %} + + + + {% if movie.director %}{% trans '导演' %}: + {% for director in movie.director %} + {% if request.GET.q %} + {{ director | highlight:request.GET.q }} + {% else %} + {{ director }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %}/ + {% endif %} + + {% if movie.genre %}{% trans '类型' %}: + {% for genre in movie.get_genre_display %} + {{ genre }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + + + {% if movie.actor %}{% trans '主演' %}: + {% for actor in movie.actor %} + 5 %}style="display: none;" {% endif %}> + {% if request.GET.q %} + {{ actor | highlight:request.GET.q }} + {% else %} + {{ actor }} + {% endif %} + + {% if forloop.counter <= 5 %} + {% if not forloop.counter == 5 and not forloop.last %} {% endif %} + {% endif %} + {% endfor %} + {% endif %} + +

    + {{ movie.brief }} +

    +
    + {% for tag_dict in movie.top_tags %} + + {{ tag_dict.content }} + + {% endfor %} +
    + + {% if mark %} +
    +
    +
    +
      +
    • + {% if mark.rating %} + + {% endif %} + {% if mark.visibility > 0 %} + + + + {% endif %} + + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: {{ mark.title }} + {% else %} + {% trans '标记' %} + {% endif %} + + {% if mark.text %} +

      {{ mark.text }}

      + {% endif %} +
    • +
    +
    + {% endif %} + + {% if collectionitem %} +
    +
    +
    +
      +
    • +

      + {% include "show_item_comment.html" %} +

      +
    • +
    +
    + {% endif %} +
    + +
  • \ No newline at end of file diff --git a/common/templates/partial/list_item_music.html b/common/templates/partial/list_item_music.html new file mode 100644 index 00000000..bcf0d29a --- /dev/null +++ b/common/templates/partial/list_item_music.html @@ -0,0 +1,171 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load neo %} +{% current_user_marked_item music as marked %} +
  • +
    + + {% if music.category_name|lower == 'album' %} + + + + {% if not marked %} + + {% endif %} + {% elif music.category_name|lower == 'song' %} + + + + {% if not marked %} + + {% endif %} + {% endif %} +
    +
    + {% if editable %} +
    + {% if not forloop.first %} + + {% endif %} + {% if not forloop.last %} + + {% endif %} + +
    + {% endif %} + +
    + + {% if music.category_name|lower == 'album' %} + + {% if request.GET.q %} + {{ music.title | highlight:request.GET.q }} + {% else %} + {{ music.title }} + {% endif %} + + {% elif music.category_name|lower == 'song' %} + + {% if request.GET.q %} + {{ music.title | highlight:request.GET.q }} + {% else %} + {{ music.title }} + {% endif %} + + {% endif %} + + + {% if not request.GET.c and not hide_category %} + [{{music.verbose_category_name}}] + {% endif %} + + {{ music.get_source_site_display }} + +
    + + {% if music.rating %} +
    + {{ music.rating }} + {% else %} +
    {% trans '暂无评分' %}
    + {% endif %} + + + {% if music.artist %}{% trans '艺术家' %}: + {% for artist in music.artist %} + {{ artist }} + {% if not forloop.last %} {% endif %} + {% endfor %} + {% endif %} + + {% if music.genre %}/ {% trans '流派' %}: + {{ music.genre }} + {% endif %} + + {% if music.release_date %}/ {% trans '发行日期' %}: + {{ music.release_date }} + {% endif %} + + + + + + {% if music.brief %} +

    + {{ music.brief }} +

    + {% elif music.category_name|lower == 'album' %} +

    + {% trans '曲目:' %}{{ music.track_list }} +

    + {% else %} + +

    + {% trans '所属专辑:' %}{{ music.album }} +

    + {% endif %} + +
    + {% for tag_dict in music.top_tags %} + + {{ tag_dict.content }} + + {% endfor %} +
    + + {% if mark %} +
    +
    +
    +
      +
    • + {% if mark.rating %} + + {% endif %} + {% if mark.visibility > 0 %} + + + + {% endif %} + + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: + {% if music.category_name|lower == 'album' %} + {{ mark.title }} + {% else %} + {{ mark.title }} + {% endif %} + {% else %} + {% trans '标记' %} + {% endif %} + + {% if mark.text %} +

      {{ mark.text }}

      + {% endif %} +
    • +
    +
    + {% endif %} + + {% if collectionitem %} +
    +
    +
    +
      +
    • +

      + {% include "show_item_comment.html" %} +

      +
    • +
    +
    + {% endif %} + +
    + +
  • \ No newline at end of file diff --git a/common/templates/partial/mark_list.html b/common/templates/partial/mark_list.html new file mode 100644 index 00000000..71c4102a --- /dev/null +++ b/common/templates/partial/mark_list.html @@ -0,0 +1,37 @@ +{% load i18n %} + + \ No newline at end of file diff --git a/common/templatetags/highlight.py b/common/templatetags/highlight.py index a2276800..02d79bbb 100644 --- a/common/templatetags/highlight.py +++ b/common/templatetags/highlight.py @@ -1,17 +1,19 @@ from django import template from django.utils.safestring import mark_safe from django.template.defaultfilters import stringfilter -from django.utils.html import format_html +from opencc import OpenCC -import re +cc = OpenCC('t2s') register = template.Library() + @register.filter @stringfilter def highlight(text, search): - to_be_replaced_words = set(re.findall(search, text, flags=re.IGNORECASE)) - - for word in to_be_replaced_words: - text = text.replace(word, f'{word}') + for s in cc.convert(search.strip().lower()).split(' '): + if s: + p = cc.convert(text.lower()).find(s) + if p != -1: + text = f'{text[0:p]}{text[p:p+len(s)]}{text[p+len(s):]}' return mark_safe(text) diff --git a/common/templatetags/neo.py b/common/templatetags/neo.py new file mode 100644 index 00000000..b915b7b9 --- /dev/null +++ b/common/templatetags/neo.py @@ -0,0 +1,48 @@ +from django import template +import datetime +from django.utils import timezone +from collection.models import Collection + + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def current_user_marked_item(context, item): + user = context['request'].user + if user and user.is_authenticated: + if isinstance(item, Collection) and item.owner == user: + return item + else: + return context['request'].user.get_mark_for_item(item) + return None + + +@register.simple_tag(takes_context=True) +def current_user_relationship(context, user): + current_user = context['request'].user + if current_user and current_user.is_authenticated: + if current_user.is_following(user): + if current_user.is_followed_by(user): + return '互相关注' + else: + return '已关注' + elif current_user.is_followed_by(user): + return '被ta关注' + return None + + +@register.filter +def prettydate(d): + diff = timezone.now() - d + s = diff.seconds + if diff.days > 14 or diff.days < 0: + return d.strftime('%Y年%m月%d日') + elif diff.days >= 1: + return '{} 天前'.format(diff.days) + elif s < 120: + return '刚刚' + elif s < 3600: + return '{} 分钟前'.format(s // 60) + else: + return '{} 小时前'.format(s // 3600) diff --git a/common/templatetags/oauth_token.py b/common/templatetags/oauth_token.py index 7aac83a1..b2f24677 100644 --- a/common/templatetags/oauth_token.py +++ b/common/templatetags/oauth_token.py @@ -7,7 +7,7 @@ register = template.Library() class OAuthTokenNode(template.Node): def render(self, context): request = context.get('request') - oauth_token = request.session.get('oauth_token', default='') + oauth_token = request.user.mastodon_token return format_html(oauth_token) diff --git a/common/templatetags/thumb.py b/common/templatetags/thumb.py index 2e21c6ca..aa698abb 100644 --- a/common/templatetags/thumb.py +++ b/common/templatetags/thumb.py @@ -12,4 +12,7 @@ def thumb(source, alias): if source.url.endswith('.svg'): return source.url else: - return thumbnail_url(source, alias) \ No newline at end of file + try: + return thumbnail_url(source, alias) + except Exception as e: + return '' diff --git a/common/urls.py b/common/urls.py index 6de4f66a..b803a47d 100644 --- a/common/urls.py +++ b/common/urls.py @@ -6,4 +6,6 @@ urlpatterns = [ path('', home), path('home/', home, name='home'), path('search/', search, name='search'), + path('search.json/', search, name='search.json'), + path('external_search/', external_search, name='external_search'), ] diff --git a/common/utils.py b/common/utils.py index 5d26a3d9..4d51ca16 100644 --- a/common/utils.py +++ b/common/utils.py @@ -10,6 +10,8 @@ class PageLinksGenerator: def __init__(self, length, current_page, total_pages): current_page = int(current_page) self.current_page = current_page + self.previous_page = current_page - 1 if current_page > 1 else None + self.next_page = current_page + 1 if current_page < total_pages else None self.start_page = None self.end_page = None self.page_range = None diff --git a/common/views.py b/common/views.py index c028184a..e527e09a 100644 --- a/common/views.py +++ b/common/views.py @@ -17,21 +17,93 @@ from music.models import Album, Song, AlbumMark, SongMark from users.models import Report, User, Preference from mastodon.decorators import mastodon_request_included from users.views import home as user_home +from timeline.views import timeline as user_timeline from common.models import MarkStatusEnum from common.utils import PageLinksGenerator -from common.scraper import scraper_registry +from common.scraper import get_scraper_by_url, get_normalized_url from common.config import * +from common.searcher import ExternalSources from management.models import Announcement +from django.conf import settings +from common.index import Indexer +from django.http import JsonResponse +from django.db.utils import IntegrityError + logger = logging.getLogger(__name__) + @login_required def home(request): - return user_home(request, request.user.id) + if request.user.get_preference().classic_homepage: + return redirect(reverse("users:home", args=[request.user.mastodon_username])) + else: + return redirect(reverse("timeline:timeline")) @login_required +def external_search(request): + category = request.GET.get("c", default='all').strip().lower() + if category == 'all': + category = None + keywords = request.GET.get("q", default='').strip() + page_number = int(request.GET.get('page', default=1)) + return render( + request, + "common/external_search_result.html", + { + "external_items": ExternalSources.search(category, keywords, page_number) if keywords else [], + } + ) + + def search(request): + if settings.SEARCH_BACKEND is None: + return search2(request) + category = request.GET.get("c", default='all').strip().lower() + if category == 'all': + category = None + keywords = request.GET.get("q", default='').strip() + tag = request.GET.get("tag", default='').strip() + p = request.GET.get('page', default='1') + page_number = int(p) if p.isdigit() else 1 + if not (keywords or tag): + return render( + request, + "common/search_result.html", + { + "items": None, + } + ) + if request.user.is_authenticated: + url_validator = URLValidator() + try: + url_validator(keywords) + # validation success + return jump_or_scrape(request, keywords) + except ValidationError as e: + pass + + result = Indexer.search(keywords, page=page_number, category=category, tag=tag) + for item in result.items: + item.tag_list = item.all_tag_list[:TAG_NUMBER_ON_LIST] + if request.path.endswith('.json/'): + return JsonResponse({ + 'num_pages': result.num_pages, + 'items':list(map(lambda i:i.get_json(), result.items)) + }) + return render( + request, + "common/search_result.html", + { + "items": result.items, + "pagination": PageLinksGenerator(PAGE_LINK_NUMBER, page_number, result.num_pages), + "categories": ['book', 'movie', 'music', 'game'], + } + ) + + +def search2(request): if request.method == 'GET': # test if input serach string is empty or not excluding param ?c= @@ -109,7 +181,7 @@ def search(request): else: ordered_queryset = list(queryset) return ordered_queryset - + def movie_param_handler(**kwargs): # keywords keywords = kwargs.get('keywords') @@ -240,7 +312,7 @@ def search(request): elif music.__class__ == Song: similarity += 1/2 * SequenceMatcher(None, keyword, music.title).quick_ratio() \ + 1/6 * SequenceMatcher(None, keyword, artist_dump).quick_ratio() \ - + 1/6 * SequenceMatcher(None, keyword, music.album.title).quick_ratio() + + 1/6 * (SequenceMatcher(None, keyword, music.album.title).quick_ratio() if music.album is not None else 0) n += 1 music.similarity = similarity / n elif tag: @@ -322,32 +394,50 @@ def jump_or_scrape(request, url): if this_site in url: return redirect(url) - # match url to registerd sites - matched_host = None - for host in scraper_registry: - if host in url: - matched_host = host - break - - if matched_host is None: + url = get_normalized_url(url) + scraper = get_scraper_by_url(url) + if scraper is None: # invalid url - return render(request, 'common/error.html', {'msg': _("链接非法,查询失败")}) + return render(request, 'common/error.html', {'msg': _("链接无效,查询失败")}) else: - scraper = scraper_registry[matched_host] + try: + effective_url = scraper.get_effective_url(url) + except ValueError: + return render(request, 'common/error.html', {'msg': _("链接无效,查询失败")}) try: # raise ObjectDoesNotExist - effective_url = scraper.get_effective_url(url) entity = scraper.data_class.objects.get(source_url=effective_url) # if exists then jump to detail page + if request.path.endswith('.json/'): + return JsonResponse({ + 'num_pages': 1, + 'items': [entity.get_json()] + }) return redirect(entity) except ObjectDoesNotExist: # scrape if not exists try: scraper.scrape(url) form = scraper.save(request_user=request.user) + except IntegrityError as ie: # duplicate key on source_url may be caused by user's double submission + try: + entity = scraper.data_class.objects.get(source_url=effective_url) + return redirect(entity) + except Exception as e: + logger.error(f"Scrape Failed URL: {url}\n{e}") + if settings.DEBUG: + logger.error("Expections during saving scraped data:", exc_info=e) + return render(request, 'common/error.html', {'msg': _("爬取数据失败😫")}) except Exception as e: - logger.error(f"Scrape Failed URL: {url}") - logger.error("Expections during saving scraped data:", exc_info=e) + logger.error(f"Scrape Failed URL: {url}\n{e}") + if settings.DEBUG: + logger.error("Expections during saving scraped data:", exc_info=e) return render(request, 'common/error.html', {'msg': _("爬取数据失败😫")}) return redirect(form.instance) + +def go_relogin(request): + return render(request, 'common/error.html', { + 'url': reverse("users:connect") + '?domain=' + request.user.mastodon_site, + 'msg': _("信息已保存,但是未能分享到联邦网络"), + 'secondary_msg': _("可能是你在联邦网络(Mastodon/Pleroma/...)的登录状态过期了,正在跳转到联邦网络重新登录😼")}) diff --git a/doc/GUIDE.md b/doc/GUIDE.md new file mode 100644 index 00000000..055ca2bb --- /dev/null +++ b/doc/GUIDE.md @@ -0,0 +1,106 @@ +NiceDB / NeoDB - Getting Start +============================== +This is a very basic guide with limited detail, contributions welcomed + +Install +------- +Install PostgreSQL, Redis and Python if not yet + +Setup database +``` +CREATE DATABASE neodb ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE template0; +\c neodb; +CREATE EXTENSION hstore WITH SCHEMA public; +CREATE ROLE neodb with LOGIN ENCRYPTED PASSWORD 'abadface'; +GRANT ALL ON DATABASE neodb TO neodb; +``` + +Create and edit your own configuration file (optional but very much recommended) +``` +mkdir mysite && cp boofilsic/settings.py mysite/ +export DJANGO_SETTINGS_MODULE=mysite.settings +``` + +Create and use `venv` as you normally would, then install packages +``` +python3 -m pip install -r requirements.txt +``` + +Quick check +``` +python3 manage.py check +``` + +Initialize database +``` +python3 manage.py makemigrations users books movies games music sync mastodon management collection +python3 manage.py migrate users +python3 manage.py migrate +``` + +Build static assets +``` +python3 manage.py collectstatic +``` + + +Start services +-------------- +Make sure PostgreSQL and Redis are running + +Start job queue server +``` +export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES # required and only for macOS, otherwise it may crash +python3 manage.py rqworker --with-scheduler doufen export mastodon +``` + +Run web server in dev mode +``` +python3 manage.py runserver 0.0.0.0:80 +``` + +It should be ready to serve from here, to run web server for production, consider `gunicorn -w 8 boofilsic.wsgi` in systemd or sth similar + + +Migrate from an earlier version +------------------------------- +Update database +``` +python3 manage.py makemigrations +python3 manage.py migrate +``` + +Rebuild static assets +``` +python3 manage.py sass common/static/sass/boofilsic.sass common/static/css/boofilsic.min.css -t compressed +python3 manage.py sass common/static/sass/boofilsic.sass common/static/css/boofilsic.css +python3 manage.py collectstatic +``` + +Add Cron Jobs +------------- +add `python manage.py refresh_mastodon` to crontab to run hourly, it will refresh cached users' follow/mute/block from mastodon + +Index and Search +---------------- +Install TypeSense or Meilisearch, change `SEARCH_BACKEND` and coniguration for search server in `settings.py` + +Build initial index, it may take a few minutes or hours +``` +python3 manage.py init_index +python3 manage.py reindex +``` + +Other maintenance tasks +----------------------- +Requeue failed jobs +``` +rq requeue --all --queue doufen +``` + +Run in Docker +``` +docker-compose build +docker-compose up db && docker exec -it app_db_1 psql -U postgres postgres -c 'CREATE EXTENSION hstore WITH SCHEMA public;' # first time only +docker-compose up +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..86f8c09f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3' + +services: + redis: + image: redis:alpine + + db: + image: postgres:14-alpine + volumes: + - /tmp/data/db:/var/lib/postgresql/data + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/code + ports: + - "8000:8000" + environment: + - DB_HOST=db + - DB_NAME=postgres + - DB_USER=postgres + - DB_PASSWORD=postgres + - REDIS_HOST=redis + - DJANGO_SETTINGS_MODULE=neodb.dev + depends_on: + - db + - redis diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..5feb0354 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +python manage.py collectstatic --noinput +python manage.py makemigrations users books movies games music sync mastodon management collection +python manage.py makemigrations +python manage.py migrate users +python manage.py migrate + +exec "$@" diff --git a/docker/start.sh b/docker/start.sh new file mode 100755 index 00000000..a54a76c7 --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +cd /app + +if [ $# -eq 0 ]; then + echo "Usage: start.sh " + exit 1 +fi + +PROCESS_TYPE=$1 + +if [ "$PROCESS_TYPE" = "server" ]; then + if [ "$DJANGO_DEBUG" = "true" ]; then + gunicorn \ + --reload \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + --worker-class eventlet \ + --log-level DEBUG \ + --access-logfile "-" \ + --error-logfile "-" \ + boofilsic.wsgi + else + gunicorn \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + --worker-class eventlet \ + --log-level DEBUG \ + --access-logfile "-" \ + --error-logfile "-" \ + boofilsic.wsgi + fi +elif [ "$PROCESS_TYPE" = "rq" ]; then + rqworker --with-scheduler doufen export mastodon +fi + diff --git a/games/admin.py b/games/admin.py index fe72bd9a..36cc44fa 100644 --- a/games/admin.py +++ b/games/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Game) +admin.site.register(Game, SimpleHistoryAdmin) admin.site.register(GameMark) admin.site.register(GameReview) admin.site.register(GameTag) diff --git a/games/apps.py b/games/apps.py index b74f62c9..a204c094 100644 --- a/games/apps.py +++ b/games/apps.py @@ -3,3 +3,8 @@ from django.apps import AppConfig class GamesConfig(AppConfig): name = 'games' + + def ready(self): + from common.index import Indexer + from .models import Game + Indexer.update_model_indexable(Game) diff --git a/games/forms.py b/games/forms.py index 5aa53330..e758f9fd 100644 --- a/games/forms.py +++ b/games/forms.py @@ -1,28 +1,24 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.utils.translation import gettext_lazy as _ -from .models import Game, GameMark, GameReview +from .models import Game, GameMark, GameReview, GameMarkStatusTranslation from common.models import MarkStatusEnum from common.forms import * def GameMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在玩"), - MarkStatusEnum.WISH.value: _("想玩"), - MarkStatusEnum.COLLECT.value: _("玩过") - } - return trans_dict[status] + return GameMarkStatusTranslation[status] class GameForm(forms.ModelForm): - # id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) other_info = JSONField(required=False, label=_("其他信息")) class Meta: model = Game fields = [ + 'id', 'title', 'source_site', 'source_url', @@ -66,11 +62,8 @@ class GameMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'game': forms.TextInput(attrs={"hidden": ""}), } @@ -85,14 +78,8 @@ class GameReviewForm(ReviewForm): 'game', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'book': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'game': forms.TextInput(attrs={"hidden": ""}), } diff --git a/games/models.py b/games/models.py index a2614f0a..fba9e639 100644 --- a/games/models.py +++ b/games/models.py @@ -1,17 +1,25 @@ import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, MarkStatusEnum from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath -from boofilsic.settings import GAME_MEDIA_PATH_ROOT, DEFAULT_GAME_IMAGE from django.utils import timezone +from django.conf import settings +from simple_history.models import HistoricalRecords + + +GameMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在玩"), + MarkStatusEnum.WISH.value: _("想玩"), + MarkStatusEnum.COLLECT.value: _("玩过") +} def game_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, GAME_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.GAME_MEDIA_PATH_ROOT) class Game(Entity): @@ -53,7 +61,7 @@ class Game(Entity): ) genre = postgres.ArrayField( - models.CharField(blank=True, default='', max_length=50), + models.CharField(blank=True, default='', max_length=200), null=True, blank=True, default=list, @@ -61,23 +69,39 @@ class Game(Entity): ) platform = postgres.ArrayField( - models.CharField(blank=True, default='', max_length=50), + models.CharField(blank=True, default='', max_length=200), null=True, blank=True, default=list, verbose_name=_("平台") ) - cover = models.ImageField(_("封面"), upload_to=game_cover_path, default=DEFAULT_GAME_IMAGE, blank=True) - + cover = models.ImageField(_("封面"), upload_to=game_cover_path, default=settings.DEFAULT_GAME_IMAGE, blank=True) + history = HistoricalRecords() def __str__(self): return self.title + def get_json(self): + r = { + 'developer': self.developer, + 'other_title': self.other_title, + 'publisher': self.publisher, + 'release_date': self.release_date, + 'platform': self.platform, + 'genre': self.genre, + } + r.update(super().get_json()) + return r + def get_absolute_url(self): return reverse("games:retrieve", args=[self.id]) + @property + def wish_url(self): + return reverse("games:wish", args=[self.id]) + def get_tags_manager(self): return self.game_tags @@ -85,6 +109,14 @@ class Game(Entity): def verbose_category_name(self): return _("游戏") + @property + def mark_class(self): + return GameMark + + @property + def tag_class(self): + return GameTag + class GameMark(Mark): game = models.ForeignKey( @@ -96,6 +128,10 @@ class GameMark(Mark): fields=['owner', 'game'], name='unique_game_mark') ] + @property + def translated_status(self): + return GameMarkStatusTranslation[self.status] + class GameReview(Review): game = models.ForeignKey( @@ -107,6 +143,14 @@ class GameReview(Review): fields=['owner', 'game'], name='unique_game_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("games:retrieve_review", args=[self.id]) + + @property + def item(self): + return self.game + class GameTag(Tag): game = models.ForeignKey( @@ -119,3 +163,7 @@ class GameTag(Tag): models.UniqueConstraint( fields=['content', 'mark'], name="unique_gamemark_tag") ] + + @property + def item(self): + return self.game diff --git a/games/templates/games/create_update.html b/games/templates/games/create_update.html index 40a768e2..178089c4 100644 --- a/games/templates/games/create_update.html +++ b/games/templates/games/create_update.html @@ -10,8 +10,8 @@ - {% trans 'NiceDB - ' %}{{ title }} - + {{ site_name }} - {{ title }} + @@ -22,9 +22,24 @@
    + {% if is_update and form.source_site.value != 'in-site' %} +
    +
    +
    +
    {% trans '源网站' %}: {{ form.source_site.value }}
    +
    +
    + {% csrf_token %} + +
    +
    +
    +
    +
    + {% endif %} +
    - {% trans '>>> 试试一键剽取~ <<<' %} + {% comment %} {% trans '>>> 试试一键剽取~ <<<' %} {% endcomment %}
    {% csrf_token %} {{ form.media }} @@ -55,12 +70,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {{ title }} + @@ -94,7 +94,7 @@
    - {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }}
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除电影/剧集' %} + @@ -55,7 +55,7 @@ {% if game.last_editor %}
    {% trans '最近编辑者:' %} - + {{ game.last_editor | default:"" }}
    @@ -89,12 +89,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除评论' %} + @@ -35,7 +35,7 @@
    {{ review.title }}
    - {% if review.is_private %} + {% if review.visibility > 0 %}
    - {{ review.owner.username }} {% if mark %} @@ -89,12 +89,6 @@
    - {% comment %} - - - - - {% endcomment %} + {% include "partial/_common_libs.html" with jquery=1 %} + - - @@ -54,11 +53,12 @@
    - {% if game.rating %} + {% if game.rating and game.rating_number >= 5 %} {{ game.rating }} + ({{ game.rating_number }}人评分) {% else %} - {% trans '评分:暂无评分' %} + {% trans '评分:评分人数不足' %} {% endif %}
    @@ -72,7 +72,7 @@ {% if game.other_title|length > 5 %} {% trans '更多' %} + {{ site_name }} - {{ game.title }}{% trans '的标记' %} + @@ -35,37 +35,7 @@
    {{ game.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 %} - -
    + {% include "partial/mark_list.html" with mark_list=marks current_item=game %}
    - {% comment %} - - - - - {% endcomment %} + + {{ site_name }}游戏评论 - {{ review.title }} + + @@ -39,7 +40,7 @@
    {{ review.title }}
    - {% if review.is_private %} + {% if review.visibility > 0 %} @@ -48,7 +49,7 @@
    - {{ review.owner.username }} + {{ review.owner.username }} {% if mark %} @@ -73,6 +74,7 @@ {{ form.content }}
    {{ form.media }} + {% csrf_token %}
    @@ -134,16 +136,8 @@
    - {% comment %} - - - - - {% endcomment %} diff --git a/games/templates/games/review_list.html b/games/templates/games/review_list.html index 10b01d43..8b05318d 100644 --- a/games/templates/games/review_list.html +++ b/games/templates/games/review_list.html @@ -14,8 +14,8 @@ - {% trans 'NiceDB - ' %}{{ game.title }}{% trans '的评论' %} - + {{ site_name }} - {{ game.title }}{% trans '的评论' %} + @@ -41,8 +41,8 @@
  • - {{ review.owner.username }} - {% if review.is_private %} + {{ review.owner.username }} + {% if review.visibility > 0 %} {% endif %} {{ review.edited_time }} @@ -137,12 +137,6 @@ - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '从豆瓣获取数据' %} + diff --git a/games/urls.py b/games/urls.py index ff984632..88ce1629 100644 --- a/games/urls.py +++ b/games/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from .views import * @@ -8,9 +8,10 @@ urlpatterns = [ path('/', retrieve, name='retrieve'), path('update//', update, name='update'), path('delete//', delete, name='delete'), + path('rescrape//', rescrape, name='rescrape'), path('mark/', create_update_mark, name='create_update_mark'), - path('/mark/list/', - retrieve_mark_list, name='retrieve_mark_list'), + path('wish//', wish, name='wish'), + re_path('(?P[0-9]+)/mark/list/(?:(?P\\d+))?', 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'), diff --git a/games/views.py b/games/views.py index f69e4918..0f0fc166 100644 --- a/games/views.py +++ b/games/views.py @@ -2,21 +2,23 @@ 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.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse 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.api import check_visibility, post_toot, TootVisibilityEnum -from mastodon.utils import rating_to_emoji +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review from common.utils import PageLinksGenerator -from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin from common.models import SourceSiteEnum from .models import * from .forms import * -from boofilsic.settings import MASTODON_TAGS +from django.conf import settings +from collection.models import CollectionItem +from common.scraper import get_scraper_by_url, get_normalized_url logger = logging.getLogger(__name__) @@ -87,6 +89,18 @@ def create(request): return HttpResponseBadRequest() +@login_required +def rescrape(request, id): + if request.method != 'POST': + return HttpResponseBadRequest() + item = get_object_or_404(Game, pk=id) + url = get_normalized_url(item.source_url) + scraper = get_scraper_by_url(url) + scraper.scrape(url) + form = scraper.save(request_user=request.user, instance=item) + return redirect(reverse("games:retrieve", args=[form.instance.id])) + + @login_required def update(request, id): if request.method == 'GET': @@ -98,6 +112,7 @@ def update(request, id): 'games/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("games:update", args=[game.id]), # provided for frontend js @@ -127,6 +142,7 @@ def update(request, id): 'games/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("games:update", args=[game.id]), # provided for frontend js @@ -167,6 +183,7 @@ def retrieve(request, id): else: mark_form = GameMarkForm(initial={ 'game': game, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -186,10 +203,8 @@ def retrieve(request, id): mark_list_more = None review_list_more = None else: - mark_list = GameMark.get_available( - game, request.user, request.session['oauth_token']) - review_list = GameReview.get_available( - game, request.user, request.session['oauth_token']) + 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: @@ -197,6 +212,7 @@ def retrieve(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(game=game))) # def strip_html_tags(text): # import re @@ -221,6 +237,7 @@ def retrieve(request, id): 'review_list_more': review_list_more, 'game_tag_list': game_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -265,12 +282,19 @@ def create_update_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + game_id = request.POST.get('game') + mark = GameMark.objects.filter(game_id=game_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(GameMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.gamemark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = GameMarkForm(request.POST, instance=mark) else: @@ -278,7 +302,7 @@ def create_update_mark(request): form = GameMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user @@ -306,28 +330,10 @@ def create_update_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("games:retrieve", - args=[game.id]) - words = GameMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{game.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("games:retrieve", args=[form.instance.game.id])) else: @@ -336,11 +342,30 @@ def create_update_mark(request): @mastodon_request_included @login_required -def retrieve_mark_list(request, game_id): +def wish(request, id): + if request.method == 'POST': + game = get_object_or_404(Game, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'game': game, + } + try: + GameMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_mark_list(request, game_id, following_only=False): if request.method == 'GET': game = get_object_or_404(Game, pk=game_id) - queryset = GameMark.get_available( - game, request.user, request.session['oauth_token']) + queryset = GameMark.get_available(game, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -401,23 +426,8 @@ def create_review(request, game_id): 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.UNLISTED - url = "https://" + request.get_host() + reverse("games:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.game.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("games:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -453,23 +463,8 @@ def update_review(request, id): 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.UNLISTED - url = "https://" + request.get_host() + reverse("games:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.game.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("games:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -504,11 +499,10 @@ def delete_review(request, id): @mastodon_request_included -@login_required def retrieve_review(request, id): if request.method == 'GET': review = get_object_or_404(GameReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -543,8 +537,7 @@ def retrieve_review(request, id): def retrieve_review_list(request, game_id): if request.method == 'GET': game = get_object_or_404(Game, pk=game_id) - queryset = GameReview.get_available( - game, request.user, request.session['oauth_token']) + queryset = GameReview.get_available(game, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) diff --git a/management/models.py b/management/models.py index 9816781e..c5bf2f64 100644 --- a/management/models.py +++ b/management/models.py @@ -1,7 +1,7 @@ import re from django.db import models from django.shortcuts import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from markdownx.models import MarkdownxField from markdown import markdown diff --git a/management/templates/management/create_update.html b/management/templates/management/create_update.html index 9ffd568c..692dc126 100644 --- a/management/templates/management/create_update.html +++ b/management/templates/management/create_update.html @@ -4,8 +4,8 @@ - - + + Create/Update Announcement diff --git a/management/templates/management/delete.html b/management/templates/management/delete.html index 11f08209..d48b740d 100644 --- a/management/templates/management/delete.html +++ b/management/templates/management/delete.html @@ -4,8 +4,8 @@ - - + + Delete Announcement diff --git a/management/templates/management/detail.html b/management/templates/management/detail.html index a7cf64e5..76150b1d 100644 --- a/management/templates/management/detail.html +++ b/management/templates/management/detail.html @@ -6,9 +6,9 @@ - + - NiceDB - {{ object.title }} + {{ site_name }} - {{ object.title }} diff --git a/management/templates/management/list.html b/management/templates/management/list.html index c8199e1f..ab5a2b24 100644 --- a/management/templates/management/list.html +++ b/management/templates/management/list.html @@ -7,10 +7,10 @@ - + - {% trans 'NiceDB - 公告栏' %} + {{ site_name }} - {% trans '公告栏' %} diff --git a/mastodon/admin.py b/mastodon/admin.py index b29c2a3c..e81bc74b 100644 --- a/mastodon/admin.py +++ b/mastodon/admin.py @@ -38,7 +38,7 @@ class MastodonApplicationModelAdmin(admin.ModelAdmin): try: response = create_app(request.POST.get('domain_name')) except (Timeout, ConnectionError): - request.POST['domain_name'] = _("长毛象请求超时。") + request.POST['domain_name'] = _("联邦网络请求超时。") except Exception as e: request.POST['domain_name'] = str(e) else: diff --git a/mastodon/api.py b/mastodon/api.py index 5e48c944..1919f099 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -2,10 +2,16 @@ import requests import string import random import functools +import logging from django.core.exceptions import ObjectDoesNotExist -from boofilsic.settings import MASTODON_TIMEOUT -from boofilsic.settings import CLIENT_NAME, APP_WEBSITE, REDIRECT_URIS -from .models import CrossSiteUserInfo +from django.conf import settings +from django.shortcuts import reverse +from urllib.parse import quote +from .models import CrossSiteUserInfo, MastodonApplication +from mastodon.utils import rating_to_emoji + + +logger = logging.getLogger(__name__) # See https://docs.joinmastodon.org/methods/accounts/ @@ -46,39 +52,76 @@ API_CREATE_APP = '/api/v1/apps' # GET API_SEARCH = '/api/v2/search' +TWITTER_DOMAIN = 'twitter.com' -get = functools.partial(requests.get, timeout=MASTODON_TIMEOUT) -post = functools.partial(requests.post, timeout=MASTODON_TIMEOUT) +TWITTER_API_ME = 'https://api.twitter.com/2/users/me' + +TWITTER_API_POST = 'https://api.twitter.com/2/tweets' + +TWITTER_API_TOKEN = 'https://api.twitter.com/2/oauth2/token' + +USER_AGENT = f"{settings.CLIENT_NAME}/1.0" + +get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT) +post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT) # low level api below -def get_relationships(site, id_list, token): +def get_relationships(site, id_list, token): # no longer in use url = 'https://' + site + API_GET_RELATIONSHIPS payload = {'id[]': id_list} headers = { + 'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}' } - response = get(url, headers=headers, data=payload) + response = get(url, headers=headers, params=payload) return response.json() def post_toot(site, content, visibility, token, local_only=False): - url = 'https://' + site + API_PUBLISH_TOOT headers = { + 'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}', 'Idempotency-Key': random_string_generator(16) } - payload = { - 'status': content, - 'visibility': visibility, - 'local_only': True, - } - if not local_only: - del payload['local_only'] - response = post(url, headers=headers, data=payload) + if site == TWITTER_DOMAIN: + url = TWITTER_API_POST + payload = { + 'text': content if len(content) <= 150 else content[0:150] + '...' + } + response = post(url, headers=headers, json=payload) + else: + url = 'https://' + site + API_PUBLISH_TOOT + payload = { + 'status': content, + 'visibility': visibility, + } + if local_only: + payload['local_only'] = True + try: + response = post(url, headers=headers, data=payload) + if response.status_code == 201: + response.status_code = 200 + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + except Exception: + response = None return response +def get_instance_info(domain_name): + if domain_name.lower().strip() == TWITTER_DOMAIN: + return TWITTER_DOMAIN, '' + try: + url = f'https://{domain_name}/api/v1/instance' + response = get(url, headers={'User-Agent': USER_AGENT}) + j = response.json() + return j['uri'].lower().split('//')[-1].split('/')[0], j['version'] + except Exception: + logger.error(f"Error {url}") + return domain_name, '' + + def create_app(domain_name): # naive protocal strip is_http = False @@ -96,18 +139,13 @@ def create_app(domain_name): url = 'http://' + domain_name + API_CREATE_APP payload = { - 'client_name': CLIENT_NAME, - 'scopes': 'read write follow', - 'redirect_uris': REDIRECT_URIS, - 'website': APP_WEBSITE + 'client_name': settings.CLIENT_NAME, + 'scopes': settings.MASTODON_CLIENT_SCOPE, + 'redirect_uris': settings.REDIRECT_URIS, + 'website': settings.APP_WEBSITE } - from boofilsic.settings import DEBUG - if DEBUG: - payload['redirect_uris'] = 'http://localhost/users/OAuth2_login/\nurn:ietf:wg:oauth:2.0:oob' - payload['client_name'] = 'test_do_not_authorise' - - response = post(url, data=payload) + response = post(url, data=payload, headers={'User-Agent': USER_AGENT}) return response @@ -116,26 +154,35 @@ def get_site_id(username, user_site, target_site, token): payload = { 'limit': 1, 'type': 'accounts', + 'resolve': True, 'q': f"{username}@{user_site}" } headers = { + 'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}' } - response = get(url, data=payload, headers=headers) - data = response.json() - if not data['accounts']: + response = get(url, params=payload, headers=headers) + try: + data = response.json() + except Exception: + logger.error(f"Error parsing JSON from {url}") + return None + if 'accounts' not in data: + return None + elif len(data['accounts']) == 0: # target site may return empty if no cache of this user + return None + elif data['accounts'][0]['acct'] != f"{username}@{user_site}": # or return another user with a similar id which needs to be skipped return None else: return data['accounts'][0]['id'] # high level api below -def get_relationship(request_user, target_user, token): - if request_user.mastodon_site == target_user.mastodon_site: - return get_relationships(request_user.mastodon_site, target_user.mastodon_id, token) - else: - cross_site_id = get_cross_site_id(target_user, request_user.mastodon_site, token) - return get_relationships(request_user.mastodon_site, [cross_site_id,], token) +def get_relationship(request_user, target_user, useless_token=None): + return [{ + 'blocked_by': target_user.is_blocking(request_user), + 'following': request_user.is_following(target_user), + }] def get_cross_site_id(target_user, target_site, token): @@ -147,6 +194,8 @@ def get_cross_site_id(target_user, target_site, token): """ if target_site == target_user.mastodon_site: return target_user.mastodon_id + if target_site == TWITTER_DOMAIN: + return None try: cross_site_info = CrossSiteUserInfo.objects.get( @@ -157,6 +206,7 @@ def get_cross_site_id(target_user, target_site, token): cross_site_id = get_site_id( target_user.username, target_user.mastodon_site, target_site, token) if not cross_site_id: + logger.error(f'unable to find cross_site_id for {target_user} on {target_site}') return None cross_site_info = CrossSiteUserInfo.objects.create( uid=f"{target_user.username}@{target_user.mastodon_site}", @@ -167,30 +217,251 @@ def get_cross_site_id(target_user, target_site, token): return cross_site_info.site_id -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_relationship(visitor, user_owned_entity.owner, 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 - - # utils below def random_string_generator(n): s = string.ascii_letters + string.punctuation + string.digits return ''.join(random.choice(s) for i in range(n)) +def verify_account(site, token): + if site == TWITTER_DOMAIN: + url = TWITTER_API_ME + '?user.fields=id,username,name,description,profile_image_url,created_at,protected' + try: + response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + return response.status_code, None + r = response.json()['data'] + r['display_name'] = r['name'] + r['note'] = r['description'] + r['avatar'] = r['profile_image_url'] + r['avatar_static'] = r['profile_image_url'] + r['locked'] = r['protected'] + r['url'] = f'https://{TWITTER_DOMAIN}/{r["username"]}' + return 200, r + except Exception: + return -1, None + url = 'https://' + site + API_VERIFY_ACCOUNT + try: + response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) + return response.status_code, (response.json() if response.status_code == 200 else None) + except Exception: + return -1, None + + +def get_related_acct_list(site, token, api): + if site == TWITTER_DOMAIN: + return [] + url = 'https://' + site + api + results = [] + while url: + response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) + url = None + if response.status_code == 200: + results.extend(map(lambda u: (u['acct'] if u['acct'].find('@') != -1 else u['acct'] + '@' + site) if 'acct' in u else u, response.json())) + if 'Link' in response.headers: + for ls in response.headers['Link'].split(','): + li = ls.strip().split(';') + if li[1].strip() == 'rel="next"': + url = li[0].strip().replace('>', '').replace('<', '') + return results + + class TootVisibilityEnum: PUBLIC = 'public' PRIVATE = 'private' DIRECT = 'direct' UNLISTED = 'unlisted' + + +def get_mastodon_application(domain): + app = MastodonApplication.objects.filter(domain_name=domain).first() + if app is not None: + return app, '' + if domain == TWITTER_DOMAIN: + return None, 'Twitter未配置' + error_msg = None + try: + response = create_app(domain) + except (requests.exceptions.Timeout, ConnectionError): + error_msg = "联邦网络请求超时。" + logger.error(f'Error creating app for {domain}: Timeout') + except Exception as e: + error_msg = "联邦网络请求失败 " + str(e) + logger.error(f'Error creating app for {domain}: {e}') + else: + # fill the form with returned data + if response.status_code != 200: + error_msg = "实例连接错误,代码: " + str(response.status_code) + logger.error(f'Error creating app for {domain}: {response.status_code}') + else: + try: + data = response.json() + except Exception: + error_msg = "实例返回内容无法识别" + logger.error(f'Error creating app for {domain}: unable to parse response') + else: + if settings.MASTODON_ALLOW_ANY_SITE: + app = MastodonApplication.objects.create(domain_name=domain, app_id=data['id'], client_id=data['client_id'], + client_secret=data['client_secret'], vapid_key=data['vapid_key'] if 'vapid_key' in data else '') + else: + error_msg = "不支持其它实例登录" + logger.error(f'Disallowed to create app for {domain}') + return app, error_msg + + +def get_mastodon_login_url(app, login_domain, version, request): + url = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') + if login_domain == TWITTER_DOMAIN: + return f"https://twitter.com/i/oauth2/authorize?response_type=code&client_id={app.client_id}&redirect_uri={quote(url)}&scope={quote(settings.TWITTER_CLIENT_SCOPE)}&state=state&code_challenge=challenge&code_challenge_method=plain" + scope = settings.MASTODON_LEGACY_CLIENT_SCOPE if 'Pixelfed' in version else settings.MASTODON_CLIENT_SCOPE + return "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(scope) + "&redirect_uri=" + url + "&response_type=code" + + +def obtain_token(site, request, code): + """ Returns token if success else None. """ + mast_app = MastodonApplication.objects.get(domain_name=site) + redirect_uri = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') + payload = { + 'client_id': mast_app.client_id, + 'client_secret': mast_app.client_secret, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code', + 'code': code + } + headers = {'User-Agent': USER_AGENT} + auth = None + if mast_app.is_proxy: + url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN + elif site == TWITTER_DOMAIN: + url = TWITTER_API_TOKEN + auth = (mast_app.client_id, mast_app.client_secret) + del payload['client_secret'] + payload['code_verifier'] = 'challenge' + else: + url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN + try: + response = post(url, data=payload, headers=headers, auth=auth) + # {"token_type":"bearer","expires_in":7200,"access_token":"VGpkOEZGR3FQRDJ5NkZ0dmYyYWIwS0dqeHpvTnk4eXp0NV9nWDJ2TEpmM1ZTOjE2NDg3ODMxNTU4Mzc6MToxOmF0OjE","scope":"block.read follows.read offline.access tweet.write users.read mute.read","refresh_token":"b1pXbGEzeUF1WE5yZHJOWmxTeWpvMTBrQmZPd0czLU0tQndZQTUyU3FwRDVIOjE2NDg3ODMxNTU4Mzg6MToxOnJ0OjE"} + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + return None, None + except Exception as e: + logger.error(f"Error {url} {e}") + return None, None + data = response.json() + return data.get('access_token'), data.get('refresh_token', '') + + +def refresh_access_token(site, refresh_token): + if site != TWITTER_DOMAIN: + return None + mast_app = MastodonApplication.objects.get(domain_name=site) + url = TWITTER_API_TOKEN + payload = { + 'client_id': mast_app.client_id, + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + } + headers = {'User-Agent': USER_AGENT} + auth = (mast_app.client_id, mast_app.client_secret) + response = post(url, data=payload, headers=headers, auth=auth) + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + return None + data = response.json() + return data.get('access_token') + + +def revoke_token(site, token): + mast_app = MastodonApplication.objects.get(domain_name=site) + + payload = { + 'client_id': mast_app.client_id, + 'client_secret': mast_app.client_secret, + 'token': token + } + + if mast_app.is_proxy: + url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN + else: + url = 'https://' + site + API_REVOKE_TOKEN + post(url, data=payload, headers={'User-Agent': USER_AGENT}) + + +def share_mark(mark): + user = mark.owner + if mark.visibility == 2: + visibility = TootVisibilityEnum.DIRECT + elif mark.visibility == 1: + visibility = TootVisibilityEnum.PRIVATE + elif user.get_preference().mastodon_publish_public: + visibility = TootVisibilityEnum.PUBLIC + else: + visibility = TootVisibilityEnum.UNLISTED + tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', str(mark.item.verbose_category_name)) if user.get_preference().mastodon_append_tag else '' + stars = rating_to_emoji(mark.rating, MastodonApplication.objects.get(domain_name=user.mastodon_site).star_mode) + content = f"{mark.translated_status}《{mark.item.title}》{stars}\n{mark.item.url}\n{mark.text}{tags}" + response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) + if response and response.status_code in [200, 201]: + j = response.json() + if 'url' in j: + mark.shared_link = j['url'] + elif 'data' in j: + mark.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" + if mark.shared_link: + mark.save(update_fields=['shared_link']) + return True + else: + return False + + +def share_review(review): + user = review.owner + if review.visibility == 2: + visibility = TootVisibilityEnum.DIRECT + elif review.visibility == 1: + visibility = TootVisibilityEnum.PRIVATE + elif user.get_preference().mastodon_publish_public: + visibility = TootVisibilityEnum.PUBLIC + else: + visibility = TootVisibilityEnum.UNLISTED + tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', str(review.item.verbose_category_name)) if user.get_preference().mastodon_append_tag else '' + content = f"发布了关于《{review.item.title}》的评论\n{review.url}\n{review.title}{tags}" + response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) + if response and response.status_code in [200, 201]: + j = response.json() + if 'url' in j: + review.shared_link = j['url'] + elif 'data' in j: + review.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" + if review.shared_link: + review.save(update_fields=['shared_link']) + return True + else: + return False + + +def share_collection(collection, comment, user, visibility_no): + if visibility_no == 2: + visibility = TootVisibilityEnum.DIRECT + elif visibility_no == 1: + visibility = TootVisibilityEnum.PRIVATE + elif user.get_preference().mastodon_publish_public: + visibility = TootVisibilityEnum.PUBLIC + else: + visibility = TootVisibilityEnum.UNLISTED + tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', '收藏单') if user.get_preference().mastodon_append_tag else '' + content = f"分享收藏单《{collection.title}》\n{collection.url}\n{comment}{tags}" + response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) + if response and response.status_code in [200, 201]: + j = response.json() + if 'url' in j: + shared_link = j['url'] + elif 'data' in j: + shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" + if shared_link: + pass + return True + else: + return False diff --git a/mastodon/auth.py b/mastodon/auth.py index 29a691e8..02d7703f 100644 --- a/mastodon/auth.py +++ b/mastodon/auth.py @@ -1,74 +1,5 @@ from django.contrib.auth.backends import ModelBackend, UserModel -from django.shortcuts import reverse -from .api import * -from .models import MastodonApplication - - -def obtain_token(site, request, code): - """ Returns token if success else None. """ - mast_app = MastodonApplication.objects.get(domain_name=site) - payload = { - 'client_id': mast_app.client_id, - 'client_secret': mast_app.client_secret, - 'redirect_uri': f"https://{request.get_host()}{reverse('users:OAuth2_login')}", - 'grant_type': 'authorization_code', - 'code': code, - 'scope': 'read write' - } - from boofilsic.settings import DEBUG - if DEBUG: - payload['redirect_uri']= f"http://{request.get_host()}{reverse('users:OAuth2_login')}", - if mast_app.is_proxy: - url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN - else: - url = 'https://' + mast_app.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(site, token): - url = 'https://' + site + 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(site, token): - mast_app = MastodonApplication.objects.get(domain_name=site) - - payload = { - 'client_id': mast_app.client_id, - 'client_secret': mast_app.client_secret, - 'scope': token - } - - if mast_app.is_proxy: - url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN - else: - url = 'https://' + site + API_REVOKE_TOKEN - response = post(url, data=payload) - - -def verify_token(site, token): - """ Check if the token is valid and is of local instance. """ - url = 'https://' + site + 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 +from .api import verify_account class OAuth2Backend(ModelBackend): @@ -76,22 +7,23 @@ class OAuth2Backend(ModelBackend): # "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, site=None, **kwargs): + + def authenticate(self, request, token=None, username=None, site=None, **kwargs): """ when username is provided, assume that token is newly obtained and valid """ if token is None or site is None: return if username is None: - user_data = get_user_data(site, token) - if user_data: - username = user_data['username'] + code, user_data = verify_account(site, token) + if code == 200: + userid = user_data['id'] 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']) + user = UserModel._default_manager.get(mastodon_id=userid, mastodon_site=site) except UserModel.DoesNotExist: return None else: diff --git a/mastodon/decorators.py b/mastodon/decorators.py index 3f2ec5cd..ef7a31d1 100644 --- a/mastodon/decorators.py +++ b/mastodon/decorators.py @@ -16,7 +16,7 @@ def mastodon_request_included(func): args[0], 'common/error.html', { - 'msg': _("长毛象请求超时叻_(´ཀ`」 ∠)__ ") + 'msg': _("联邦网络请求超时叻_(´ཀ`」 ∠)__ ") } ) return wrapper diff --git a/mastodon/management/commands/wrong_sites.py b/mastodon/management/commands/wrong_sites.py new file mode 100644 index 00000000..f985a1e7 --- /dev/null +++ b/mastodon/management/commands/wrong_sites.py @@ -0,0 +1,21 @@ +from django.core.management.base import BaseCommand +from mastodon.models import MastodonApplication +from django.conf import settings +from mastodon.api import get_instance_info +from users.models import User + + +class Command(BaseCommand): + help = 'Find wrong sites' + + def handle(self, *args, **options): + for site in MastodonApplication.objects.all(): + d = site.domain_name + login_domain = d.strip().lower().split('//')[-1].split('/')[0].split('@')[-1] + domain, version = get_instance_info(login_domain) + if d != domain: + print(f'{d} should be {domain}') + for u in User.objects.filter(mastodon_site=d, is_active=True): + u.mastodon_site = domain + print(f'fixing {u}') + u.save() diff --git a/mastodon/models.py b/mastodon/models.py index b6ac1207..49346810 100644 --- a/mastodon/models.py +++ b/mastodon/models.py @@ -1,14 +1,16 @@ from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class MastodonApplication(models.Model): domain_name = models.CharField(_('site domain name'), max_length=100, unique=True) - app_id = models.PositiveIntegerField(_('in-site app id')) + app_id = models.PositiveIntegerField(_('in-site app id')) # TODO Remove? bc 1) it seems useless 2) GoToSocial returns a hash text id client_id = models.CharField(_('client id'), max_length=100) client_secret = models.CharField(_('client secret'), max_length=100) - vapid_key = models.CharField(_('vapid key'), max_length=200) + vapid_key = models.CharField(_('vapid key'), max_length=200, null=True, blank=True) + star_mode = models.PositiveIntegerField(_('0: custom emoji; 1: unicode moon; 2: text'), blank=False, default=0) + max_status_len = models.PositiveIntegerField(_('max toot len'), blank=False, default=500) is_proxy = models.BooleanField(default=False, blank=True) proxy_to = models.CharField(max_length=100, blank=True, default='') @@ -27,7 +29,7 @@ class CrossSiteUserInfo(models.Model): # target site domain name target_site = models.CharField(_("target site domain name"), max_length=100) # target site id - site_id = models.PositiveIntegerField() + site_id = models.CharField(max_length=100, blank=False) class Meta: constraints = [ diff --git a/mastodon/utils.py b/mastodon/utils.py index 7da7f94e..8bada43a 100644 --- a/mastodon/utils.py +++ b/mastodon/utils.py @@ -1,14 +1,17 @@ -from boofilsic.settings import STAR_EMPTY, STAR_HALF, STAR_SOLID +from django.conf import settings -def rating_to_emoji(score): +def rating_to_emoji(score, star_mode = 0): """ convert score to mastodon star emoji code """ if score is None or score == '' or score == 0: return '' solid_stars = score // 2 half_star = int(bool(score % 2)) empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1 - emoji_code = STAR_SOLID * solid_stars + STAR_HALF * half_star + STAR_EMPTY * empty_stars + if star_mode == 1: + emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars + else: + emoji_code = settings.STAR_SOLID * solid_stars + settings.STAR_HALF * half_star + settings.STAR_EMPTY * empty_stars emoji_code = emoji_code.replace("::", ": :") emoji_code = ' ' + emoji_code + ' ' return emoji_code \ No newline at end of file diff --git a/movies/admin.py b/movies/admin.py index ca4d1b0e..bb2b1a54 100644 --- a/movies/admin.py +++ b/movies/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Movie) +admin.site.register(Movie, SimpleHistoryAdmin) admin.site.register(MovieMark) admin.site.register(MovieReview) admin.site.register(MovieTag) diff --git a/movies/apps.py b/movies/apps.py index bda16f08..3b025da4 100644 --- a/movies/apps.py +++ b/movies/apps.py @@ -3,3 +3,8 @@ from django.apps import AppConfig class MoviesConfig(AppConfig): name = 'movies' + + def ready(self): + from common.index import Indexer + from .models import Movie + Indexer.update_model_indexable(Movie) diff --git a/movies/forms.py b/movies/forms.py index aa320bd5..587d92e9 100644 --- a/movies/forms.py +++ b/movies/forms.py @@ -1,18 +1,13 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.utils.translation import gettext_lazy as _ -from .models import Movie, MovieMark, MovieReview, MovieGenreEnum +from .models import Movie, MovieMark, MovieReview, MovieGenreEnum, MovieMarkStatusTranslation from common.models import MarkStatusEnum from common.forms import * def MovieMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在看"), - MarkStatusEnum.WISH.value: _("想看"), - MarkStatusEnum.COLLECT.value: _("看过") - } - return trans_dict[status] + return MovieMarkStatusTranslation[status] class MovieForm(forms.ModelForm): @@ -119,11 +114,8 @@ class MovieMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'movie': forms.TextInput(attrs={"hidden": ""}), } @@ -138,14 +130,8 @@ class MovieReviewForm(ReviewForm): 'movie', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'book': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'movie': forms.TextInput(attrs={"hidden": ""}), } diff --git a/movies/management/commands/fix-movie-poster.py b/movies/management/commands/fix-movie-poster.py new file mode 100644 index 00000000..b8a35f85 --- /dev/null +++ b/movies/management/commands/fix-movie-poster.py @@ -0,0 +1,203 @@ +from django.core.management.base import BaseCommand +from django.core.files.uploadedfile import SimpleUploadedFile +from common.scraper import * +from django.conf import settings +from movies.models import Movie +from movies.forms import MovieForm +import requests +import re +import filetype +from lxml import html +from PIL import Image +from io import BytesIO + + +class DoubanPatcherMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + + def get(url, timeout): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=timeout) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content + content = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + if content.find('你的 IP 发出') == -1: + error = error + 'Content not authentic' # response is garbage + else: + error = error + 'IP banned' + content = None + elif re.search('不存在[^<]+', content, re.MULTILINE): + content = None + error = error + 'Not found or hidden by Douban' + else: + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url, 10) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url'], 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url, 10) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url, 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is None: + error = error + '\nDirect: ' + get(url, 60) + else: + error = error + '\nScraperAPI: ' + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}', 60) + check_content() + + # wayback_cdx() + # if content is None: + latest() + + if content is None: + logger.error(error) + content = '' + # with open('/tmp/temp.html', 'w', encoding='utf-8') as fp: + # fp.write(content) + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + if url is None: + return None, None + raw_img = None + ext = None + + dl_url = url + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + if raw_img is None and settings.SCRAPESTACK_KEY is not None: + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanMoviePatcher(DoubanPatcherMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'movie.douban.com' + data_class = Movie + form_class = MovieForm + + regex = re.compile(r"https://movie\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + img_url_elem = content.xpath("//img[@rel='v:image']/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + return raw_img, ext + + +class Command(BaseCommand): + help = 'fix cover image' + + def add_arguments(self, parser): + parser.add_argument('threadId', type=int, help='% 8') + + def handle(self, *args, **options): + t = int(options['threadId']) + for m in Movie.objects.filter(cover='movie/default.svg', source_site='douban'): + if m.id % 8 == t: + print(f'Re-fetching {m.source_url}') + try: + raw_img, img_ext = DoubanMoviePatcher.scrape(m.source_url) + if img_ext is not None: + m.cover = SimpleUploadedFile('temp.' + img_ext, raw_img) + m.save() + print(f'Saved {m.source_url}') + else: + print(f'Skipped {m.source_url}') + except Exception as e: + print(e) + # return diff --git a/movies/models.py b/movies/models.py index be6d4ba3..ae3a9990 100644 --- a/movies/models.py +++ b/movies/models.py @@ -1,17 +1,27 @@ import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, MarkStatusEnum from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath -from boofilsic.settings import MOVIE_MEDIA_PATH_ROOT, DEFAULT_MOVIE_IMAGE from django.utils import timezone +from django.conf import settings +from django.db.models import Q +import re +from simple_history.models import HistoricalRecords + + +MovieMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在看"), + MarkStatusEnum.WISH.value: _("想看"), + MarkStatusEnum.COLLECT.value: _("看过") +} def movie_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, MOVIE_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.MOVIE_MEDIA_PATH_ROOT) class MovieGenreEnum(models.TextChoices): @@ -47,6 +57,10 @@ class MovieGenreEnum(models.TextChoices): REALITY_TV = 'Reality-TV', _('真人秀') FAMILY = 'Family', _('家庭') TALK_SHOW = 'Talk-Show', _('脱口秀') + NEWS = 'News', _('新闻') + SOAP = 'Soap', _('肥皂剧') + TV_MOVIE = 'TV Movie', _('电视电影') + THEATRE = 'Theatre', _('舞台艺术') OTHER = 'Other', _('其他') @@ -58,13 +72,13 @@ class Movie(Entity): Can either be movie or series. ''' # widely recognized name, usually in Chinese - title = models.CharField(_("title"), max_length=200) + title = models.CharField(_("title"), max_length=500) # original name, for books in foreign language orig_title = models.CharField( - _("original title"), blank=True, default='', max_length=200) + _("original title"), blank=True, default='', max_length=500) other_title = postgres.ArrayField( models.CharField(_("other title"), blank=True, - default='', max_length=300), + default='', max_length=500), null=True, blank=True, default=list, @@ -73,21 +87,21 @@ class Movie(Entity): blank=True, max_length=10, null=False, db_index=True, default='') director = postgres.ArrayField( models.CharField(_("director"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, ) playwright = postgres.ArrayField( models.CharField(_("playwright"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, ) actor = postgres.ArrayField( models.CharField(_("actor"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, @@ -112,7 +126,7 @@ class Movie(Entity): default=list, ) site = models.URLField(_('site url'), blank=True, default='', max_length=200) - + # country or region area = postgres.ArrayField( models.CharField( @@ -140,7 +154,7 @@ class Movie(Entity): year = models.PositiveIntegerField(null=True, blank=True) duration = models.CharField(blank=True, default='', max_length=200) - cover = models.ImageField(_("poster"), upload_to=movie_cover_path, default=DEFAULT_MOVIE_IMAGE, blank=True) + cover = models.ImageField(_("poster"), upload_to=movie_cover_path, default=settings.DEFAULT_MOVIE_IMAGE, blank=True) ############################################ # exclusive fields to series @@ -157,27 +171,67 @@ class Movie(Entity): ############################################ is_series = models.BooleanField(default=False) + history = HistoricalRecords() def __str__(self): if self.year: - return self.title + f"({self.year})" + return self.title + f"({self.year})" else: return self.title + def get_json(self): + r = { + 'other_title': self.other_title, + 'original_title': self.orig_title, + 'director': self.director, + 'playwright': self.playwright, + 'actor': self.actor, + 'release_year': self.year, + 'genre': self.genre, + 'language': self.language, + 'season': self.season, + 'duration': self.duration, + 'imdb_code': self.imdb_code, + } + r.update(super().get_json()) + return r def get_absolute_url(self): return reverse("movies:retrieve", args=[self.id]) + @property + def wish_url(self): + return reverse("movies:wish", args=[self.id]) + def get_tags_manager(self): return self.movie_tags - def get_genre_display(self): translated_genre = [] for g in self.genre: translated_genre.append(MovieGenreTranslator[g]) return translated_genre + def get_related_movies(self): + imdb = 'no match' if self.imdb_code is None or self.imdb_code == '' else self.imdb_code + qs = Q(imdb_code=imdb) + if self.is_series: + prefix = re.sub(r'\d+', '', re.sub(r'\s+第.+季', '', self.title)) + if not prefix: + prefix = self.title + qs = qs | Q(title__startswith=prefix) + qs = qs & ~Q(id=self.id) + return Movie.objects.filter(qs).order_by('season') + + def get_identicals(self): + qs = Q(orig_title=self.title) + if self.imdb_code: + qs = Q(imdb_code=self.imdb_code) + # qs = qs & ~Q(id=self.id) + return Movie.objects.filter(qs) + else: + return [self] # Book.objects.filter(id=self.id) + @property def verbose_category_name(self): if self.is_series: @@ -185,23 +239,45 @@ class Movie(Entity): else: return _("电影") + @property + def mark_class(self): + return MovieMark + + @property + def tag_class(self): + return MovieTag + class MovieMark(Mark): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_marks', null=True) + class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'movie'], name='unique_movie_mark') ] + @property + def translated_status(self): + return MovieMarkStatusTranslation[self.status] + class MovieReview(Review): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_reviews', null=True) + class Meta: constraints = [ models.UniqueConstraint( fields=['owner', 'movie'], name='unique_movie_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("movies:retrieve_review", args=[self.id]) + + @property + def item(self): + return self.movie + class MovieTag(Tag): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_tags', null=True) @@ -211,3 +287,7 @@ class MovieTag(Tag): models.UniqueConstraint( fields=['content', 'mark'], name="unique_moviemark_tag") ] + + @property + def item(self): + return self.movie diff --git a/movies/templates/movies/create_update.html b/movies/templates/movies/create_update.html index f55c026b..497c9bce 100644 --- a/movies/templates/movies/create_update.html +++ b/movies/templates/movies/create_update.html @@ -10,8 +10,8 @@ - {% trans 'NiceDB - ' %}{{ title }} - + {{ site_name }} - {{ title }} + @@ -21,10 +21,25 @@ {% include "partial/_navbar.html" %}
    -
    +
    + {% if is_update and form.source_site.value != 'in-site' %} +
    +
    +
    +
    {% trans '源网站' %}: {{ form.source_site.value }}
    +
    + + {% csrf_token %} + + +
    +
    +
    +
    + {% endif %} +
    - {% trans '>>> 试试一键剽取~ <<<' %} + {% comment %} {% trans '>>> 试试一键剽取~ <<<' %} {% endcomment %}
    {% csrf_token %} {{ form.media }} @@ -53,12 +68,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {{ title }} + @@ -61,7 +61,7 @@ {% if movie.director|length > 5 %} {% trans '更多' %} + {{ site_name }} - {% trans '删除电影/剧集' %} + @@ -62,7 +62,7 @@ {% if movie.last_editor %}
    {% trans '最近编辑者:' %} - + {{ movie.last_editor | default:"" }}
    @@ -96,12 +96,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除评论' %} + @@ -35,7 +35,7 @@
    {{ review.title }}
    - {% if review.is_private %} + {% if review.visibility > 0 %}
    - {{ review.owner.username }} {% if mark %} @@ -89,12 +89,6 @@
    - {% comment %} - - - - - {% endcomment %} + {% include "partial/_common_libs.html" with jquery=1 %} + - - - @@ -78,11 +76,12 @@
    - {% if movie.rating %} + {% if movie.rating and movie.rating_number >= 5 %} {{ movie.rating }} + ({{ movie.rating_number }}人评分) {% else %} - {% trans '评分:暂无评分' %} + {% trans '评分:评分人数不足' %} {% endif %}
    {% if movie.imdb_code %} @@ -99,7 +98,7 @@ {% if movie.director|length > 5 %} {% trans '更多' %} + {{ site_name }} - {{ movie.title }}{% trans '的标记' %} + @@ -35,37 +35,7 @@
    {{ movie.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 %} - -
    + {% include "partial/mark_list.html" with mark_list=marks current_item=movie %}
    @@ -108,7 +110,7 @@ {% if movie.director|length > 5 %} {% trans '更多' %} diff --git a/movies/templates/movies/review_list.html b/movies/templates/movies/review_list.html index ed32cfe0..daedf296 100644 --- a/movies/templates/movies/review_list.html +++ b/movies/templates/movies/review_list.html @@ -14,8 +14,8 @@ - {% trans 'NiceDB - ' %}{{ movie.title }}{% trans '的评论' %} - + {{ site_name }} - {{ movie.title }}{% trans '的评论' %} + @@ -41,11 +41,14 @@
  • - {{ review.owner.username }} - {% if review.is_private %} + {{ review.owner.username }} + {% if review.visibility > 0 %} {% endif %} {{ review.edited_time }} + {% if review.movie != movie %} + {{ review.movie.get_source_site_display }} + {% endif %} {{ review.title }} @@ -116,7 +119,7 @@ {% if movie.director|length > 5 %} {% trans '更多' %} + {{ site_name }} - {% trans '从豆瓣获取数据' %} + diff --git a/movies/urls.py b/movies/urls.py index 9f359e9e..0495a9f0 100644 --- a/movies/urls.py +++ b/movies/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from .views import * @@ -8,8 +8,10 @@ urlpatterns = [ path('/', retrieve, name='retrieve'), path('update//', update, name='update'), path('delete//', delete, name='delete'), + path('rescrape//', rescrape, name='rescrape'), path('mark/', create_update_mark, name='create_update_mark'), - path('/mark/list/', retrieve_mark_list, name='retrieve_mark_list'), + path('wish//', wish, name='wish'), + re_path('(?P[0-9]+)/mark/list/(?:(?P\\d+))?', 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'), diff --git a/movies/views.py b/movies/views.py index f63cdeef..d4fc21f5 100644 --- a/movies/views.py +++ b/movies/views.py @@ -2,21 +2,23 @@ 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.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse 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.api import check_visibility, post_toot, TootVisibilityEnum -from mastodon.utils import rating_to_emoji +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review from common.utils import PageLinksGenerator -from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin from common.models import SourceSiteEnum from .models import * from .forms import * -from boofilsic.settings import MASTODON_TAGS +from django.conf import settings +from collection.models import CollectionItem +from common.scraper import get_scraper_by_url, get_normalized_url logger = logging.getLogger(__name__) @@ -87,6 +89,18 @@ def create(request): return HttpResponseBadRequest() +@login_required +def rescrape(request, id): + if request.method != 'POST': + return HttpResponseBadRequest() + item = get_object_or_404(Movie, pk=id) + url = get_normalized_url(item.source_url) + scraper = get_scraper_by_url(url) + scraper.scrape(url) + form = scraper.save(request_user=request.user, instance=item) + return redirect(reverse("movies:retrieve", args=[form.instance.id])) + + @login_required def update(request, id): if request.method == 'GET': @@ -98,6 +112,7 @@ def update(request, id): 'movies/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("movies:update", args=[movie.id]), # provided for frontend js @@ -127,6 +142,7 @@ def update(request, id): 'movies/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("movies:update", args=[movie.id]), # provided for frontend js @@ -167,6 +183,7 @@ def retrieve(request, id): else: mark_form = MovieMarkForm(initial={ 'movie': movie, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -185,10 +202,8 @@ def retrieve(request, id): mark_list_more = None review_list_more = None else: - mark_list = MovieMark.get_available( - movie, request.user, request.session['oauth_token']) - review_list = MovieReview.get_available( - movie, request.user, request.session['oauth_token']) + mark_list = MovieMark.get_available_for_identicals(movie, request.user) + review_list = MovieReview.get_available_for_identicals(movie, 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: @@ -196,6 +211,7 @@ def retrieve(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(movie=movie))) # def strip_html_tags(text): # import re @@ -220,6 +236,7 @@ def retrieve(request, id): 'review_list_more': review_list_more, 'movie_tag_list': movie_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -264,12 +281,19 @@ def create_update_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + movie_id = request.POST.get('movie') + mark = MovieMark.objects.filter(movie_id=movie_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(MovieMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.moviemark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = MovieMarkForm(request.POST, instance=mark) else: @@ -277,7 +301,7 @@ def create_update_mark(request): form = MovieMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user @@ -305,28 +329,10 @@ def create_update_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("movies:retrieve", - args=[movie.id]) - words = MovieMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{movie.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("movies:retrieve", args=[form.instance.movie.id])) else: @@ -335,11 +341,30 @@ def create_update_mark(request): @mastodon_request_included @login_required -def retrieve_mark_list(request, movie_id): +def wish(request, id): + if request.method == 'POST': + movie = get_object_or_404(Movie, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'movie': movie, + } + try: + MovieMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_mark_list(request, movie_id, following_only=False): if request.method == 'GET': movie = get_object_or_404(Movie, pk=movie_id) - queryset = MovieMark.get_available( - movie, request.user, request.session['oauth_token']) + queryset = MovieMark.get_available_for_identicals(movie, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -400,23 +425,8 @@ def create_review(request, movie_id): 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.UNLISTED - url = "https://" + request.get_host() + reverse("movies:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.movie.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("movies:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -452,23 +462,8 @@ def update_review(request, id): 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.UNLISTED - url = "https://" + request.get_host() + reverse("movies:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.movie.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("movies:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -503,11 +498,10 @@ def delete_review(request, id): @mastodon_request_included -@login_required def retrieve_review(request, id): if request.method == 'GET': review = get_object_or_404(MovieReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -542,8 +536,7 @@ def retrieve_review(request, id): def retrieve_review_list(request, movie_id): if request.method == 'GET': movie = get_object_or_404(Movie, pk=movie_id) - queryset = MovieReview.get_available( - movie, request.user, request.session['oauth_token']) + queryset = MovieReview.get_available_for_identicals(movie, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) diff --git a/music/admin.py b/music/admin.py index eb770458..33eb056d 100644 --- a/music/admin.py +++ b/music/admin.py @@ -1,11 +1,12 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Song) +admin.site.register(Song, SimpleHistoryAdmin) admin.site.register(SongMark) admin.site.register(SongReview) admin.site.register(SongTag) -admin.site.register(Album) +admin.site.register(Album, SimpleHistoryAdmin) admin.site.register(AlbumMark) admin.site.register(AlbumReview) admin.site.register(AlbumTag) diff --git a/music/apps.py b/music/apps.py index d909c7fb..6fb97b37 100644 --- a/music/apps.py +++ b/music/apps.py @@ -3,3 +3,9 @@ from django.apps import AppConfig class MusicConfig(AppConfig): name = 'music' + + def ready(self): + from common.index import Indexer + from .models import Album, Song + Indexer.update_model_indexable(Album) + Indexer.update_model_indexable(Song) diff --git a/music/forms.py b/music/forms.py index 3eec5646..9e487592 100644 --- a/music/forms.py +++ b/music/forms.py @@ -7,12 +7,7 @@ from common.forms import * def MusicMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在听"), - MarkStatusEnum.WISH.value: _("想听"), - MarkStatusEnum.COLLECT.value: _("听过") - } - return trans_dict[status] + return MusicMarkStatusTranslation[status] class SongForm(forms.ModelForm): @@ -65,11 +60,8 @@ class SongMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'song': forms.TextInput(attrs={"hidden": ""}), } @@ -84,14 +76,8 @@ class SongReviewForm(ReviewForm): 'song', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'song': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'song': forms.TextInput(attrs={"hidden": ""}), } @@ -148,11 +134,8 @@ class AlbumMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'album': forms.TextInput(attrs={"hidden": ""}), } @@ -167,14 +150,8 @@ class AlbumReviewForm(ReviewForm): 'album', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'album': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'album': forms.TextInput(attrs={"hidden": ""}), } diff --git a/music/management/commands/fix-album-cover.py b/music/management/commands/fix-album-cover.py new file mode 100644 index 00000000..9b9f4d3e --- /dev/null +++ b/music/management/commands/fix-album-cover.py @@ -0,0 +1,199 @@ +from django.core.management.base import BaseCommand +from django.core.files.uploadedfile import SimpleUploadedFile +from common.scraper import * +from django.conf import settings +from music.models import Album +from music.forms import AlbumForm +import requests +import re +import filetype +from lxml import html +from PIL import Image +from io import BytesIO + + +class DoubanPatcherMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + + def get(url, timeout): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=timeout) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content + content = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + content = None + error = error + 'Content not authentic' # response is garbage + elif re.search('不存在[^<]+', content, re.MULTILINE): + content = None + error = error + 'Not found or hidden by Douban' + else: + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url, 10) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url'], 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url, 10) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url, 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is None: + error = error + '\nDirect: ' + get(url, 60) + else: + error = error + '\nScraperAPI: ' + # get(f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}', 60) + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}', 60) + check_content() + + wayback_cdx() + if content is None: + latest() + + if content is None: + logger.error(error) + content = '' + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + if url is None: + return None, None + raw_img = None + ext = None + + dl_url = url + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + # f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}' + + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + if raw_img is None and settings.SCRAPESTACK_KEY is not None: + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanAlbumPatcher(DoubanPatcherMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'music.douban.com' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"https://music\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + img_url_elem = content.xpath("//div[@id='mainpic']//img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + return raw_img, ext + + +class Command(BaseCommand): + help = 'fix cover image' + + def add_arguments(self, parser): + parser.add_argument('threadId', type=int, help='% 8') + + def handle(self, *args, **options): + t = int(options['threadId']) + for m in Album.objects.filter(cover='album/default.svg', source_site='douban'): + if m.id % 8 == t: + self.stdout.write(f'Re-fetching {m.source_url}') + try: + raw_img, img_ext = DoubanAlbumPatcher.scrape(m.source_url) + if img_ext is not None: + m.cover = SimpleUploadedFile('temp.' + img_ext, raw_img) + m.save() + self.stdout.write(self.style.SUCCESS(f'Saved {m.source_url}')) + else: + self.stdout.write(self.style.ERROR(f'Skipped {m.source_url}')) + except Exception as e: + print(e) diff --git a/music/models.py b/music/models.py index c738db5b..131f9a14 100644 --- a/music/models.py +++ b/music/models.py @@ -1,21 +1,29 @@ import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, SourceSiteEnum, MarkStatusEnum from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath -from boofilsic.settings import SONG_MEDIA_PATH_ROOT, DEFAULT_SONG_IMAGE, ALBUM_MEDIA_PATH_ROOT, DEFAULT_ALBUM_IMAGE from django.utils import timezone +from django.conf import settings +from simple_history.models import HistoricalRecords + + +MusicMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在听"), + MarkStatusEnum.WISH.value: _("想听"), + MarkStatusEnum.COLLECT.value: _("听过") +} def song_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, SONG_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.SONG_MEDIA_PATH_ROOT) def album_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, ALBUM_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.ALBUM_MEDIA_PATH_ROOT) class Album(Entity): @@ -23,11 +31,11 @@ class Album(Entity): release_date = models.DateField( _('发行日期'), auto_now=False, auto_now_add=False, null=True, blank=True) cover = models.ImageField( - _("封面"), upload_to=album_cover_path, default=DEFAULT_ALBUM_IMAGE, blank=True) + _("封面"), upload_to=album_cover_path, default=settings.DEFAULT_ALBUM_IMAGE, blank=True) duration = models.PositiveIntegerField(_("时长"), null=True, blank=True) artist = postgres.ArrayField( models.CharField(_("artist"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, @@ -45,12 +53,36 @@ class Album(Entity): ) track_list = models.TextField(_("曲目"), blank=True, default="") + history = HistoricalRecords() + def __str__(self): return self.title + def get_json(self): + r = { + 'artist': self.artist, + 'release_date': self.release_date, + 'genre': self.genre, + 'publisher': self.company, + } + r.update(super().get_json()) + return r + + def get_embed_link(self): + if self.source_site == SourceSiteEnum.SPOTIFY.value: + return self.source_url.replace("open.spotify.com/", "open.spotify.com/embed/") + elif self.source_site == SourceSiteEnum.BANDCAMP.value and self.other_info and 'bandcamp_album_id' in self.other_info: + return f"https://bandcamp.com/EmbeddedPlayer/album={self.other_info['bandcamp_album_id']}/size=large/bgcol=ffffff/linkcol=19A2CA/artwork=small/transparent=true/" + else: + return None + def get_absolute_url(self): return reverse("music:retrieve_album", args=[self.id]) + @property + def wish_url(self): + return reverse("music:wish_album", args=[self.id]) + def get_tags_manager(self): return self.album_tags @@ -58,6 +90,14 @@ class Album(Entity): def verbose_category_name(self): return _("专辑") + @property + def mark_class(self): + return AlbumMark + + @property + def tag_class(self): + return AlbumTag + class Song(Entity): ''' @@ -70,7 +110,7 @@ class Song(Entity): # duration in ms duration = models.PositiveIntegerField(_("时长"), null=True, blank=True) cover = models.ImageField( - _("封面"), upload_to=song_cover_path, default=DEFAULT_SONG_IMAGE, blank=True) + _("封面"), upload_to=song_cover_path, default=settings.DEFAULT_SONG_IMAGE, blank=True) artist = postgres.ArrayField( models.CharField(blank=True, default='', max_length=100), @@ -84,19 +124,46 @@ class Song(Entity): album = models.ForeignKey( Album, models.SET_NULL, "album_songs", null=True, blank=True, verbose_name=_("所属专辑")) + history = HistoricalRecords() + def __str__(self): return self.title + def get_json(self): + r = { + 'artist': self.artist, + 'release_date': self.release_date, + 'genre': self.genre, + } + r.update(super().get_json()) + return r + + def get_embed_link(self): + return self.source_url.replace("open.spotify.com/", "open.spotify.com/embed/") if self.source_site == SourceSiteEnum.SPOTIFY.value else None + def get_absolute_url(self): return reverse("music:retrieve_song", args=[self.id]) + @property + def wish_url(self): + return reverse("music:wish_song", args=[self.id]) + def get_tags_manager(self): return self.song_tags - + @property def verbose_category_name(self): return _("单曲") + @property + def mark_class(self): + return SongMark + + @property + def tag_class(self): + return SongTag + + class SongMark(Mark): song = models.ForeignKey( Song, on_delete=models.CASCADE, related_name='song_marks', null=True) @@ -107,6 +174,10 @@ class SongMark(Mark): fields=['owner', 'song'], name='unique_song_mark') ] + @property + def translated_status(self): + return MusicMarkStatusTranslation[self.status] + class SongReview(Review): song = models.ForeignKey( @@ -118,6 +189,14 @@ class SongReview(Review): fields=['owner', 'song'], name='unique_song_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("music:retrieve_song_review", args=[self.id]) + + @property + def item(self): + return self.song + class SongTag(Tag): song = models.ForeignKey( @@ -131,6 +210,10 @@ class SongTag(Tag): fields=['content', 'mark'], name="unique_songmark_tag") ] + @property + def item(self): + return self.song + class AlbumMark(Mark): album = models.ForeignKey( @@ -142,6 +225,10 @@ class AlbumMark(Mark): fields=['owner', 'album'], name='unique_album_mark') ] + @property + def translated_status(self): + return MusicMarkStatusTranslation[self.status] + class AlbumReview(Review): album = models.ForeignKey( @@ -153,6 +240,14 @@ class AlbumReview(Review): fields=['owner', 'album'], name='unique_album_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("music:retrieve_album_review", args=[self.id]) + + @property + def item(self): + return self.album + class AlbumTag(Tag): album = models.ForeignKey( @@ -165,3 +260,7 @@ class AlbumTag(Tag): models.UniqueConstraint( fields=['content', 'mark'], name="unique_albummark_tag") ] + + @property + def item(self): + return self.album diff --git a/music/templates/music/album_detail.html b/music/templates/music/album_detail.html index 68fe1143..7de3d0ae 100644 --- a/music/templates/music/album_detail.html +++ b/music/templates/music/album_detail.html @@ -6,6 +6,7 @@ {% load mastodon %} {% load oauth_token %} {% load truncate %} +{% load strip_scheme %} {% load thumb %} @@ -13,21 +14,19 @@ - + - - + + - {% trans 'NiceDB - 音乐详情' %} | {{ album.title }} + {{ site_name }} - {% trans '音乐详情' %} | {{ album.title }} - + {% include "partial/_common_libs.html" with jquery=1 %} + - - - @@ -55,11 +54,12 @@
    - {% if album.rating %} + {% if album.rating and album.rating_number >= 5 %} {{ album.rating }} + ({{ album.rating_number }}人评分) {% else %} - {% trans '评分:暂无评分' %} + {% trans '评分:评分人数不足' %} {% endif %}
    {% if album.artist %}{% trans '艺术家:' %} @@ -72,7 +72,7 @@ {% if album.artist|length > 5 %} {% trans '更多' %} + {{ site_name }} - {{ album.title }}{% trans '的标记' %} + @@ -35,38 +35,7 @@
    {{ album.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 %} - -
    + {% include "partial/mark_list.html" with mark_list=marks current_item=album %}
    @@ -109,7 +111,7 @@ {% if album.artist|length > 5 %} {% trans '更多' %} diff --git a/music/templates/music/album_review_list.html b/music/templates/music/album_review_list.html index a4e4f04a..40ce5b6d 100644 --- a/music/templates/music/album_review_list.html +++ b/music/templates/music/album_review_list.html @@ -13,8 +13,8 @@ - {% trans 'NiceDB - ' %}{{ album.title }}{% trans '的评论' %} - + {{ site_name }} - {{ album.title }}{% trans '的评论' %} + @@ -40,9 +40,9 @@
  • - {{ review.owner.username }} - {% if review.is_private %} + {% if review.visibility > 0 %} 5 %} {% trans '更多' %}
  • - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {{ title }} + @@ -22,9 +22,24 @@
    + {% if is_update and form.source_site.value != 'in-site' %} +
    +
    +
    +
    {% trans '源网站' %}: {{ form.source_site.value }}
    +
    + + {% csrf_token %} + + +
    +
    +
    +
    + {% endif %} +
    - {% trans '>>> 试试一键剽取~ <<<' %} + {% comment %} {% trans '>>> 试试一键剽取~ <<<' %} {% endcomment %}
    {% csrf_token %} {{ form.media }} @@ -54,12 +69,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {{ title }} + @@ -52,7 +52,7 @@ {% if album.artist|length > 5 %} {% trans '更多' %} + {{ site_name }} - {{ title }} + @@ -57,12 +57,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {{ title }} + @@ -52,7 +52,7 @@ {% if song.artist|length > 5 %} {% trans '更多' %} + {{ site_name }} - {% trans '删除音乐' %} + @@ -55,7 +55,7 @@ {% if album.last_editor %}
    {% trans '最近编辑者:' %} - + {{ album.last_editor | default:"" }}
    @@ -89,12 +89,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除评论' %} + @@ -35,7 +35,7 @@
    {{ review.title }}
    - {% if review.is_private %} + {% if review.visibility > 0 %}
    - {{ review.owner.username }} {% if mark %} @@ -89,12 +89,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除音乐' %} + @@ -55,7 +55,7 @@ {% if song.last_editor %}
    {% trans '最近编辑者:' %} - + {{ song.last_editor | default:"" }}
    @@ -89,12 +89,6 @@ - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '删除评论' %} + @@ -35,7 +35,7 @@
    {{ review.title }}
    - {% if review.is_private %} + {% if review.visibility > 0 %}
    - {{ review.owner.username }} {% if mark %} @@ -89,12 +89,6 @@
    - {% comment %} - - - - - {% endcomment %} + {{ site_name }} - {% trans '从豆瓣获取数据' %} + diff --git a/music/templates/music/scrape_song.html b/music/templates/music/scrape_song.html index e2cd4730..2ffa1b3b 100644 --- a/music/templates/music/scrape_song.html +++ b/music/templates/music/scrape_song.html @@ -10,8 +10,8 @@ - {% trans 'NiceDB - 从豆瓣获取数据' %} - + {{ site_name }} - {% trans '从豆瓣获取数据' %} + diff --git a/music/templates/music/song_detail.html b/music/templates/music/song_detail.html index 6aa9b87b..48a8c7be 100644 --- a/music/templates/music/song_detail.html +++ b/music/templates/music/song_detail.html @@ -6,6 +6,7 @@ {% load mastodon %} {% load oauth_token %} {% load truncate %} +{% load strip_scheme %} {% load thumb %} @@ -13,21 +14,19 @@ - + - - + + - {% trans 'NiceDB - 音乐详情' %} | {{ song.title }} + {{ site_name }} - {% trans '音乐详情' %} | {{ song.title }} - + {% include "partial/_common_libs.html" with jquery=1 %} + - - - @@ -54,11 +53,12 @@
    - {% if song.rating %} + {% if song.rating and song.rating_number >= 5 %} {{ song.rating }} + ({{ song.rating_number }}人评分) {% else %} - {% trans '评分:暂无评分' %} + {% trans '评分:评分人数不足' %} {% endif %}
    {% if song.artist %}{% trans '艺术家:' %} @@ -71,7 +71,7 @@ {% if song.artist|length > 5 %} {% trans '更多' %} + {{ site_name }} - {{ song.title }}{% trans '的标记' %} + @@ -32,41 +33,9 @@
    - {{ song.title }}{% trans ' - 的标记' %} + {{ song.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 %} - -
    + {% include "partial/mark_list.html" with mark_list=marks current_item=song %}
    + + {% if user == request.user %} + +
    +
    + + {% trans '编辑布局' %} + + + + + + + + +
    -
    -
    - -
    -
    - {% trans '关注的人' %} -
    - {% trans '更多' %} - -
    - -
    -
    - {% trans '被他们关注' %} -
    - {% trans '更多' %} - -
    - -
    - - - {% if user == request.user %} -
    -
    -
    {% trans '导入豆瓣标记数据' %}
    - ? -
    -
    - - {% csrf_token %} - - {% trans '导入:' %} -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - {% trans '覆盖:' %} -
    - - -
    - ? -
    - {% trans '可见性:' %} -
    - - -
    - ? -
    -
    - -
    - -
    - - -
    -
    -
    - {% endif %} - -
    - {% if request.user.is_staff and request.user == user%} -
    -
    {% trans '举报信息' %}
    - 全部举报 -
    - -
    -
    - {% endif %} -
    +
    + +
    + {% csrf_token %} + +
    + + + {% endif %} +
    + + {% include "partial/_sidebar.html" %}
    - - {% include "partial/_footer.html" %} - - -
    -
    - - - {% if user == request.user %} - - {% else %} - - {% endif %} - - - - - {% if unread_announcements %} -
    - - -
    -
    + {% include "partial/_announcement.html" %} {% endif %} - - - - - - - - \ No newline at end of file diff --git a/users/templates/users/home_anonymous.html b/users/templates/users/home_anonymous.html new file mode 100644 index 00000000..946ac96e --- /dev/null +++ b/users/templates/users/home_anonymous.html @@ -0,0 +1,17 @@ +{% load static %} +{% load i18n %} + + + + + + {{ site_name }} - {{ username }}@{{ site }} + + + + + + + Mastodon homepage + + \ No newline at end of file diff --git a/users/templates/users/item_list.html b/users/templates/users/item_list.html new file mode 100644 index 00000000..321ad0fe --- /dev/null +++ b/users/templates/users/item_list.html @@ -0,0 +1,94 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} + + + + + + + {{ site_name }} - {{ user.mastodon_username }} {{ list_title }} + + + + + + + + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    + +
    +
    + {{ user.mastodon_username }} {{ list_title }} +
    +
    +
      + {% for mark in marks %} + {% include "partial/list_item.html" with item=mark.item hide_category=True %} + {% empty %} +
      {% trans '无结果' %}
      + {% endfor %} +
    +
    + +
    +
    + + {% include "partial/_sidebar.html" %} +
    +
    +
    + {% include "partial/_footer.html" %} +
    + + + + + + diff --git a/users/templates/users/login.html b/users/templates/users/login.html index 27a0a0fa..d843543c 100644 --- a/users/templates/users/login.html +++ b/users/templates/users/login.html @@ -1,4 +1,3 @@ - {% load i18n %} {% load static %} @@ -6,74 +5,81 @@ - + + - - + {{ site_name }} - {% trans '登录' %} + {% include "partial/_common_libs.html" %} + - - - {% trans 'NiceDB - 登录' %} - - - + +
    - - - -
    - - + +
    {% if user.is_authenticated %} - {% trans '前往我的主页' %} + {% trans '前往首页' %} + {% else %} +
    + {% if allow_any_site %} + + +
    {% trans '了解更多' %} + {% else %} - {% for site in sites %} {% endfor %} - + {% endif %} - +
    + {% endif %} +
    网页加载超时,请检查网络(翻墙)设置。
    - -
    - {% if not user.is_authenticated %} - - - - {% endif %} - \ No newline at end of file + diff --git a/users/templates/users/manage_report.html b/users/templates/users/manage_report.html index 35d3604b..f077a434 100644 --- a/users/templates/users/manage_report.html +++ b/users/templates/users/manage_report.html @@ -10,8 +10,8 @@ - {% trans 'NiceDB - 管理举报' %} - + {{ site_name }} - {% trans '管理举报' %} + @@ -27,9 +27,9 @@ {% for report in reports %}
    - {{ report.submit_user.username }} + {{ report.submit_user.username }} {% trans '举报了' %} - {{ report.reported_user.username }} + {{ report.reported_user.username }} @{{ report.submitted_time }} {% if report.image %} @@ -49,12 +49,6 @@
    - {% comment %} - - - - - {% endcomment %} - - - - - - - - - -
    -
    - {% include "partial/_navbar.html" %} - -
    -
    -
    -
    -
    - -
    -
    - {{ user.username }}{{ list_title }} -
    -
    - -
    - -
    -
    - -
    -
    - -
    - -
    -
    - - - - - -
    -
    -
    - -
    -
    - {% trans '关注的人' %} -
    - {% trans '更多' %} - -
    - -
    -
    - {% trans '被他们关注' %} -
    - {% trans '更多' %} - -
    - -
    -
    -
    - -
    -
    -
    -
    - {% include "partial/_footer.html" %} -
    - - - - - - {% if user == request.user %} - - {% else %} - - {% endif %} - - - - - - - diff --git a/users/templates/users/music_list.html b/users/templates/users/music_list.html deleted file mode 100644 index 18c0fe74..00000000 --- a/users/templates/users/music_list.html +++ /dev/null @@ -1,290 +0,0 @@ -{% load static %} -{% load i18n %} -{% load l10n %} -{% load humanize %} -{% load admin_url %} -{% load mastodon %} -{% load oauth_token %} -{% load truncate %} -{% load thumb %} - - - - - - - - {% trans 'NiceDB - ' %}{{ user.username }}{{ list_title }} - - - - - - - - - - -
    -
    - {% include "partial/_navbar.html" %} - -
    -
    -
    -
    -
    - -
    -
    - {{ user.username }}{{ list_title }} -
    -
    -
      - - {% for mark in marks %} - - {% with mark.music as music %} - -
    • -
      - {% if music.category_name|lower == 'album' %} - - - - {% elif music.category_name|lower == 'song' %} - - - - {% endif %} -
      -
      -
      - {% if music.category_name|lower == 'album' %} - - {{ music.title }} - - {% elif music.category_name|lower == 'song' %} - - {{ music.title }} - - {% endif %} - - {{ music.get_source_site_display }} - -
      - - {% if music.artist %}{% trans '艺术家' %} - {% for artist in music.artist %} - {{ artist }} - {% if not forloop.last %} {% endif %} - {% endfor %} - {% endif %} - - {% if music.genre %}/ {% trans '流派' %} - {{ music.genre }} - {% endif %} - - {% if music.release_date %}/ {% trans '发行日期' %} - {{ music.release_date }} - {% endif %} - - {% if music.brief %} -

      - {{ music.brief }} -

      - {% elif music.category_name|lower == 'album' %} -

      - {% trans '曲目:' %}{{ music.track_list }} -

      - {% else %} - -

      - {% trans '所属专辑:' %}{{ music.album }} -

      - {% endif %} -
      - {% for tag_dict in music.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - - {{ v }} - - {% endif %} - {% endfor %} - {% endfor %} -
      -
      -
      -
      -
        -
      • - - {% if mark.rating %} - - {% endif %} - {% if mark.is_private %} - - - - {% endif %} - {% trans '于' %} {{ mark.edited_time }} {% trans '标记' %} - {% if mark.text %} -

        {{ mark.text }}

        - {% endif %} -
      • -
      -
      -
      - -
    • - - {% endwith %} - - {% empty %} -
      {% trans '无结果' %}
      - {% endfor %} - - - -
    -
    - -
    -
    - -
    -
    - -
    - -
    -
    - - - - - -
    -
    -
    - -
    -
    - {% trans '关注的人' %} -
    - {% trans '更多' %} - -
    - -
    -
    - {% trans '被他们关注' %} -
    - {% trans '更多' %} - -
    - -
    -
    -
    - -
    -
    -
    -
    - {% include "partial/_footer.html" %} -
    - - - - - - {% if user == request.user %} - - {% else %} - - {% endif %} - - - - - - - diff --git a/users/templates/users/preferences.html b/users/templates/users/preferences.html new file mode 100644 index 00000000..cca66196 --- /dev/null +++ b/users/templates/users/preferences.html @@ -0,0 +1,93 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} + + + + + + + {{ site_name }} - 设置 + {% include "partial/_common_libs.html" %} + + + + + +
    +
    + {% include "partial/_navbar.html" %} + +
    +
    +
    +
    +
    +
    +
    +
    {% trans '使用偏好设置' %}
    +
    + {% csrf_token %} + {% trans '新标记默认可见性:' %} +
    + + + +
    +
    + {% trans '登录后显示个人主页:' %} +
    + + +
    +
    +
    +
    + +
    +
    +
    {% trans '社交网络分享相关设置' %}
    +
    + {% csrf_token %} + {% trans '在联邦网络上以公开方式分享的帖文是否发布到公共时间轴上:' %} +
    + + +
    +

    + {% trans '在联邦网络上分享帖文时附加标签:' %} +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    +
    + + {% include "partial/_sidebar.html" %} +
    +
    + +
    + + {% include "partial/_footer.html" %} +
    + + + + + \ No newline at end of file diff --git a/users/templates/users/register.html b/users/templates/users/register.html index 9b750a66..cb63b5c6 100644 --- a/users/templates/users/register.html +++ b/users/templates/users/register.html @@ -6,11 +6,10 @@ - - + - {% trans 'NiceDB - 注册' %} + {{ site_name }} - {% trans '注册' %} @@ -19,18 +18,18 @@
    -

    欢迎来到NiceDB书影音!

    +

    欢迎来到{{ site_name }}!

    - NiceDB书影音继承了长毛象的用户关系,比如您在里瓣屏蔽了某人,那您将不会在书影音的公共区域看到TA的痕迹。 - 这里仍是一片处女地,丰富的内容需要大家共同创造。 - 请注意虽然您可以随意发表任何言论,但试图添加垃圾数据到公共数据领域(如添加不存在的乱码的书籍)将会受到制裁! - BTW欧盟惯例本站使用了Cookie,请理解! + {{ site_name }}还在不断完善中,丰富的内容需要大家共同创造。 + 试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 + {{ site_name }}继承了联邦宇宙的用户关系,比如您在联邦宇宙屏蔽了某人,那您将不会在书影音的公共区域看到TA的痕迹。 + 本站为非盈利站点,cookie和其他数据保管使用原则请参阅站内公告

    - 此外NiceDB书影音现处于“公开阿尔法测试”阶段,您的数据存在丢失的可能。使用过程中遇到的问题或者Bug欢迎向作者提出。 + 此外,{{ site_name }}现处于测试阶段,疏漏在所难免,请妥善备份您的数据。 + 使用过程中遇到的问题或者错误欢迎向维护者提出。感谢理解和支持!

    -
    - {% csrf_token %} +
    diff --git a/users/templates/users/relation_list.html b/users/templates/users/relation_list.html index 6984bbd3..ae14e2ea 100644 --- a/users/templates/users/relation_list.html +++ b/users/templates/users/relation_list.html @@ -11,11 +11,13 @@ {% if is_followers_page %} - {% trans 'NiceDB - 被他们关注' %} + {{ site_name }} - {% trans '被他们关注' %} {% else %} - {% trans 'NiceDB - 关注的人' %} + {{ site_name }} - {% trans '关注的人' %} {% endif %} - + + {% include "partial/_common_libs.html" with jquery=1 %} + {% if is_followers_page %} @@ -23,8 +25,6 @@ {% else %} {% endif %} - - @@ -64,7 +64,7 @@ @@ -94,7 +94,7 @@
    {% trans '关注的人' %}
    - {% trans '更多' %}