api for notes

This commit is contained in:
Your Name 2025-01-19 16:04:22 -05:00 committed by Henri Dickson
parent 1d3bf81a68
commit 875dfd4711
19 changed files with 832 additions and 647 deletions

View file

@ -95,3 +95,6 @@ api = NinjaAPI(
version="1.0.0", version="1.0.0",
description=f"{settings.SITE_INFO['site_name']} API <hr/><a href='{settings.SITE_INFO['site_url']}'>Learn more</a>", description=f"{settings.SITE_INFO['site_name']} API <hr/><a href='{settings.SITE_INFO['site_url']}'>Learn more</a>",
) )
NOT_FOUND = 404, {"message": "Note not found"}
OK = 200, {"message": "OK"}

View file

@ -3,7 +3,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<link rel="stylesheet" <link rel="stylesheet"
href="{{ cdn_url }}/npm/swagger-ui-dist@5.13.0/swagger-ui.min.css"> href="{{ cdn_url }}/npm/swagger-ui-dist@5.18.2/swagger-ui.min.css">
<title>{{ api.title }} Developer Console</title> <title>{{ api.title }} Developer Console</title>
{% include "common_libs.html" %} {% include "common_libs.html" %}
<style type="text/css"> <style type="text/css">
@ -28,6 +28,11 @@
#swagger-ui>div>div>.wrapper button.btn.execute { #swagger-ui>div>div>.wrapper button.btn.execute {
background-color: #4990e2; background-color: #4990e2;
} }
button {
:first-letter {
text-transform: none;
}
}
</style> </style>
</head> </head>
<body> <body>

View file

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

5
journal/apis/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from .collection import * # noqa
from .note import * # noqa
from .review import * # noqa
from .shelf import * # noqa
from .tag import * # noqa

192
journal/apis/collection.py Normal file
View file

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

112
journal/apis/note.py Normal file
View file

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

115
journal/apis/review.py Normal file
View file

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

128
journal/apis/shelf.py Normal file
View file

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

195
journal/apis/tag.py Normal file
View file

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

View file

@ -9,7 +9,7 @@ class JournalConfig(AppConfig):
# load key modules in proper order, make sure class inject and signal works as expected # load key modules in proper order, make sure class inject and signal works as expected
from catalog.models import Indexer from catalog.models import Indexer
from . import api # noqa from . import apis # noqa
from .models import Rating, Tag from .models import Rating, Tag
Indexer.register_list_model(Tag) Indexer.register_list_model(Tag)

View file

