From 875dfd4711dc168514790295568e18e6f1c9005e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 19 Jan 2025 16:04:22 -0500 Subject: [PATCH] api for notes --- common/api.py | 3 + common/templates/console.html | 7 +- journal/api.py | 604 ---------------------------------- journal/apis/__init__.py | 5 + journal/apis/collection.py | 192 +++++++++++ journal/apis/note.py | 112 +++++++ journal/apis/review.py | 115 +++++++ journal/apis/shelf.py | 128 +++++++ journal/apis/tag.py | 195 +++++++++++ journal/apps.py | 2 +- journal/models/collection.py | 4 +- journal/models/comment.py | 2 +- journal/models/common.py | 48 ++- journal/models/note.py | 20 +- journal/models/rating.py | 2 +- journal/models/review.py | 21 +- journal/models/shelf.py | 4 +- journal/views/note.py | 13 +- neodb-takahe | 2 +- 19 files changed, 832 insertions(+), 647 deletions(-) delete mode 100644 journal/api.py create mode 100644 journal/apis/__init__.py create mode 100644 journal/apis/collection.py create mode 100644 journal/apis/note.py create mode 100644 journal/apis/review.py create mode 100644 journal/apis/shelf.py create mode 100644 journal/apis/tag.py diff --git a/common/api.py b/common/api.py index d0a29c3c..c9e369bd 100644 --- a/common/api.py +++ b/common/api.py @@ -95,3 +95,6 @@ api = NinjaAPI( version="1.0.0", description=f"{settings.SITE_INFO['site_name']} API
Learn more", ) + +NOT_FOUND = 404, {"message": "Note not found"} +OK = 200, {"message": "OK"} diff --git a/common/templates/console.html b/common/templates/console.html index 15a31566..26221ebc 100644 --- a/common/templates/console.html +++ b/common/templates/console.html @@ -3,7 +3,7 @@ + href="{{ cdn_url }}/npm/swagger-ui-dist@5.18.2/swagger-ui.min.css"> {{ api.title }} Developer Console {% include "common_libs.html" %} diff --git a/journal/api.py b/journal/api.py deleted file mode 100644 index 1b5ad088..00000000 --- a/journal/api.py +++ /dev/null @@ -1,604 +0,0 @@ -from datetime import datetime -from typing import List - -from django.http import HttpResponse -from ninja import Field, Schema -from ninja.pagination import paginate - -from catalog.common.models import * -from common.api import * - -from .models import ( - Collection, - Mark, - Review, - ShelfType, - Tag, - q_item_in_category, -) - -# Mark - - -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 = "" - rating_grade: int = Field(0, ge=0, le=10) - tags: list[str] = [] - created_time: datetime | None = None - post_to_fediverse: bool = False - - -@api.get( - "/me/shelf/{type}", - response={200: List[MarkSchema], 401: Result, 403: Result}, - tags=["mark"], -) -@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, marks for all categories 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, 302: Result, 401: Result, 403: Result, 404: Result}, - tags=["mark"], -) -def get_mark_by_item(request, item_uuid: str, response: HttpResponse): - """ - Get holding mark on current user's shelf by item uuid - """ - item = Item.get_by_url(item_uuid) - if not item or item.is_deleted: - return 404, {"message": "Item not found"} - if item.merged_to_item: - response["Location"] = f"/api/me/shelf/item/{item.merged_to_item.uuid}" - return 302, {"message": "Item merged", "url": item.merged_to_item.api_url} - 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, 401: Result, 403: Result, 404: Result}, - tags=["mark"], -) -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 or item.is_deleted or item.merged_to_item: - return 404, {"message": "Item not found"} - if mark.created_time and mark.created_time >= timezone.now(): - mark.created_time = None - m = Mark(request.user.identity, item) - m.update( - mark.shelf_type, - mark.comment_text, - mark.rating_grade, - mark.tags, - mark.visibility, - created_time=mark.created_time, - share_to_mastodon=mark.post_to_fediverse, - ) - return 200, {"message": "OK"} - - -@api.delete( - "/me/shelf/item/{item_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["mark"], -) -def delete_mark(request, item_uuid: str): - """ - Remove a holding mark about an item for current user, unlike the web behavior, this does not clean up tags. - """ - item = Item.get_by_url(item_uuid) - if not item: - return 404, {"message": "Item not found"} - m = Mark(request.user.identity, item) - m.delete(keep_tags=True) - return 200, {"message": "OK"} - - -# Review - - -class ReviewSchema(Schema): - url: str - visibility: int = Field(ge=0, le=2) - item: ItemSchema - created_time: datetime - title: str - body: str - html_content: str - - -class ReviewInSchema(Schema): - visibility: int = Field(ge=0, le=2) - created_time: datetime | None = None - title: str - body: str - post_to_fediverse: bool = False - - -@api.get( - "/me/review/", - response={200: List[ReviewSchema], 401: Result, 403: Result}, - tags=["review"], -) -@paginate(PageNumberPagination) -def list_reviews(request, category: AvailableItemCategory | None = None): - """ - Get reviews by current user - - `category` is optional, reviews for all categories will be returned if not specified. - """ - queryset = Review.objects.filter(owner=request.user.identity) - if category: - queryset = queryset.filter(q_item_in_category(category)) # type: ignore[arg-type] - return queryset.prefetch_related("item") - - -@api.get( - "/me/review/item/{item_uuid}", - response={200: ReviewSchema, 401: Result, 403: Result, 404: Result}, - tags=["review"], -) -def get_review_by_item(request, item_uuid: str): - """ - Get review on current user's shelf by item uuid - """ - item = Item.get_by_url(item_uuid) - if not item: - return 404, {"message": "Item not found"} - review = Review.objects.filter(owner=request.user.identity, item=item).first() - if not review: - return 404, {"message": "Review not found"} - return review - - -@api.post( - "/me/review/item/{item_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["review"], -) -def review_item(request, item_uuid: str, review: ReviewInSchema): - """ - Create or update a review about an item for current user. - - `title`, `body` (markdown formatted) and`visibility` are required; - `created_time` is optional, default to now. - if the item is already reviewed, this will update the review. - """ - item = Item.get_by_url(item_uuid) - if not item: - return 404, {"message": "Item not found"} - if review.created_time and review.created_time >= timezone.now(): - review.created_time = None - Review.update_item_review( - item, - request.user.identity, - review.title, - review.body, - review.visibility, - created_time=review.created_time, - share_to_mastodon=review.post_to_fediverse, - ) - return 200, {"message": "OK"} - - -@api.delete( - "/me/review/item/{item_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["review"], -) -def delete_review(request, item_uuid: str): - """ - Remove a review about an item for current user. - """ - item = Item.get_by_url(item_uuid) - if not item: - return 404, {"message": "Item not found"} - Review.update_item_review(item, request.user.identity, None, None) - return 200, {"message": "OK"} - - -# Collection - - -class CollectionSchema(Schema): - uuid: str - url: str - visibility: int = Field(ge=0, le=2) - created_time: datetime - title: str - brief: str - cover: str - html_content: str - - -class CollectionInSchema(Schema): - title: str - brief: str - visibility: int = Field(ge=0, le=2) - - -class CollectionItemSchema(Schema): - item: ItemSchema - note: str - - -class CollectionItemInSchema(Schema): - item_uuid: str - note: str - - -@api.get( - "/me/collection/", - response={200: List[CollectionSchema], 401: Result, 403: Result}, - tags=["collection"], -) -@paginate(PageNumberPagination) -def list_collections(request): - """ - Get collections created by current user - """ - queryset = Collection.objects.filter(owner=request.user.identity) - return queryset - - -@api.get( - "/me/collection/{collection_uuid}", - response={200: CollectionSchema, 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -def get_collection(request, collection_uuid: str): - """ - Get collections by its uuid - """ - c = Collection.get_by_url(collection_uuid) - if not c: - return 404, {"message": "Collection not found"} - if c.owner != request.user.identity: - return 403, {"message": "Not owner"} - return c - - -@api.post( - "/me/collection/", - response={200: CollectionSchema, 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -def create_collection(request, c_in: CollectionInSchema): - """ - Create collection. - - `title`, `brief` (markdown formatted) and `visibility` are required; - """ - c = Collection.objects.create( - owner=request.user.identity, - title=c_in.title, - brief=c_in.brief, - visibility=c_in.visibility, - ) - return c - - -@api.put( - "/me/collection/{collection_uuid}", - response={200: CollectionSchema, 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -def update_collection(request, collection_uuid: str, c_in: CollectionInSchema): - """ - Update collection. - """ - c = Collection.get_by_url(collection_uuid) - if not c: - return 404, {"message": "Collection not found"} - if c.owner != request.user.identity: - return 403, {"message": "Not owner"} - c.title = c_in.title - c.brief = c_in.brief - c.visibility = c_in.visibility - c.save() - return c - - -@api.delete( - "/me/collection/{collection_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -def delete_collection(request, collection_uuid: str): - """ - Remove a collection. - """ - c = Collection.get_by_url(collection_uuid) - if not c: - return 404, {"message": "Collection not found"} - if c.owner != request.user.identity: - return 403, {"message": "Not owner"} - c.delete() - return 200, {"message": "OK"} - - -@api.get( - "/me/collection/{collection_uuid}/item/", - response={200: List[CollectionItemSchema], 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -@paginate(PageNumberPagination) -def collection_list_items(request, collection_uuid: str): - """ - Get items in a collection collections - """ - c = Collection.get_by_url(collection_uuid) - if not c: - return 404, {"message": "Collection not found"} - if c.owner != request.user.identity: - return 403, {"message": "Not owner"} - return c.ordered_members - - -@api.post( - "/me/collection/{collection_uuid}/item/", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -def collection_add_item( - request, collection_uuid: str, collection_item: CollectionItemInSchema -): - """ - Add an item to collection - """ - c = Collection.get_by_url(collection_uuid) - if not c: - return 404, {"message": "Collection not found"} - if c.owner != request.user.identity: - return 403, {"message": "Not owner"} - if not collection_item.item_uuid: - return 404, {"message": "Item not found"} - item = Item.get_by_url(collection_item.item_uuid) - if not item: - return 404, {"message": "Item not found"} - c.append_item(item, note=collection_item.note) - return 200, {"message": "OK"} - - -@api.delete( - "/me/collection/{collection_uuid}/item/{item_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["collection"], -) -def collection_delete_item(request, collection_uuid: str, item_uuid: str): - """ - Remove an item from collection - """ - c = Collection.get_by_url(collection_uuid) - if not c: - return 404, {"message": "Collection not found"} - if c.owner != request.user.identity: - return 403, {"message": "Not owner"} - item = Item.get_by_url(item_uuid) - if not item: - return 404, {"message": "Item not found"} - c.remove_item(item) - return 200, {"message": "OK"} - - -class TagSchema(Schema): - uuid: str - title: str - visibility: int = Field(ge=0, le=2) - - -class TagInSchema(Schema): - title: str - visibility: int = Field(ge=0, le=2) - - -class TagItemSchema(Schema): - item: ItemSchema - - -class TagItemInSchema(Schema): - item_uuid: str - - -@api.get( - "/me/tag/", - response={200: List[TagSchema], 401: Result, 403: Result}, - tags=["tag"], -) -@paginate(PageNumberPagination) -def list_tags(request, title: str | None = None): - """ - Get tags created by current user - - `title` is optional, all tags will be returned if not specified. - """ - queryset = Tag.objects.filter(owner=request.user.identity) - if title: - queryset = queryset.filter(title=Tag.cleanup_title(title)) - return queryset - - -@api.get( - "/me/tag/{tag_uuid}", - response={200: TagSchema, 401: Result, 403: Result, 404: Result}, - tags=["tag"], -) -def get_tag(request, tag_uuid: str): - """ - Get tags by its uuid - """ - tag = Tag.get_by_url(tag_uuid) - if not tag: - return 404, {"message": "Tag not found"} - if tag.owner != request.user.identity: - return 403, {"message": "Not owner"} - return tag - - -@api.post( - "/me/tag/", - response={200: TagSchema, 401: Result, 403: Result, 404: Result}, - tags=["tag"], -) -def create_tag(request, t_in: TagInSchema): - """ - Create tag. - - `title` is required, `visibility` can only be 0 or 2; if tag with same title exists, existing tag will be returned. - """ - title = Tag.cleanup_title(t_in.title) - visibility = 2 if t_in.visibility else 0 - tag, created = Tag.objects.get_or_create( - owner=request.user.identity, - title=title, - defaults={"visibility": visibility}, - ) - if not created: - tag.visibility = visibility - tag.save() - return tag - - -@api.put( - "/me/tag/{tag_uuid}", - response={200: TagSchema, 401: Result, 403: Result, 404: Result, 409: Result}, - tags=["tag"], -) -def update_tag(request, tag_uuid: str, t_in: TagInSchema): - """ - Update tag. - - rename tag with an existing title will return HTTP 409 error - """ - tag = Tag.get_by_url(tag_uuid) - if not tag: - return 404, {"message": "Tag not found"} - if tag.owner != request.user.identity: - return 403, {"message": "Not owner"} - title = Tag.cleanup_title(tag.title) - visibility = 2 if t_in.visibility else 0 - if title != tag.title: - try: - tag.title = title - tag.visibility = visibility - tag.save() - except Exception: - return 409, {"message": "Tag with same title exists"} - return tag - - -@api.delete( - "/me/tag/{tag_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["tag"], -) -def delete_tag(request, tag_uuid: str): - """ - Remove a tag. - """ - tag = Tag.get_by_url(tag_uuid) - if not tag: - return 404, {"message": "Tag not found"} - if tag.owner != request.user.identity: - return 403, {"message": "Not owner"} - tag.delete() - return 200, {"message": "OK"} - - -@api.get( - "/me/tag/{tag_uuid}/item/", - response={200: List[TagItemSchema], 401: Result, 403: Result, 404: Result}, - tags=["tag"], -) -@paginate(PageNumberPagination) -def tag_list_items(request, tag_uuid: str): - """ - Get items in a tag tags - """ - tag = Tag.get_by_url(tag_uuid) - if not tag: - return 404, {"message": "Tag not found"} - if tag.owner != request.user.identity: - return 403, {"message": "Not owner"} - return tag.members.all() - - -@api.post( - "/me/tag/{tag_uuid}/item/", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["tag"], -) -def tag_add_item(request, tag_uuid: str, tag_item: TagItemInSchema): - """ - Add an item to tag - """ - tag = Tag.get_by_url(tag_uuid) - if not tag: - return 404, {"message": "Tag not found"} - if tag.owner != request.user.identity: - return 403, {"message": "Not owner"} - if not tag_item.item_uuid: - return 404, {"message": "Item not found"} - item = Item.get_by_url(tag_item.item_uuid) - if not item: - return 404, {"message": "Item not found"} - tag.append_item(item) - return 200, {"message": "OK"} - - -@api.delete( - "/me/tag/{tag_uuid}/item/{item_uuid}", - response={200: Result, 401: Result, 403: Result, 404: Result}, - tags=["tag"], -) -def tag_delete_item(request, tag_uuid: str, item_uuid: str): - """ - Remove an item from tag - """ - tag = Tag.get_by_url(tag_uuid) - if not tag: - return 404, {"message": "Tag not found"} - if tag.owner != request.user.identity: - return 403, {"message": "Not owner"} - item = Item.get_by_url(item_uuid) - if not item: - return 404, {"message": "Item not found"} - tag.remove_item(item) - return 200, {"message": "OK"} diff --git a/journal/apis/__init__.py b/journal/apis/__init__.py new file mode 100644 index 00000000..0cf9e977 --- /dev/null +++ b/journal/apis/__init__.py @@ -0,0 +1,5 @@ +from .collection import * # noqa +from .note import * # noqa +from .review import * # noqa +from .shelf import * # noqa +from .tag import * # noqa diff --git a/journal/apis/collection.py b/journal/apis/collection.py new file mode 100644 index 00000000..37b33678 --- /dev/null +++ b/journal/apis/collection.py @@ -0,0 +1,192 @@ +from datetime import datetime +from typing import List + +from ninja import Field, Schema +from ninja.pagination import paginate + +from catalog.common.models import Item, ItemSchema +from common.api import PageNumberPagination, Result, api + +from ..models import Collection + + +class CollectionSchema(Schema): + uuid: str + url: str + + visibility: int = Field(ge=0, le=2) + created_time: datetime + title: str + brief: str + cover: str + html_content: str + + +class CollectionInSchema(Schema): + title: str + brief: str + visibility: int = Field(ge=0, le=2) + + +class CollectionItemSchema(Schema): + item: ItemSchema + note: str + + +class CollectionItemInSchema(Schema): + item_uuid: str + note: str + + +@api.get( + "/me/collection/", + response={200: List[CollectionSchema], 401: Result, 403: Result}, + tags=["collection"], +) +@paginate(PageNumberPagination) +def list_collections(request): + """ + Get collections created by current user + """ + queryset = Collection.objects.filter(owner=request.user.identity) + return queryset + + +@api.get( + "/me/collection/{collection_uuid}", + response={200: CollectionSchema, 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +def get_collection(request, collection_uuid: str): + """ + Get collections by its uuid + """ + c = Collection.get_by_url(collection_uuid) + if not c: + return 404, {"message": "Collection not found"} + if c.owner != request.user.identity: + return 403, {"message": "Not owner"} + return c + + +@api.post( + "/me/collection/", + response={200: CollectionSchema, 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +def create_collection(request, c_in: CollectionInSchema): + """ + Create collection. + + `title`, `brief` (markdown formatted) and `visibility` are required; + """ + c = Collection.objects.create( + owner=request.user.identity, + title=c_in.title, + brief=c_in.brief, + visibility=c_in.visibility, + ) + return c + + +@api.put( + "/me/collection/{collection_uuid}", + response={200: CollectionSchema, 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +def update_collection(request, collection_uuid: str, c_in: CollectionInSchema): + """ + Update collection. + """ + c = Collection.get_by_url(collection_uuid) + if not c: + return 404, {"message": "Collection not found"} + if c.owner != request.user.identity: + return 403, {"message": "Not owner"} + c.title = c_in.title + c.brief = c_in.brief + c.visibility = c_in.visibility + c.save() + return c + + +@api.delete( + "/me/collection/{collection_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +def delete_collection(request, collection_uuid: str): + """ + Remove a collection. + """ + c = Collection.get_by_url(collection_uuid) + if not c: + return 404, {"message": "Collection not found"} + if c.owner != request.user.identity: + return 403, {"message": "Not owner"} + c.delete() + return 200, {"message": "OK"} + + +@api.get( + "/me/collection/{collection_uuid}/item/", + response={200: List[CollectionItemSchema], 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +@paginate(PageNumberPagination) +def collection_list_items(request, collection_uuid: str): + """ + Get items in a collection collections + """ + c = Collection.get_by_url(collection_uuid) + if not c: + return 404, {"message": "Collection not found"} + if c.owner != request.user.identity: + return 403, {"message": "Not owner"} + return c.ordered_members + + +@api.post( + "/me/collection/{collection_uuid}/item/", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +def collection_add_item( + request, collection_uuid: str, collection_item: CollectionItemInSchema +): + """ + Add an item to collection + """ + c = Collection.get_by_url(collection_uuid) + if not c: + return 404, {"message": "Collection not found"} + if c.owner != request.user.identity: + return 403, {"message": "Not owner"} + if not collection_item.item_uuid: + return 404, {"message": "Item not found"} + item = Item.get_by_url(collection_item.item_uuid) + if not item: + return 404, {"message": "Item not found"} + c.append_item(item, note=collection_item.note) + return 200, {"message": "OK"} + + +@api.delete( + "/me/collection/{collection_uuid}/item/{item_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["collection"], +) +def collection_delete_item(request, collection_uuid: str, item_uuid: str): + """ + Remove an item from collection + """ + c = Collection.get_by_url(collection_uuid) + if not c: + return 404, {"message": "Collection not found"} + if c.owner != request.user.identity: + return 403, {"message": "Not owner"} + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + c.remove_item(item) + return 200, {"message": "OK"} diff --git a/journal/apis/note.py b/journal/apis/note.py new file mode 100644 index 00000000..7efa1b84 --- /dev/null +++ b/journal/apis/note.py @@ -0,0 +1,112 @@ +from datetime import datetime +from typing import List + +from ninja import Field, Schema +from ninja.pagination import paginate + +from catalog.common.models import Item, ItemSchema +from common.api import NOT_FOUND, OK, PageNumberPagination, Result, api + +from ..models import Note + + +class NoteSchema(Schema): + uuid: str + item: ItemSchema + title: str + content: str + sensitive: bool = False + progress_type: Note.ProgressType | None = None + progress_value: str | None = None + visibility: int = Field(ge=0, le=2) + created_time: datetime + + +class NoteInSchema(Schema): + title: str + content: str + sensitive: bool = False + progress_type: Note.ProgressType | None = None + progress_value: str | None = None + visibility: int = Field(ge=0, le=2) + post_to_fediverse: bool = False + + +@api.get( + "/me/note/item/{item_uuid}/", + response={200: List[NoteSchema], 401: Result, 403: Result}, + tags=["note"], +) +@paginate(PageNumberPagination) +def list_notes_for_item(request, item_uuid): + """ + List notes by current user for an item + """ + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + queryset = Note.objects.filter(owner=request.user.identity, item=item) + return queryset.prefetch_related("item") + + +@api.post( + "/me/note/item/{item_uuid}/", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["note"], +) +def add_note_for_item(request, item_uuid: str, n_in: NoteInSchema): + """ + Add a note for an item + """ + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + note = Note() + note.title = n_in.title + note.content = n_in.content + note.sensitive = n_in.sensitive + note.progress_type = n_in.progress_type + note.progress_value = n_in.progress_value + note.visibility = n_in.visibility + note.crosspost_when_save = n_in.post_to_fediverse + note.save() + return 200, {"message": "OK"} + + +@api.put( + "/me/note/{note_uuid}", + response={200: NoteSchema, 401: Result, 403: Result, 404: Result}, + tags=["note"], +) +def update_note(request, tag_uuid: str, n_in: NoteInSchema): + """ + Update a note. + """ + note = Note.get_by_url_and_owner(tag_uuid, request.user.identity.pk) + if not note: + return NOT_FOUND + note.title = n_in.title + note.content = n_in.content + note.sensitive = n_in.sensitive + note.progress_type = n_in.progress_type + note.progress_value = n_in.progress_value + note.visibility = n_in.visibility + note.crosspost_when_save = n_in.post_to_fediverse + note.save() + return note + + +@api.delete( + "/me/note/{note_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["note"], +) +def delete_note(request, tag_uuid: str): + """ + Delete a note. + """ + note = Note.get_by_url_and_owner(tag_uuid, request.user.identity.pk) + if not note: + return NOT_FOUND + note.delete() + return OK diff --git a/journal/apis/review.py b/journal/apis/review.py new file mode 100644 index 00000000..93d6a725 --- /dev/null +++ b/journal/apis/review.py @@ -0,0 +1,115 @@ +from datetime import datetime +from typing import List + +from django.utils import timezone +from ninja import Field, Schema +from ninja.pagination import paginate + +from catalog.common.models import AvailableItemCategory, Item, ItemSchema +from common.api import PageNumberPagination, Result, api + +from ..models import ( + Review, + q_item_in_category, +) + + +class ReviewSchema(Schema): + url: str + + visibility: int = Field(ge=0, le=2) + item: ItemSchema + created_time: datetime + title: str + body: str + html_content: str + + +class ReviewInSchema(Schema): + visibility: int = Field(ge=0, le=2) + created_time: datetime | None = None + title: str + body: str + post_to_fediverse: bool = False + + +@api.get( + "/me/review/", + response={200: List[ReviewSchema], 401: Result, 403: Result}, + tags=["review"], +) +@paginate(PageNumberPagination) +def list_reviews(request, category: AvailableItemCategory | None = None): + """ + Get reviews by current user + + `category` is optional, reviews for all categories will be returned if not specified. + """ + queryset = Review.objects.filter(owner=request.user.identity) + if category: + queryset = queryset.filter(q_item_in_category(category)) # type: ignore[arg-type] + return queryset.prefetch_related("item") + + +@api.get( + "/me/review/item/{item_uuid}", + response={200: ReviewSchema, 401: Result, 403: Result, 404: Result}, + tags=["review"], +) +def get_review_by_item(request, item_uuid: str): + """ + Get review on current user's shelf by item uuid + """ + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + review = Review.objects.filter(owner=request.user.identity, item=item).first() + if not review: + return 404, {"message": "Review not found"} + return review + + +@api.post( + "/me/review/item/{item_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["review"], +) +def review_item(request, item_uuid: str, review: ReviewInSchema): + """ + Create or update a review about an item for current user. + + `title`, `body` (markdown formatted) and`visibility` are required; + `created_time` is optional, default to now. + if the item is already reviewed, this will update the review. + """ + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + if review.created_time and review.created_time >= timezone.now(): + review.created_time = None + Review.update_item_review( + item, + request.user.identity, + review.title, + review.body, + review.visibility, + created_time=review.created_time, + share_to_mastodon=review.post_to_fediverse, + ) + return 200, {"message": "OK"} + + +@api.delete( + "/me/review/item/{item_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["review"], +) +def delete_review(request, item_uuid: str): + """ + Remove a review about an item for current user. + """ + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + Review.update_item_review(item, request.user.identity, None, None) + return 200, {"message": "OK"} diff --git a/journal/apis/shelf.py b/journal/apis/shelf.py new file mode 100644 index 00000000..66cf8555 --- /dev/null +++ b/journal/apis/shelf.py @@ -0,0 +1,128 @@ +from datetime import datetime +from typing import List + +from django.http import HttpResponse +from django.utils import timezone +from ninja import Field, Schema +from ninja.pagination import paginate + +from catalog.common.models import AvailableItemCategory, Item, ItemSchema +from common.api import PageNumberPagination, Result, api + +from ..models import ( + Mark, + ShelfType, +) + + +# Mark +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 = "" + rating_grade: int = Field(0, ge=0, le=10) + tags: list[str] = [] + created_time: datetime | None = None + post_to_fediverse: bool = False + + +@api.get( + "/me/shelf/{type}", + response={200: List[MarkSchema], 401: Result, 403: Result}, + tags=["mark"], +) +@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, marks for all categories 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, 302: Result, 401: Result, 403: Result, 404: Result}, + tags=["mark"], +) +def get_mark_by_item(request, item_uuid: str, response: HttpResponse): + """ + Get holding mark on current user's shelf by item uuid + """ + item = Item.get_by_url(item_uuid) + if not item or item.is_deleted: + return 404, {"message": "Item not found"} + if item.merged_to_item: + response["Location"] = f"/api/me/shelf/item/{item.merged_to_item.uuid}" + return 302, {"message": "Item merged", "url": item.merged_to_item.api_url} + 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, 401: Result, 403: Result, 404: Result}, + tags=["mark"], +) +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 or item.is_deleted or item.merged_to_item: + return 404, {"message": "Item not found"} + if mark.created_time and mark.created_time >= timezone.now(): + mark.created_time = None + m = Mark(request.user.identity, item) + m.update( + mark.shelf_type, + mark.comment_text, + mark.rating_grade, + mark.tags, + mark.visibility, + created_time=mark.created_time, + share_to_mastodon=mark.post_to_fediverse, + ) + return 200, {"message": "OK"} + + +@api.delete( + "/me/shelf/item/{item_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["mark"], +) +def delete_mark(request, item_uuid: str): + """ + Remove a holding mark about an item for current user, unlike the web behavior, this does not clean up tags. + """ + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + m = Mark(request.user.identity, item) + m.delete(keep_tags=True) + return 200, {"message": "OK"} diff --git a/journal/apis/tag.py b/journal/apis/tag.py new file mode 100644 index 00000000..5b821854 --- /dev/null +++ b/journal/apis/tag.py @@ -0,0 +1,195 @@ +from typing import List + +from ninja import Field, Schema +from ninja.pagination import paginate + +from catalog.common.models import Item, ItemSchema +from common.api import PageNumberPagination, Result, api + +from ..models import Tag + + +class TagSchema(Schema): + uuid: str + title: str + visibility: int = Field(ge=0, le=2) + + +class TagInSchema(Schema): + title: str + visibility: int = Field(ge=0, le=2) + + +class TagItemSchema(Schema): + item: ItemSchema + + +class TagItemInSchema(Schema): + item_uuid: str + + +@api.get( + "/me/tag/", + response={200: List[TagSchema], 401: Result, 403: Result}, + tags=["tag"], +) +@paginate(PageNumberPagination) +def list_tags(request, title: str | None = None): + """ + Get tags created by current user + + `title` is optional, all tags will be returned if not specified. + """ + queryset = Tag.objects.filter(owner=request.user.identity) + if title: + queryset = queryset.filter(title=Tag.cleanup_title(title)) + return queryset + + +@api.get( + "/me/tag/{tag_uuid}", + response={200: TagSchema, 401: Result, 403: Result, 404: Result}, + tags=["tag"], +) +def get_tag(request, tag_uuid: str): + """ + Get tags by its uuid + """ + tag = Tag.get_by_url(tag_uuid) + if not tag: + return 404, {"message": "Tag not found"} + if tag.owner != request.user.identity: + return 403, {"message": "Not owner"} + return tag + + +@api.post( + "/me/tag/", + response={200: TagSchema, 401: Result, 403: Result, 404: Result}, + tags=["tag"], +) +def create_tag(request, t_in: TagInSchema): + """ + Create tag. + + `title` is required, `visibility` can only be 0 or 2; if tag with same title exists, existing tag will be returned. + """ + title = Tag.cleanup_title(t_in.title) + visibility = 2 if t_in.visibility else 0 + tag, created = Tag.objects.get_or_create( + owner=request.user.identity, + title=title, + defaults={"visibility": visibility}, + ) + if not created: + tag.visibility = visibility + tag.save() + return tag + + +@api.put( + "/me/tag/{tag_uuid}", + response={200: TagSchema, 401: Result, 403: Result, 404: Result, 409: Result}, + tags=["tag"], +) +def update_tag(request, tag_uuid: str, t_in: TagInSchema): + """ + Update tag. + + rename tag with an existing title will return HTTP 409 error + """ + tag = Tag.get_by_url(tag_uuid) + if not tag: + return 404, {"message": "Tag not found"} + if tag.owner != request.user.identity: + return 403, {"message": "Not owner"} + title = Tag.cleanup_title(tag.title) + visibility = 2 if t_in.visibility else 0 + if title != tag.title: + try: + tag.title = title + tag.visibility = visibility + tag.save() + except Exception: + return 409, {"message": "Tag with same title exists"} + return tag + + +@api.delete( + "/me/tag/{tag_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["tag"], +) +def delete_tag(request, tag_uuid: str): + """ + Remove a tag. + """ + tag = Tag.get_by_url(tag_uuid) + if not tag: + return 404, {"message": "Tag not found"} + if tag.owner != request.user.identity: + return 403, {"message": "Not owner"} + tag.delete() + return 200, {"message": "OK"} + + +@api.get( + "/me/tag/{tag_uuid}/item/", + response={200: List[TagItemSchema], 401: Result, 403: Result, 404: Result}, + tags=["tag"], +) +@paginate(PageNumberPagination) +def tag_list_items(request, tag_uuid: str): + """ + Get items in a tag tags + """ + tag = Tag.get_by_url(tag_uuid) + if not tag: + return 404, {"message": "Tag not found"} + if tag.owner != request.user.identity: + return 403, {"message": "Not owner"} + return tag.members.all() + + +@api.post( + "/me/tag/{tag_uuid}/item/", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["tag"], +) +def tag_add_item(request, tag_uuid: str, tag_item: TagItemInSchema): + """ + Add an item to tag + """ + tag = Tag.get_by_url(tag_uuid) + if not tag: + return 404, {"message": "Tag not found"} + if tag.owner != request.user.identity: + return 403, {"message": "Not owner"} + if not tag_item.item_uuid: + return 404, {"message": "Item not found"} + item = Item.get_by_url(tag_item.item_uuid) + if not item: + return 404, {"message": "Item not found"} + tag.append_item(item) + return 200, {"message": "OK"} + + +@api.delete( + "/me/tag/{tag_uuid}/item/{item_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, + tags=["tag"], +) +def tag_delete_item(request, tag_uuid: str, item_uuid: str): + """ + Remove an item from tag + """ + tag = Tag.get_by_url(tag_uuid) + if not tag: + return 404, {"message": "Tag not found"} + if tag.owner != request.user.identity: + return 403, {"message": "Not owner"} + item = Item.get_by_url(item_uuid) + if not item: + return 404, {"message": "Item not found"} + tag.remove_item(item) + return 200, {"message": "OK"} diff --git a/journal/apps.py b/journal/apps.py index 9d8477a0..55ec8a3f 100644 --- a/journal/apps.py +++ b/journal/apps.py @@ -9,7 +9,7 @@ class JournalConfig(AppConfig): # load key modules in proper order, make sure class inject and signal works as expected from catalog.models import Indexer - from . import api # noqa + from . import apis # noqa from .models import Rating, Tag Indexer.register_list_model(Tag) diff --git a/journal/models/collection.py b/journal/models/collection.py index 32c61bd0..3a07ef4e 100644 --- a/journal/models/collection.py +++ b/journal/models/collection.py @@ -49,6 +49,8 @@ class Collection(List): if TYPE_CHECKING: members: models.QuerySet[CollectionMember] url_path = "collection" + post_when_save = True + index_when_save = True MEMBER_CLASS = CollectionMember catalog_item = models.OneToOneField( CatalogCollection, on_delete=models.PROTECT, related_name="journal_item" @@ -116,8 +118,6 @@ class Collection(List): self.catalog_item.cover = self.cover self.catalog_item.save() super().save(*args, **kwargs) - self.sync_to_timeline() - self.update_index() def get_ap_data(self): return { diff --git a/journal/models/comment.py b/journal/models/comment.py index 47d2ca07..36928986 100644 --- a/journal/models/comment.py +++ b/journal/models/comment.py @@ -38,7 +38,7 @@ class Comment(Content): return d @classmethod - def update_by_ap_object(cls, owner, item, obj, post): + def update_by_ap_object(cls, owner, item, obj, post, crosspost=None): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed diff --git a/journal/models/common.py b/journal/models/common.py index cd3f19ac..918da578 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -106,11 +106,32 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): posts = models.ManyToManyField( "takahe.Post", related_name="pieces", through="PiecePost" ) + previous_visibility: int | None = None + post_when_save: bool = False + crosspost_when_save: bool = False + index_when_save: bool = False @property def classname(self) -> str: return self.__class__.__name__.lower() + @classmethod + def from_db(cls, db, field_names, values): + instance = super().from_db(db, field_names, values) + instance.previous_visibility = instance.visibility + return instance + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.local and self.post_when_save: + visibility_changed = self.previous_visibility != self.visibility + self.previous_visibility = self.visibility + self.sync_to_timeline(1 if visibility_changed else 0) + if self.crosspost_when_save: + self.sync_to_social_accounts(0) + if self.index_when_save: + self.update_index() + def delete(self, *args, **kwargs): if self.local: self.delete_from_timeline() @@ -173,6 +194,19 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): obj = None return obj + @classmethod + def get_by_url_and_owner(cls, url_or_b62, owner_id): + b62 = url_or_b62.strip().split("/")[-1] + if len(b62) not in [21, 22]: + r = re.search(r"[A-Za-z0-9]{21,22}", url_or_b62) + if r: + b62 = r[0] + try: + obj = cls.objects.get(uid=uuid.UUID(int=b62_decode(b62)), owner_id=owner_id) + except Exception: + obj = None + return obj + @classmethod def get_by_post_id(cls, post_id: int): pp = PiecePost.objects.filter(post_id=post_id).first() @@ -236,7 +270,12 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): @classmethod def update_by_ap_object( - cls, owner: APIdentity, item: Item, obj, post: "Post" + cls, + owner: APIdentity, + item: Item, + obj, + post: "Post", + crosspost: bool | None = False, ) -> Self | None: """ Create or update a content piece with related AP message @@ -257,6 +296,8 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): d["edited_time"] = edited for k, v in d.items(): setattr(p, k, v) + if crosspost is not None: + p.crosspost_when_save = crosspost p.save(update_fields=d.keys()) else: # no previously linked piece, create a new one and link to post @@ -275,7 +316,10 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): else: d["created_time"] = datetime.fromisoformat(obj["published"]) d["edited_time"] = datetime.fromisoformat(obj["updated"]) - p = cls.objects.create(**d) + p = cls(**d) + if crosspost is not None: + p.crosspost_when_save = crosspost + p.save() p.link_post_id(post.id) # subclass may have to add additional code to update type_data in local post return p diff --git a/journal/models/note.py b/journal/models/note.py index 21dbb2e8..0aa1f750 100644 --- a/journal/models/note.py +++ b/journal/models/note.py @@ -27,6 +27,9 @@ _separaters = {"–", "―", "−", "—", "-"} class Note(Content): + post_when_save = True + index_when_save = True + class ProgressType(models.TextChoices): PAGE = "page", _("Page") CHAPTER = "chapter", _("Chapter") @@ -150,16 +153,13 @@ class Note(Content): @override @classmethod - def update_by_ap_object(cls, owner, item, obj, post): - # new_piece = cls.get_by_post_id(post.id) is None - p = super().update_by_ap_object(owner, item, obj, post) - if p and p.local: - # if local piece is created from a post, update post type_data and fanout - p.sync_to_timeline() - if owner.user.preference.mastodon_default_repost and owner.user.mastodon: - p.sync_to_social_accounts() - p.update_index() - return p + def update_by_ap_object(cls, owner, item, obj, post, crosspost=None): + crosspost = ( + owner.local + and owner.user.preference.mastodon_default_repost + and owner.user.mastodon is not None + ) + return super().update_by_ap_object(owner, item, obj, post, crosspost) @cached_property def shelfmember(self) -> ShelfMember | None: diff --git a/journal/models/rating.py b/journal/models/rating.py index eefc1ba1..b2f4d09f 100644 --- a/journal/models/rating.py +++ b/journal/models/rating.py @@ -39,7 +39,7 @@ class Rating(Content): } @classmethod - def update_by_ap_object(cls, owner, item, obj, post): + def update_by_ap_object(cls, owner, item, obj, post, crosspost=None): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed diff --git a/journal/models/review.py b/journal/models/review.py index bc67c432..b3b27433 100644 --- a/journal/models/review.py +++ b/journal/models/review.py @@ -25,6 +25,8 @@ _RE_SPOILER_TAG = re.compile(r'<(div|span)\sclass="spoiler">.*') class Review(Content): url_path = "review" + post_when_save = True + index_when_save = True title = models.CharField(max_length=500, blank=False, null=False) body = MarkdownxField() @@ -64,7 +66,7 @@ class Review(Content): } @classmethod - def update_by_ap_object(cls, owner, item, obj, post): + def update_by_ap_object(cls, owner, item, obj, post, crosspost=None): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed @@ -142,12 +144,10 @@ class Review(Content): share_to_mastodon: bool = False, ): review = Review.objects.filter(owner=owner, item=item).first() - delete_existing_post = False if review is not None: if title is None: review.delete() return - delete_existing_post = review.visibility != visibility defaults = { "title": title, "body": body, @@ -157,14 +157,13 @@ class Review(Content): defaults["created_time"] = ( created_time if created_time < timezone.now() else timezone.now() ) - review, created = cls.objects.update_or_create( - item=item, owner=owner, defaults=defaults - ) - update_mode = 1 if delete_existing_post else 0 - review.sync_to_timeline(update_mode) - if share_to_mastodon: - review.sync_to_social_accounts(update_mode) - review.update_index() + if not review: + review = Review(item=item, owner=owner, **defaults) + else: + for name, value in defaults.items(): + setattr(review, name, value) + review.crosspost_when_save = share_to_mastodon + review.save() return review def to_indexable_doc(self) -> dict[str, Any]: diff --git a/journal/models/shelf.py b/journal/models/shelf.py index 0a4b88e1..c41a2994 100644 --- a/journal/models/shelf.py +++ b/journal/models/shelf.py @@ -338,7 +338,9 @@ class ShelfMember(ListMember): } @classmethod - def update_by_ap_object(cls, owner: APIdentity, item: Item, obj: dict, post): + def update_by_ap_object( + cls, owner: APIdentity, item: Item, obj: dict, post, crosspost=None + ): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed diff --git a/journal/views/note.py b/journal/views/note.py index 1a1c3b05..74d10f95 100644 --- a/journal/views/note.py +++ b/journal/views/note.py @@ -98,19 +98,8 @@ def note_edit(request: AuthedHttpRequest, item_uuid: str, note_uuid: str = ""): raise Http404(_("Content not found")) note.delete() return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) - if note: - orig_visibility = note.visibility - else: - orig_visibility = None if not form.is_valid(): raise BadRequest(_("Invalid form data")) + form.instance.crosspost_when_save = form.cleaned_data["share_to_mastodon"] note = form.save() - delete_existing_post = ( - orig_visibility is not None and orig_visibility != note.visibility - ) - update_mode = 1 if delete_existing_post else 0 - note.sync_to_timeline(update_mode) - if form.cleaned_data["share_to_mastodon"]: - note.sync_to_social_accounts(update_mode) - note.update_index() return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) diff --git a/neodb-takahe b/neodb-takahe index bf287254..7ff7d545 160000 --- a/neodb-takahe +++ b/neodb-takahe @@ -1 +1 @@ -Subproject commit bf2872547f95fd6dd8b5fea08682f3eb63ef8c99 +Subproject commit 7ff7d545cfb5e79acbecdf9ee3dbf2d06321ed28