add authentication to api; add me and shelf api; add developer console
This commit is contained in:
parent
1bb0a989f2
commit
a7857d99b2
33 changed files with 628 additions and 185 deletions
|
@ -52,6 +52,7 @@ INSTALLED_APPS = [
|
|||
"django_sass",
|
||||
"django_rq",
|
||||
"django_bleach",
|
||||
"oauth2_provider",
|
||||
"tz_detect",
|
||||
"sass_processor",
|
||||
"simple_history",
|
||||
|
@ -80,6 +81,7 @@ MIDDLEWARE = [
|
|||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"oauth2_provider.middleware.OAuth2TokenMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
|
@ -153,6 +155,7 @@ else:
|
|||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"mastodon.auth.OAuth2Backend",
|
||||
"oauth2_provider.backends.OAuth2Backend",
|
||||
]
|
||||
|
||||
|
||||
|
@ -418,3 +421,8 @@ MAINTENANCE_MODE_IGNORE_ANONYMOUS_USER = True
|
|||
MAINTENANCE_MODE_IGNORE_URLS = (r"^/users/connect/", r"^/users/OAuth2_login/")
|
||||
|
||||
DISCORD_WEBHOOKS = {}
|
||||
|
||||
NINJA_PAGINATION_PER_PAGE = 20
|
||||
OAUTH2_PROVIDER = {"ACCESS_TOKEN_EXPIRE_SECONDS": 3600 * 24 * 365}
|
||||
|
||||
DEVELOPER_CONSOLE_APPLICATION_CLIENT_ID = "NEODB_DEVELOPER_CONSOLE"
|
||||
|
|
|
@ -31,6 +31,7 @@ urlpatterns = [
|
|||
path("hijack/", include("hijack.urls")),
|
||||
path("", include("common.urls")),
|
||||
path("", include("legacy.urls")),
|
||||
# path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
path("tz_detect/", include("tz_detect.urls")),
|
||||
path(settings.ADMIN_URL + "/", admin.site.urls),
|
||||
path(settings.ADMIN_URL + "-rq/", include("django_rq.urls")),
|
||||
|
|
175
catalog/api.py
175
catalog/api.py
|
@ -3,25 +3,45 @@ from .common import *
|
|||
from .sites import *
|
||||
from ninja import Schema
|
||||
from django.http import Http404
|
||||
from common.api import api, Result
|
||||
from common.api import *
|
||||
from .search.views import enqueue_fetch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
|
||||
class SearchResult(Schema):
|
||||
items: list[ItemSchema]
|
||||
|
||||
|
||||
@api.post("/catalog/search", response={200: SearchResult, 400: Result})
|
||||
def search_item(request, query: str, category: ItemCategory | None = None):
|
||||
@api.get(
|
||||
"/catalog/search",
|
||||
response={200: SearchResult, 400: Result},
|
||||
summary="Search items in catalog",
|
||||
auth=None,
|
||||
)
|
||||
def search_item(request, query: str, category: AvailableItemCategory | None = None):
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return 200, {"message": "Invalid query"}
|
||||
return 400, {"message": "Invalid query"}
|
||||
result = Indexer.search(query, page=1, category=category)
|
||||
return 200, {"items": result.items}
|
||||
|
||||
|
||||
@api.post("/catalog/fetch", response={200: ItemSchema, 202: Result})
|
||||
@api.get(
|
||||
"/catalog/fetch",
|
||||
response={200: ItemSchema, 202: Result},
|
||||
summary="Fetch item from URL of a supported site",
|
||||
auth=None,
|
||||
)
|
||||
def fetch_item(request, url: str):
|
||||
"""
|
||||
Convert a URL from a supported site (e.g. https://m.imdb.com/title/tt2852400/) to an item.
|
||||
|
||||
If the item is not available in the catalog, HTTP 202 will be returned.
|
||||
Wait 10 seconds or longer, call with same input again, it may return the actual fetched item.
|
||||
Some site may take ~90 seconds to fetch.
|
||||
"""
|
||||
site = SiteManager.get_site_by_url(url)
|
||||
if not site:
|
||||
raise Http404(url)
|
||||
|
@ -32,60 +52,125 @@ def fetch_item(request, url: str):
|
|||
return 202, {"message": "Fetch in progress"}
|
||||
|
||||
|
||||
@api.get("/book/{uuid}/", response=EditionSchema)
|
||||
def get_edition(request, uuid: str):
|
||||
item = Edition.get_by_url(uuid)
|
||||
@api.post(
|
||||
"/catalog/search",
|
||||
response={200: SearchResult, 400: Result},
|
||||
summary="This method is deprecated, will be removed by Aug 1 2023; use GET instead",
|
||||
auth=None,
|
||||
deprecated=True,
|
||||
)
|
||||
def search_item_legacy(
|
||||
request, query: str, category: AvailableItemCategory | None = None
|
||||
):
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return 400, {"message": "Invalid query"}
|
||||
result = Indexer.search(query, page=1, category=category)
|
||||
return 200, {"items": result.items}
|
||||
|
||||
|
||||
@api.post(
|
||||
"/catalog/fetch",
|
||||
response={200: ItemSchema, 202: Result},
|
||||
summary="This method is deprecated, will be removed by Aug 1 2023; use GET instead",
|
||||
auth=None,
|
||||
deprecated=True,
|
||||
)
|
||||
def fetch_item_legacy(request, url: str):
|
||||
site = SiteManager.get_site_by_url(url)
|
||||
if not site:
|
||||
raise Http404(url)
|
||||
item = site.get_item()
|
||||
if item:
|
||||
return 200, item
|
||||
enqueue_fetch(url, False)
|
||||
return 202, {"message": "Fetch in progress"}
|
||||
|
||||
|
||||
def _get_item(cls, uuid, response):
|
||||
item = cls.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return 404, {"message": "Item not found"}
|
||||
if item.merged_to_item:
|
||||
response["Location"] = item.merged_to_item.api_url
|
||||
return 302, {"message": "Item merged", "url": item.merged_to_item.api_url}
|
||||
if item.is_deleted:
|
||||
return 404, {"message": "Item not found"}
|
||||
return item
|
||||
|
||||
|
||||
@api.get("/movie/{uuid}/", response=MovieSchema)
|
||||
def get_movie(request, uuid: str):
|
||||
item = Movie.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return item
|
||||
@api.get(
|
||||
"/book/{uuid}/",
|
||||
response={200: EditionSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_book(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(Edition, uuid, response)
|
||||
|
||||
|
||||
@api.get("/tv/{uuid}/", response=TVShowSchema)
|
||||
def get_tvshow(request, uuid: str):
|
||||
item = TVShow.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return item
|
||||
@api.get(
|
||||
"/movie/{uuid}/",
|
||||
response={200: MovieSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_movie(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(Movie, uuid, response)
|
||||
|
||||
|
||||
@api.get("/tvseason/{uuid}/", response=TVSeasonSchema)
|
||||
def get_tvseason(request, uuid: str):
|
||||
item = TVSeason.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return item
|
||||
@api.get(
|
||||
"/tv/{uuid}/",
|
||||
response={200: TVShowSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_tv_show(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(TVShow, uuid, response)
|
||||
|
||||
|
||||
@api.get("/podcast/{uuid}/", response=PodcastSchema)
|
||||
def get_podcast(request, uuid: str):
|
||||
item = Podcast.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return item
|
||||
@api.get(
|
||||
"/tv/season/{uuid}/",
|
||||
response={200: TVSeasonSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_tv_season(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(TVSeason, uuid, response)
|
||||
|
||||
|
||||
@api.get("/album/{uuid}/", response=AlbumSchema)
|
||||
def get_album(request, uuid: str):
|
||||
item = Album.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return item
|
||||
@api.get(
|
||||
"/tvseason/{uuid}/",
|
||||
response={200: TVSeasonSchema, 302: RedirectedResult, 404: Result},
|
||||
summary="This method is deprecated, will be removed by Aug 1 2023; use /api/tv/season instead",
|
||||
auth=None,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_tv_season_legacy(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(TVSeason, uuid, response)
|
||||
|
||||
|
||||
@api.get("/game/{uuid}/", response=GameSchema)
|
||||
def get_game(request, uuid: str):
|
||||
item = Game.get_by_url(uuid)
|
||||
if not item:
|
||||
raise Http404(uuid)
|
||||
return item
|
||||
@api.get(
|
||||
"/podcast/{uuid}/",
|
||||
response={200: PodcastSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_podcast(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(Podcast, uuid, response)
|
||||
|
||||
|
||||
@api.get(
|
||||
"/album/{uuid}/",
|
||||
response={200: AlbumSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_album(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(Album, uuid, response)
|
||||
|
||||
|
||||
@api.get(
|
||||
"/game/{uuid}/",
|
||||
response={200: GameSchema, 302: RedirectedResult, 404: Result},
|
||||
auth=None,
|
||||
)
|
||||
def get_game(request, uuid: str, response: HttpResponse):
|
||||
return _get_item(Game, uuid, response)
|
||||
|
||||
|
||||
# @api.get("/book", response=List[EditionSchema])
|
||||
|
|
|
@ -9,6 +9,7 @@ __all__ = (
|
|||
"IdType",
|
||||
"SiteName",
|
||||
"ItemCategory",
|
||||
"AvailableItemCategory",
|
||||
"Item",
|
||||
"ExternalResource",
|
||||
"ResourceContent",
|
||||
|
|
|
@ -124,6 +124,20 @@ class ItemCategory(models.TextChoices):
|
|||
Collection = "collection", _("收藏单")
|
||||
|
||||
|
||||
class AvailableItemCategory(models.TextChoices):
|
||||
Book = "book", _("书")
|
||||
Movie = "movie", _("电影")
|
||||
TV = "tv", _("剧集")
|
||||
Music = "music", _("音乐")
|
||||
Game = "game", _("游戏")
|
||||
# Boardgame = "boardgame", _("桌游")
|
||||
Podcast = "podcast", _("播客")
|
||||
# FanFic = "fanfic", _("网文")
|
||||
# Performance = "performance", _("演出")
|
||||
# Exhibition = "exhibition", _("展览")
|
||||
# Collection = "collection", _("收藏单")
|
||||
|
||||
|
||||
# class SubItemType(models.TextChoices):
|
||||
# Season = "season", _("剧集分季")
|
||||
# Episode = "episode", _("剧集分集")
|
||||
|
@ -206,8 +220,8 @@ class BaseSchema(Schema):
|
|||
url: str
|
||||
api_url: str
|
||||
category: ItemCategory
|
||||
primary_lookup_id_type: str | None
|
||||
primary_lookup_id_value: str | None
|
||||
# primary_lookup_id_type: str | None
|
||||
# primary_lookup_id_value: str | None
|
||||
external_resources: list[ExternalResourceSchema] | None
|
||||
|
||||
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
<i>⛔️ 条目已被删除</i>
|
||||
{% elif item.merged_to_item and not request.user.is_staff %}
|
||||
<i>⛔️ 条目已被合并</i>
|
||||
{% elif not item.journal_exist and not request.user.is_staff %}
|
||||
{% elif item.journal_exist and not request.user.is_staff %}
|
||||
<i>⛔️ 条目已被用户标记过</i>
|
||||
{% else %}
|
||||
<details>
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
<script defer>
|
||||
(function(){
|
||||
const s = localStorage.getItem("user_style");
|
||||
console.log(s);
|
||||
if (s) {
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = s;
|
||||
|
|
|
@ -92,9 +92,9 @@
|
|||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ mark.created_time | date }} {% trans mark.action_label %}
|
||||
{% if mark.rating %}
|
||||
{% if mark.rating_grade %}
|
||||
<br>
|
||||
{{ mark.rating | rating_star }}
|
||||
{{ mark.rating_grade | rating_star }}
|
||||
<!-- <span style="white-space: nowrap;">
|
||||
<i class="fa-solid fa-star"></i><i class="fa-solid fa-star"></i><i class="fa-solid fa-star"></i><i class="fa-solid fa-star-half-stroke"></i><i class="fa-regular fa-star"></i>
|
||||
</span> -->
|
||||
|
@ -142,7 +142,7 @@
|
|||
class="item-mark-icon"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{% if mark.text %}
|
||||
{% if mark.comment_text %}
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
{% else %}
|
||||
<i class="fa-regular fa-square-plus"></i>
|
||||
|
@ -194,7 +194,7 @@
|
|||
<span>
|
||||
<a target="_blank"
|
||||
rel="noopener"
|
||||
{% if mark.review.metadata.shared_link %} href="{{ mark.review.metadata.shared_link }}" title="打开联邦网络分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if mark.review.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %}"></i></a>
|
||||
{% if mark.review.metadata.shared_link %} href="{{ mark.review.metadata.shared_link }}" title="打<EFBFBD><EFBFBD><EFBFBD>联邦网<EFBFBD><EFBFBD><EFBFBD>分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if mark.review.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %}"></i></a>
|
||||
</span>
|
||||
<span class="timestamp">{{ mark.review.created_time|date }}</span>
|
||||
</span>
|
||||
|
|
|
@ -193,7 +193,7 @@ def delete(request, item_path, item_uuid):
|
|||
if request.method != "POST":
|
||||
raise BadRequest()
|
||||
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
|
||||
if not request.user.is_staff and not item.journal_exist:
|
||||
if not request.user.is_staff and item.journal_exist:
|
||||
raise PermissionDenied()
|
||||
for res in item.external_resources.all():
|
||||
res.item = None
|
||||
|
@ -255,7 +255,7 @@ def merge(request, item_path, item_uuid):
|
|||
if request.method != "POST":
|
||||
raise BadRequest()
|
||||
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
|
||||
if not request.user.is_staff and not item.journal_exist:
|
||||
if not request.user.is_staff and item.journal_exist:
|
||||
raise PermissionDenied()
|
||||
if request.POST.get("new_item_url"):
|
||||
new_item = Item.get_by_url(request.POST.get("new_item_url"))
|
||||
|
@ -405,7 +405,7 @@ def discover(request):
|
|||
if user.is_authenticated:
|
||||
podcast_ids = [
|
||||
p.item_id
|
||||
for p in user.shelf_manager.get_members(
|
||||
for p in user.shelf_manager.get_latest_members(
|
||||
ShelfType.PROGRESS, ItemCategory.Podcast
|
||||
)
|
||||
]
|
||||
|
@ -415,17 +415,17 @@ def discover(request):
|
|||
books_in_progress = Edition.objects.filter(
|
||||
id__in=[
|
||||
p.item_id
|
||||
for p in user.shelf_manager.get_members(
|
||||
for p in user.shelf_manager.get_latest_members(
|
||||
ShelfType.PROGRESS, ItemCategory.Book
|
||||
).order_by("-created_time")[:10]
|
||||
)[:10]
|
||||
]
|
||||
)
|
||||
tvshows_in_progress = Item.objects.filter(
|
||||
id__in=[
|
||||
p.item_id
|
||||
for p in user.shelf_manager.get_members(
|
||||
for p in user.shelf_manager.get_latest_members(
|
||||
ShelfType.PROGRESS, ItemCategory.TV
|
||||
).order_by("-created_time")[:10]
|
||||
)[:10]
|
||||
]
|
||||
)
|
||||
else:
|
||||
|
|
|
@ -1,13 +1,69 @@
|
|||
from ninja import NinjaAPI, Schema
|
||||
from django.conf import settings
|
||||
from typing import Any, Callable, List, Optional, Tuple, Type
|
||||
from ninja.pagination import PageNumberPagination as NinjaPageNumberPagination
|
||||
from django.db.models import QuerySet
|
||||
from ninja.security import HttpBearer
|
||||
from oauthlib.oauth2 import Server
|
||||
from oauth2_provider.oauth2_backends import OAuthLibCore
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthAccessTokenAuth(HttpBearer):
|
||||
def authenticate(self, request, token):
|
||||
if not token or not request.user.is_authenticated:
|
||||
_logger.debug("API auth: no access token or user not authenticated")
|
||||
return False
|
||||
request_scopes = []
|
||||
if request.method.upper() in ["GET", "HEAD", "OPTIONS"]:
|
||||
request_scopes = ["read"]
|
||||
else:
|
||||
request_scopes = ["write"]
|
||||
validator = OAuth2Validator()
|
||||
core = OAuthLibCore(Server(validator))
|
||||
valid, oauthlib_req = core.verify_request(request, scopes=request_scopes)
|
||||
if not valid:
|
||||
_logger.debug(f"API auth: request scope {request_scopes} not verified")
|
||||
return valid
|
||||
|
||||
|
||||
class EmptyResult(Schema):
|
||||
pass
|
||||
|
||||
|
||||
class Result(Schema):
|
||||
message: str
|
||||
message: str | None
|
||||
# error: Optional[str]
|
||||
|
||||
|
||||
class RedirectedResult(Schema):
|
||||
message: str | None
|
||||
url: str
|
||||
|
||||
|
||||
class PageNumberPagination(NinjaPageNumberPagination):
|
||||
class Output(Schema):
|
||||
items: List[Any]
|
||||
pages: int
|
||||
count: int
|
||||
|
||||
def paginate_queryset(
|
||||
self,
|
||||
queryset: QuerySet,
|
||||
pagination: NinjaPageNumberPagination.Input,
|
||||
**params: Any,
|
||||
) -> Output:
|
||||
val = super().paginate_queryset(queryset, pagination, **params)
|
||||
val["pages"] = (val["count"] + self.page_size - 1) // self.page_size
|
||||
return val
|
||||
|
||||
|
||||
api = NinjaAPI(
|
||||
title=settings.SITE_INFO["site_name"],
|
||||
auth=OAuthAccessTokenAuth(),
|
||||
title=settings.SITE_INFO["site_name"] + " API",
|
||||
version="1.0.0",
|
||||
description=f"{settings.SITE_INFO['site_name']} API <hr/><a href='{settings.APP_WEBSITE}'>Learn more</a>",
|
||||
)
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.min.css"
|
||||
integrity="sha512-JArlzA682ixxKlWoGxYQxF+vHv527K1/NMnGbMxZERWr/16D7ZlPZUdq9+n5cA3TM030G57bSXYdN706FU9doQ=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>{{ api.title }} API Documentation</title>
|
||||
{% include "common_libs.html" with jquery=0 v2=1 %}
|
||||
<style type="text/css">
|
||||
.information-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
<div id="swagger-ui" class="container"></div>
|
||||
{% include "partial/_footer.html" %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-bundle.min.js"
|
||||
integrity="sha512-8FFvTCXo6KgUt72SMpgMgMHoHnNUOPxndku0/mc+B98qIeEfZ6dpPDYJv6a1TRWHoEZeMQAKQzcwSmQixM9v2w=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"></script>
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: '{{ openapi_json_url }}',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
{% if api.csrf and csrf_token %}
|
||||
requestInterceptor: (req) => {
|
||||
req.headers['X-CSRFToken'] = "{{csrf_token}}"
|
||||
return req;
|
||||
},
|
||||
{% endif %}
|
||||
deepLinking: true
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
70
common/templates/developer.html
Normal file
70
common/templates/developer.html
Normal file
|
@ -0,0 +1,70 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.min.css"
|
||||
integrity="sha512-JArlzA682ixxKlWoGxYQxF+vHv527K1/NMnGbMxZERWr/16D7ZlPZUdq9+n5cA3TM030G57bSXYdN706FU9doQ=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>{{ api.title }} Developer Console</title>
|
||||
{% include "common_libs.html" with jquery=0 v2=1 %}
|
||||
<style type="text/css">
|
||||
.information-container, #operations-tag-default {
|
||||
display: none;
|
||||
}
|
||||
.scheme-container {
|
||||
margin: 0 !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
button svg {
|
||||
fill: var(--pico-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
<main class="container">
|
||||
<h5>Developer Console</h5>
|
||||
<details {% if token %}open{% endif %}>
|
||||
<summary>
|
||||
<b>Access Token</b>
|
||||
</summary>
|
||||
<form method="post" role="group">
|
||||
{% csrf_token %}
|
||||
<input type="text"
|
||||
readonly
|
||||
value="{{ token | default:'Token will only be shown once here, previous tokens will be cleared. If you lose it, you can generate a new one.' }}">
|
||||
<input type="submit" value="Generate" />
|
||||
</form>
|
||||
<p>
|
||||
Click <code>Authorize</code> button below, input your token there to invoke APIs with your account, which is required for APIs like <code>/api/me</code>
|
||||
<br>
|
||||
Or use it in command line, like
|
||||
<code>curl -H "Authorization: Bearer YOUR_TOKEN" {{ site_url }}/api/me</code>
|
||||
</p>
|
||||
</details>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.18.3/swagger-ui-bundle.min.js"
|
||||
integrity="sha512-PijkKcRp7VDW1K2S8nNgljcNRrEQmazUc8sPiVRMciEuNzJzz2KeKb2Cjz/HdjZrKwmEYEyhOFZlOi0xzqWdqg=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"></script>
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: '{{ openapi_json_url }}',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
{% if api.csrf and csrf_token %}
|
||||
requestInterceptor: (req) => {req.headers['X-CSRFToken'] = "{{csrf_token}}"; return req;},
|
||||
{% endif %}
|
||||
deepLinking: true
|
||||
})
|
||||
</script>
|
||||
</main>
|
||||
{% include "partial/_footer.html" %}
|
||||
</body>
|
||||
</html>
|
|
@ -13,10 +13,6 @@
|
|||
rel="noopener"
|
||||
href="{{ support_link }}">问题反馈</a>
|
||||
{% endif %}
|
||||
<a class="footer__link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://github.com/neodb-social">源代码</a>
|
||||
{% if donation_link %}
|
||||
<a class="footer__link"
|
||||
target="_blank"
|
||||
|
@ -24,7 +20,11 @@
|
|||
href="{{ donation_link }}">捐助本站</a>
|
||||
{% endif %}
|
||||
<a class="footer__link" href="{% url 'management:list' %}">公告栏</a>
|
||||
<a class="footer__link" href="{% url 'common:api_doc' %}">API</a>
|
||||
<a class="footer__link" href="{% url 'common:developer' %}">API</a>
|
||||
<a class="footer__link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://github.com/neodb-social">源代码</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
@ -4,7 +4,7 @@ from .views import *
|
|||
app_name = "common"
|
||||
urlpatterns = [
|
||||
path("", home),
|
||||
path("api-doc/", api_doc, name="api_doc"),
|
||||
path("developer/", developer, name="developer"),
|
||||
path("home/", home, name="home"),
|
||||
path("me/", me, name="me"),
|
||||
]
|
||||
|
|
|
@ -3,6 +3,12 @@ from django.shortcuts import redirect, render
|
|||
from django.urls import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from .api import api
|
||||
from oauthlib.common import generate_token
|
||||
from oauth2_provider.models import AccessToken, Application
|
||||
from django.utils import timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from oauth2_provider.models import RefreshToken
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -48,9 +54,30 @@ def error_500(request, exception=None):
|
|||
return render(request, "500.html", status=500)
|
||||
|
||||
|
||||
def api_doc(request):
|
||||
@login_required
|
||||
def developer(request):
|
||||
token = None
|
||||
if request.method == "POST":
|
||||
user = request.user
|
||||
app = Application.objects.filter(
|
||||
client_id=settings.DEVELOPER_CONSOLE_APPLICATION_CLIENT_ID
|
||||
).first()
|
||||
if app:
|
||||
for token in AccessToken.objects.filter(user=user, application=app):
|
||||
token.revoke()
|
||||
token = generate_token()
|
||||
AccessToken.objects.create(
|
||||
user=user,
|
||||
application=app,
|
||||
scope="read write",
|
||||
expires=timezone.now() + relativedelta(days=365),
|
||||
token=token,
|
||||
)
|
||||
else:
|
||||
token = "Configuration error, contact admin"
|
||||
context = {
|
||||
"api": api,
|
||||
"token": token,
|
||||
"openapi_json_url": reverse(f"{api.urls_namespace}:openapi-json"),
|
||||
}
|
||||
return render(request, "api_doc.html", context)
|
||||
return render(request, "developer.html", context)
|
||||
|
|
|
@ -114,3 +114,8 @@ Run Tests
|
|||
coverage run --source='.' manage.py test
|
||||
coverage report
|
||||
```
|
||||
|
||||
Enable Developer Console
|
||||
```
|
||||
python3 manage.py createapplication --client-id NEODB_DEVELOPER_CONSOLE --skip-authorization --name 'NeoDB Developer Console' --redirect-uris 'https://example.org/lol' confidential authorization-code
|
||||
```
|
||||
|
|
133
journal/api.py
Normal file
133
journal/api.py
Normal file
|
@ -0,0 +1,133 @@
|
|||
from .models import *
|
||||
from ninja import Schema
|
||||
from common.api import *
|
||||
from oauth2_provider.decorators import protected_resource
|
||||
from ninja.security import django_auth
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from catalog.common.models import *
|
||||
from typing import List
|
||||
from ninja.pagination import paginate
|
||||
from ninja import Field
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class MarkSchema(Schema):
|
||||
shelf_type: ShelfType
|
||||
visibility: int = Field(ge=0, le=2)
|
||||
item: ItemSchema
|
||||
created_time: datetime
|
||||
comment_text: str | None
|
||||
rating_grade: int | None = Field(ge=1, le=10)
|
||||
tags: list[str]
|
||||
|
||||
|
||||
class MarkInSchema(Schema):
|
||||
shelf_type: ShelfType
|
||||
visibility: int = Field(ge=0, le=2)
|
||||
comment_text: str | None
|
||||
rating_grade: int | None = Field(ge=1, le=10)
|
||||
tags: list[str] = []
|
||||
created_time: datetime | None
|
||||
post_to_fediverse: bool = False
|
||||
|
||||
|
||||
@api.get("/me/shelf", response={200: List[MarkSchema], 403: Result})
|
||||
@protected_resource()
|
||||
@paginate(PageNumberPagination)
|
||||
def list_marks_on_shelf(
|
||||
request, type: ShelfType, category: AvailableItemCategory | None = None
|
||||
):
|
||||
"""
|
||||
Get holding marks on current user's shelf
|
||||
|
||||
Shelf's `type` should be one of `wishlist` / `progress` / `complete`;
|
||||
`category` is optional, all marks will be returned if not specified.
|
||||
"""
|
||||
queryset = request.user.shelf_manager.get_latest_members(
|
||||
type, category
|
||||
).prefetch_related("item")
|
||||
return queryset
|
||||
|
||||
|
||||
@api.get(
|
||||
"/me/shelf/item/{item_uuid}",
|
||||
response={200: MarkSchema, 403: Result, 404: Result},
|
||||
auth=OAuthAccessTokenAuth(),
|
||||
)
|
||||
# @protected_resource()
|
||||
def get_mark_by_item(request, item_uuid: str):
|
||||
"""
|
||||
Get holding mark on current user's shelf by item uuid
|
||||
"""
|
||||
item = Item.get_by_url(item_uuid)
|
||||
if not item:
|
||||
return 404, {"message": "Item not found"}
|
||||
shelfmember = request.user.shelf_manager.locate_item(item)
|
||||
if not shelfmember:
|
||||
return 404, {"message": "Mark not found"}
|
||||
return shelfmember
|
||||
|
||||
|
||||
@api.post(
|
||||
"/me/shelf/item/{item_uuid}", response={200: Result, 403: Result, 404: Result}
|
||||
)
|
||||
@protected_resource()
|
||||
def mark_item(request, item_uuid: str, mark: MarkInSchema):
|
||||
"""
|
||||
Create or update a holding mark about an item for current user.
|
||||
|
||||
`shelf_type` and `visibility` are required; `created_time` is optional, default to now.
|
||||
if the item is already marked, this will update the mark.
|
||||
|
||||
updating mark without `rating_grade`, `comment_text` or `tags` field will clear them.
|
||||
"""
|
||||
item = Item.get_by_url(item_uuid)
|
||||
if not item:
|
||||
return 404, {"message": "Item not found"}
|
||||
m = Mark(request.user, item)
|
||||
try:
|
||||
TagManager.tag_item_by_user(item, request.user, mark.tags, mark.visibility)
|
||||
m.update(
|
||||
mark.shelf_type,
|
||||
mark.comment_text,
|
||||
mark.rating_grade,
|
||||
mark.visibility,
|
||||
created_time=mark.created_time,
|
||||
share_to_mastodon=mark.post_to_fediverse,
|
||||
)
|
||||
except ValueError as e:
|
||||
pass # ignore sharing error
|
||||
return 200, {"message": "OK"}
|
||||
|
||||
|
||||
@api.delete(
|
||||
"/me/shelf/item/{item_uuid}", response={200: Result, 403: Result, 404: Result}
|
||||
)
|
||||
@protected_resource()
|
||||
def delete_mark(request, item_uuid: str):
|
||||
"""
|
||||
Remove a holding mark about an item for current user.
|
||||
"""
|
||||
item = Item.get_by_url(item_uuid)
|
||||
if not item:
|
||||
return 404, {"message": "Item not found"}
|
||||
m = Mark(request.user, item)
|
||||
m.delete()
|
||||
TagManager.tag_item_by_user(item, request.user, [], 0)
|
||||
return 200, {"message": "OK"}
|
||||
|
||||
|
||||
# @api.get("/me/review/")
|
||||
# @api.get("/me/review/item/{item_uuid}")
|
||||
# @api.post("/me/review/item/{item_uuid}")
|
||||
# @api.delete("/me/review/item/{item_uuid}")
|
||||
|
||||
# @api.get("/me/collection/")
|
||||
# @api.get("/me/collection/{uuid}")
|
||||
# @api.post("/me/collection/{uuid}")
|
||||
# @api.delete("/me/collection/{uuid}")
|
||||
# @api.get("/me/collection/{uuid}/items")
|
||||
# @api.post("/me/collection/{uuid}/items")
|
||||
# @api.delete("/me/collection/{uuid}/items")
|
||||
# @api.patch("/me/collection/{uuid}/items")
|
|
@ -4,3 +4,7 @@ from django.apps import AppConfig
|
|||
class JournalConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "journal"
|
||||
|
||||
def ready(self):
|
||||
# load key modules in proper order, make sure class inject and signal works as expected
|
||||
from . import api
|
||||
|
|
|
@ -68,7 +68,7 @@ def export_marks_task(user):
|
|||
tags = ",".join(mark.tags)
|
||||
world_rating = (movie.rating / 2) if movie.rating else None
|
||||
timestamp = mark.created_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
my_rating = (mark.rating / 2) if mark.rating else None
|
||||
my_rating = (mark.rating_grade / 2) if mark.rating_grade else None
|
||||
text = mark.text
|
||||
source_url = _get_source_url(movie)
|
||||
url = movie.absolute_url
|
||||
|
@ -108,7 +108,7 @@ def export_marks_task(user):
|
|||
tags = ",".join(mark.tags)
|
||||
world_rating = (album.rating / 2) if album.rating else None
|
||||
timestamp = mark.created_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
my_rating = (mark.rating / 2) if mark.rating else None
|
||||
my_rating = (mark.rating_grade / 2) if mark.rating_grade else None
|
||||
text = mark.text
|
||||
source_url = _get_source_url(album)
|
||||
url = album.absolute_url
|
||||
|
@ -150,7 +150,7 @@ def export_marks_task(user):
|
|||
tags = ",".join(mark.tags)
|
||||
world_rating = (book.rating / 2) if book.rating else None
|
||||
timestamp = mark.created_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
my_rating = (mark.rating / 2) if mark.rating else None
|
||||
my_rating = (mark.rating_grade / 2) if mark.rating_grade else None
|
||||
text = mark.text
|
||||
source_url = _get_source_url(book)
|
||||
url = book.absolute_url
|
||||
|
@ -192,7 +192,7 @@ def export_marks_task(user):
|
|||
tags = ",".join(mark.tags)
|
||||
world_rating = (game.rating / 2) if game.rating else None
|
||||
timestamp = mark.created_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
my_rating = (mark.rating / 2) if mark.rating else None
|
||||
my_rating = (mark.rating_grade / 2) if mark.rating_grade else None
|
||||
text = mark.text
|
||||
source_url = _get_source_url(game)
|
||||
url = game.absolute_url
|
||||
|
@ -228,7 +228,7 @@ def export_marks_task(user):
|
|||
tags = ",".join(mark.tags)
|
||||
world_rating = (podcast.rating / 2) if podcast.rating else None
|
||||
timestamp = mark.created_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
my_rating = (mark.rating / 2) if mark.rating else None
|
||||
my_rating = (mark.rating_grade / 2) if mark.rating_grade else None
|
||||
text = mark.text
|
||||
source_url = _get_source_url(podcast)
|
||||
url = podcast.absolute_url
|
||||
|
@ -273,7 +273,7 @@ def export_marks_task(user):
|
|||
target = "《" + review.item.title + "》"
|
||||
url = review.absolute_url
|
||||
timestamp = review.created_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
my_rating = None # (mark.rating / 2) if mark.rating else None
|
||||
my_rating = None # (mark.rating_grade / 2) if mark.rating_grade else None
|
||||
content = review.body
|
||||
target_source_url = _get_source_url(review.item)
|
||||
target_url = review.item.absolute_url
|
||||
|
|
|
@ -75,7 +75,10 @@ class GoodreadsImporter:
|
|||
for book in shelf["books"]:
|
||||
mark = Mark(user, book["book"])
|
||||
if (
|
||||
(mark.shelf_type == shelf_type and mark.text == book["review"])
|
||||
(
|
||||
mark.shelf_type == shelf_type
|
||||
and mark.comment_text == book["review"]
|
||||
)
|
||||
or (
|
||||
mark.shelf_type == ShelfType.COMPLETE
|
||||
and shelf_type != ShelfType.COMPLETE
|
||||
|
|
|
@ -235,7 +235,7 @@ class Comment(Content):
|
|||
return self.item.url
|
||||
|
||||
@staticmethod
|
||||
def comment_item_by_user(item, user, text, visibility=0):
|
||||
def comment_item_by_user(item, user, text, visibility=0, created_time=None):
|
||||
comment = Comment.objects.filter(
|
||||
owner=user, item=item, focus_item__isnull=True
|
||||
).first()
|
||||
|
@ -245,11 +245,17 @@ class Comment(Content):
|
|||
comment = None
|
||||
elif comment is None:
|
||||
comment = Comment.objects.create(
|
||||
owner=user, item=item, text=text, visibility=visibility
|
||||
owner=user,
|
||||
item=item,
|
||||
text=text,
|
||||
visibility=visibility,
|
||||
created_time=created_time or timezone.now(),
|
||||
)
|
||||
elif comment.text != text or comment.visibility != visibility:
|
||||
comment.text = text
|
||||
comment.visibility = visibility
|
||||
if created_time:
|
||||
comment.created_time = created_time
|
||||
comment.save()
|
||||
return comment
|
||||
|
||||
|
@ -355,7 +361,7 @@ class Rating(Content):
|
|||
@staticmethod
|
||||
def get_item_rating_by_user(item, user):
|
||||
rating = Rating.objects.filter(owner=user, item=item).first()
|
||||
return rating.grade if rating else None
|
||||
return (rating.grade or None) if rating else None
|
||||
|
||||
@staticmethod
|
||||
def get_rating_distribution_for_item(item):
|
||||
|
@ -443,9 +449,6 @@ class List(Piece):
|
|||
def recent_members(self):
|
||||
return self.members.all().order_by("-created_time")
|
||||
|
||||
def get_members_in_category(self, item_category):
|
||||
return self.members.all().filter(query_item_category(item_category))
|
||||
|
||||
def get_member_for_item(self, item):
|
||||
return self.members.filter(item=item).first()
|
||||
|
||||
|
@ -613,6 +616,30 @@ class ShelfMember(ListMember):
|
|||
m.shelfmember = self
|
||||
return m
|
||||
|
||||
@property
|
||||
def shelf_label(self):
|
||||
return ShelfManager.get_label(self.parent.shelf_type, self.item.category)
|
||||
|
||||
@property
|
||||
def shelf_type(self):
|
||||
return self.parent.shelf_type
|
||||
|
||||
@property
|
||||
def rating_grade(self):
|
||||
return self.mark.rating_grade
|
||||
|
||||
@property
|
||||
def comment_text(self):
|
||||
return self.mark.comment_text
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
return self.mark.tags
|
||||
|
||||
@property
|
||||
def public_tags(self):
|
||||
return self.mark.public_tags
|
||||
|
||||
|
||||
class Shelf(List):
|
||||
class Meta:
|
||||
|
@ -730,11 +757,12 @@ class ShelfManager:
|
|||
def get_shelf(self, shelf_type):
|
||||
return self.shelf_list[shelf_type]
|
||||
|
||||
def get_members(self, shelf_type, item_category=None):
|
||||
def get_latest_members(self, shelf_type, item_category=None):
|
||||
qs = self.shelf_list[shelf_type].members.all().order_by("-created_time")
|
||||
if item_category:
|
||||
return self.shelf_list[shelf_type].get_members_in_category(item_category)
|
||||
return qs.filter(query_item_category(item_category))
|
||||
else:
|
||||
return self.shelf_list[shelf_type].members.all()
|
||||
return qs
|
||||
|
||||
# def get_items_on_shelf(self, item_category, shelf_type):
|
||||
# shelf = (
|
||||
|
@ -751,9 +779,10 @@ class ShelfManager:
|
|||
]
|
||||
return sts[0] if sts else shelf_type
|
||||
|
||||
def get_label(self, shelf_type, item_category):
|
||||
@classmethod
|
||||
def get_label(cls, shelf_type, item_category):
|
||||
ic = ItemCategory(item_category).label
|
||||
st = self.get_action_label(shelf_type, item_category)
|
||||
st = cls.get_action_label(shelf_type, item_category)
|
||||
return _("{shelf_label}的{item_category}").format(
|
||||
shelf_label=st, item_category=ic
|
||||
)
|
||||
|
@ -1017,6 +1046,16 @@ class TagManager:
|
|||
]
|
||||
)
|
||||
|
||||
def get_item_public_tags(self, item):
|
||||
return sorted(
|
||||
[
|
||||
m["parent__title"]
|
||||
for m in TagMember.objects.filter(
|
||||
parent__owner=self.owner, item=item, visibility=0
|
||||
).values("parent__title")
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
Item.tags = property(TagManager.public_tags_for_item)
|
||||
User.tags = property(TagManager.all_tags_for_user)
|
||||
|
@ -1025,7 +1064,11 @@ User.tag_manager.__set_name__(User, "tag_manager")
|
|||
|
||||
|
||||
class Mark:
|
||||
"""this mimics previous mark behaviour"""
|
||||
"""
|
||||
Holding Mark for an item on an shelf,
|
||||
which is a combo object of ShelfMember, Comment, Rating and Tags.
|
||||
it mimics previous mark behaviour.
|
||||
"""
|
||||
|
||||
def __init__(self, user, item):
|
||||
self.owner = user
|
||||
|
@ -1039,22 +1082,20 @@ class Mark:
|
|||
def id(self):
|
||||
return self.shelfmember.id if self.shelfmember else None
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def shelf(self):
|
||||
return self.shelfmember.parent if self.shelfmember else None
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def shelf_type(self):
|
||||
return self.shelfmember.parent.shelf_type if self.shelfmember else None
|
||||
|
||||
@property
|
||||
def action_label(self):
|
||||
if self.shelfmember:
|
||||
return self.owner.shelf_manager.get_action_label(
|
||||
self.shelf_type, self.item.category
|
||||
)
|
||||
return ShelfManager.get_action_label(self.shelf_type, self.item.category)
|
||||
if self.comment:
|
||||
return self.owner.shelf_manager.get_action_label(
|
||||
return ShelfManager.get_action_label(
|
||||
ShelfType.PROGRESS, self.comment.item.category
|
||||
)
|
||||
return ""
|
||||
|
@ -1062,7 +1103,7 @@ class Mark:
|
|||
@property
|
||||
def shelf_label(self):
|
||||
return (
|
||||
self.owner.shelf_manager.get_label(self.shelf_type, self.item.category)
|
||||
ShelfManager.get_label(self.shelf_type, self.item.category)
|
||||
if self.shelfmember
|
||||
else None
|
||||
)
|
||||
|
@ -1088,8 +1129,8 @@ class Mark:
|
|||
return self.owner.tag_manager.get_item_tags(self.item)
|
||||
|
||||
@cached_property
|
||||
def rating(self):
|
||||
return Rating.get_item_rating_by_user(self.item, self.owner)
|
||||
def public_tags(self):
|
||||
return self.owner.tag_manager.get_item_public_tags(self.item)
|
||||
|
||||
@cached_property
|
||||
def rating_grade(self):
|
||||
|
@ -1102,8 +1143,8 @@ class Mark:
|
|||
).first()
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self.comment.text if self.comment else None
|
||||
def comment_text(self):
|
||||
return (self.comment.text or None) if self.comment else None
|
||||
|
||||
@property
|
||||
def comment_html(self):
|
||||
|
@ -1128,10 +1169,12 @@ class Mark:
|
|||
and shelf_type is not None
|
||||
and (
|
||||
shelf_type != self.shelf_type
|
||||
or comment_text != self.text
|
||||
or rating_grade != self.rating
|
||||
or comment_text != self.comment_text
|
||||
or rating_grade != self.rating_grade
|
||||
)
|
||||
)
|
||||
if created_time and created_time >= timezone.now():
|
||||
created_time = None
|
||||
share_as_new_post = shelf_type != self.shelf_type
|
||||
original_visibility = self.visibility
|
||||
if shelf_type != self.shelf_type or visibility != original_visibility:
|
||||
|
@ -1139,6 +1182,8 @@ class Mark:
|
|||
self.item, shelf_type, visibility=visibility, metadata=metadata
|
||||
)
|
||||
if self.shelfmember and created_time:
|
||||
# if it's an update(not delete) and created_time is specified,
|
||||
# update the timestamp of the shelfmember and log
|
||||
log = ShelfLogEntry.objects.filter(
|
||||
owner=self.owner,
|
||||
item=self.item,
|
||||
|
@ -1157,13 +1202,17 @@ class Mark:
|
|||
metadata=self.metadata,
|
||||
timestamp=created_time,
|
||||
)
|
||||
if comment_text != self.text or visibility != original_visibility:
|
||||
if comment_text != self.comment_text or visibility != original_visibility:
|
||||
self.comment = Comment.comment_item_by_user(
|
||||
self.item, self.owner, comment_text, visibility
|
||||
self.item,
|
||||
self.owner,
|
||||
comment_text,
|
||||
visibility,
|
||||
self.shelfmember.created_time if self.shelfmember else None,
|
||||
)
|
||||
if rating_grade != self.rating or visibility != original_visibility:
|
||||
if rating_grade != self.rating_grade or visibility != original_visibility:
|
||||
Rating.rate_item_by_user(self.item, self.owner, rating_grade, visibility)
|
||||
self.rating = rating_grade
|
||||
self.rating_grade = rating_grade
|
||||
if share:
|
||||
# this is a bit hacky but let's keep it until move to implement ActivityPub,
|
||||
# by then, we'll just change this to boost
|
||||
|
@ -1235,8 +1284,8 @@ def update_journal_for_merged_item(legacy_item_uuid):
|
|||
def journal_exists_for_item(item):
|
||||
for cls in list(Content.__subclasses__()) + list(ListMember.__subclasses__()):
|
||||
if cls.objects.filter(item=item).exists():
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
Item.journal_exist = property(journal_exists_for_item)
|
||||
|
|
|
@ -45,13 +45,13 @@
|
|||
<input type="hidden"
|
||||
name="rating"
|
||||
id="id_rating"
|
||||
value="{{ mark.rating|floatformat:0 }}">
|
||||
value="{{ mark.rating_grade }}">
|
||||
<div class="mark-modal__clear"></div>
|
||||
<textarea name="text"
|
||||
cols="40"
|
||||
rows="10"
|
||||
placeholder="超过360字部分实例可能无法显示"
|
||||
id="id_text">{% if mark.text %}{{ mark.text }}{% endif %}</textarea>
|
||||
id="id_text">{% if mark.comment_text %}{{ mark.comment_text }}{% endif %}</textarea>
|
||||
<div class="mark-modal__tag">
|
||||
<label>标签</label>
|
||||
<div class="tag-input">
|
||||
|
|
|
@ -54,11 +54,11 @@
|
|||
in me set current_value to star_input.value set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' remove .yellow from star_div end " class="rating-editor
|
||||
{% if shelf_type == "wishlist" %}hidden{% endif %}
|
||||
">
|
||||
{{ mark.rating|rating_star }}
|
||||
{{ mark.rating_grade|rating_star }}
|
||||
<input type="hidden"
|
||||
name="rating"
|
||||
name="rating_grade"
|
||||
id="id_rating"
|
||||
value="{{ mark.rating|floatformat:0 }}">
|
||||
value="{{ mark.rating_grade }}">
|
||||
</span>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
@ -66,7 +66,7 @@
|
|||
<textarea name="text"
|
||||
rows="4"
|
||||
placeholder="提示: 善用 >!文字!< 标记可隐藏剧透; 超过360字可能无法分享到联邦网络实例时间线。"
|
||||
id="id_text">{{ mark.text|default:"" }}</textarea>
|
||||
id="id_text">{{ mark.comment_text|default:"" }}</textarea>
|
||||
<div class="mark-modal__tag">
|
||||
<div class="tag-input">
|
||||
<input name="tags" type="text" placeholder="回车增加标签" id="id_tags" value="">
|
||||
|
|
|
@ -151,8 +151,8 @@ class MarkTest(TestCase):
|
|||
mark = Mark(self.user1, self.book1)
|
||||
self.assertEqual(mark.shelf_type, None)
|
||||
self.assertEqual(mark.shelf_label, None)
|
||||
self.assertEqual(mark.text, None)
|
||||
self.assertEqual(mark.rating, None)
|
||||
self.assertEqual(mark.comment_text, None)
|
||||
self.assertEqual(mark.rating_grade, None)
|
||||
self.assertEqual(mark.visibility, 2)
|
||||
self.assertEqual(mark.review, None)
|
||||
self.assertEqual(mark.tags, [])
|
||||
|
@ -161,8 +161,8 @@ class MarkTest(TestCase):
|
|||
mark = Mark(self.user1, self.book1)
|
||||
self.assertEqual(mark.shelf_type, ShelfType.WISHLIST)
|
||||
self.assertEqual(mark.shelf_label, "想读的书")
|
||||
self.assertEqual(mark.text, "a gentle comment")
|
||||
self.assertEqual(mark.rating, 9)
|
||||
self.assertEqual(mark.comment_text, "a gentle comment")
|
||||
self.assertEqual(mark.rating_grade, 9)
|
||||
self.assertEqual(mark.visibility, 1)
|
||||
self.assertEqual(mark.review, None)
|
||||
self.assertEqual(mark.tags, [])
|
||||
|
|
|
@ -161,8 +161,8 @@ def mark(request, item_uuid):
|
|||
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
||||
else:
|
||||
visibility = int(request.POST.get("visibility", default=0))
|
||||
rating = request.POST.get("rating", default=0)
|
||||
rating = int(rating) if rating else None
|
||||
rating_grade = request.POST.get("rating_grade", default=0)
|
||||
rating_grade = int(rating_grade) if rating_grade else None
|
||||
status = ShelfType(request.POST.get("status"))
|
||||
text = request.POST.get("text")
|
||||
tags = request.POST.get("tags")
|
||||
|
@ -181,12 +181,12 @@ def mark(request, item_uuid):
|
|||
mark.update(
|
||||
status,
|
||||
text,
|
||||
rating,
|
||||
rating_grade,
|
||||
visibility,
|
||||
share_to_mastodon=share_to_mastodon,
|
||||
created_time=mark_date,
|
||||
)
|
||||
except Exception as e:
|
||||
except ValueError as e:
|
||||
_logger.warn(f"post to mastodon error {e}")
|
||||
return render_relogin(request)
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
||||
|
@ -635,7 +635,7 @@ def _render_list(
|
|||
return render_user_blocked(request)
|
||||
tag = None
|
||||
if type == "mark":
|
||||
queryset = user.shelf_manager.get_members(shelf_type, item_category)
|
||||
queryset = user.shelf_manager.get_latest_members(shelf_type, item_category)
|
||||
elif type == "tagmember":
|
||||
tag = Tag.objects.filter(owner=user, title=tag_title).first()
|
||||
if not tag:
|
||||
|
@ -842,11 +842,9 @@ def profile(request, user_name):
|
|||
for category in visbile_categories:
|
||||
shelf_list[category] = {}
|
||||
for shelf_type in ShelfType:
|
||||
members = (
|
||||
user.shelf_manager.get_members(shelf_type, category)
|
||||
.filter(qv)
|
||||
.order_by("-created_time")
|
||||
)
|
||||
members = user.shelf_manager.get_latest_members(
|
||||
shelf_type, category
|
||||
).filter(qv)
|
||||
shelf_list[category][shelf_type] = {
|
||||
"title": user.shelf_manager.get_label(shelf_type, category),
|
||||
"count": members.count(),
|
||||
|
|
|
@ -444,10 +444,10 @@ def share_mark(mark):
|
|||
else ""
|
||||
)
|
||||
stars = rating_to_emoji(
|
||||
mark.rating,
|
||||
mark.rating_grade,
|
||||
MastodonApplication.objects.get(domain_name=user.mastodon_site).star_mode,
|
||||
)
|
||||
content = f"{mark.translated_status}《{mark.item.title}》{stars}\n{mark.item.absolute_url}\n{mark.text or ''}{tags}"
|
||||
content = f"{mark.translated_status}《{mark.item.title}》{stars}\n{mark.item.absolute_url}\n{mark.comment_text or ''}{tags}"
|
||||
update_id = get_status_id_by_url(mark.shared_link)
|
||||
spoiler_text, content = get_spoiler_text(content, mark.item)
|
||||
response = post_toot(
|
||||
|
|
|
@ -20,6 +20,7 @@ django-maintenance-mode
|
|||
django-tz-detect
|
||||
django-bleach
|
||||
django-redis
|
||||
django-oauth-toolkit
|
||||
meilisearch
|
||||
easy-thumbnails
|
||||
lxml
|
||||
|
|
|
@ -61,11 +61,13 @@
|
|||
{% if activity.action_object.metadata.position %}
|
||||
<span class="muted">{{ activity.action_object.metadata.position|duration_format:1 }}</span>
|
||||
{% endif %}
|
||||
{% if activity.action_object.mark.rating %}{{ activity.action_object.mark.rating | rating_star }}{% endif %}
|
||||
{% if activity.action_object.mark.rating_grade %}
|
||||
{{ activity.action_object.mark.rating_grade | rating_star }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<article>
|
||||
{% include "_item_card.html" with item=activity.action_object.item allow_embed=1 %}
|
||||
{% if activity.action_object.mark.text %}
|
||||
{% if activity.action_object.mark.comment_text %}
|
||||
<footer>
|
||||
<p>{{ activity.action_object.mark.comment_html|safe }}</p>
|
||||
</footer>
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<a><i class="fa-regular fa-comment"></i></a>
|
||||
</span>
|
||||
{% endcomment %}
|
||||
{% if activity.action_object.mark.text %}
|
||||
{% if activity.action_object.mark.comment_text %}
|
||||
<span>
|
||||
{% liked_piece activity.action_object.mark.comment as liked %}
|
||||
{% include 'like_stats.html' with liked=liked piece=activity.action_object.mark.comment %}
|
||||
|
@ -51,11 +51,13 @@
|
|||
<div class="spacing">
|
||||
{{ activity.action_object.mark.action_label }}
|
||||
{% comment %} {{ activity.action_object.item.title }} {% endcomment %}
|
||||
{% if activity.action_object.mark.rating %}{{ activity.action_object.mark.rating | rating_star }}{% endif %}
|
||||
{% if activity.action_object.mark.rating_grade %}
|
||||
{{ activity.action_object.mark.rating_grade | rating_star }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<article>
|
||||
{% include "_item_card.html" with item=activity.action_object.item allow_embed=1 %}
|
||||
{% if activity.action_object.mark.text %}
|
||||
{% if activity.action_object.mark.comment_text %}
|
||||
<footer>
|
||||
<p>{{ activity.action_object.mark.comment_html|safe }}</p>
|
||||
</footer>
|
||||
|
|
|
@ -54,7 +54,9 @@
|
|||
<div class="spacing">
|
||||
写了评论
|
||||
<a href="{{ activity.action_object.url }}">{{ activity.action_object.title }}</a>
|
||||
{% if activity.action_object.mark.rating %}{{ activity.action_object.mark.rating | rating_star }}{% endif %}
|
||||
{% if activity.action_object.mark.rating_grade %}
|
||||
{{ activity.action_object.mark.rating_grade | rating_star }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<article>
|
||||
{% include "_item_card.html" with item=activity.action_object.item allow_embed=1 %}
|
||||
|
|
|
@ -20,7 +20,7 @@ def feed(request):
|
|||
user = request.user
|
||||
podcast_ids = [
|
||||
p.item_id
|
||||
for p in user.shelf_manager.get_members(
|
||||
for p in user.shelf_manager.get_latest_members(
|
||||
ShelfType.PROGRESS, ItemCategory.Podcast
|
||||
)
|
||||
]
|
||||
|
@ -30,17 +30,17 @@ def feed(request):
|
|||
books_in_progress = Edition.objects.filter(
|
||||
id__in=[
|
||||
p.item_id
|
||||
for p in user.shelf_manager.get_members(
|
||||
for p in user.shelf_manager.get_latest_members(
|
||||
ShelfType.PROGRESS, ItemCategory.Book
|
||||
).order_by("-created_time")[:10]
|
||||
)[:10]
|
||||
]
|
||||
)
|
||||
tvshows_in_progress = Item.objects.filter(
|
||||
id__in=[
|
||||
p.item_id
|
||||
for p in user.shelf_manager.get_members(
|
||||
for p in user.shelf_manager.get_latest_members(
|
||||
ShelfType.PROGRESS, ItemCategory.TV
|
||||
).order_by("-created_time")[:10]
|
||||
)[:10]
|
||||
]
|
||||
)
|
||||
return render(
|
||||
|
|
24
users/api.py
Normal file
24
users/api.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from ninja import Schema
|
||||
from common.api import *
|
||||
from oauth2_provider.decorators import protected_resource
|
||||
from ninja.security import django_auth
|
||||
|
||||
|
||||
class UserSchema(Schema):
|
||||
external_acct: str
|
||||
display_name: str
|
||||
avatar: str
|
||||
|
||||
|
||||
@api.get(
|
||||
"/me",
|
||||
response={200: UserSchema, 400: Result, 403: Result},
|
||||
summary="Get current user's basic info",
|
||||
)
|
||||
@protected_resource()
|
||||
def me(request):
|
||||
return 200, {
|
||||
"external_acct": request.user.mastodon_username,
|
||||
"display_name": request.user.display_name,
|
||||
"avatar": request.user.mastodon_account.get("avatar"),
|
||||
}
|
|
@ -3,3 +3,6 @@ from django.apps import AppConfig
|
|||
|
||||
class UsersConfig(AppConfig):
|
||||
name = "users"
|
||||
|
||||
def ready(self):
|
||||
from . import api
|
||||
|
|
Loading…
Add table
Reference in a new issue