Initial SFERA platform baseline
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# sfera-collaboration
|
||||
|
||||
In-memory collaboration primitives for SFERA.
|
||||
|
||||
Provides:
|
||||
|
||||
- users, workspaces, projects, and tasks;
|
||||
- task-linked change sessions with explicit finish time;
|
||||
- project/target scoped comments;
|
||||
- activity feed events;
|
||||
- project/target scoped ownership assignments.
|
||||
@@ -0,0 +1,10 @@
|
||||
[project]
|
||||
name = "sfera-collaboration"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
OPEN = "OPEN"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
DONE = "DONE"
|
||||
CANCELED = "CANCELED"
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
user_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class Workspace(BaseModel):
|
||||
workspace_id: str
|
||||
name: str
|
||||
owner_user_id: str | None = None
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
project_id: str
|
||||
workspace_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
task_id: str
|
||||
project_id: str
|
||||
title: str
|
||||
status: TaskStatus = TaskStatus.OPEN
|
||||
assignee_user_id: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ChangeSession(BaseModel):
|
||||
session_id: str
|
||||
task_id: str
|
||||
user_id: str
|
||||
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
finished_at: datetime | None = None
|
||||
|
||||
|
||||
class Comment(BaseModel):
|
||||
comment_id: str
|
||||
project_id: str
|
||||
target_id: str
|
||||
user_id: str
|
||||
body: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ActivityEvent(BaseModel):
|
||||
event_id: str
|
||||
project_id: str
|
||||
actor_user_id: str
|
||||
verb: str
|
||||
target_id: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
attributes: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Ownership(BaseModel):
|
||||
owner_user_id: str
|
||||
project_id: str
|
||||
target_id: str
|
||||
role: str = "OWNER"
|
||||
assigned_by: str | None = None
|
||||
assigned_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
attributes: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class InMemoryCollaborationStore:
|
||||
def __init__(self) -> None:
|
||||
self.users: dict[str, User] = {}
|
||||
self.workspaces: dict[str, Workspace] = {}
|
||||
self.projects: dict[str, Project] = {}
|
||||
self.tasks: dict[str, Task] = {}
|
||||
self.sessions: dict[str, ChangeSession] = {}
|
||||
self.comments: dict[str, Comment] = {}
|
||||
self.activities: list[ActivityEvent] = []
|
||||
self.ownership: dict[str, Ownership] = {}
|
||||
|
||||
def upsert_user(self, user: User) -> User:
|
||||
self.users[user.user_id] = user
|
||||
return user
|
||||
|
||||
def upsert_workspace(self, workspace: Workspace) -> Workspace:
|
||||
self.workspaces[workspace.workspace_id] = workspace
|
||||
return workspace
|
||||
|
||||
def upsert_project(self, project: Project) -> Project:
|
||||
self.projects[project.project_id] = project
|
||||
return project
|
||||
|
||||
def upsert_task(self, task: Task) -> Task:
|
||||
self.tasks[task.task_id] = task
|
||||
return task
|
||||
|
||||
def start_session(self, session: ChangeSession) -> ChangeSession:
|
||||
if session.task_id not in self.tasks:
|
||||
raise KeyError(f"Unknown task: {session.task_id}")
|
||||
if session.user_id not in self.users:
|
||||
raise KeyError(f"Unknown user: {session.user_id}")
|
||||
self.sessions[session.session_id] = session
|
||||
return session
|
||||
|
||||
def finish_session(self, session_id: str) -> ChangeSession:
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError(f"Unknown session: {session_id}")
|
||||
if session.finished_at is None:
|
||||
session = session.model_copy(update={"finished_at": datetime.now(timezone.utc)})
|
||||
self.sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def add_comment(self, comment: Comment) -> Comment:
|
||||
if comment.user_id not in self.users:
|
||||
raise KeyError(f"Unknown user: {comment.user_id}")
|
||||
self.comments[comment.comment_id] = comment
|
||||
return comment
|
||||
|
||||
def comments_for_project(self, project_id: str) -> list[Comment]:
|
||||
return sorted(
|
||||
[
|
||||
comment
|
||||
for comment in self.comments.values()
|
||||
if comment.project_id == project_id
|
||||
],
|
||||
key=lambda item: item.created_at,
|
||||
)
|
||||
|
||||
def comments_for_target(self, project_id: str, target_id: str) -> list[Comment]:
|
||||
return [
|
||||
comment
|
||||
for comment in self.comments_for_project(project_id)
|
||||
if comment.target_id == target_id
|
||||
]
|
||||
|
||||
def add_activity(self, event: ActivityEvent) -> ActivityEvent:
|
||||
self.activities.append(event)
|
||||
return event
|
||||
|
||||
def assign_owner(self, ownership: Ownership) -> Ownership:
|
||||
if ownership.owner_user_id not in self.users:
|
||||
raise KeyError(f"Unknown user: {ownership.owner_user_id}")
|
||||
self.ownership[self._ownership_key(ownership)] = ownership
|
||||
return ownership
|
||||
|
||||
def owners_for_project(self, project_id: str) -> list[Ownership]:
|
||||
return sorted(
|
||||
[
|
||||
ownership
|
||||
for ownership in self.ownership.values()
|
||||
if ownership.project_id == project_id
|
||||
],
|
||||
key=lambda item: (item.target_id, item.role, item.owner_user_id),
|
||||
)
|
||||
|
||||
def owners_for_target(self, project_id: str, target_id: str) -> list[Ownership]:
|
||||
return [
|
||||
ownership
|
||||
for ownership in self.owners_for_project(project_id)
|
||||
if ownership.target_id == target_id
|
||||
]
|
||||
|
||||
def activity_feed(self, project_id: str) -> list[ActivityEvent]:
|
||||
return [
|
||||
event
|
||||
for event in sorted(self.activities, key=lambda item: item.created_at, reverse=True)
|
||||
if event.project_id == project_id
|
||||
]
|
||||
|
||||
def _ownership_key(self, ownership: Ownership) -> str:
|
||||
return f"{ownership.project_id}:{ownership.target_id}:{ownership.role}:{ownership.owner_user_id}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActivityEvent",
|
||||
"ChangeSession",
|
||||
"Comment",
|
||||
"InMemoryCollaborationStore",
|
||||
"Ownership",
|
||||
"Project",
|
||||
"Task",
|
||||
"TaskStatus",
|
||||
"User",
|
||||
"Workspace",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
from collaboration import ChangeSession, Comment, InMemoryCollaborationStore, Ownership, Task, User
|
||||
|
||||
|
||||
def test_start_session_requires_known_task_and_user():
|
||||
store = InMemoryCollaborationStore()
|
||||
store.upsert_user(User(user_id="user.1", display_name="Tester"))
|
||||
store.upsert_task(Task(task_id="task.1", project_id="demo", title="Index project"))
|
||||
|
||||
session = store.start_session(
|
||||
ChangeSession(session_id="session.1", task_id="task.1", user_id="user.1")
|
||||
)
|
||||
|
||||
assert session.session_id == "session.1"
|
||||
|
||||
|
||||
def test_finish_session_sets_finished_at_once():
|
||||
store = InMemoryCollaborationStore()
|
||||
store.upsert_user(User(user_id="user.1", display_name="Tester"))
|
||||
store.upsert_task(Task(task_id="task.1", project_id="demo", title="Index project"))
|
||||
store.start_session(ChangeSession(session_id="session.1", task_id="task.1", user_id="user.1"))
|
||||
|
||||
finished = store.finish_session("session.1")
|
||||
finished_again = store.finish_session("session.1")
|
||||
|
||||
assert finished.finished_at is not None
|
||||
assert finished_again.finished_at == finished.finished_at
|
||||
|
||||
|
||||
def test_assign_owner_is_project_and_target_scoped():
|
||||
store = InMemoryCollaborationStore()
|
||||
store.upsert_user(User(user_id="user.1", display_name="Tester"))
|
||||
|
||||
ownership = store.assign_owner(
|
||||
Ownership(
|
||||
owner_user_id="user.1",
|
||||
project_id="demo",
|
||||
target_id="lineage.document.order",
|
||||
role="RESPONSIBLE",
|
||||
)
|
||||
)
|
||||
|
||||
assert ownership.owner_user_id == "user.1"
|
||||
assert store.owners_for_project("demo") == [ownership]
|
||||
assert store.owners_for_target("demo", "lineage.document.order") == [ownership]
|
||||
|
||||
|
||||
def test_add_comment_is_project_and_target_scoped():
|
||||
store = InMemoryCollaborationStore()
|
||||
store.upsert_user(User(user_id="user.1", display_name="Tester"))
|
||||
|
||||
comment = store.add_comment(
|
||||
Comment(
|
||||
comment_id="comment.1",
|
||||
project_id="demo",
|
||||
target_id="lineage.document.order",
|
||||
user_id="user.1",
|
||||
body="Check posting rules.",
|
||||
)
|
||||
)
|
||||
|
||||
assert store.comments_for_project("demo") == [comment]
|
||||
assert store.comments_for_target("demo", "lineage.document.order") == [comment]
|
||||
Reference in New Issue
Block a user