diff --git a/README.md b/README.md index 3885add0..7b5972f6 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Boofilsic/NeoDB is an open source project and free service to help users manage, + view home feed with friends' activities * every activity can be set as viewable to self/follower-only/public * eligible items, e.g. podcasts and albums, are playable in feed - + link Fediverse account and import social graph + + link Fediverse account and import social graph + share collections and reviews to Fediverse ~~and Twitter~~ feed + there's a plan to add ActivityPub support in the near future @@ -48,8 +48,9 @@ Please see [doc/install.md](doc/install.md) - to file a bug or request new features for NeoDB, please contact NeoDB on [Fediverse](https://mastodon.social/@neodb) or [Twitter](https://twitter.com/NeoDBsocial) ## Contribution - - Please see [doc/development.md](doc/development.md) for some basics to start with - - Join our Discord community, links available on [our Fediverse profile](https://mastodon.social/@neodb) + - To build application with NeoDB API, documentation is available in [NeoDB API Developer Console](https://neodb.social/developer/) + - To help develop NeoDB, please see [doc/development.md](doc/development.md) for some basics to start with + - Join our Discord community to share your ideas/questions/creations, links available on [our Fediverse profile](https://mastodon.social/@neodb) ## Sponsor If you like this project, please consider sponsoring diff --git a/common/api.py b/common/api.py index cf19592f..233434ca 100644 --- a/common/api.py +++ b/common/api.py @@ -45,8 +45,10 @@ class RedirectedResult(Schema): class PageNumberPagination(NinjaPageNumberPagination): + items_attribute = "data" + class Output(Schema): - items: List[Any] + data: List[Any] pages: int count: int @@ -57,6 +59,7 @@ class PageNumberPagination(NinjaPageNumberPagination): **params: Any, ) -> Output: val = super().paginate_queryset(queryset, pagination, **params) + val["data"] = val["items"] val["pages"] = (val["count"] + self.page_size - 1) // self.page_size return val diff --git a/common/templates/developer.html b/common/templates/developer.html index e6cd53e3..8368f0be 100644 --- a/common/templates/developer.html +++ b/common/templates/developer.html @@ -28,13 +28,13 @@
diff --git a/journal/api.py b/journal/api.py index 68f9e245..35af1ea5 100644 --- a/journal/api.py +++ b/journal/api.py @@ -32,8 +32,7 @@ class MarkInSchema(Schema): post_to_fediverse: bool = False -@api.get("/me/shelf", response={200: List[MarkSchema], 403: Result}) -@protected_resource() +@api.get("/me/shelf/{type}", response={200: List[MarkSchema], 401: Result, 403: Result}) @paginate(PageNumberPagination) def list_marks_on_shelf( request, type: ShelfType, category: AvailableItemCategory | None = None @@ -42,7 +41,7 @@ def list_marks_on_shelf( 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. + `category` is optional, marks for all categories will be returned if not specified. """ queryset = request.user.shelf_manager.get_latest_members( type, category @@ -52,10 +51,8 @@ def list_marks_on_shelf( @api.get( "/me/shelf/item/{item_uuid}", - response={200: MarkSchema, 403: Result, 404: Result}, - auth=OAuthAccessTokenAuth(), + response={200: MarkSchema, 401: Result, 403: Result, 404: Result}, ) -# @protected_resource() def get_mark_by_item(request, item_uuid: str): """ Get holding mark on current user's shelf by item uuid @@ -70,9 +67,9 @@ def get_mark_by_item(request, item_uuid: str): @api.post( - "/me/shelf/item/{item_uuid}", response={200: Result, 403: Result, 404: Result} + "/me/shelf/item/{item_uuid}", + response={200: Result, 401: 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. @@ -102,9 +99,9 @@ def mark_item(request, item_uuid: str, mark: MarkInSchema): @api.delete( - "/me/shelf/item/{item_uuid}", response={200: Result, 403: Result, 404: Result} + "/me/shelf/item/{item_uuid}", + response={200: Result, 401: Result, 403: Result, 404: Result}, ) -@protected_resource() def delete_mark(request, item_uuid: str): """ Remove a holding mark about an item for current user. @@ -118,16 +115,106 @@ def delete_mark(request, item_uuid: str): 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}") +class ReviewSchema(Schema): + 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 + title: str + body: str + post_to_fediverse: bool = False + + +@api.get("/me/review/", response={200: List[ReviewSchema], 401: Result, 403: Result}) +@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) + if category: + queryset = queryset.filter(query_item_category(category)) + return queryset.prefetch_related("item") + + +@api.get( + "/me/review/item/{item_uuid}", + response={200: ReviewSchema, 401: Result, 403: Result, 404: Result}, +) +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, 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}, +) +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"} + Review.review_item_by_user( + item, + request.user, + 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}, +) +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.review_item_by_user(item, request.user, None, None) + return 200, {"message": "OK"} + # @api.get("/me/collection/") +# @api.post("/me/collection/") # @api.get("/me/collection/{uuid}") -# @api.post("/me/collection/{uuid}") +# @api.put("/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") +# @api.get("/me/collection/{uuid}/item/") +# @api.post("/me/collection/{uuid}/item/") + +# @api.get("/me/tag/") +# @api.post("/me/tag/") +# @api.get("/me/tag/{title}") +# @api.put("/me/tag/{title}") +# @api.delete("/me/tag/{title}") diff --git a/journal/models.py b/journal/models.py index 9b305205..6d98133e 100644 --- a/journal/models.py +++ b/journal/models.py @@ -1,5 +1,6 @@ from django.db import models from polymorphic.models import PolymorphicModel +from mastodon.api import share_review from users.models import User from catalog.common.models import Item, ItemCategory from .mixins import UserOwnedObjectMixin @@ -286,31 +287,36 @@ class Review(Content): def rating_grade(self): return Rating.get_item_rating_by_user(self.item, self.owner) - @staticmethod - def review_item_by_user(item, user, title, body, metadata={}, visibility=0): - # allow multiple reviews per item per user. - review = Review.objects.create( - owner=user, - item=item, - title=title, - body=body, - metadata=metadata, - visibility=visibility, - ) - """ - review = Review.objects.filter(owner=user, item=item).first() + @classmethod + def review_item_by_user( + cls, + item, + user, + title, + body, + visibility=0, + created_time=None, + share_to_mastodon=False, + ): if title is None: + review = Review.objects.filter(owner=user, item=item).first() if review is not None: review.delete() - review = None - elif review is None: - review = Review.objects.create(owner=user, item=item, title=title, body=body, visibility=visibility) - else: - review.title = title - review.body = body - review.visibility = visibility - review.save() - """ + return None + defaults = { + "title": title, + "body": body, + "visibility": visibility, + } + if created_time: + defaults["created_time"] = ( + created_time if created_time < timezone.now() else timezone.now() + ) + review, created = cls.objects.update_or_create( + item=item, owner=user, defaults=defaults + ) + if share_to_mastodon: + share_review(review) return review diff --git a/journal/views.py b/journal/views.py index 67060b98..1ffbc852 100644 --- a/journal/views.py +++ b/journal/views.py @@ -563,32 +563,29 @@ def review_edit(request, item_uuid, review_uuid=None): else ReviewForm(request.POST) ) if form.is_valid(): - if not review: - form.instance.owner = request.user - form.instance.edited_time = timezone.now() mark_date = None if request.POST.get("mark_anotherday"): mark_date = timezone.get_current_timezone().localize( parse_datetime(request.POST.get("mark_date") + " 20:00:00") ) - if mark_date and mark_date >= timezone.now(): - mark_date = None - if mark_date: - form.instance.created_time = mark_date + body = form.instance.body if request.POST.get("leading_space"): - form.instance.body = re.sub( + body = re.sub( r"^(\u2003*)( +)", lambda s: "\u2003" * ((len(s[2]) + 1) // 2 + len(s[1])), - form.instance.body, + body, flags=re.MULTILINE, ) - form.save() - if form.cleaned_data["share_to_mastodon"]: - if not share_review(form.instance): - return render_relogin(request) - return redirect( - reverse("journal:review_retrieve", args=[form.instance.uuid]) + review = Review.review_item_by_user( + item, + request.user, + form.cleaned_data["title"], + body, + form.cleaned_data["visibility"], + mark_date, + form.cleaned_data["share_to_mastodon"], ) + return redirect(reverse("journal:review_retrieve", args=[review.uuid])) else: raise BadRequest() else: diff --git a/users/api.py b/users/api.py index e5201d61..b30c1831 100644 --- a/users/api.py +++ b/users/api.py @@ -5,6 +5,7 @@ from ninja.security import django_auth class UserSchema(Schema): + url: str external_acct: str display_name: str avatar: str @@ -12,10 +13,9 @@ class UserSchema(Schema): @api.get( "/me", - response={200: UserSchema, 400: Result, 403: Result}, + response={200: UserSchema, 401: Result}, summary="Get current user's basic info", ) -@protected_resource() def me(request): return 200, { "external_acct": request.user.mastodon_username,