lib.itmens/games/views.py
Henri Dickson 14b003a44a add all NeoDB features to NiceDB (#115)
* fix scraping failure with wepb image (merge upstream/fix-webp-scrape)

* add filetype to requirements

* add proxycrawl.com as fallback for douban scraper

* load 3p js/css from cdn

* add fix-cover task

* fix book/album cover tasks

* scrapestack

* bandcamp scrape and preview ;
manage.py scrape <url> ;
make ^C work when DEBUG

* use scrapestack when fix cover

* add user agent to improve compatibility

* search BandCamp for music albums

* add missing MovieGenre

* fix search 500 when song has no parent album

* adjust timeout

* individual scrapers

* fix tmdb parser

* export marks via rq; pref to send public toot; move import to data page

* fix spotify import

* fix edge cases

* export: fix dupe tags

* use rq to manage doufen import

* add django command to manage rq jobs

* fix export edge case

* tune rq admin

* fix detail page 502 step 1: async pull mastodon follow/block/mute list

* fix detail page 502 step 2: calculate relationship by local cached data

* manual sync mastodon follow info

* domain_blocks parsing fix

* marks by who i follows

* adjust label

* use username in urls

* add page to list a user\'s review

* review widget on user home page

* fix preview 500

* fix typo

* minor fix

* fix google books parsing

* allow mark/review visible to oneself

* fix auto sync masto for new user

* fix search 500

* add command to restart a sync task

* reset visibility

* delete user data

* fix tag search result pagination

* not upgrade to django 4 yet

* basic doc

* wip: collection

* wip

* wip

* collection use htmx

* show in-collection section for entities

* fix typo

* add su for easier debug

* fix some 500s

* fix login using alternative domain

* hide data from disabled user

* add item to list from detail page

* my tags

* collection: inline comment edit

* show number of ratings

* fix collection delete

* more detail in collection view

* use item template in search result

* fix 500

* write index to meilisearch

* fix search

* reindex in batch

* fix 500

* show search result from meilisearch

* more search commands

* index less fields

* index new items only

* search highlights

* fix 500

* auto set search category

* classic search if no meili server

* fix index stats error

* support typesense backend

* workaround typesense bug

* make external search async

* fix 500, typo

* fix cover scripts

* fix minor issue in douban parser

* supports m.douban.com and customized bandcamp domain

* move account

* reword with gender-friendly and instance-neutral language

* Friendica does not have vapid_key in api response

* enable anonymous search

* tweak book result template

* API v0

API v0

* fix meilisearch reindex

* fix search by url error

* login via twitter.com

* login via pixelfed

* minor fix

* no refresh on inactive users

* support refresh access token

* get rid of /users/number-id/

* refresh twitter handler automatically

* paste image when review

* support PixelFed (very long token)

* fix django-markdownx version

* ignore single quote for meilisearch for now

* update logo

* show book review/mark from same isbn

* show movie review/mark from same imdb

* fix login with older mastodon servers

* import Goodreads book list and profile

* add timestamp to Goodreads import

* support new google books api

* import goodreads list

* minor goodreads fix

* click corner action icon to add to wishlist

* clean up duplicated code

* fix anonymous search

* fix 500

* minor fix search 500

* show rating only if votes > 5

* Entity.refresh_rating()

* preference to append text when sharing; clean up duplicated code

* fix missing data for user tagged view

* fix page link for tag view

* fix 500 when language field longer than 10

* fix 500 when sharing mark for song

* fix error when reimport goodread profile

* fix minor typo

* fix a rare 500

* error log dump less

* fix tags in marks export

* fix missing param in pagination

* import douban review

* clarify text

* fix missing sheet in review import

* review: show in progress

* scrape douban: ignore unknown genre

* minor fix

* improve review import by guess entity urls

* clear guide text for review import

* improve review import form text

* workaround some 500

* fix mark import error

* fix img in review import

* load external results earlier

* ignore search server errors

* simplify user register flow to avoid inconsistent state

* Add a learn more link on login page

* Update login.html

* show mark created timestamp as mark time

* no 500 for api error

* redirect for expired tokens

* ensure preference object created.

* mark collections

* tag list

* fix tag display

* fix sorting etc

* fix 500

* fix potential export 500; save shared links

* fix share to twittwe

* fix review url

* fix 500

* fix 500

* add timeline, etc

* missing status change in timeline

* missing id in timeline

* timeline view by default

* workaround bug in markdownx...

* fix typo

* option to create new collection when add from detail page

* add missing announcement and tags in timeline home

* add missing announcement

* add missing announcement

* opensearch

* show fediverse shared link

* public review no longer requires login

* fix markdownx bug

* fix 500

* use cloudflare cdn

* validate jquery load and domain input

* fix 500

* tips for goodreads import

* collaborative collection

* show timeline and profile link on nav bar

* minor tweak

* share collection

* fix Goodreads search

* show wish mark in timeline

* resync failed urls with local proxy

* resync failed urls with local proxy: check proxy first

* scraper minor fix

* resync failed urls

* fix fields limit

* fix douban parsing error

* resync

* scraper minor fix

* scraper minor fix

* scraper minor fix

* local proxy

* local proxy

* sync default config from neodb

* configurable site name

* fix 500

* fix 500 for anonymous user

* add sentry

* add git version in log

* add git version in log

* no longer rely on cdnjs.cloudflare.com

* move jq/cash to _common_libs template partial

* fix rare js error

* fix 500

* avoid double submission error

* import tag in lower case

* catch some js network errors

* catch some js network errors

* support more goodread urls

* fix unaired tv in tmdb

* support more google book urls

* fix related series

* more goodreads urls

* robust googlebooks search

* robust search

* Update settings.py

* Update scraper.py

* Update requirements.txt

* make nicedb work

* doc update

* simplify permission check

* update doc

* update doc for bug report link

* skip spotify tracks

* fix 500

* improve search api

* blind fix import compatibility

* show years for movie in timeline

* show years for movie in timeline; thinner font

* export reviews

* revert user home to use jquery https://github.com/fabiospampinato/cash/issues/246

* IGDB

* use IGDB for Steam

* use TMDB for IMDb

* steam: igdb then fallback to steam

* keep change history

* keep change history: add django settings

* Steam: keep localized title/brief while merging IGDB

* basic Docker support

* rescrape

* Create codeql-analysis.yml

* Create SECURITY.md

* Create pysa.yml

Co-authored-by: doubaniux <goodsir@vivaldi.net>
Co-authored-by: Your Name <you@example.com>
Co-authored-by: Their Name <they@example.com>
Co-authored-by: Mt. Front <mfcndw@gmail.com>
2022-11-09 19:56:50 +01:00

584 lines
20 KiB
Python

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, 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.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, go_relogin
from common.models import SourceSiteEnum
from .models import *
from .forms import *
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__)
mastodon_logger = logging.getLogger("django.mastodon")
# how many marks showed on the detail page
MARK_NUMBER = 5
# how many marks at the mark page
MARK_PER_PAGE = 20
# how many reviews showed on the detail page
REVIEW_NUMBER = 5
# how many reviews at the mark page
REVIEW_PER_PAGE = 20
# max tags on detail page
TAG_NUMBER = 10
# public data
###########################
@login_required
def create(request):
if request.method == 'GET':
form = GameForm()
return render(
request,
'games/create_update.html',
{
'form': form,
'title': _('添加游戏'),
'submit_url': reverse("games:create"),
# provided for frontend js
'this_site_enum_value': SourceSiteEnum.IN_SITE.value,
}
)
elif request.method == 'POST':
if request.user.is_authenticated:
# only local user can alter public data
form = GameForm(request.POST, request.FILES)
if form.is_valid():
form.instance.last_editor = request.user
try:
with transaction.atomic():
form.save()
if form.instance.source_site == SourceSiteEnum.IN_SITE.value:
real_url = form.instance.get_absolute_url()
form.instance.source_url = real_url
form.instance.save()
except IntegrityError as e:
logger.error(e.__str__())
return HttpResponseServerError("integrity error")
return redirect(reverse("games:retrieve", args=[form.instance.id]))
else:
return render(
request,
'games/create_update.html',
{
'form': form,
'title': _('添加游戏'),
'submit_url': reverse("games:create"),
# provided for frontend js
'this_site_enum_value': SourceSiteEnum.IN_SITE.value,
}
)
else:
return redirect(reverse("users:login"))
else:
return HttpResponseBadRequest()
@login_required
def 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':
game = get_object_or_404(Game, pk=id)
form = GameForm(instance=game)
page_title = _('修改游戏')
return render(
request,
'games/create_update.html',
{
'form': form,
'is_update': True,
'title': page_title,
'submit_url': reverse("games:update", args=[game.id]),
# provided for frontend js
'this_site_enum_value': SourceSiteEnum.IN_SITE.value,
}
)
elif request.method == 'POST':
game = get_object_or_404(Game, pk=id)
form = GameForm(request.POST, request.FILES, instance=game)
page_title = _("修改游戏")
if form.is_valid():
form.instance.last_editor = request.user
form.instance.edited_time = timezone.now()
try:
with transaction.atomic():
form.save()
if form.instance.source_site == SourceSiteEnum.IN_SITE.value:
real_url = form.instance.get_absolute_url()
form.instance.source_url = real_url
form.instance.save()
except IntegrityError as e:
logger.error(e.__str__())
return HttpResponseServerError("integrity error")
else:
return render(
request,
'games/create_update.html',
{
'form': form,
'is_update': True,
'title': page_title,
'submit_url': reverse("games:update", args=[game.id]),
# provided for frontend js
'this_site_enum_value': SourceSiteEnum.IN_SITE.value,
}
)
return redirect(reverse("games:retrieve", args=[form.instance.id]))
else:
return HttpResponseBadRequest()
@mastodon_request_included
# @login_required
def retrieve(request, id):
if request.method == 'GET':
game = get_object_or_404(Game, pk=id)
mark = None
mark_tags = None
review = None
# retreive tags
game_tag_list = game.game_tags.values('content').annotate(
tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER]
# retrieve user mark and initialize mark form
try:
if request.user.is_authenticated:
mark = GameMark.objects.get(owner=request.user, game=game)
except ObjectDoesNotExist:
mark = None
if mark:
mark_tags = mark.gamemark_tags.all()
mark.get_status_display = GameMarkStatusTranslator(mark.status)
mark_form = GameMarkForm(instance=mark, initial={
'tags': mark_tags
})
else:
mark_form = GameMarkForm(initial={
'game': game,
'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0,
'tags': mark_tags
})
# retrieve user review
try:
if request.user.is_authenticated:
review = GameReview.objects.get(
owner=request.user, game=game)
except ObjectDoesNotExist:
review = None
# retrieve other related reviews and marks
if request.user.is_anonymous:
# hide all marks and reviews for anonymous user
mark_list = None
review_list = None
mark_list_more = None
review_list_more = None
else:
mark_list = GameMark.get_available(game, request.user)
review_list = GameReview.get_available(game, request.user)
mark_list_more = True if len(mark_list) > MARK_NUMBER else False
mark_list = mark_list[:MARK_NUMBER]
for m in mark_list:
m.get_status_display = GameMarkStatusTranslator(m.status)
review_list_more = True if len(
review_list) > REVIEW_NUMBER else False
review_list = review_list[:REVIEW_NUMBER]
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
# regex = re.compile('<.*?>')
# return re.sub(regex, '', text)
# for r in review_list:
# r.content = strip_html_tags(r.content)
return render(
request,
'games/detail.html',
{
'game': game,
'mark': mark,
'review': review,
'status_enum': MarkStatusEnum,
'mark_form': mark_form,
'mark_list': mark_list,
'mark_list_more': mark_list_more,
'review_list': review_list,
'review_list_more': review_list_more,
'game_tag_list': game_tag_list,
'mark_tags': mark_tags,
'collection_list': collection_list,
}
)
else:
logger.warning('non-GET method at /games/<id>')
return HttpResponseBadRequest()
@permission_required("games.delete_game")
@login_required
def delete(request, id):
if request.method == 'GET':
game = get_object_or_404(Game, pk=id)
return render(
request,
'games/delete.html',
{
'game': game,
}
)
elif request.method == 'POST':
if request.user.is_staff:
# only staff has right to delete
game = get_object_or_404(Game, pk=id)
game.delete()
return redirect(reverse("common:home"))
else:
raise PermissionDenied()
else:
return HttpResponseBadRequest()
# user owned entites
###########################
@mastodon_request_included
@login_required
def create_update_mark(request):
# check list:
# clean rating if is wish
# transaction on updating game rating
# owner check(guarantee)
if request.method == 'POST':
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:
# create
form = GameMarkForm(request.POST)
if form.is_valid():
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
form.instance.edited_time = timezone.now()
game = form.instance.game
try:
with transaction.atomic():
# update game rating
game.update_rating(old_rating, form.instance.rating)
form.save()
# update tags
if old_tags:
for tag in old_tags:
tag.delete()
if form.cleaned_data['tags']:
for tag in form.cleaned_data['tags']:
GameTag.objects.create(
content=tag,
game=game,
mark=form.instance
)
except IntegrityError as e:
logger.error(e.__str__())
return HttpResponseServerError("integrity error")
if form.cleaned_data['share_to_mastodon']:
if not share_mark(form.instance):
return go_relogin(request)
else:
return HttpResponseBadRequest(f"invalid form data {form.errors}")
return redirect(reverse("games:retrieve", args=[form.instance.game.id]))
else:
return HttpResponseBadRequest("invalid method")
@mastodon_request_included
@login_required
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, following_only=following_only)
paginator = Paginator(queryset, MARK_PER_PAGE)
page_number = request.GET.get('page', default=1)
marks = paginator.get_page(page_number)
marks.pagination = PageLinksGenerator(
PAGE_LINK_NUMBER, page_number, paginator.num_pages)
for m in marks:
m.get_status_display = GameMarkStatusTranslator(m.status)
return render(
request,
'games/mark_list.html',
{
'marks': marks,
'game': game,
}
)
else:
return HttpResponseBadRequest()
@login_required
def delete_mark(request, id):
if request.method == 'POST':
mark = get_object_or_404(GameMark, pk=id)
if request.user != mark.owner:
return HttpResponseBadRequest()
game_id = mark.game.id
try:
with transaction.atomic():
# update game rating
mark.game.update_rating(mark.rating, None)
mark.delete()
except IntegrityError as e:
return HttpResponseServerError()
return redirect(reverse("games:retrieve", args=[game_id]))
else:
return HttpResponseBadRequest()
@mastodon_request_included
@login_required
def create_review(request, game_id):
if request.method == 'GET':
form = GameReviewForm(initial={'game': game_id})
game = get_object_or_404(Game, pk=game_id)
return render(
request,
'games/create_update_review.html',
{
'form': form,
'title': _("添加评论"),
'game': game,
'submit_url': reverse("games:create_review", args=[game_id]),
}
)
elif request.method == 'POST':
form = GameReviewForm(request.POST)
if form.is_valid():
form.instance.owner = request.user
form.save()
if form.cleaned_data['share_to_mastodon']:
if not share_review(form.instance):
return go_relogin(request)
return redirect(reverse("games:retrieve_review", args=[form.instance.id]))
else:
return HttpResponseBadRequest()
else:
return HttpResponseBadRequest()
@mastodon_request_included
@login_required
def update_review(request, id):
if request.method == 'GET':
review = get_object_or_404(GameReview, pk=id)
if request.user != review.owner:
return HttpResponseBadRequest()
form = GameReviewForm(instance=review)
game = review.game
return render(
request,
'games/create_update_review.html',
{
'form': form,
'title': _("编辑评论"),
'game': game,
'submit_url': reverse("games:update_review", args=[review.id]),
}
)
elif request.method == 'POST':
review = get_object_or_404(GameReview, pk=id)
if request.user != review.owner:
return HttpResponseBadRequest()
form = GameReviewForm(request.POST, instance=review)
if form.is_valid():
form.instance.edited_time = timezone.now()
form.save()
if form.cleaned_data['share_to_mastodon']:
if not share_review(form.instance):
return go_relogin(request)
return redirect(reverse("games:retrieve_review", args=[form.instance.id]))
else:
return HttpResponseBadRequest()
else:
return HttpResponseBadRequest()
@login_required
def delete_review(request, id):
if request.method == 'GET':
review = get_object_or_404(GameReview, pk=id)
if request.user != review.owner:
return HttpResponseBadRequest()
review_form = GameReviewForm(instance=review)
return render(
request,
'games/delete_review.html',
{
'form': review_form,
'review': review,
}
)
elif request.method == 'POST':
review = get_object_or_404(GameReview, pk=id)
if request.user != review.owner:
return HttpResponseBadRequest()
game_id = review.game.id
review.delete()
return redirect(reverse("games:retrieve", args=[game_id]))
else:
return HttpResponseBadRequest()
@mastodon_request_included
def retrieve_review(request, id):
if request.method == 'GET':
review = get_object_or_404(GameReview, pk=id)
if not review.is_visible_to(request.user):
msg = _("你没有访问这个页面的权限😥")
return render(
request,
'common/error.html',
{
'msg': msg,
}
)
review_form = GameReviewForm(instance=review)
game = review.game
try:
mark = GameMark.objects.get(owner=review.owner, game=game)
mark.get_status_display = GameMarkStatusTranslator(mark.status)
except ObjectDoesNotExist:
mark = None
return render(
request,
'games/review_detail.html',
{
'form': review_form,
'review': review,
'game': game,
'mark': mark,
}
)
else:
return HttpResponseBadRequest()
@mastodon_request_included
@login_required
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)
paginator = Paginator(queryset, REVIEW_PER_PAGE)
page_number = request.GET.get('page', default=1)
reviews = paginator.get_page(page_number)
reviews.pagination = PageLinksGenerator(
PAGE_LINK_NUMBER, page_number, paginator.num_pages)
return render(
request,
'games/review_list.html',
{
'reviews': reviews,
'game': game,
}
)
else:
return HttpResponseBadRequest()
@login_required
def scrape(request):
if request.method == 'GET':
keywords = request.GET.get('q')
form = GameForm()
return render(
request,
'games/scrape.html',
{
'q': keywords,
'form': form,
}
)
else:
return HttpResponseBadRequest()
@login_required
def click_to_scrape(request):
if request.method == "POST":
url = request.POST.get("url")
if url:
return jump_or_scrape(request, url)
else:
return HttpResponseBadRequest()
else:
return HttpResponseBadRequest()