@ -49,6 +49,8 @@ class Collection(List):
if TYPE_CHECKING: if TYPE_CHECKING:
members: models.QuerySet[CollectionMember] members: models.QuerySet[CollectionMember]
url_path = "collection" url_path = "collection"
post_when_save = True
index_when_save = True
MEMBER_CLASS = CollectionMember MEMBER_CLASS = CollectionMember
catalog_item = models.OneToOneField( catalog_item = models.OneToOneField(
CatalogCollection, on_delete=models.PROTECT, related_name="journal_item" 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.cover = self.cover
self.catalog_item.save() self.catalog_item.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.sync_to_timeline()
self.update_index()
def get_ap_data(self): def get_ap_data(self):
return { return {

View file

@ -38,7 +38,7 @@ class Comment(Content):
return d return d
@classmethod @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() p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed return p # incoming ap object is older than what we have, no update needed

View file

@ -106,11 +106,32 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
posts = models.ManyToManyField( posts = models.ManyToManyField(
"takahe.Post", related_name="pieces", through="PiecePost" "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 @property
def classname(self) -> str: def classname(self) -> str:
return self.__class__.__name__.lower() 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): def delete(self, *args, **kwargs):
if self.local: if self.local:
self.delete_from_timeline() self.delete_from_timeline()
@ -173,6 +194,19 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
obj = None obj = None
return obj 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 @classmethod
def get_by_post_id(cls, post_id: int): def get_by_post_id(cls, post_id: int):
pp = PiecePost.objects.filter(post_id=post_id).first() pp = PiecePost.objects.filter(post_id=post_id).first()
@ -236,7 +270,12 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
@classmethod @classmethod
def update_by_ap_object( 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: ) -> Self | None:
""" """
Create or update a content piece with related AP message Create or update a content piece with related AP message
@ -257,6 +296,8 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
d["edited_time"] = edited d["edited_time"] = edited
for k, v in d.items(): for k, v in d.items():
setattr(p, k, v) setattr(p, k, v)
if crosspost is not None:
p.crosspost_when_save = crosspost
p.save(update_fields=d.keys()) p.save(update_fields=d.keys())
else: else:
# no previously linked piece, create a new one and link to post # no previously linked piece, create a new one and link to post
@ -275,7 +316,10 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
else: else:
d["created_time"] = datetime.fromisoformat(obj["published"]) d["created_time"] = datetime.fromisoformat(obj["published"])
d["edited_time"] = datetime.fromisoformat(obj["updated"]) 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) p.link_post_id(post.id)
# subclass may have to add additional code to update type_data in local post # subclass may have to add additional code to update type_data in local post
return p return p

View file

@ -27,6 +27,9 @@ _separaters = {"", "―", "", "—", "-"}
class Note(Content): class Note(Content):
post_when_save = True
index_when_save = True
class ProgressType(models.TextChoices): class ProgressType(models.TextChoices):
PAGE = "page", _("Page") PAGE = "page", _("Page")
CHAPTER = "chapter", _("Chapter") CHAPTER = "chapter", _("Chapter")
@ -150,16 +153,13 @@ class Note(Content):
@override @override
@classmethod @classmethod
def update_by_ap_object(cls, owner, item, obj, post): def update_by_ap_object(cls, owner, item, obj, post, crosspost=None):
# new_piece = cls.get_by_post_id(post.id) is None crosspost = (
p = super().update_by_ap_object(owner, item, obj, post) owner.local
if p and p.local: and owner.user.preference.mastodon_default_repost
# if local piece is created from a post, update post type_data and fanout and owner.user.mastodon is not None
p.sync_to_timeline() )
if owner.user.preference.mastodon_default_repost and owner.user.mastodon: return super().update_by_ap_object(owner, item, obj, post, crosspost)
p.sync_to_social_accounts()
p.update_index()
return p
@cached_property @cached_property
def shelfmember(self) -> ShelfMember | None: def shelfmember(self) -> ShelfMember | None:

View file

@ -39,7 +39,7 @@ class Rating(Content):
} }
@classmethod @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() p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed return p # incoming ap object is older than what we have, no update needed

View file

@ -25,6 +25,8 @@ _RE_SPOILER_TAG = re.compile(r'<(div|span)\sclass="spoiler">.*</(div|span)>')
class Review(Content): class Review(Content):
url_path = "review" url_path = "review"
post_when_save = True
index_when_save = True
title = models.CharField(max_length=500, blank=False, null=False) title = models.CharField(max_length=500, blank=False, null=False)
body = MarkdownxField() body = MarkdownxField()
@ -64,7 +66,7 @@ class Review(Content):
} }
@classmethod @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() p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed 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, share_to_mastodon: bool = False,
): ):
review = Review.objects.filter(owner=owner, item=item).first() review = Review.objects.filter(owner=owner, item=item).first()
delete_existing_post = False
if review is not None: if review is not None:
if title is None: if title is None:
review.delete() review.delete()
return return
delete_existing_post = review.visibility != visibility
defaults = { defaults = {
"title": title, "title": title,
"body": body, "body": body,
@ -157,14 +157,13 @@ class Review(Content):
defaults["created_time"] = ( defaults["created_time"] = (
created_time if created_time < timezone.now() else timezone.now() created_time if created_time < timezone.now() else timezone.now()
) )
review, created = cls.objects.update_or_create( if not review:
item=item, owner=owner, defaults=defaults review = Review(item=item, owner=owner, **defaults)
) else:
update_mode = 1 if delete_existing_post else 0 for name, value in defaults.items():
review.sync_to_timeline(update_mode) setattr(review, name, value)
if share_to_mastodon: review.crosspost_when_save = share_to_mastodon
review.sync_to_social_accounts(update_mode) review.save()
review.update_index()
return review return review
def to_indexable_doc(self) -> dict[str, Any]: def to_indexable_doc(self) -> dict[str, Any]:

View file

@ -338,7 +338,9 @@ class ShelfMember(ListMember):
} }
@classmethod @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() p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed return p # incoming ap object is older than what we have, no update needed

View file

@ -98,19 +98,8 @@ def note_edit(request: AuthedHttpRequest, item_uuid: str, note_uuid: str = ""):
raise Http404(_("Content not found")) raise Http404(_("Content not found"))
note.delete() note.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
if note:
orig_visibility = note.visibility
else:
orig_visibility = None
if not form.is_valid(): if not form.is_valid():
raise BadRequest(_("Invalid form data")) raise BadRequest(_("Invalid form data"))
form.instance.crosspost_when_save = form.cleaned_data["share_to_mastodon"]
note = form.save() 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", "/")) return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

@ -1 +1 @@
Subproject commit bf2872547f95fd6dd8b5fea08682f3eb63ef8c99 Subproject commit 7ff7d545cfb5e79acbecdf9ee3dbf2d06321ed28