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
218 changes: 218 additions & 0 deletions freenit/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
Column,
ColumnOptional,
Project,
ProjectGroup,
ProjectGroupOptional,
ProjectGroupPermission,
ProjectMember,
ProjectOptional,
Task,
TaskOptional,
Expand Down Expand Up @@ -98,6 +102,37 @@ class TaskResponse(pydantic.BaseModel):
updated_at: datetime | None = None


class ProjectGroupCreate(pydantic.BaseModel):
name: str
description: str | None = None
permissions: list[str] = []


class ProjectGroupResponse(pydantic.BaseModel):
model_config = pydantic.ConfigDict(from_attributes=True)

id: int
project_id: int
name: str
description: str | None = None
permissions: list[str] = []
created_at: datetime | None = None
updated_at: datetime | None = None


class ProjectGroupMemberCreate(pydantic.BaseModel):
user_id: int


class ProjectGroupMemberResponse(pydantic.BaseModel):
model_config = pydantic.ConfigDict(from_attributes=True)

id: int
group_id: int
user_id: int
created_at: datetime | None = None


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -131,6 +166,20 @@ async def _get_task(id: int) -> Task:
raise HTTPException(status_code=404, detail="No such task")


async def _get_project_group(id: int) -> ProjectGroup:
try:
return await ProjectGroup.objects.get(id=id)
except oxyde.NotFoundError:
raise HTTPException(status_code=404, detail="No such project group")


async def _get_project_member(group_id: int, user_id: int) -> ProjectMember:
try:
return await ProjectMember.objects.get(group_id=group_id, user_id=user_id)
except oxyde.NotFoundError:
raise HTTPException(status_code=404, detail="No such project member")


async def _next_position(model, filter_kwargs: dict) -> int:
"""Return the next available position for a child model query."""
existing = await model.objects.filter(**filter_kwargs).all()
Expand Down Expand Up @@ -190,6 +239,40 @@ async def _check_column_name_unique(
)


async def _check_project_group_name_unique(
project_id: int, name: str, exclude_id: int | None = None
) -> None:
existing = await ProjectGroup.objects.filter(project_id=project_id, name=name).all()
if any(item.id != exclude_id for item in existing):
raise HTTPException(
status_code=409, detail="Group name already exists in this project"
)


async def _validate_project_group_member(group_id: int, user_id: int) -> None:
try:
await User.objects.get(id=user_id)
except oxyde.NotFoundError:
raise HTTPException(status_code=404, detail="No such user")


async def _get_project_group_permissions(group_id: int) -> list[str]:
permissions = await ProjectGroupPermission.objects.filter(group_id=group_id).all()
return [p.permission for p in permissions]


async def _set_project_group_permissions(
group_id: int, permissions: list[str]
) -> None:
existing = await ProjectGroupPermission.objects.filter(group_id=group_id).all()
for perm in existing:
await perm.delete()
for permission in permissions:
await ProjectGroupPermission.objects.create(
group_id=group_id, permission=permission
)


# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -481,3 +564,138 @@ async def delete(id: int, _: User = Depends(project_perms)) -> TaskResponse:
task = await _get_task(id)
await task.delete()
return TaskResponse.model_validate(task)


# ---------------------------------------------------------------------------
# Project Groups
# ---------------------------------------------------------------------------


@route("/projects/{project_id}/groups", tags=tags)
class ProjectGroupListAPI:
@staticmethod
@description("Get project groups")
async def get(
project_id: int,
page: int = Header(default=1),
perpage: int = Header(default=10),
_: User = Depends(project_perms),
) -> Page[ProjectGroupResponse]:
await _get_project(project_id)
result = await paginate(
ProjectGroup.objects.filter(project_id=project_id),
page,
perpage,
)
for item in result.data:
object.__setattr__(item, "permissions", await _get_project_group_permissions(item.id))
return result

@staticmethod
@description("Create project group")
async def post(
project_id: int,
data: ProjectGroupCreate,
_: User = Depends(project_perms),
) -> ProjectGroupResponse:
await _get_project(project_id)
await _check_project_group_name_unique(project_id, data.name)
now = datetime.utcnow()
group = await ProjectGroup.objects.create(
project_id=project_id,
name=data.name,
description=data.description,
created_at=now,
updated_at=now,
)
await _set_project_group_permissions(group.id, data.permissions)
object.__setattr__(group, "permissions", await _get_project_group_permissions(group.id))
return ProjectGroupResponse.model_validate(group)


@route("/project-groups/{id}", tags=tags)
class ProjectGroupDetailAPI:
@staticmethod
async def get(id: int, _: User = Depends(project_perms)) -> ProjectGroupResponse:
group = await _get_project_group(id)
object.__setattr__(group, "permissions", await _get_project_group_permissions(id))
return ProjectGroupResponse.model_validate(group)

