feat(comments): add document comments, annotations, and @mention support
- Add DocumentComment and DocumentAnnotation models to app/models.py - Create migration 041_add_document_comments_and_annotations - Add API endpoints for CRUD operations on comments and annotations - Add threaded comment support with parent_id relationships - Add @mention extraction from comment body text - Add resolve/unresolve comment thread endpoint - Add mentionable users endpoint (GET /api/users/mentionable) - Add 43 unit tests covering all endpoints and edge cases - Add 29 i18n translation keys to en.json - Update API documentation in docs/API.md Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/3894af37-0f19-457b-8811-f1feb18b17ef
This commit is contained in:
@@ -14,6 +14,7 @@ from app.api.azure import router as azure_router
|
|||||||
from app.api.backup import router as backup_router
|
from app.api.backup import router as backup_router
|
||||||
from app.api.billing import router as billing_router
|
from app.api.billing import router as billing_router
|
||||||
from app.api.classification_rules import router as classification_rules_router
|
from app.api.classification_rules import router as classification_rules_router
|
||||||
|
from app.api.comments import router as comments_router
|
||||||
from app.api.compliance import router as compliance_router
|
from app.api.compliance import router as compliance_router
|
||||||
from app.api.database import router as database_router
|
from app.api.database import router as database_router
|
||||||
from app.api.diagnostic import router as diagnostic_router
|
from app.api.diagnostic import router as diagnostic_router
|
||||||
@@ -108,3 +109,4 @@ router.include_router(system_reset_router)
|
|||||||
router.include_router(translation_router)
|
router.include_router(translation_router)
|
||||||
router.include_router(classification_rules_router)
|
router.include_router(classification_rules_router)
|
||||||
router.include_router(automation_router)
|
router.include_router(automation_router)
|
||||||
|
router.include_router(comments_router)
|
||||||
|
|||||||
@@ -0,0 +1,679 @@
|
|||||||
|
"""Document comments and annotations API endpoints.
|
||||||
|
|
||||||
|
Provides CRUD operations for threaded comments on documents,
|
||||||
|
text annotations on PDF pages, and a list of mentionable users
|
||||||
|
for the @mention feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_user_id, require_login
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["comments"])
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
MAX_COMMENT_BODY_LENGTH = 10_000
|
||||||
|
MAX_ANNOTATION_CONTENT_LENGTH = 5_000
|
||||||
|
|
||||||
|
# Allowed annotation types
|
||||||
|
ALLOWED_ANNOTATION_TYPES = frozenset({"note", "highlight", "underline", "strikethrough"})
|
||||||
|
|
||||||
|
# Simple pattern for @mentions – matches @username tokens inside comment body
|
||||||
|
_MENTION_PATTERN = re.compile(r"@([\w.\-]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_mentions(body: str) -> list[str]:
|
||||||
|
"""Extract unique @mentioned usernames from a comment body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: The raw comment text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A deduplicated list of mentioned usernames (without the ``@`` prefix).
|
||||||
|
"""
|
||||||
|
return list(dict.fromkeys(_MENTION_PATTERN.findall(body)))
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_comment(c: DocumentComment) -> dict[str, Any]:
|
||||||
|
"""Serialize a DocumentComment to a JSON-friendly dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: The comment model instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary representation of the comment.
|
||||||
|
"""
|
||||||
|
mentions: list[str] = []
|
||||||
|
if c.mentions:
|
||||||
|
try:
|
||||||
|
mentions = json.loads(c.mentions)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"file_id": c.file_id,
|
||||||
|
"user_id": c.user_id,
|
||||||
|
"parent_id": c.parent_id,
|
||||||
|
"body": c.body,
|
||||||
|
"mentions": mentions,
|
||||||
|
"is_resolved": c.is_resolved,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_annotation(a: DocumentAnnotation) -> dict[str, Any]:
|
||||||
|
"""Serialize a DocumentAnnotation to a JSON-friendly dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a: The annotation model instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary representation of the annotation.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": a.id,
|
||||||
|
"file_id": a.file_id,
|
||||||
|
"user_id": a.user_id,
|
||||||
|
"page": a.page,
|
||||||
|
"x": a.x,
|
||||||
|
"y": a.y,
|
||||||
|
"width": a.width,
|
||||||
|
"height": a.height,
|
||||||
|
"content": a.content,
|
||||||
|
"annotation_type": a.annotation_type,
|
||||||
|
"color": a.color,
|
||||||
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
|
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]:
|
||||||
|
"""Organize a flat list of comments into a threaded tree structure.
|
||||||
|
|
||||||
|
Top-level comments (``parent_id is None``) appear as root nodes.
|
||||||
|
Replies are nested inside their parent's ``replies`` list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comments: All comments for a given document, ordered by ``created_at``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of root-level comment dicts, each with a ``replies`` key.
|
||||||
|
"""
|
||||||
|
by_id: dict[int, dict[str, Any]] = {}
|
||||||
|
roots: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for c in comments:
|
||||||
|
node = _serialize_comment(c)
|
||||||
|
node["replies"] = []
|
||||||
|
by_id[c.id] = node
|
||||||
|
|
||||||
|
for c in comments:
|
||||||
|
node = by_id[c.id]
|
||||||
|
if c.parent_id and c.parent_id in by_id:
|
||||||
|
by_id[c.parent_id]["replies"].append(node)
|
||||||
|
else:
|
||||||
|
roots.append(node)
|
||||||
|
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comments endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/comments")
|
||||||
|
@require_login
|
||||||
|
def list_comments(request: Request, file_id: int, db: DbSession):
|
||||||
|
"""List all comments for a document, organised into threads.
|
||||||
|
|
||||||
|
Returns a threaded tree where top-level comments contain nested
|
||||||
|
``replies``.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``file_id``, ``comments`` (threaded), and ``total``.
|
||||||
|
"""
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
comments = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_id": file_id,
|
||||||
|
"comments": _build_thread_tree(comments),
|
||||||
|
"total": len(comments),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/comments", status_code=status.HTTP_201_CREATED)
|
||||||
|
@require_login
|
||||||
|
def create_comment(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
body: str = Body(..., embed=True),
|
||||||
|
parent_id: int | None = Body(None, embed=True),
|
||||||
|
):
|
||||||
|
"""Create a new comment on a document.
|
||||||
|
|
||||||
|
Automatically extracts @mentions from the comment body and stores
|
||||||
|
them for later notification or UI highlighting.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document to comment on.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
body: Comment text (required, max 10 000 characters).
|
||||||
|
parent_id: ID of the parent comment for threaded replies (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created comment object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not isinstance(body, str) or not body.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="body is required and must be non-empty",
|
||||||
|
)
|
||||||
|
body = body.strip()
|
||||||
|
if len(body) > MAX_COMMENT_BODY_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
if parent_id is not None:
|
||||||
|
parent = (
|
||||||
|
db.query(DocumentComment)
|
||||||
|
.filter(DocumentComment.id == parent_id, DocumentComment.file_id == file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Parent comment not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
mentions = _extract_mentions(body)
|
||||||
|
|
||||||
|
comment = DocumentComment(
|
||||||
|
file_id=file_id,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
body=body,
|
||||||
|
mentions=json.dumps(mentions) if mentions else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(comment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to create comment on file_id=%s", file_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment created: id=%s, file_id=%s, user=%s", comment.id, file_id, user_id)
|
||||||
|
return _serialize_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/files/{file_id}/comments/{comment_id}")
|
||||||
|
@require_login
|
||||||
|
def update_comment(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
body: str = Body(..., embed=True),
|
||||||
|
):
|
||||||
|
"""Update the body of an existing comment.
|
||||||
|
|
||||||
|
Only the comment author may update the comment. Mentions are
|
||||||
|
re-extracted from the updated body.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
comment_id: The ID of the comment to update.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
body: New comment text (required).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated comment object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
comment = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||||
|
)
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
if comment.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own comments")
|
||||||
|
|
||||||
|
if not isinstance(body, str) or not body.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="body is required and must be non-empty",
|
||||||
|
)
|
||||||
|
body = body.strip()
|
||||||
|
if len(body) > MAX_COMMENT_BODY_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
mentions = _extract_mentions(body)
|
||||||
|
comment.body = body
|
||||||
|
comment.mentions = json.dumps(mentions) if mentions else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to update comment id=%s", comment_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment updated: id=%s, user=%s", comment_id, user_id)
|
||||||
|
return _serialize_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@require_login
|
||||||
|
def delete_comment(request: Request, file_id: int, comment_id: int, db: DbSession):
|
||||||
|
"""Delete a comment.
|
||||||
|
|
||||||
|
Only the comment author may delete the comment. Replies to the
|
||||||
|
deleted comment are **not** removed — they become orphaned root
|
||||||
|
comments so that conversation context is preserved.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
comment_id: The ID of the comment to delete.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
comment = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||||
|
)
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
if comment.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own comments")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(comment)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to delete comment id=%s", comment_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment deleted: id=%s, user=%s", comment_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/files/{file_id}/comments/{comment_id}/resolve")
|
||||||
|
@require_login
|
||||||
|
def resolve_comment(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
is_resolved: bool = Body(..., embed=True),
|
||||||
|
):
|
||||||
|
"""Mark a top-level comment thread as resolved or unresolved.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
comment_id: The ID of the comment to resolve / unresolve.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
is_resolved: ``true`` to resolve, ``false`` to unresolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated comment object.
|
||||||
|
"""
|
||||||
|
comment = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||||
|
)
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
comment.is_resolved = is_resolved
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to resolve comment id=%s", comment_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment %s: id=%s", "resolved" if is_resolved else "unresolved", comment_id)
|
||||||
|
return _serialize_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Annotations endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/annotations")
|
||||||
|
@require_login
|
||||||
|
def list_annotations(request: Request, file_id: int, db: DbSession):
|
||||||
|
"""List all annotations for a document.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``file_id``, ``annotations``, and ``total``.
|
||||||
|
"""
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
annotations = (
|
||||||
|
db.query(DocumentAnnotation)
|
||||||
|
.filter(DocumentAnnotation.file_id == file_id)
|
||||||
|
.order_by(DocumentAnnotation.page, DocumentAnnotation.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_id": file_id,
|
||||||
|
"annotations": [_serialize_annotation(a) for a in annotations],
|
||||||
|
"total": len(annotations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/annotations", status_code=status.HTTP_201_CREATED)
|
||||||
|
@require_login
|
||||||
|
def create_annotation(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
page: int = Body(..., embed=True),
|
||||||
|
x: float = Body(..., embed=True),
|
||||||
|
y: float = Body(..., embed=True),
|
||||||
|
content: str = Body(..., embed=True),
|
||||||
|
width: float = Body(0, embed=True),
|
||||||
|
height: float = Body(0, embed=True),
|
||||||
|
annotation_type: str = Body("note", embed=True),
|
||||||
|
color: str | None = Body(None, embed=True),
|
||||||
|
):
|
||||||
|
"""Create a new annotation on a PDF page.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
page: Page number (1-based, required).
|
||||||
|
x: Horizontal position on the page (required).
|
||||||
|
y: Vertical position on the page (required).
|
||||||
|
content: Annotation text (required, max 5 000 characters).
|
||||||
|
width: Width of the annotation bounding box (default 0).
|
||||||
|
height: Height of the annotation bounding box (default 0).
|
||||||
|
annotation_type: One of ``note``, ``highlight``, ``underline``,
|
||||||
|
``strikethrough`` (default ``note``).
|
||||||
|
color: Optional CSS colour string (e.g. ``#ff0000``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created annotation object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="content is required and must be non-empty",
|
||||||
|
)
|
||||||
|
content = content.strip()
|
||||||
|
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
if page < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="page must be >= 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
annotation = DocumentAnnotation(
|
||||||
|
file_id=file_id,
|
||||||
|
user_id=user_id,
|
||||||
|
page=page,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
content=content,
|
||||||
|
annotation_type=annotation_type,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(annotation)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(annotation)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to create annotation on file_id=%s", file_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create annotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Annotation created: id=%s, file_id=%s, user=%s", annotation.id, file_id, user_id)
|
||||||
|
return _serialize_annotation(annotation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/files/{file_id}/annotations/{annotation_id}")
|
||||||
|
@require_login
|
||||||
|
def update_annotation(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
annotation_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
content: str | None = Body(None, embed=True),
|
||||||
|
x: float | None = Body(None, embed=True),
|
||||||
|
y: float | None = Body(None, embed=True),
|
||||||
|
width: float | None = Body(None, embed=True),
|
||||||
|
height: float | None = Body(None, embed=True),
|
||||||
|
annotation_type: str | None = Body(None, embed=True),
|
||||||
|
color: str | None = Body(None, embed=True),
|
||||||
|
):
|
||||||
|
"""Update an existing annotation.
|
||||||
|
|
||||||
|
Only the annotation author may update the annotation.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
annotation_id: The ID of the annotation to update.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
Any subset of ``content``, ``x``, ``y``, ``width``, ``height``,
|
||||||
|
``annotation_type``, and ``color``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated annotation object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
annotation = (
|
||||||
|
db.query(DocumentAnnotation)
|
||||||
|
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not annotation:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
|
||||||
|
|
||||||
|
if annotation.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own annotations")
|
||||||
|
|
||||||
|
if content is not None:
|
||||||
|
content = content.strip() if isinstance(content, str) else ""
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="content must be non-empty",
|
||||||
|
)
|
||||||
|
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
|
||||||
|
)
|
||||||
|
annotation.content = content
|
||||||
|
|
||||||
|
if x is not None:
|
||||||
|
annotation.x = x
|
||||||
|
if y is not None:
|
||||||
|
annotation.y = y
|
||||||
|
if width is not None:
|
||||||
|
annotation.width = width
|
||||||
|
if height is not None:
|
||||||
|
annotation.height = height
|
||||||
|
if annotation_type is not None:
|
||||||
|
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
|
||||||
|
)
|
||||||
|
annotation.annotation_type = annotation_type
|
||||||
|
if color is not None:
|
||||||
|
annotation.color = color
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(annotation)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to update annotation id=%s", annotation_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update annotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Annotation updated: id=%s, user=%s", annotation_id, user_id)
|
||||||
|
return _serialize_annotation(annotation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@require_login
|
||||||
|
def delete_annotation(request: Request, file_id: int, annotation_id: int, db: DbSession):
|
||||||
|
"""Delete an annotation.
|
||||||
|
|
||||||
|
Only the annotation author may delete the annotation.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
annotation_id: The ID of the annotation to delete.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
annotation = (
|
||||||
|
db.query(DocumentAnnotation)
|
||||||
|
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not annotation:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
|
||||||
|
|
||||||
|
if annotation.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own annotations")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(annotation)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to delete annotation id=%s", annotation_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete annotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Annotation deleted: id=%s, user=%s", annotation_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mentionable users endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/mentionable")
|
||||||
|
@require_login
|
||||||
|
def list_mentionable_users(request: Request, db: DbSession):
|
||||||
|
"""List users that can be @mentioned in comments.
|
||||||
|
|
||||||
|
Returns all user profiles that are not blocked, sorted by
|
||||||
|
``display_name``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of ``{user_id, display_name}`` objects.
|
||||||
|
"""
|
||||||
|
profiles = (
|
||||||
|
db.query(UserProfile)
|
||||||
|
.filter(UserProfile.is_blocked == False) # noqa: E712
|
||||||
|
.order_by(UserProfile.display_name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": p.user_id,
|
||||||
|
"display_name": p.display_name or p.user_id,
|
||||||
|
}
|
||||||
|
for p in profiles
|
||||||
|
]
|
||||||
@@ -1192,3 +1192,48 @@ class PipelineRoutingRule(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentComment(Base):
|
||||||
|
"""Threaded comment on a document.
|
||||||
|
|
||||||
|
Supports threaded replies via ``parent_id`` and @mentions via the
|
||||||
|
``mentions`` column (comma-separated user identifiers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "document_comments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||||
|
user_id = Column(String, nullable=False, index=True)
|
||||||
|
parent_id = Column(Integer, ForeignKey("document_comments.id"), nullable=True, index=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
mentions = Column(Text, nullable=True)
|
||||||
|
is_resolved = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAnnotation(Base):
|
||||||
|
"""Text annotation on a specific page and position of a PDF document.
|
||||||
|
|
||||||
|
Stores the bounding-box coordinates (``x``, ``y``, ``width``,
|
||||||
|
``height``) relative to the page dimensions so that the annotation
|
||||||
|
can be rendered on top of the PDF viewer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "document_annotations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||||
|
user_id = Column(String, nullable=False, index=True)
|
||||||
|
page = Column(Integer, nullable=False)
|
||||||
|
x = Column(Float, nullable=False)
|
||||||
|
y = Column(Float, nullable=False)
|
||||||
|
width = Column(Float, nullable=False, default=0)
|
||||||
|
height = Column(Float, nullable=False, default=0)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
annotation_type = Column(String(50), nullable=False, default="note", server_default="note")
|
||||||
|
color = Column(String(20), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
+199
@@ -2909,3 +2909,202 @@ Move original files to a reimport folder, wipe everything, and configure the rei
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comments & Annotations
|
||||||
|
|
||||||
|
Threaded comments and PDF annotations for document collaboration.
|
||||||
|
|
||||||
|
### List Comments
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/comments`
|
||||||
|
|
||||||
|
Returns all comments for a document, organised into a threaded tree.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": 1,
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "alice",
|
||||||
|
"parent_id": null,
|
||||||
|
"body": "Please review section 3.",
|
||||||
|
"mentions": ["bob"],
|
||||||
|
"is_resolved": false,
|
||||||
|
"created_at": "2026-03-21T12:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:00:00+00:00",
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "bob",
|
||||||
|
"parent_id": 1,
|
||||||
|
"body": "Done!",
|
||||||
|
"mentions": [],
|
||||||
|
"is_resolved": false,
|
||||||
|
"created_at": "2026-03-21T12:05:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:05:00+00:00",
|
||||||
|
"replies": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Comment
|
||||||
|
|
||||||
|
**POST** `/api/files/{file_id}/comments`
|
||||||
|
|
||||||
|
Create a new comment on a document. @mentions are automatically extracted from the body.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"body": "Hey @bob, please review this section.",
|
||||||
|
"parent_id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "alice",
|
||||||
|
"parent_id": null,
|
||||||
|
"body": "Hey @bob, please review this section.",
|
||||||
|
"mentions": ["bob"],
|
||||||
|
"is_resolved": false,
|
||||||
|
"created_at": "2026-03-21T12:10:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:10:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Comment
|
||||||
|
|
||||||
|
**PUT** `/api/files/{file_id}/comments/{comment_id}`
|
||||||
|
|
||||||
|
Update the body of an existing comment. Only the comment author may update it.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"body": "Updated comment text @charlie"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Comment
|
||||||
|
|
||||||
|
**DELETE** `/api/files/{file_id}/comments/{comment_id}`
|
||||||
|
|
||||||
|
Delete a comment. Only the comment author may delete it.
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
### Resolve / Unresolve Comment
|
||||||
|
|
||||||
|
**PATCH** `/api/files/{file_id}/comments/{comment_id}/resolve`
|
||||||
|
|
||||||
|
Mark a comment thread as resolved or unresolved.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"is_resolved": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Annotations
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/annotations`
|
||||||
|
|
||||||
|
Returns all PDF page annotations for a document, ordered by page then creation time.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": 1,
|
||||||
|
"annotations": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "alice",
|
||||||
|
"page": 1,
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 200.0,
|
||||||
|
"width": 150.0,
|
||||||
|
"height": 20.0,
|
||||||
|
"content": "Important paragraph",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"color": "#ffff00",
|
||||||
|
"created_at": "2026-03-21T12:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Annotation
|
||||||
|
|
||||||
|
**POST** `/api/files/{file_id}/annotations`
|
||||||
|
|
||||||
|
Create a new annotation on a PDF page.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 200.0,
|
||||||
|
"width": 150.0,
|
||||||
|
"height": 20.0,
|
||||||
|
"content": "Important paragraph",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"color": "#ffff00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed `annotation_type` values: `note`, `highlight`, `underline`, `strikethrough`.
|
||||||
|
|
||||||
|
### Update Annotation
|
||||||
|
|
||||||
|
**PUT** `/api/files/{file_id}/annotations/{annotation_id}`
|
||||||
|
|
||||||
|
Update an existing annotation. Only the annotation author may update it.
|
||||||
|
|
||||||
|
**Request** (all fields optional):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "Updated note",
|
||||||
|
"color": "#00ff00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Annotation
|
||||||
|
|
||||||
|
**DELETE** `/api/files/{file_id}/annotations/{annotation_id}`
|
||||||
|
|
||||||
|
Delete an annotation. Only the annotation author may delete it.
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
### List Mentionable Users
|
||||||
|
|
||||||
|
**GET** `/api/users/mentionable`
|
||||||
|
|
||||||
|
Returns all non-blocked user profiles for the @mention autocomplete.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "user_id": "alice", "display_name": "Alice Anderson" },
|
||||||
|
{ "user_id": "bob", "display_name": "Bob Baker" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|||||||
@@ -315,6 +315,20 @@
|
|||||||
"admin_users.total_count_users": "{count} users",
|
"admin_users.total_count_users": "{count} users",
|
||||||
"admin_users.total_no_users": "No users",
|
"admin_users.total_no_users": "No users",
|
||||||
"admin_users.total_one_user": "1 user",
|
"admin_users.total_one_user": "1 user",
|
||||||
|
"annotations.add": "Add annotation",
|
||||||
|
"annotations.color": "Color",
|
||||||
|
"annotations.content_placeholder": "Write an annotation...",
|
||||||
|
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
|
||||||
|
"annotations.deleted": "Annotation deleted",
|
||||||
|
"annotations.empty": "No annotations yet",
|
||||||
|
"annotations.heading": "Annotations",
|
||||||
|
"annotations.page": "Page",
|
||||||
|
"annotations.save": "Save",
|
||||||
|
"annotations.type_highlight": "Highlight",
|
||||||
|
"annotations.type_note": "Note",
|
||||||
|
"annotations.type_strikethrough": "Strikethrough",
|
||||||
|
"annotations.type_underline": "Underline",
|
||||||
|
"annotations.updated": "Annotation updated",
|
||||||
"api_tokens.col_created": "Created",
|
"api_tokens.col_created": "Created",
|
||||||
"api_tokens.col_expires": "Expires",
|
"api_tokens.col_expires": "Expires",
|
||||||
"api_tokens.col_last_ip": "Last IP",
|
"api_tokens.col_last_ip": "Last IP",
|
||||||
@@ -484,6 +498,21 @@
|
|||||||
"billing.success_heading": "You're all set!",
|
"billing.success_heading": "You're all set!",
|
||||||
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
|
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
|
||||||
"billing.success_page_title": "DocuElevate - Subscription Activated",
|
"billing.success_page_title": "DocuElevate - Subscription Activated",
|
||||||
|
"comments.add_comment": "Add comment",
|
||||||
|
"comments.add_reply": "Reply",
|
||||||
|
"comments.body_placeholder": "Write a comment... Use @username to mention someone",
|
||||||
|
"comments.delete_confirm": "Are you sure you want to delete this comment?",
|
||||||
|
"comments.deleted": "Comment deleted",
|
||||||
|
"comments.edit": "Edit",
|
||||||
|
"comments.empty": "No comments yet",
|
||||||
|
"comments.heading": "Comments",
|
||||||
|
"comments.mention_users": "Mention users",
|
||||||
|
"comments.reply_placeholder": "Write a reply...",
|
||||||
|
"comments.resolve": "Resolve",
|
||||||
|
"comments.resolved": "Resolved",
|
||||||
|
"comments.save": "Save",
|
||||||
|
"comments.unresolve": "Reopen",
|
||||||
|
"comments.updated": "Comment updated",
|
||||||
"common.actions": "Actions",
|
"common.actions": "Actions",
|
||||||
"common.active": "Active",
|
"common.active": "Active",
|
||||||
"common.all": "All",
|
"common.all": "All",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from app.models import ( # noqa: F401
|
|||||||
BackupRecord,
|
BackupRecord,
|
||||||
ClassificationRuleModel,
|
ClassificationRuleModel,
|
||||||
ComplianceTemplate,
|
ComplianceTemplate,
|
||||||
|
DocumentAnnotation,
|
||||||
|
DocumentComment,
|
||||||
DocumentMetadata,
|
DocumentMetadata,
|
||||||
FileProcessingStep,
|
FileProcessingStep,
|
||||||
FileRecord,
|
FileRecord,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Add document_comments and document_annotations tables.
|
||||||
|
|
||||||
|
Revision ID: 041_add_document_comments_and_annotations
|
||||||
|
Revises: 040_add_automation_hooks
|
||||||
|
Create Date: 2026-03-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "041_add_document_comments_and_annotations"
|
||||||
|
down_revision: Union[str, None] = "040_add_automation_hooks"
|
||||||
|
depends_on: Union[str, None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add document_comments and document_annotations tables."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
existing_tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
if "document_comments" not in existing_tables:
|
||||||
|
op.create_table(
|
||||||
|
"document_comments",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("file_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("body", sa.Text(), nullable=False),
|
||||||
|
sa.Column("mentions", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_resolved", sa.Boolean(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["parent_id"], ["document_comments.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_document_comments_id", "document_comments", ["id"])
|
||||||
|
op.create_index("ix_document_comments_file_id", "document_comments", ["file_id"])
|
||||||
|
op.create_index("ix_document_comments_user_id", "document_comments", ["user_id"])
|
||||||
|
op.create_index("ix_document_comments_parent_id", "document_comments", ["parent_id"])
|
||||||
|
|
||||||
|
if "document_annotations" not in existing_tables:
|
||||||
|
op.create_table(
|
||||||
|
"document_annotations",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("file_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("page", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("x", sa.Float(), nullable=False),
|
||||||
|
sa.Column("y", sa.Float(), nullable=False),
|
||||||
|
sa.Column("width", sa.Float(), nullable=False),
|
||||||
|
sa.Column("height", sa.Float(), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("annotation_type", sa.String(50), nullable=False, server_default="note"),
|
||||||
|
sa.Column("color", sa.String(20), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_document_annotations_id", "document_annotations", ["id"])
|
||||||
|
op.create_index("ix_document_annotations_file_id", "document_annotations", ["file_id"])
|
||||||
|
op.create_index("ix_document_annotations_user_id", "document_annotations", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop document_comments and document_annotations tables."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
existing_tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
if "document_annotations" in existing_tables:
|
||||||
|
op.drop_index("ix_document_annotations_user_id", "document_annotations")
|
||||||
|
op.drop_index("ix_document_annotations_file_id", "document_annotations")
|
||||||
|
op.drop_index("ix_document_annotations_id", "document_annotations")
|
||||||
|
op.drop_table("document_annotations")
|
||||||
|
|
||||||
|
if "document_comments" in existing_tables:
|
||||||
|
op.drop_index("ix_document_comments_parent_id", "document_comments")
|
||||||
|
op.drop_index("ix_document_comments_user_id", "document_comments")
|
||||||
|
op.drop_index("ix_document_comments_file_id", "document_comments")
|
||||||
|
op.drop_index("ix_document_comments_id", "document_comments")
|
||||||
|
op.drop_table("document_comments")
|
||||||
@@ -65,6 +65,8 @@ from app.models import ( # noqa: F401, E402
|
|||||||
AutomationHook,
|
AutomationHook,
|
||||||
ClassificationRuleModel,
|
ClassificationRuleModel,
|
||||||
ComplianceTemplate,
|
ComplianceTemplate,
|
||||||
|
DocumentAnnotation,
|
||||||
|
DocumentComment,
|
||||||
DocumentMetadata,
|
DocumentMetadata,
|
||||||
FileRecord,
|
FileRecord,
|
||||||
Pipeline,
|
Pipeline,
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""Tests for the document comments and annotations API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
def _create_file(db_session, owner_id="testuser") -> FileRecord:
|
||||||
|
"""Helper to create a minimal FileRecord for testing."""
|
||||||
|
f = FileRecord(
|
||||||
|
owner_id=owner_id,
|
||||||
|
filehash="abc123",
|
||||||
|
original_filename="test.pdf",
|
||||||
|
local_filename="test.pdf",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
db_session.add(f)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(f)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comment tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestListComments:
|
||||||
|
"""Tests for GET /api/files/{file_id}/comments."""
|
||||||
|
|
||||||
|
def test_list_comments_empty(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.get(f"/api/files/{f.id}/comments")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["file_id"] == f.id
|
||||||
|
assert data["comments"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
def test_list_comments_file_not_found(self, client):
|
||||||
|
resp = client.get("/api/files/99999/comments")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_list_comments_threaded(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
# Root comment
|
||||||
|
c1 = DocumentComment(file_id=f.id, user_id="alice", body="Hello")
|
||||||
|
db_session.add(c1)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c1)
|
||||||
|
# Reply
|
||||||
|
c2 = DocumentComment(file_id=f.id, user_id="bob", parent_id=c1.id, body="Hi back")
|
||||||
|
db_session.add(c2)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
resp = client.get(f"/api/files/{f.id}/comments")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
assert len(data["comments"]) == 1 # only root
|
||||||
|
assert len(data["comments"][0]["replies"]) == 1
|
||||||
|
assert data["comments"][0]["replies"][0]["body"] == "Hi back"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCreateComment:
|
||||||
|
"""Tests for POST /api/files/{file_id}/comments."""
|
||||||
|
|
||||||
|
def test_create_comment(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/comments",
|
||||||
|
json={"body": "Great document!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["body"] == "Great document!"
|
||||||
|
assert data["file_id"] == f.id
|
||||||
|
assert data["parent_id"] is None
|
||||||
|
|
||||||
|
def test_create_comment_with_mention(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/comments",
|
||||||
|
json={"body": "Hey @alice please review"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["mentions"] == ["alice"]
|
||||||
|
|
||||||
|
def test_create_comment_with_parent(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="user1", body="root")
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/comments",
|
||||||
|
json={"body": "reply", "parent_id": c.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["parent_id"] == c.id
|
||||||
|
|
||||||
|
def test_create_comment_parent_not_found(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/comments",
|
||||||
|
json={"body": "reply", "parent_id": 99999},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_create_comment_empty_body(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/comments",
|
||||||
|
json={"body": " "},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_create_comment_file_not_found(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/files/99999/comments",
|
||||||
|
json={"body": "test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_create_comment_body_too_long(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/comments",
|
||||||
|
json={"body": "x" * 10_001},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUpdateComment:
|
||||||
|
"""Tests for PUT /api/files/{file_id}/comments/{comment_id}."""
|
||||||
|
|
||||||
|
def test_update_comment(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="anonymous", body="old body")
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/comments/{c.id}",
|
||||||
|
json={"body": "new body @bob"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["body"] == "new body @bob"
|
||||||
|
assert data["mentions"] == ["bob"]
|
||||||
|
|
||||||
|
def test_update_comment_not_found(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/comments/99999",
|
||||||
|
json={"body": "new"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_comment_forbidden(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="other_user", body="old")
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/comments/{c.id}",
|
||||||
|
json={"body": "new"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDeleteComment:
|
||||||
|
"""Tests for DELETE /api/files/{file_id}/comments/{comment_id}."""
|
||||||
|
|
||||||
|
def test_delete_comment(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="anonymous", body="to delete")
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
assert db_session.query(DocumentComment).filter(DocumentComment.id == c.id).first() is None
|
||||||
|
|
||||||
|
def test_delete_comment_not_found(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.delete(f"/api/files/{f.id}/comments/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_comment_forbidden(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="other_user", body="mine")
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestResolveComment:
|
||||||
|
"""Tests for PATCH /api/files/{file_id}/comments/{comment_id}/resolve."""
|
||||||
|
|
||||||
|
def test_resolve_comment(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue")
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/files/{f.id}/comments/{c.id}/resolve",
|
||||||
|
json={"is_resolved": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_resolved"] is True
|
||||||
|
|
||||||
|
def test_unresolve_comment(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue", is_resolved=True)
|
||||||
|
db_session.add(c)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(c)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/files/{f.id}/comments/{c.id}/resolve",
|
||||||
|
json={"is_resolved": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_resolved"] is False
|
||||||
|
|
||||||
|
def test_resolve_not_found(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/files/{f.id}/comments/99999/resolve",
|
||||||
|
json={"is_resolved": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Annotation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestListAnnotations:
|
||||||
|
"""Tests for GET /api/files/{file_id}/annotations."""
|
||||||
|
|
||||||
|
def test_list_annotations_empty(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.get(f"/api/files/{f.id}/annotations")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["file_id"] == f.id
|
||||||
|
assert data["annotations"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
def test_list_annotations_file_not_found(self, client):
|
||||||
|
resp = client.get("/api/files/99999/annotations")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCreateAnnotation:
|
||||||
|
"""Tests for POST /api/files/{file_id}/annotations."""
|
||||||
|
|
||||||
|
def test_create_annotation(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/annotations",
|
||||||
|
json={
|
||||||
|
"page": 1,
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 200.0,
|
||||||
|
"content": "Important note",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["x"] == 100.0
|
||||||
|
assert data["y"] == 200.0
|
||||||
|
assert data["content"] == "Important note"
|
||||||
|
assert data["annotation_type"] == "note"
|
||||||
|
|
||||||
|
def test_create_annotation_with_all_fields(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/annotations",
|
||||||
|
json={
|
||||||
|
"page": 2,
|
||||||
|
"x": 50.0,
|
||||||
|
"y": 100.0,
|
||||||
|
"width": 200.0,
|
||||||
|
"height": 30.0,
|
||||||
|
"content": "Highlighted text",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"color": "#ffff00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["annotation_type"] == "highlight"
|
||||||
|
assert data["color"] == "#ffff00"
|
||||||
|
assert data["width"] == 200.0
|
||||||
|
assert data["height"] == 30.0
|
||||||
|
|
||||||
|
def test_create_annotation_file_not_found(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/files/99999/annotations",
|
||||||
|
json={"page": 1, "x": 0, "y": 0, "content": "test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_create_annotation_empty_content(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/annotations",
|
||||||
|
json={"page": 1, "x": 0, "y": 0, "content": " "},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_create_annotation_invalid_page(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/annotations",
|
||||||
|
json={"page": 0, "x": 0, "y": 0, "content": "test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_create_annotation_invalid_type(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/annotations",
|
||||||
|
json={"page": 1, "x": 0, "y": 0, "content": "test", "annotation_type": "invalid"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_create_annotation_content_too_long(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/files/{f.id}/annotations",
|
||||||
|
json={"page": 1, "x": 0, "y": 0, "content": "x" * 5_001},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUpdateAnnotation:
|
||||||
|
"""Tests for PUT /api/files/{file_id}/annotations/{annotation_id}."""
|
||||||
|
|
||||||
|
def test_update_annotation(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
|
||||||
|
db_session.add(a)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(a)
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/annotations/{a.id}",
|
||||||
|
json={"content": "updated note", "color": "#00ff00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["content"] == "updated note"
|
||||||
|
assert data["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_update_annotation_not_found(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/annotations/99999",
|
||||||
|
json={"content": "new"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_annotation_forbidden(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
|
||||||
|
db_session.add(a)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(a)
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/annotations/{a.id}",
|
||||||
|
json={"content": "hijack"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_update_annotation_invalid_type(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
|
||||||
|
db_session.add(a)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(a)
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/files/{f.id}/annotations/{a.id}",
|
||||||
|
json={"annotation_type": "invalid"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDeleteAnnotation:
|
||||||
|
"""Tests for DELETE /api/files/{file_id}/annotations/{annotation_id}."""
|
||||||
|
|
||||||
|
def test_delete_annotation(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
a = DocumentAnnotation(
|
||||||
|
file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="to delete"
|
||||||
|
)
|
||||||
|
db_session.add(a)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(a)
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert db_session.query(DocumentAnnotation).filter(DocumentAnnotation.id == a.id).first() is None
|
||||||
|
|
||||||
|
def test_delete_annotation_not_found(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
resp = client.delete(f"/api/files/{f.id}/annotations/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_annotation_forbidden(self, client, db_session):
|
||||||
|
f = _create_file(db_session)
|
||||||
|
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
|
||||||
|
db_session.add(a)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(a)
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mentionable users tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestListMentionableUsers:
|
||||||
|
"""Tests for GET /api/users/mentionable."""
|
||||||
|
|
||||||
|
def test_list_mentionable_empty(self, client, db_session):
|
||||||
|
resp = client.get("/api/users/mentionable")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
def test_list_mentionable_users(self, client, db_session):
|
||||||
|
p1 = UserProfile(user_id="alice", display_name="Alice A")
|
||||||
|
p2 = UserProfile(user_id="bob", display_name="Bob B")
|
||||||
|
db_session.add_all([p1, p2])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/users/mentionable")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["user_id"] == "alice"
|
||||||
|
assert data[1]["user_id"] == "bob"
|
||||||
|
|
||||||
|
def test_blocked_users_excluded(self, client, db_session):
|
||||||
|
p1 = UserProfile(user_id="alice", display_name="Alice A", is_blocked=False)
|
||||||
|
p2 = UserProfile(user_id="blocked", display_name="Blocked", is_blocked=True)
|
||||||
|
db_session.add_all([p1, p2])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/users/mentionable")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["user_id"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mention extraction helper tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExtractMentions:
|
||||||
|
"""Tests for the _extract_mentions helper function."""
|
||||||
|
|
||||||
|
def test_no_mentions(self):
|
||||||
|
from app.api.comments import _extract_mentions
|
||||||
|
|
||||||
|
assert _extract_mentions("Hello world") == []
|
||||||
|
|
||||||
|
def test_single_mention(self):
|
||||||
|
from app.api.comments import _extract_mentions
|
||||||
|
|
||||||
|
assert _extract_mentions("Hey @alice check this") == ["alice"]
|
||||||
|
|
||||||
|
def test_multiple_mentions(self):
|
||||||
|
from app.api.comments import _extract_mentions
|
||||||
|
|
||||||
|
assert _extract_mentions("@alice @bob @charlie") == ["alice", "bob", "charlie"]
|
||||||
|
|
||||||
|
def test_duplicate_mentions(self):
|
||||||
|
from app.api.comments import _extract_mentions
|
||||||
|
|
||||||
|
result = _extract_mentions("@alice and @alice again")
|
||||||
|
assert result == ["alice"]
|
||||||
|
|
||||||
|
def test_mention_with_dots_and_dashes(self):
|
||||||
|
from app.api.comments import _extract_mentions
|
||||||
|
|
||||||
|
result = _extract_mentions("@user.name @user-name")
|
||||||
|
assert result == ["user.name", "user-name"]
|
||||||
Reference in New Issue
Block a user