- Added tenant API for managing workspaces, including listing, creating, and inviting members. - Introduced new models for organizations, workspaces, and workspace memberships in the backend. - Created frontend components for workspace administration, allowing users to manage workspaces and invite members. - Updated document listing to support workspace-specific filtering. - Implemented account settings page for user profile management and password changes. - Added end-to-end tests for workspace administration and dashboard functionalities.
349 lines
13 KiB
Python
349 lines
13 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.dependencies import get_current_user, get_mongo_db_dep
|
|
from app.database import get_db
|
|
from app.services.tenant_service import get_workspace
|
|
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"),
|
|
workspace_id: int | None = Query(None, description="Filter by workspace"),
|
|
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),
|
|
auth_db: Session = Depends(get_db),
|
|
) -> list[DocumentResponse]:
|
|
if workspace_id is not None:
|
|
if get_workspace(auth_db, workspace_id, current_user.id) is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
|
|
await db["documents"].update_many(
|
|
{"owner_id": current_user.id, "workspace_id": {"$exists": False}},
|
|
{"$set": {"workspace_id": workspace_id}},
|
|
)
|
|
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,
|
|
workspace_id=workspace_id,
|
|
)
|
|
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),
|
|
auth_db: Session = Depends(get_db),
|
|
) -> DocumentResponse:
|
|
if data.workspace_id is not None and get_workspace(auth_db, data.workspace_id, current_user.id) is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
|
|
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")
|