diff --git a/app/api/__init__.py b/app/api/__init__.py index de5c41e8..325b514c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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.billing import router as billing_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.database import router as database_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(classification_rules_router) router.include_router(automation_router) +router.include_router(comments_router) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 00000000..b4a39220 --- /dev/null +++ b/app/api/comments.py @@ -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 + ] diff --git a/app/models.py b/app/models.py index 94697fc4..7fcc72a2 100644 --- a/app/models.py +++ b/app/models.py @@ -1192,3 +1192,48 @@ class PipelineRoutingRule(Base): created_at = Column(DateTime(timezone=True), server_default=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()) diff --git a/docs/API.md b/docs/API.md index cfc1a844..97b79294 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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" } +] +``` diff --git a/frontend/translations/en.json b/frontend/translations/en.json index dbb9f8fb..9f5b042d 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -315,6 +315,20 @@ "admin_users.total_count_users": "{count} users", "admin_users.total_no_users": "No users", "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_expires": "Expires", "api_tokens.col_last_ip": "Last IP", @@ -484,6 +498,21 @@ "billing.success_heading": "You're all set!", "billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!", "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.active": "Active", "common.all": "All", diff --git a/migrations/env.py b/migrations/env.py index 14566b67..79d91540 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -26,6 +26,8 @@ from app.models import ( # noqa: F401 BackupRecord, ClassificationRuleModel, ComplianceTemplate, + DocumentAnnotation, + DocumentComment, DocumentMetadata, FileProcessingStep, FileRecord, diff --git a/migrations/versions/041_add_document_comments_and_annotations.py b/migrations/versions/041_add_document_comments_and_annotations.py new file mode 100644 index 00000000..f0aac21a --- /dev/null +++ b/migrations/versions/041_add_document_comments_and_annotations.py @@ -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") diff --git a/tests/conftest.py b/tests/conftest.py index fd110d82..b8b654a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,6 +65,8 @@ from app.models import ( # noqa: F401, E402 AutomationHook, ClassificationRuleModel, ComplianceTemplate, + DocumentAnnotation, + DocumentComment, DocumentMetadata, FileRecord, Pipeline, diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 00000000..dda56809 --- /dev/null +++ b/tests/test_comments.py @@ -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"]