diff --git a/freenit/api/project.py b/freenit/api/project.py index fa64686..a9ad9b5 100644 --- a/freenit/api/project.py +++ b/freenit/api/project.py @@ -13,6 +13,10 @@ Column, ColumnOptional, Project, + ProjectGroup, + ProjectGroupOptional, + ProjectGroupPermission, + ProjectMember, ProjectOptional, Task, TaskOptional, @@ -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 # --------------------------------------------------------------------------- @@ -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() @@ -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 # --------------------------------------------------------------------------- @@ -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) diff --git a/freenit/models/project.py b/freenit/models/project.py index f6be4e3..42f4bdc 100644 --- a/freenit/models/project.py +++ b/freenit/models/project.py @@ -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 diff --git a/freenit/models/sql/project.py b/freenit/models/sql/project.py index 3ecb45e..28f2ab7 100644 --- a/freenit/models/sql/project.py +++ b/freenit/models/sql/project.py @@ -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() diff --git a/migrations/0005_add_project_groups.py b/migrations/0005_add_project_groups.py new file mode 100644 index 0000000..0810c3b --- /dev/null +++ b/migrations/0005_add_project_groups.py @@ -0,0 +1,276 @@ +"""Auto-generated migration. + +Created: 2026-06-19 22:30:00 +""" + +depends_on = "0004_add_project_kanban" + + +def upgrade(ctx): + """Apply migration.""" + ctx.create_table( + "project_group", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': True, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'name', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'description', + 'python_type': 'str', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'created_at', + 'python_type': 'datetime', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'updated_at', + 'python_type': 'datetime', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'project_id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + } + ], + foreign_keys=[ + { + 'name': 'fk_project_group_project_id', + 'columns': [ + 'project_id' + ], + 'ref_table': 'project', + 'ref_columns': [ + 'id' + ], + 'on_delete': 'CASCADE', + 'on_update': 'CASCADE' + } + ], + indexes=[ + { + 'name': 'idx_project_group_project_id_name', + 'fields': ['project_id', 'name'], + 'unique': True, + } + ], + ) + ctx.create_table( + "project_member", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': True, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'created_at', + 'python_type': 'datetime', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'group_id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'user_id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + } + ], + foreign_keys=[ + { + 'name': 'fk_project_member_group_id', + 'columns': [ + 'group_id' + ], + 'ref_table': 'project_group', + 'ref_columns': [ + 'id' + ], + 'on_delete': 'CASCADE', + 'on_update': 'CASCADE' + }, + { + 'name': 'fk_project_member_user_id', + 'columns': [ + 'user_id' + ], + 'ref_table': 'user', + 'ref_columns': [ + 'id' + ], + 'on_delete': 'CASCADE', + 'on_update': 'CASCADE' + } + ], + indexes=[ + { + 'name': 'idx_project_member_group_id_user_id', + 'fields': ['group_id', 'user_id'], + 'unique': True, + } + ], + ) + ctx.create_table( + "project_group_permission", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': True, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'permission', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + }, + { + 'name': 'group_id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False, + 'max_length': None, + 'max_digits': None, + 'decimal_places': None + } + ], + foreign_keys=[ + { + 'name': 'fk_project_group_permission_group_id', + 'columns': [ + 'group_id' + ], + 'ref_table': 'project_group', + 'ref_columns': [ + 'id' + ], + 'on_delete': 'CASCADE', + 'on_update': 'CASCADE' + } + ], + indexes=[ + { + 'name': 'idx_project_group_permission_group_id_permission', + 'fields': ['group_id', 'permission'], + 'unique': True, + } + ], + ) + + +def downgrade(ctx): + """Revert migration.""" + ctx.drop_table("project_group_permission") + ctx.drop_table("project_member") + ctx.drop_table("project_group") diff --git a/tests/factories.py b/tests/factories.py index cd6cb3f..a51feaa 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -2,7 +2,15 @@ from passlib.hash import pbkdf2_sha256 from freenit.config import getConfig -from freenit.models.project import Board, Column, Project, Task +from freenit.models.project import ( + Board, + Column, + Project, + ProjectGroup, + ProjectGroupPermission, + ProjectMember, + Task, +) from freenit.models.role import Role as RoleModel config = getConfig() @@ -65,3 +73,28 @@ class Meta: position = factory.Faker("random_int") column_id = None assignee_id = None + + +class ProjectGroupFactory(factory.Factory): + class Meta: + model = ProjectGroup + + name = factory.Faker("pystr") + description = factory.Faker("sentence") + project_id = None + + +class ProjectMemberFactory(factory.Factory): + class Meta: + model = ProjectMember + + group_id = None + user_id = None + + +class ProjectGroupPermissionFactory(factory.Factory): + class Meta: + model = ProjectGroupPermission + + group_id = None + permission = factory.Faker("pystr") diff --git a/tests/test_project.py b/tests/test_project.py index 79ddddc..224ec84 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -405,3 +405,206 @@ async def test_task_self_parent_rejected(self, client): f"/tasks/{task.id}", data={"parent_id": task.id} ) assert response.status_code == 400 + + +@pytest.mark.asyncio +class TestProjectGroup: + async def test_create_project_group(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + data = {"name": "Test Group", "description": "A test group"} + response = client.post(f"/projects/{project.id}/groups", data=data) + assert response.status_code == 200 + result = response.json() + assert result["name"] == data["name"] + assert result["description"] == data["description"] + assert result["project_id"] == project.id + + async def test_get_project_groups(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + response = client.get(f"/projects/{project.id}/groups") + assert response.status_code == 200 + assert response.json()["total"] >= 1 + + async def test_get_project_group(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + response = client.get(f"/project-groups/{group.id}") + assert response.status_code == 200 + assert response.json()["id"] == group.id + + async def test_update_project_group(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + data = {"name": "Updated Group"} + response = client.patch(f"/project-groups/{group.id}", data=data) + assert response.status_code == 200 + assert response.json()["name"] == data["name"] + + async def test_delete_project_group(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + response = client.delete(f"/project-groups/{group.id}") + assert response.status_code == 200 + response = client.get(f"/project-groups/{group.id}") + assert response.status_code == 404 + + async def test_create_project_group_duplicate_name(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id, name="Group X") + await group.save() + response = client.post(f"/projects/{project.id}/groups", data={"name": "Group X"}) + assert response.status_code == 409 + + async def test_update_project_group_duplicate_name(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group1 = factories.ProjectGroupFactory(project_id=project.id, name="Group One") + await group1.save() + group2 = factories.ProjectGroupFactory(project_id=project.id, name="Group Two") + await group2.save() + response = client.patch(f"/project-groups/{group2.id}", data={"name": "Group One"}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +class TestProjectGroupMember: + async def test_add_project_group_member(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + response = client.post(f"/project-groups/{group.id}/members", data={"user_id": user.id}) + assert response.status_code == 200 + result = response.json() + assert result["group_id"] == group.id + assert result["user_id"] == user.id + + async def test_get_project_group_members(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + member = factories.ProjectMemberFactory(group_id=group.id, user_id=user.id) + await member.save() + response = client.get(f"/project-groups/{group.id}/members") + assert response.status_code == 200 + assert response.json()["total"] >= 1 + + async def test_remove_project_group_member(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + member = factories.ProjectMemberFactory(group_id=group.id, user_id=user.id) + await member.save() + response = client.delete(f"/project-groups/{group.id}/members/{user.id}") + assert response.status_code == 200 + response = client.get(f"/project-groups/{group.id}/members") + assert response.json()["total"] == 0 + + async def test_add_duplicate_project_group_member(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + member = factories.ProjectMemberFactory(group_id=group.id, user_id=user.id) + await member.save() + response = client.post(f"/project-groups/{group.id}/members", data={"user_id": user.id}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +class TestProjectGroupPermissions: + async def test_create_project_group_with_permissions(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + data = { + "name": "Test Group", + "description": "A test group", + "permissions": ["create_board", "delete_board"], + } + response = client.post(f"/projects/{project.id}/groups", data=data) + assert response.status_code == 200 + result = response.json() + assert result["permissions"] == data["permissions"] + + async def test_get_project_group_with_permissions(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + perm = factories.ProjectGroupPermissionFactory( + group_id=group.id, permission="create_column" + ) + await perm.save() + response = client.get(f"/project-groups/{group.id}") + assert response.status_code == 200 + assert "create_column" in response.json()["permissions"] + + async def test_update_project_group_permissions(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + group = factories.ProjectGroupFactory(project_id=project.id) + await group.save() + perm = factories.ProjectGroupPermissionFactory( + group_id=group.id, permission="create_column" + ) + await perm.save() + data = {"permissions": ["create_project", "delete_project"]} + response = client.patch(f"/project-groups/{group.id}", data=data) + assert response.status_code == 200 + result = response.json() + assert result["permissions"] == data["permissions"]