Silos/backend/app/routers/documents.py
AnuragJangid18 809235e962 feat: implement comments and activity panel in workspace
- Added CommentsPanel component for managing comments on documents.
- Integrated comment creation, deletion, and resolution functionalities.
- Implemented activity tracking for document interactions.
- Enhanced SketchPage with editing and saving capabilities.
- Introduced template management API endpoints for creating, sharing, and deleting templates.
- Added runtime validation report for frontend and backend.
- Created GettingStartedPanel for user onboarding with template options.
- Implemented Playwright E2E tests for accessibility and export functionality.
2026-10-22 21:18:10 +05:30

335 lines
12 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep
from app.models.user import User
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
from app.schemas.collaboration import (
ActivityResponse,
CommentCreate,
CommentResolveRequest,
CommentResponse,
NotificationResponse,
)
from app.services.collaboration_service import (
create_comment,
delete_comment,
list_activity,
list_comments,
list_notifications,
mark_notifications_read,
mention_names,
resolve_comment,
)
from app.services.document_service import (
add_member,
create_document,
delete_document,
get_document,
list_documents,
record_visit,
remove_member,
search_documents,
update_document,
)
from app.services.user_service import get_user_by_email_or_username
from app.services.history_service import list_versions, restore_version
from html import escape
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("/{doc_id}/comments", response_model=list[CommentResponse])
async def get_comments(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[CommentResponse]:
comments = await list_comments(db, doc_id, current_user.id)
if comments is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return [CommentResponse(**comment) for comment in comments]
@router.post("/{doc_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def post_comment(
doc_id: str,
data: CommentCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
sql: Session = Depends(get_db),
) -> CommentResponse:
mentioned_users = []
for name in mention_names(data.body):
mentioned = get_user_by_email_or_username(sql, name.lower())
if mentioned is not None and mentioned.id != current_user.id:
mentioned_users.append({"id": mentioned.id})
comment = await create_comment(
db,
doc_id,
current_user.id,
current_user.full_name or current_user.username,
data.body,
data.parent_id,
mentioned_users,
)
if comment is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or parent comment is invalid")
return CommentResponse(**comment)
@router.patch("/{doc_id}/comments/{comment_id}/resolve", response_model=CommentResponse)
async def update_comment_resolution(
doc_id: str,
comment_id: str,
data: CommentResolveRequest,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> CommentResponse:
comment = await resolve_comment(
db, doc_id, comment_id, current_user.id, data.resolved,
current_user.full_name or current_user.username,
)
if comment is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or access denied")
return CommentResponse(**comment)
@router.get("/{doc_id}/activity", response_model=list[ActivityResponse])
async def get_activity(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[ActivityResponse]:
entries = await list_activity(db, doc_id, current_user.id)
if entries is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return [ActivityResponse(**entry) for entry in entries]
@router.get("/notifications", response_model=list[NotificationResponse])
async def get_notifications(
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[NotificationResponse]:
return [NotificationResponse(**item) for item in await list_notifications(db, current_user.id)]
@router.post("/notifications/read", status_code=status.HTTP_204_NO_CONTENT)
async def read_notifications(
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> None:
await mark_notifications_read(db, current_user.id)
@router.get("/{doc_id}/history", response_model=list[DocumentVersionResponse])
async def get_history(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentVersionResponse]:
versions = await list_versions(db, doc_id, current_user.id)
if versions is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return [DocumentVersionResponse(**version) for version in versions]
@router.post("/{doc_id}/history/{version_id}/restore", response_model=DocumentResponse)
async def restore_history(
doc_id: str,
version_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
document = await restore_version(
db,
doc_id,
version_id,
current_user.id,
current_user.full_name or current_user.username,
)
if document is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Version not found or restore access denied")
from app.services.document_service import _serialize
return DocumentResponse(**_serialize(document))
@router.get("/{doc_id}/export")
async def export_document(
doc_id: str,
format: str = Query("html", pattern="^(html|text)$"),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> Response:
document = await get_document(db, doc_id, current_user.id)
if document is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
body_html = str(document["content"].get("bodyHtml", document["content"].get("body", "")))
if format == "text":
import re
return Response(re.sub(r"<[^>]+>", " ", body_html).strip(), media_type="text/plain")
html = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<title>{escape(document['title'])}</title></head><body>"
f"<h1>{escape(document['title'])}</h1>{body_html}</body></html>"
)
return Response(html, media_type="text/html")
@router.delete("/{doc_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_comment(
doc_id: str,
comment_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> None:
deleted = await delete_comment(db, comment_id, current_user.id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or you are not its author")
@router.get("", response_model=list[DocumentResponse])
async def list_docs(
type: str | None = Query(None, description="Filter by document type"),
stead_id: str | None = Query(None, description="Filter by parent stead"),
parent_page_id: str | None = Query(None, description="Filter by parent page"),
starred: bool | None = Query(None, description="Filter starred projects"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentResponse]:
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
docs = await list_documents(
db,
current_user.id,
doc_type=type,
stead_id=stead_id,
parent_page_id=parent_page_id,
starred=starred,
sort=sort_field,
)
return [DocumentResponse(**doc) for doc in docs]
@router.get("/search", response_model=list[DocumentResponse])
async def search_docs(
q: str = Query("", min_length=0),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentResponse]:
docs = await search_documents(db, current_user.id, q)
return [DocumentResponse(**doc) for doc in docs]
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await get_document(db, doc_id, current_user.id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return DocumentResponse(**doc)
@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def create_doc(
data: DocumentCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
created_by = current_user.full_name or current_user.username
try:
doc = await create_document(db, current_user.id, created_by, data)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return DocumentResponse(**doc)
@router.patch("/{doc_id}", response_model=DocumentResponse)
async def update_doc(
doc_id: str,
data: DocumentUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await update_document(db, doc_id, current_user.id, data)
if doc is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Document not found or you do not have edit access",
)
return DocumentResponse(**doc)
@router.post("/{doc_id}/visit", response_model=DocumentResponse)
async def visit_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await record_visit(db, doc_id, current_user.id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return DocumentResponse(**doc)
@router.post("/{doc_id}/share", response_model=DocumentResponse)
async def share_doc(
doc_id: str,
data: ShareRequest,
current_user: User = Depends(get_current_user),
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
sql: Session = Depends(get_db),
) -> DocumentResponse:
identifier = data.email_or_username.strip().lower()
user = get_user_by_email_or_username(sql, identifier)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="You already own this document")
doc = await add_member(
mongo,
doc_id,
current_user.id,
{
"user_id": user.id,
"name": user.full_name or user.username,
"email": user.email,
},
data.role,
)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this document")
return DocumentResponse(**doc)
@router.delete("/{doc_id}/share/{user_id}", response_model=DocumentResponse)
async def unshare_doc(
doc_id: str,
user_id: int,
current_user: User = Depends(get_current_user),
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await remove_member(mongo, doc_id, current_user.id, user_id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can update sharing")
return DocumentResponse(**doc)
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> None:
deleted = await delete_document(db, doc_id, current_user.id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")