- 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.
293 lines
9.1 KiB
Python
293 lines
9.1 KiB
Python
import re
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal
|
|
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
from pymongo import ReturnDocument
|
|
|
|
from app.schemas.document import CreateType, DocumentCreate, DocumentUpdate, MemberRole
|
|
|
|
COLLECTION = "documents"
|
|
SortField = Literal["updated_at", "last_visited_at"]
|
|
|
|
|
|
async def ensure_indexes(db: AsyncIOMotorDatabase) -> None:
|
|
col = db[COLLECTION]
|
|
await col.create_index([("owner_id", 1), ("updated_at", -1)])
|
|
await col.create_index([("owner_id", 1), ("last_visited_at", -1)])
|
|
await col.create_index([("owner_id", 1), ("stead_id", 1)])
|
|
await col.create_index([("owner_id", 1), ("type", 1)])
|
|
await col.create_index([("owner_id", 1), ("starred", 1)])
|
|
await col.create_index([("members.user_id", 1)])
|
|
await col.create_index([("title", "text"), ("created_by", "text")])
|
|
|
|
|
|
def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": doc["_id"],
|
|
"type": doc["type"],
|
|
"title": doc["title"],
|
|
"template_id": doc.get("template_id"),
|
|
"stead_id": doc.get("stead_id"),
|
|
"parent_page_id": doc.get("parent_page_id"),
|
|
"content": doc.get("content", {}),
|
|
"created_by": doc["created_by"],
|
|
"created_at": doc["created_at"],
|
|
"updated_at": doc["updated_at"],
|
|
"starred": doc.get("starred", False),
|
|
"last_visited_at": doc.get("last_visited_at"),
|
|
"owner_id": doc.get("owner_id"),
|
|
"visibility": doc.get("visibility", "private"),
|
|
"members": doc.get("members", []),
|
|
"workspace_id": doc.get("workspace_id"),
|
|
}
|
|
|
|
|
|
def access_query(user_id: int) -> dict[str, Any]:
|
|
return {"$or": [{"owner_id": user_id}, {"members.user_id": user_id}]}
|
|
|
|
|
|
def can_edit(doc: dict[str, Any], user_id: int) -> bool:
|
|
if doc.get("owner_id") == user_id:
|
|
return True
|
|
for member in doc.get("members", []):
|
|
if member.get("user_id") == user_id and member.get("role") == "editor":
|
|
return True
|
|
return False
|
|
|
|
|
|
async def create_document(
|
|
db: AsyncIOMotorDatabase,
|
|
owner_id: int,
|
|
created_by: str,
|
|
data: DocumentCreate,
|
|
) -> dict[str, Any]:
|
|
if data.parent_page_id:
|
|
parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(owner_id)})
|
|
if parent is None or parent.get("type") == "stead":
|
|
raise ValueError("Parent page is not accessible or is not a page")
|
|
now = datetime.now(UTC)
|
|
doc_id = str(uuid.uuid4())
|
|
doc = {
|
|
"_id": doc_id,
|
|
"owner_id": owner_id,
|
|
"type": data.type,
|
|
"title": data.title,
|
|
"template_id": data.template_id,
|
|
"stead_id": data.stead_id,
|
|
"parent_page_id": data.parent_page_id,
|
|
"content": data.content,
|
|
"created_by": created_by,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"starred": False,
|
|
"last_visited_at": now,
|
|
"visibility": "private",
|
|
"members": [],
|
|
"workspace_id": data.workspace_id,
|
|
}
|
|
await db[COLLECTION].insert_one(doc)
|
|
return _serialize(doc)
|
|
|
|
|
|
async def get_document(
|
|
db: AsyncIOMotorDatabase,
|
|
doc_id: str,
|
|
user_id: int,
|
|
) -> dict[str, Any] | None:
|
|
doc = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)})
|
|
return _serialize(doc) if doc else None
|
|
|
|
|
|
async def list_documents(
|
|
db: AsyncIOMotorDatabase,
|
|
user_id: int,
|
|
doc_type: CreateType | None = None,
|
|
stead_id: str | None = None,
|
|
parent_page_id: str | None = None,
|
|
workspace_id: int | None = None,
|
|
starred: bool | None = None,
|
|
sort: SortField = "updated_at",
|
|
) -> list[dict[str, Any]]:
|
|
query: dict[str, Any] = access_query(user_id)
|
|
extras: dict[str, Any] = {}
|
|
if doc_type:
|
|
extras["type"] = doc_type
|
|
if stead_id:
|
|
extras["stead_id"] = stead_id
|
|
if parent_page_id:
|
|
extras["parent_page_id"] = parent_page_id
|
|
if starred is not None:
|
|
extras["starred"] = starred
|
|
if workspace_id is not None:
|
|
extras["workspace_id"] = workspace_id
|
|
if extras:
|
|
query = {"$and": [query, extras]}
|
|
|
|
cursor = db[COLLECTION].find(query).sort(sort, -1)
|
|
return [_serialize(doc) async for doc in cursor]
|
|
|
|
|
|
async def search_documents(
|
|
db: AsyncIOMotorDatabase,
|
|
user_id: int,
|
|
q: str,
|
|
limit: int = 20,
|
|
) -> list[dict[str, Any]]:
|
|
term = q.strip()
|
|
if not term:
|
|
return []
|
|
|
|
pattern = re.escape(term)
|
|
regex = {"$regex": pattern, "$options": "i"}
|
|
query = {
|
|
"$and": [
|
|
access_query(user_id),
|
|
{
|
|
"$or": [
|
|
{"title": regex},
|
|
{"type": regex},
|
|
{"created_by": regex},
|
|
{"content.body": regex},
|
|
{"content.bodyHtml": regex},
|
|
]
|
|
},
|
|
]
|
|
}
|
|
cursor = db[COLLECTION].find(query).sort([("last_visited_at", -1), ("updated_at", -1)]).limit(limit)
|
|
return [_serialize(doc) async for doc in cursor]
|
|
|
|
|
|
async def update_document(
|
|
db: AsyncIOMotorDatabase,
|
|
doc_id: str,
|
|
user_id: int,
|
|
data: DocumentUpdate,
|
|
) -> dict[str, Any] | None:
|
|
existing = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)})
|
|
if existing is None:
|
|
return None
|
|
if not can_edit(existing, user_id):
|
|
return None
|
|
if data.content is not None or data.title is not None:
|
|
from app.services.history_service import create_version
|
|
|
|
await create_version(
|
|
db,
|
|
existing,
|
|
user_id,
|
|
existing.get("created_by", "Collaborator"),
|
|
"Before update",
|
|
)
|
|
if "parent_page_id" in data.model_fields_set:
|
|
if data.parent_page_id == doc_id:
|
|
return None
|
|
if data.parent_page_id is not None:
|
|
parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(user_id)})
|
|
if parent is None or parent.get("type") == "stead":
|
|
return None
|
|
|
|
updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
|
|
if data.title is not None:
|
|
updates["title"] = data.title
|
|
if data.content is not None:
|
|
updates["content"] = data.content
|
|
if data.starred is not None:
|
|
updates["starred"] = data.starred
|
|
if data.stead_id is not None:
|
|
updates["stead_id"] = data.stead_id
|
|
if data.workspace_id is not None:
|
|
updates["workspace_id"] = data.workspace_id
|
|
if "parent_page_id" in data.model_fields_set:
|
|
updates["parent_page_id"] = data.parent_page_id
|
|
|
|
result = await db[COLLECTION].find_one_and_update(
|
|
{"_id": doc_id},
|
|
{"$set": updates},
|
|
return_document=ReturnDocument.AFTER,
|
|
)
|
|
return _serialize(result) if result else None
|
|
|
|
|
|
async def record_visit(
|
|
db: AsyncIOMotorDatabase,
|
|
doc_id: str,
|
|
user_id: int,
|
|
) -> dict[str, Any] | None:
|
|
now = datetime.now(UTC)
|
|
result = await db[COLLECTION].find_one_and_update(
|
|
{"_id": doc_id, **access_query(user_id)},
|
|
{"$set": {"last_visited_at": now}},
|
|
return_document=ReturnDocument.AFTER,
|
|
)
|
|
return _serialize(result) if result else None
|
|
|
|
|
|
async def add_member(
|
|
db: AsyncIOMotorDatabase,
|
|
doc_id: str,
|
|
owner_id: int,
|
|
member: dict[str, Any],
|
|
role: MemberRole,
|
|
) -> dict[str, Any] | None:
|
|
existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id})
|
|
if existing is None:
|
|
return None
|
|
|
|
members = list(existing.get("members", []))
|
|
members = [m for m in members if m.get("user_id") != member["user_id"]]
|
|
members.append(
|
|
{
|
|
"user_id": member["user_id"],
|
|
"role": role,
|
|
"name": member.get("name", ""),
|
|
"email": member.get("email", ""),
|
|
}
|
|
)
|
|
result = await db[COLLECTION].find_one_and_update(
|
|
{"_id": doc_id, "owner_id": owner_id},
|
|
{
|
|
"$set": {
|
|
"members": members,
|
|
"visibility": "members",
|
|
"updated_at": datetime.now(UTC),
|
|
}
|
|
},
|
|
return_document=ReturnDocument.AFTER,
|
|
)
|
|
return _serialize(result) if result else None
|
|
|
|
|
|
async def remove_member(
|
|
db: AsyncIOMotorDatabase,
|
|
doc_id: str,
|
|
owner_id: int,
|
|
member_user_id: int,
|
|
) -> dict[str, Any] | None:
|
|
existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id})
|
|
if existing is None:
|
|
return None
|
|
members = [m for m in existing.get("members", []) if m.get("user_id") != member_user_id]
|
|
visibility = "members" if members else "private"
|
|
result = await db[COLLECTION].find_one_and_update(
|
|
{"_id": doc_id, "owner_id": owner_id},
|
|
{
|
|
"$set": {
|
|
"members": members,
|
|
"visibility": visibility,
|
|
"updated_at": datetime.now(UTC),
|
|
}
|
|
},
|
|
return_document=ReturnDocument.AFTER,
|
|
)
|
|
return _serialize(result) if result else None
|
|
|
|
|
|
async def delete_document(
|
|
db: AsyncIOMotorDatabase,
|
|
doc_id: str,
|
|
owner_id: int,
|
|
) -> bool:
|
|
result = await db[COLLECTION].delete_one({"_id": doc_id, "owner_id": owner_id})
|
|
return result.deleted_count > 0
|