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",
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">
<head>
<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>
{% include "common_libs.html" %}
<style type="text/css">
@ -28,6 +28,11 @@
#swagger-ui>div>div>.wrapper button.btn.execute {
background-color: #4990e2;
}
button {
:first-letter {
text-transform: none;
}
}
</style>
</head>
<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
from catalog.models import Indexer
from . import api # noqa
from . import apis # noqa
from .models import Rating, Tag
Indexer.register_list_model(Tag)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,8 @@ _RE_SPOILER_TAG = re.compile(r'<(div|span)\sclass="spoiler">.*</(div|span)>')
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]:

View file

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

View file

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

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