Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
22 changes: 18 additions & 4 deletions backend/app/api/endpoints/cloud_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@
router = APIRouter()


def _project_response(
db: Session, project: object, current_user: User
) -> CloudProjectResponse:
access = cloud_project_service.access(db, int(project.id), current_user.id)
return CloudProjectResponse.model_validate(
{
**project.__dict__,
"current_user_id": current_user.id,
"current_user_name": current_user.user_name,
"access_role": access.role,
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@router.post(
"", response_model=CloudProjectResponse, status_code=status.HTTP_201_CREATED
)
Expand All @@ -46,7 +60,7 @@ def create_cloud_project(
current_user: User = Depends(get_current_user),
) -> CloudProjectResponse:
project = cloud_project_service.create(db, current_user.id, values)
return CloudProjectResponse.model_validate(project)
return _project_response(db, project, current_user)


@router.get("", response_model=CloudProjectListResponse)
Expand All @@ -56,7 +70,7 @@ def list_cloud_projects(
) -> CloudProjectListResponse:
projects = cloud_project_service.list_accessible(db, current_user.id)
return CloudProjectListResponse(
items=[CloudProjectResponse.model_validate(project) for project in projects]
items=[_project_response(db, project, current_user) for project in projects]
)


Expand All @@ -67,7 +81,7 @@ def get_cloud_project(
current_user: User = Depends(get_current_user),
) -> CloudProjectResponse:
project = cloud_project_service.get(db, project_id, current_user.id)
return CloudProjectResponse.model_validate(project)
return _project_response(db, project, current_user)


@router.get(
Expand Down Expand Up @@ -95,7 +109,7 @@ def update_cloud_project(
current_user: User = Depends(get_current_user),
) -> CloudProjectResponse:
project = cloud_project_service.update(db, project_id, current_user.id, values)
return CloudProjectResponse.model_validate(project)
return _project_response(db, project, current_user)


@router.post(
Expand Down
44 changes: 36 additions & 8 deletions backend/app/api/endpoints/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,21 @@
MyWorkItemResponse,
MyWorkListResponse,
)
from app.services.cloud_projects import cloud_project_service
from app.services.delivery import delivery_service
from app.services.loop_items import loop_item_service

router = APIRouter()


def _loop_item_response(
db: Session, item: object, current_user: User
) -> LoopItemResponse:
return LoopItemResponse.model_validate(
loop_item_service.response_values(db, item, current_user.id)
)


def _delivery_response(db: Session, delivery: Delivery) -> DeliveryResponse:
return DeliveryResponse.model_validate(
{
Expand Down Expand Up @@ -117,7 +126,7 @@ def find_runtime_task_loop_item(
item = loop_item_service.find_for_runtime_task(
db, current_user.id, device_id, task_id
)
return LoopItemResponse.model_validate(item)
return _loop_item_response(db, item, current_user)


@router.get("/runtime-tasks/cloud-context", response_model=CloudTaskContextResponse)
Expand All @@ -133,8 +142,19 @@ def find_runtime_task_cloud_context(
return CloudTaskContextResponse.model_validate(
{
**binding.__dict__,
"project": project,
"loop_item": item,
"project": {
**project.__dict__,
"current_user_id": current_user.id,
"current_user_name": current_user.user_name,
"access_role": cloud_project_service.access(
db, project.id, current_user.id
).role,
},
"loop_item": (
loop_item_service.response_values(db, item, current_user.id)
if item is not None
else None
),
}
)

Expand Down Expand Up @@ -175,8 +195,16 @@ def list_loop_items(
current_user: User = Depends(get_current_user),
) -> LoopItemListResponse:
items = loop_item_service.list(db, project_id, current_user.id)
access = cloud_project_service.access(db, project_id, current_user.id)
return LoopItemListResponse(
items=[LoopItemResponse.model_validate(item) for item in items]
items=[
LoopItemResponse.model_validate(
loop_item_service.response_values(
db, item, current_user.id, access=access
)
)
for item in items
]
)


Expand All @@ -192,7 +220,7 @@ def create_loop_item(
current_user: User = Depends(get_current_user),
) -> LoopItemResponse:
item = loop_item_service.create(db, project_id, current_user.id, values)
return LoopItemResponse.model_validate(item)
return _loop_item_response(db, item, current_user)


@router.post(
Expand All @@ -207,7 +235,7 @@ def reorder_loop_items(
) -> LoopItemListResponse:
items = loop_item_service.reorder(db, project_id, current_user.id, values)
return LoopItemListResponse(
items=[LoopItemResponse.model_validate(item) for item in items]
items=[_loop_item_response(db, item, current_user) for item in items]
)


Expand All @@ -218,7 +246,7 @@ def get_loop_item(
current_user: User = Depends(get_current_user),
) -> LoopItemResponse:
item = loop_item_service.get(db, item_id, current_user.id)
return LoopItemResponse.model_validate(item)
return _loop_item_response(db, item, current_user)


@router.patch("/loop-items/{item_id}", response_model=LoopItemResponse)
Expand All @@ -229,7 +257,7 @@ def update_loop_item(
current_user: User = Depends(get_current_user),
) -> LoopItemResponse:
item = loop_item_service.update(db, item_id, current_user.id, values)
return LoopItemResponse.model_validate(item)
return _loop_item_response(db, item, current_user)


@router.get(
Expand Down
7 changes: 7 additions & 0 deletions backend/app/models/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ class LoopNode(Base):
class CloudProject(LoopNode):
__mapper_args__ = {"polymorphic_identity": "project"}

@property
def visibility(self) -> str:
metadata = self.metadata_json
if not isinstance(metadata, dict):
return "private"
return "public" if metadata.get("visibility") == "public" else "private"

@property
def tags(self) -> list[str]:
"""Project-level tag registry stored inside the metadata JSON column."""
Expand Down
10 changes: 10 additions & 0 deletions backend/app/schemas/cloud_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

SnowflakeId = Annotated[str, BeforeValidator(str)]
TaskProvider = Literal["local", "github", "gitlab"]
ProjectVisibility = Literal["private", "public"]


def _normalize_repository(task_provider: str, repository: str) -> str:
Expand Down Expand Up @@ -64,6 +65,7 @@ class CloudProjectCreate(BaseModel):
description: str = ""
task_provider: TaskProvider = "local"
provider_config: dict[str, object] = Field(default_factory=dict)
visibility: ProjectVisibility = "private"

@field_validator("project_key")
@classmethod
Expand All @@ -83,6 +85,7 @@ class CloudProjectUpdate(BaseModel):
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
version: int = Field(ge=1)

@field_validator("tags", mode="before")
Expand Down Expand Up @@ -116,7 +119,11 @@ class CloudProjectResponse(BaseModel):
project_store: Literal["backend"] = "backend"
task_provider: TaskProvider = "local"
provider_config: dict[str, object] = Field(default_factory=dict)
visibility: ProjectVisibility = "private"
created_by_user_id: int
current_user_id: int = 0
current_user_name: str = ""
access_role: BaseRole = BaseRole.RestrictedAnalyst
status: str
tags: list[str] = []
version: int
Expand All @@ -137,6 +144,9 @@ def populate_tags(cls, value: object) -> object:
"provider_config": mask_provider_config(
metadata.get("provider_config", {})
),
"visibility": (
"public" if metadata.get("visibility") == "public" else "private"
),
"tags": normalize_tags(metadata.get("tags")),
}
return value
Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ class LoopItemResponse(BaseModel):
sort_order: int
tags: list[str] = []
created_by_user_id: int
can_view_detail: bool = True
can_edit: bool = True
current_delivery_id: str | None
version: int
created_at: datetime
Expand Down
23 changes: 16 additions & 7 deletions backend/app/services/cloud_projects/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class CloudProjectAccess:
project: CloudProject
role: BaseRole

@property
def is_public_visitor(self) -> bool:
return self.role == BaseRole.RestrictedAnalyst


def require_cloud_project_role(
db: Session,
Expand Down Expand Up @@ -53,13 +57,18 @@ def require_cloud_project_role(
.first()
)
if membership is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Cloud project not found")
try:
role = BaseRole(membership.role)
except ValueError as exc:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "Invalid cloud project role"
) from exc
if project.visibility != "public":
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Cloud project not found"
)
role = BaseRole.RestrictedAnalyst
else:
try:
role = BaseRole(membership.role)
except ValueError as exc:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "Invalid cloud project role"
) from exc

if not has_permission(role, required_role):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission")
Expand Down
20 changes: 18 additions & 2 deletions backend/app/services/cloud_projects/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def create(
"project_store": "backend",
"task_provider": values.task_provider,
"provider_config": provider_config,
"visibility": values.visibility,
"tags": [],
},
)
Expand Down Expand Up @@ -114,20 +115,28 @@ def list_accessible(self, db: Session, user_id: int) -> list[CloudProject]:
or_(
CloudProject.created_by_user_id == user_id,
CloudProject.id.in_(member_project_ids),
CloudProject.metadata_json["visibility"].as_string() == "public",
),
)
.order_by(CloudProject.updated_at.desc())
.all()
)

def get(self, db: Session, project_id: int, user_id: int) -> CloudProject:
return require_cloud_project_role(db, project_id, user_id).project
return require_cloud_project_role(
db, project_id, user_id, BaseRole.RestrictedAnalyst
).project

def access(self, db: Session, project_id: int, user_id: int):
return require_cloud_project_role(
db, project_id, user_id, BaseRole.RestrictedAnalyst
)

def get_provider_credential(
self, db: Session, project_id: int, user_id: int
) -> str:
project = require_cloud_project_role(
db, project_id, user_id, BaseRole.Developer
db, project_id, user_id, BaseRole.RestrictedAnalyst
).project
metadata = (
project.metadata_json if isinstance(project.metadata_json, dict) else {}
Expand Down Expand Up @@ -158,6 +167,7 @@ def update(
if (
"tags" in values.model_fields_set
or "provider_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:
Expand Down Expand Up @@ -189,6 +199,12 @@ def update(
status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)
) from exc
updates.pop("provider_config", None)
if (
"visibility" in values.model_fields_set
and values.visibility is not None
):
metadata["visibility"] = values.visibility
updates.pop("visibility", None)
updates["metadata_json"] = metadata
updated = (
db.query(CloudProject)
Expand Down
11 changes: 9 additions & 2 deletions backend/app/services/delivery/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from sqlalchemy.orm import Session

from app.models.delivery import LoopItem
from app.schemas.base_role import BaseRole
from app.schemas.base_role import BaseRole, has_permission
from app.services.cloud_projects.access import require_cloud_project_role


Expand All @@ -21,5 +21,12 @@ def require_loop_item_access(
item = db.query(LoopItem).filter(LoopItem.id == item_id).first()
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found")
require_cloud_project_role(db, item.cloud_project_id, user_id, required_role)
access = require_cloud_project_role(
db, item.cloud_project_id, user_id, BaseRole.RestrictedAnalyst
)
if access.is_public_visitor:
if item.created_by_user_id != user_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found")
elif not has_permission(access.role, required_role):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permission")
return item
Loading
Loading