diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 85800e71..20244adf 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -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" diff --git a/boofilsic/urls.py b/boofilsic/urls.py index 9f477aa6..fcfe733d 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -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")), diff --git a/catalog/api.py b/catalog/api.py index 2a060629..9a31f9f1 100644 --- a/catalog/api.py +++ b/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]) diff --git a/catalog/common/__init__.py b/catalog/common/__init__.py index 3c0208b0..2e0fa0a5 100644 --- a/catalog/common/__init__.py +++ b/catalog/common/__init__.py @@ -9,6 +9,7 @@ __all__ = ( "IdType", "SiteName", "ItemCategory", + "AvailableItemCategory", "Item", "ExternalResource", "ResourceContent", diff --git a/catalog/common/models.py b/catalog/common/models.py index a863392c..1e0f3de9 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -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 diff --git a/catalog/templates/_sidebar_edit.html b/catalog/templates/_sidebar_edit.html index fb83c46f..7ff10187 100644 --- a/catalog/templates/_sidebar_edit.html +++ b/catalog/templates/_sidebar_edit.html @@ -74,7 +74,7 @@ ⛔️ 条目已被删除 {% elif item.merged_to_item and not request.user.is_staff %} ⛔️ 条目已被合并 - {% elif not item.journal_exist and not request.user.is_staff %} + {% elif item.journal_exist and not request.user.is_staff %} ⛔️ 条目已被用户标记过 {% else %}
diff --git a/catalog/templates/common_libs.html b/catalog/templates/common_libs.html index a72d2166..64263779 100644 --- a/catalog/templates/common_libs.html +++ b/catalog/templates/common_libs.html @@ -38,7 +38,6 @@ - - - diff --git a/common/templates/developer.html b/common/templates/developer.html new file mode 100644 index 00000000..e6cd53e3 --- /dev/null +++ b/common/templates/developer.html @@ -0,0 +1,70 @@ + + + + + {{ api.title }} Developer Console + {% include "common_libs.html" with jquery=0 v2=1 %} + + + + {% include "_header.html" %} +
+
Developer Console
+
+ + Access Token + +
+ {% csrf_token %} + + +
+

+ Click Authorize button below, input your token there to invoke APIs with your account, which is required for APIs like /api/me +
+ Or use it in command line, like + curl -H "Authorization: Bearer YOUR_TOKEN" {{ site_url }}/api/me +

+
+
+ + +
+ {% include "partial/_footer.html" %} + + diff --git a/common/templates/partial/_footer.html b/common/templates/partial/_footer.html index 4ed97380..ddefdc83 100644 --- a/common/templates/partial/_footer.html +++ b/common/templates/partial/_footer.html @@ -13,10 +13,6 @@ rel="noopener" href="{{ support_link }}">问题反馈 {% endif %} - 源代码 {% if donation_link %} 捐助本站 {% endif %} 公告栏 - API + API + 源代码 diff --git a/common/urls.py b/common/urls.py index aa327496..db569554 100644 --- a/common/urls.py +++ b/common/urls.py @@ -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"), ] diff --git a/common/views.py b/common/views.py index ca0765f4..f416d293 100644 --- a/common/views.py +++ b/common/views.py @@ -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) diff --git a/doc/install.md b/doc/install.md index 2a9bd7be..9a89f04c 100644 --- a/doc/install.md +++ b/doc/install.md @@ -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 +``` diff --git a/journal/api.py b/journal/api.py new file mode 100644 index 00000000..68f9e245 --- /dev/null +++ b/journal/api.py @@ -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") diff --git a/journal/apps.py b/journal/apps.py index e10a1714..4c656fc9 100644 --- a/journal/apps.py +++ b/journal/apps.py @@ -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 diff --git a/journal/exporters/doufen.py b/journal/exporters/doufen.py index 297e7768..fc76a691 100644 --- a/journal/exporters/doufen.py +++ b/journal/exporters/doufen.py @@ -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 diff --git a/journal/importers/goodreads.py b/journal/importers/goodreads.py index e2c68fd1..4329b788 100644 --- a/journal/importers/goodreads.py +++ b/journal/importers/goodreads.py @@ -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 diff --git a/journal/models.py b/journal/models.py index 534ca186..9b305205 100644 --- a/journal/models.py +++ b/journal/models.py @@ -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) diff --git a/journal/templates/mark.html b/journal/templates/mark.html index 492b1fed..a676de6b 100644 --- a/journal/templates/mark.html +++ b/journal/templates/mark.html @@ -45,13 +45,13 @@ + value="{{ mark.rating_grade }}">
+ id="id_text">{% if mark.comment_text %}{{ mark.comment_text }}{% endif %}
diff --git a/journal/templates/mark2.html b/journal/templates/mark2.html index 33b7566b..1b266769 100644 --- a/journal/templates/mark2.html +++ b/journal/templates/mark2.html @@ -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 }} + value="{{ mark.rating_grade }}">
@@ -66,7 +66,7 @@ + id="id_text">{{ mark.comment_text|default:"" }}
diff --git a/journal/tests.py b/journal/tests.py index ad59c227..5ee1dd09 100644 --- a/journal/tests.py +++ b/journal/tests.py @@ -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, []) diff --git a/journal/views.py b/journal/views.py index 623aafab..67060b98 100644 --- a/journal/views.py +++ b/journal/views.py @@ -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(), diff --git a/mastodon/api.py b/mastodon/api.py index 4ae40771..12921347 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -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( diff --git a/requirements.txt b/requirements.txt index 2269e2ad..87bd75ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ django-maintenance-mode django-tz-detect django-bleach django-redis +django-oauth-toolkit meilisearch easy-thumbnails lxml diff --git a/social/templates/activity/comment_focus_item.html b/social/templates/activity/comment_focus_item.html index 0f5cb23d..e1dc7276 100644 --- a/social/templates/activity/comment_focus_item.html +++ b/social/templates/activity/comment_focus_item.html @@ -61,11 +61,13 @@ {% if activity.action_object.metadata.position %} {{ activity.action_object.metadata.position|duration_format:1 }} {% 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 %}
{% 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 %}

{{ activity.action_object.mark.comment_html|safe }}

diff --git a/social/templates/activity/mark_item.html b/social/templates/activity/mark_item.html index 032fb9e0..20253850 100644 --- a/social/templates/activity/mark_item.html +++ b/social/templates/activity/mark_item.html @@ -19,7 +19,7 @@ {% endcomment %} - {% if activity.action_object.mark.text %} + {% if activity.action_object.mark.comment_text %} {% 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 @@
{{ 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 %}
{% 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 %}

{{ activity.action_object.mark.comment_html|safe }}

diff --git a/social/templates/activity/review_item.html b/social/templates/activity/review_item.html index 71d85ca0..ea92765c 100644 --- a/social/templates/activity/review_item.html +++ b/social/templates/activity/review_item.html @@ -54,7 +54,9 @@
写了评论 {{ activity.action_object.title }} - {% 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 %}
{% include "_item_card.html" with item=activity.action_object.item allow_embed=1 %} diff --git a/social/views.py b/social/views.py index bf17a8ab..f6f3f6ca 100644 --- a/social/views.py +++ b/social/views.py @@ -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( diff --git a/users/api.py b/users/api.py new file mode 100644 index 00000000..e5201d61 --- /dev/null +++ b/users/api.py @@ -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"), + } diff --git a/users/apps.py b/users/apps.py index 3ef1284a..53a4bcc9 100644 --- a/users/apps.py +++ b/users/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class UsersConfig(AppConfig): name = "users" + + def ready(self): + from . import api