@staticmethod
async def patch(
id: int,
data: ProjectGroupOptional,
_: User = Depends(project_perms),
) -> ProjectGroupResponse:
group = await _get_project_group(id)
if data.name:
await _check_project_group_name_unique(group.project_id, data.name, exclude_id=id)
update = data.model_dump(exclude_none=True)
if "permissions" in update:
await _set_project_group_permissions(id, update.pop("permissions"))
if update:
await group.patch(ProjectGroupOptional(**update))
object.__setattr__(group, "permissions", await _get_project_group_permissions(id))
return ProjectGroupResponse.model_validate(group)

@staticmethod
async def delete(id: int, _: User = Depends(project_perms)) -> ProjectGroupResponse:
group = await _get_project_group(id)
await group.delete()
return ProjectGroupResponse.model_validate(group)


# ---------------------------------------------------------------------------
# Project Group Members
# ---------------------------------------------------------------------------


@route("/project-groups/{group_id}/members", tags=tags)
class ProjectGroupMemberListAPI:
@staticmethod
@description("Get project group members")
async def get(
group_id: int,
page: int = Header(default=1),
perpage: int = Header(default=10),
_: User = Depends(project_perms),
) -> Page[ProjectGroupMemberResponse]:
await _get_project_group(group_id)
return await paginate(
ProjectMember.objects.filter(group_id=group_id),
page,
perpage,
)

@staticmethod
@description("Add member to project group")
async def post(
group_id: int,
data: ProjectGroupMemberCreate,
_: User = Depends(project_perms),
) -> ProjectGroupMemberResponse:
await _get_project_group(group_id)
await _validate_project_group_member(group_id, data.user_id)
now = datetime.utcnow()
try:
member = await ProjectMember.objects.create(
group_id=group_id,
user_id=data.user_id,
created_at=now,
)
except oxyde.IntegrityError:
raise HTTPException(status_code=409, detail="User already in group")
return ProjectGroupMemberResponse.model_validate(member)


@route("/project-groups/{group_id}/members/{user_id}", tags=tags)
class ProjectGroupMemberDetailAPI:
@staticmethod
async def delete(
group_id: int,
user_id: int,
_: User = Depends(project_perms),
) -> ProjectGroupMemberResponse:
member = await _get_project_member(group_id, user_id)
await member.delete()
return ProjectGroupMemberResponse.model_validate(member)
8 changes: 8 additions & 0 deletions freenit/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@
Board = project.Board
Column = project.Column
Task = project.Task
Project = project.Project
Board = project.Board
Column = project.Column
Task = project.Task
ProjectGroup = project.ProjectGroup
ProjectGroupPermission = project.ProjectGroupPermission
ProjectMember = project.ProjectMember
ProjectOptional = project.ProjectOptional
BoardOptional = project.BoardOptional
ColumnOptional = project.ColumnOptional
TaskOptional = project.TaskOptional
ProjectGroupOptional = project.ProjectGroupOptional
NotFoundError = project.NotFoundError
IntegrityError = project.IntegrityError
54 changes: 54 additions & 0 deletions freenit/models/sql/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,61 @@ class TaskOptional(pydantic.BaseModel):
parent_id: int | None = None


class ProjectGroup(OxydeBaseModel):
id: int | None = oxyde.Field(default=None, db_pk=True)
project: Project | None = oxyde.Field(
default=None, db_fk="id", db_on_delete="CASCADE"
)
name: str = oxyde.Field()
description: str | None = oxyde.Field(default=None)
created_at: datetime | None = oxyde.Field(default=None)
updated_at: datetime | None = oxyde.Field(default=None)

class Meta:
is_table = True
table_name = "project_group"
unique_together = [("project_id", "name")]


class ProjectGroupOptional(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="forbid")

name: str | None = None
description: str | None = None
permissions: list[str] | None = None


class ProjectGroupPermission(OxydeBaseModel):
id: int | None = oxyde.Field(default=None, db_pk=True)
group: ProjectGroup | None = oxyde.Field(
default=None, db_fk="id", db_on_delete="CASCADE"
)
permission: str = oxyde.Field()

class Meta:
is_table = True
table_name = "project_group_permission"
unique_together = [("group_id", "permission")]


class ProjectMember(OxydeBaseModel):
id: int | None = oxyde.Field(default=None, db_pk=True)
group: ProjectGroup | None = oxyde.Field(
default=None, db_fk="id", db_on_delete="CASCADE"
)
user: User | None = oxyde.Field(default=None, db_fk="id", db_on_delete="CASCADE")
created_at: datetime | None = oxyde.Field(default=None)

class Meta:
is_table = True
table_name = "project_member"
unique_together = [("group_id", "user_id")]


Project.model_rebuild()
Board.model_rebuild()
Column.model_rebuild()
Task.model_rebuild()
ProjectGroup.model_rebuild()
ProjectGroupPermission.model_rebuild()
ProjectMember.model_rebuild()
Loading
Loading