add authentication to api; add me and shelf api; add developer console

This commit is contained in:
Your Name 2023-06-02 21:54:48 -04:00 committed by Henri Dickson
parent 1bb0a989f2
commit a7857d99b2
33 changed files with 628 additions and 185 deletions

View file

@ -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"

View file

@ -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")),

View file

@ -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])

View file

@ -9,6 +9,7 @@ __all__ = (
"IdType",
"SiteName",
"ItemCategory",
"AvailableItemCategory",
"Item",
"ExternalResource",
"ResourceContent",

View file

@ -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

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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:

View file

@ -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>",
)

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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"),
]

View file

@ -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)

View file

@ -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
View 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")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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">

View file

@ -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="提示: 善用 &gt;!文字!&lt; 标记可隐藏剧透; 超过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="">

View file

@ -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, [])

View file

@ -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(),

View file

@ -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(

View file

@ -20,6 +20,7 @@ django-maintenance-mode
django-tz-detect
django-bleach
django-redis
django-oauth-toolkit
meilisearch
easy-thumbnails
lxml

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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
View 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"),
}

View file

@ -3,3 +3,6 @@ from django.apps import AppConfig
class UsersConfig(AppConfig):
name = "users"
def ready(self):
from . import api