Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/app/api/endpoints/cloud_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ def update_cloud_project(
return _project_response(db, project, current_user)


@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def archive_cloud_project(
project_id: int,
version: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
cloud_project_service.archive(db, project_id, current_user.id, version)


@router.post(
"/{project_id}/local-bindings",
response_model=LocalBindingResponse,
Expand Down
14 changes: 14 additions & 0 deletions backend/app/api/endpoints/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,20 @@ def update_loop_item(
return _loop_item_response(db, item, current_user)


@router.delete("/loop-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def archive_loop_item(
item_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
if external_loop_item_provider.is_external_item(db, item_id):
raise HTTPException(
status.HTTP_409_CONFLICT,
"External provider tasks cannot be archived from Wegent",
)
loop_item_service.delete(db, item_id, current_user.id)


@router.post(
"/loop-items/{item_id}/comments",
response_model=LoopItemCommentResponse,
Expand Down
49 changes: 49 additions & 0 deletions backend/app/schemas/cloud_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,53 @@ def validate_provider(self) -> "CloudProjectCreate":
return self


class CloudProjectCardDisplay(BaseModel):
show_assignee: bool = True
show_priority: bool = True
show_tags: bool = True
show_date: bool = True


class CloudProjectBoardStatus(BaseModel):
id: str = Field(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")
name: str = Field(min_length=1, max_length=40)
color: Literal["gray", "blue", "orange", "purple", "green", "red"] = "gray"


def default_board_statuses() -> list[CloudProjectBoardStatus]:
return [
CloudProjectBoardStatus(id="inbox", name="收集箱", color="gray"),
CloudProjectBoardStatus(id="pending", name="待开始", color="blue"),
CloudProjectBoardStatus(id="in_progress", name="进行中", color="orange"),
CloudProjectBoardStatus(id="in_review", name="待确认", color="purple"),
CloudProjectBoardStatus(id="completed", name="已完成", color="green"),
]


class CloudProjectBoardConfig(BaseModel):
group_by: Literal["status", "priority", "assignee", "tag"] = "status"
statuses: list[CloudProjectBoardStatus] = Field(
default_factory=default_board_statuses
)

@model_validator(mode="after")
def validate_statuses(self) -> "CloudProjectBoardConfig":
ids = [item.id for item in self.statuses]
if len(ids) != len(set(ids)):
raise ValueError("board status ids must be unique")
if len(self.statuses) > 50:
raise ValueError("board supports at most 50 statuses")
return self


class CloudProjectUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
tags: list[str] | None = Field(default=None, max_length=MAX_TAGS_PER_ITEM)
provider_config: dict[str, object] | None = None
visibility: ProjectVisibility | None = None
card_display: CloudProjectCardDisplay | None = None
board_config: CloudProjectBoardConfig | None = None
version: int = Field(ge=1)

@field_validator("tags", mode="before")
Expand Down Expand Up @@ -136,6 +177,12 @@ class CloudProjectResponse(BaseModel):
# provider kinds it can operate.
task_provider: str = "local"
provider_config: dict[str, object] = Field(default_factory=dict)
card_display: CloudProjectCardDisplay = Field(
default_factory=CloudProjectCardDisplay
)
board_config: CloudProjectBoardConfig = Field(
default_factory=CloudProjectBoardConfig
)
visibility: ProjectVisibility = "private"
created_by_user_id: int
current_user_id: int = 0
Expand All @@ -161,6 +208,8 @@ def populate_tags(cls, value: object) -> object:
"provider_config": mask_provider_config(
metadata.get("provider_config", {})
),
"card_display": metadata.get("card_display", {}),
"board_config": metadata.get("board_config", {}),
"visibility": (
"public" if metadata.get("visibility") == "public" else "private"
),
Expand Down
11 changes: 4 additions & 7 deletions backend/app/schemas/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@
class LoopItemCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["inbox", "pending", "in_progress", "in_review", "completed"] = (
"inbox"
)
status: str | None = Field(default=None, max_length=32)
assignee_user_id: int | None = None
priority: Literal["none", "low", "medium", "high", "urgent"] = "none"
due_at: datetime | None = None
Expand All @@ -40,9 +38,7 @@ class LoopItemUpdate(BaseModel):
version: int = Field(ge=1)
title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
status: (
Literal["inbox", "pending", "in_progress", "in_review", "completed"] | None
) = None
status: str | None = Field(default=None, max_length=32)
assignee_user_id: int | None = None
priority: Literal["none", "low", "medium", "high", "urgent"] | None = None
due_at: datetime | None = None
Expand All @@ -58,7 +54,7 @@ class LoopItemReorder(BaseModel):
"""Manual order of the TODOs inside one board lane (parent + status)."""

parent_id: str | None = Field(default=None, max_length=64)
status: Literal["inbox", "pending", "in_progress", "in_review", "completed"]
status: str = Field(max_length=32)
item_ids: list[str] = Field(min_length=1, max_length=1000)


Expand All @@ -73,6 +69,7 @@ class LoopItemResponse(BaseModel):
description: str
status: str
assignee_user_id: int | None
assignee_name: str | None = None
priority: str
due_at: datetime | None
sort_order: int
Expand Down
73 changes: 73 additions & 0 deletions backend/app/services/cloud_projects/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from app.core.provider_credentials import store_provider_config
from app.models.cloud_project import CloudProject, CloudProjectLocalBinding
from app.models.delivery import LoopItem, loop_datetime_is_unset
from app.models.project import Project
from app.models.resource_member import MemberStatus, ResourceMember
from app.models.share_link import ResourceType
Expand All @@ -26,6 +27,7 @@
CloudProjectMemberUpdate,
CloudProjectUpdate,
LocalBindingCreate,
default_board_statuses,
normalize_provider_config,
)
from app.services.cloud_projects.access import require_cloud_project_role
Expand Down Expand Up @@ -74,6 +76,12 @@ def create(
"provider_config": provider_config,
"visibility": values.visibility,
"tags": [],
"board_config": {
"group_by": "status",
"statuses": [
item.model_dump() for item in default_board_statuses()
],
},
},
)
db.add(project)
Expand Down Expand Up @@ -143,11 +151,51 @@ def update(
if (
"tags" in values.model_fields_set
or "provider_config" in values.model_fields_set
or "card_display" in values.model_fields_set
or "board_config" in values.model_fields_set
or "visibility" in values.model_fields_set
):
metadata = dict(project.metadata_json or {})
if "tags" in values.model_fields_set and values.tags is not None:
metadata["tags"] = updates.pop("tags")
if (
"card_display" in values.model_fields_set
and values.card_display is not None
):
metadata["card_display"] = values.card_display.model_dump()
updates.pop("card_display", None)
if (
"board_config" in values.model_fields_set
and values.board_config is not None
):
previous = metadata.get("board_config")
previous = previous if isinstance(previous, dict) else {}
previous_statuses = previous.get("statuses")
previous_statuses = (
previous_statuses if isinstance(previous_statuses, list) else []
)
previous_ids = {
str(item.get("id"))
for item in previous_statuses
if isinstance(item, dict) and item.get("id")
}
next_ids = {item.id for item in values.board_config.statuses}
removed_ids = previous_ids - next_ids
if removed_ids:
db.query(LoopItem).filter(
LoopItem.cloud_project_id == project.id,
LoopItem.status.in_(removed_ids),
loop_datetime_is_unset(LoopItem.deleted_at),
).update(
{
"status": "",
"completed_at": None,
"version": LoopItem.version + 1,
},
synchronize_session=False,
)
metadata["board_config"] = values.board_config.model_dump()
updates.pop("board_config", None)
if (
"provider_config" in values.model_fields_set
and values.provider_config is not None
Expand Down Expand Up @@ -197,6 +245,31 @@ def update(
db.refresh(project)
return project

def archive(self, db: Session, project_id: int, user_id: int, version: int) -> None:
"""Archive a project so it no longer appears in active project lists."""

project = require_cloud_project_role(
db, project_id, user_id, BaseRole.Maintainer
).project
updated = (
db.query(CloudProject)
.filter(
CloudProject.id == project.id,
CloudProject.version == version,
CloudProject.status == "active",
)
.update(
{
"status": "archived",
"version": CloudProject.version + 1,
}
)
)
if updated != 1:
db.rollback()
raise HTTPException(status.HTTP_409_CONFLICT, "Cloud project changed")
db.commit()

def add_local_binding(
self,
db: Session,
Expand Down
61 changes: 57 additions & 4 deletions backend/app/services/loop_items/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@


class LoopItemService:
@staticmethod
def _project_status_ids(project: CloudProject) -> list[str]:
metadata = (
project.metadata_json if isinstance(project.metadata_json, dict) else {}
)
board = metadata.get("board_config")
board = board if isinstance(board, dict) else {}
statuses = board.get("statuses")
if not isinstance(statuses, list):
return ["inbox", "pending", "in_progress", "in_review", "completed"]
return [
str(item["id"])
for item in statuses
if isinstance(item, dict) and item.get("id")
]

def _require_internal_task_project(
self,
db: Session,
Expand Down Expand Up @@ -104,6 +120,9 @@ def response_values(
"can_view_detail": can_view_detail,
"can_edit": can_edit,
}
if item.assignee_user_id:
assignee = db.get(User, item.assignee_user_id)
values["assignee_name"] = assignee.user_name if assignee else None
Comment on lines +123 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find every caller of response_values and check for per-item loops.
set -euo pipefail

rg -n -C8 'response_values' --glob '*.py'

Repository: wecode-ai/Wegent

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf 'Tracked Python paths under backend/app/services/loop_items/service.py:\n'
git ls-files 'backend/app/services/loop_items/service.py' 'backend/**/*.py' | sed -n '1,120p'
printf '\nFile exists/stat:\n'
if [ -f backend/app/services/loop_items/service.py ]; then
  wc -l backend/app/services/loop_items/service.py
  ast-grep outline backend/app/services/loop_items/service.py --view expanded || true
fi

Repository: wecode-ai/Wegent

Length of output: 8595


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'LoopItemService relevant sections:\n'
cat -n backend/app/services/loop_items/service.py | sed -n '1,340p'

printf '\nSearch for response_values in tracked Python files:\n'
rg -n -C6 'response_values|list_my_work|\\.response_values|list\\(' --glob '*.py' backend/app | sed -n '1,240p'

Repository: wecode-ai/Wegent

Length of output: 14395


🏁 Script executed:

#!/bin/bash
set -xeu

printf 'Remaining LoopItemService relevant section:\n'
cat -n backend/app/services/loop_items/service.py | sed -n '340,900p'

printf '\nSearch for response_values / list_my_work / loop_item_service in tracked Python files:\n'
rg -n -C6 'response_values|list_my_work|loop_item_service|service\.list\(|service\.get\(' --glob '*.py' backend/app

Repository: wecode-ai/Wegent

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Response schema definitions:\n'
rg -n -C5 'class (LoopItemResponse|LoopItemListResponse|MyWorkItemResponse|MyWorkListResponse)|assignee_name|assignee_user_id' backend/app/schemas --glob '*.py'

Repository: wecode-ai/Wegent

Length of output: 5803


Batch assignee lookups on list response paths.

backend/app/api/endpoints/deliveries.py builds LoopItemListResponse and MyWorkListResponse by calling loop_item_service.response_values() for each item. Since response_values() runs one db.get(User, item.assignee_user_id) per task, use a batch user lookup or eager-load assignees before building repeated item responses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/loop_items/service.py` around lines 123 - 125, Update
the list response paths that repeatedly call
loop_item_service.response_values(), especially the LoopItemListResponse and
MyWorkListResponse construction in deliveries.py, to batch-load or eager-load
all assignee users before mapping items. Refactor response_values() and its
callers to reuse the preloaded user data instead of executing db.get(User,
item.assignee_user_id) for each item, while preserving assignee_name behavior
for missing or unassigned users.

if not can_view_detail:
values["description"] = ""
return values
Expand Down Expand Up @@ -252,6 +271,16 @@ def create(
project.next_item_number += 1
payload = values.model_dump()
tags = payload.pop("tags")
if payload.get("assignee_user_id") is None:
payload["assignee_user_id"] = user_id
configured_statuses = self._project_status_ids(project)
requested_status = payload.get("status")
if requested_status is None:
payload["status"] = configured_statuses[0] if configured_statuses else ""
elif requested_status not in configured_statuses:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Unknown board status"
)
item = LoopItem(
id=f"{project.project_key}-{sequence}",
cloud_project_id=project.id,
Expand Down Expand Up @@ -520,6 +549,13 @@ def update(
metadata["tags"] = updates.pop("tags") or []
updates["metadata_json"] = metadata
next_status = updates.get("status")
if "status" in values.model_fields_set and next_status is not None:
project = db.get(CloudProject, item.cloud_project_id)
if project is None or next_status not in self._project_status_ids(project):
if next_status != "":
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Unknown board status"
)
if next_status and next_status != item.status:
updates["completed_at"] = (
self._now() if next_status == "completed" else None
Expand All @@ -543,12 +579,28 @@ def update(
return item

def delete(self, db: Session, item_id: str, user_id: int) -> LoopItem:
"""Soft delete a TODO; the row is kept for the recycle bin."""
"""Soft delete a TODO subtree; rows are kept for the recycle bin."""

item = self.get(db, item_id, user_id)
self._require_item_access(db, item, user_id, edit=True)
item.deleted_at = self._now()
item.version += 1
archived_at = self._now()
pending_parent_ids = [item.id]
archived_items = [item]
while pending_parent_ids:
children = (
db.query(LoopItem)
.filter(
LoopItem.cloud_project_id == item.cloud_project_id,
LoopItem.parent_id.in_(pending_parent_ids),
loop_datetime_is_unset(LoopItem.deleted_at),
)
.all()
)
pending_parent_ids = [child.id for child in children]
archived_items.extend(children)
for archived_item in archived_items:
archived_item.deleted_at = archived_at
archived_item.version += 1
db.commit()
db.refresh(item)
return item
Expand Down Expand Up @@ -894,7 +946,8 @@ def list_my_work(self, db: Session, user_id: int) -> list[dict[str, object]]:
.filter(
LoopItem.cloud_project_id.in_(project_by_id),
loop_datetime_is_unset(LoopItem.deleted_at),
(LoopItem.assignee_user_id == user_id)
(LoopItem.created_by_user_id == user_id)
| (LoopItem.assignee_user_id == user_id)
| LoopItem.id.in_(active_task_items)
| LoopItem.id.in_(collaborator_items),
)
Expand Down
Loading
Loading