Skip to content
Closed
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
4 changes: 3 additions & 1 deletion src/maasapiserver/v3/api/public/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2024-2025 Canonical Ltd. This software is licensed under the
# Copyright 2024-2026 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

from maasapiserver.common.api.base import API
Expand Down Expand Up @@ -47,6 +47,7 @@
UISubnetsHandler,
)
from maasapiserver.v3.api.public.handlers.tags import TagsHandler
from maasapiserver.v3.api.public.handlers.usergroups import UserGroupsHandler
from maasapiserver.v3.api.public.handlers.users import UsersHandler
from maasapiserver.v3.api.public.handlers.vlans import VlansHandler
from maasapiserver.v3.api.public.handlers.zones import ZonesHandler
Expand Down Expand Up @@ -81,6 +82,7 @@
SSLKeysHandler(),
SubnetsHandler(),
TagsHandler(),
UserGroupsHandler(),
UsersHandler(),
VlansHandler(),
ZonesHandler(),
Expand Down
202 changes: 202 additions & 0 deletions src/maasapiserver/v3/api/public/handlers/usergroups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Copyright 2026 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

from typing import Union

from fastapi import Depends, Header, Response
from starlette import status

from maasapiserver.common.api.base import Handler, handler
from maasapiserver.common.api.models.responses.errors import (
ConflictBodyResponse,
NotFoundBodyResponse,
)
from maasapiserver.v3.api import services
from maasapiserver.v3.api.public.models.requests.query import PaginationParams
from maasapiserver.v3.api.public.models.requests.usergroups import (
UserGroupRequest,
)
from maasapiserver.v3.api.public.models.responses.base import (
OPENAPI_ETAG_HEADER,
)
from maasapiserver.v3.api.public.models.responses.usergroups import (
UserGroupResponse,
UserGroupsListResponse,
)
from maasapiserver.v3.auth.base import check_permissions
from maasapiserver.v3.constants import V3_API_PREFIX
from maasservicelayer.auth.jwt import UserRole
from maasservicelayer.exceptions.catalog import NotFoundException
from maasservicelayer.services import ServiceCollectionV3


class UserGroupsHandler(Handler):
"""User Groups API handler."""

TAGS = ["UserGroups"]

@handler(
path="/groups",
methods=["GET"],
tags=TAGS,
responses={
200: {
"model": UserGroupsListResponse,
},
},
response_model_exclude_none=True,
status_code=200,
dependencies=[
Depends(check_permissions(required_roles={UserRole.USER}))
],
)
async def list_groups(
self,
pagination_params: PaginationParams = Depends(), # noqa: B008
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> UserGroupsListResponse:
groups = await services.usergroups.list(
page=pagination_params.page,
size=pagination_params.size,
)
next_link = None
if groups.has_next(pagination_params.page, pagination_params.size):
next_link = (
f"{V3_API_PREFIX}/groups?"
f"{pagination_params.to_next_href_format()}"
)

return UserGroupsListResponse(
items=[
UserGroupResponse.from_model(
usergroup=group,
self_base_hyperlink=f"{V3_API_PREFIX}/groups",
)
for group in groups.items
],
total=groups.total,
next=next_link,
)

@handler(
path="/groups",
methods=["POST"],
tags=TAGS,
responses={
201: {
"model": UserGroupResponse,
"headers": {"ETag": OPENAPI_ETAG_HEADER},
},
409: {"model": ConflictBodyResponse},
},
response_model_exclude_none=True,
status_code=201,
dependencies=[
Depends(check_permissions(required_roles={UserRole.ADMIN}))
],
)
async def create_group(
self,
response: Response,
group_request: UserGroupRequest,
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> UserGroupResponse:
group = await services.usergroups.create(group_request.to_builder())
response.headers["ETag"] = group.etag()
return UserGroupResponse.from_model(
usergroup=group,
self_base_hyperlink=f"{V3_API_PREFIX}/groups",
)

@handler(
path="/groups/{group_id}",
methods=["GET"],
tags=TAGS,
responses={
200: {
"model": UserGroupResponse,
"headers": {"ETag": OPENAPI_ETAG_HEADER},
},
404: {"model": NotFoundBodyResponse},
},
response_model_exclude_none=True,
status_code=200,
dependencies=[
Depends(check_permissions(required_roles={UserRole.USER}))
],
)
async def get_group(
self,
group_id: int,
response: Response,
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> UserGroupResponse:
group = await services.usergroups.get_by_id(group_id)
if not group:
raise NotFoundException()

response.headers["ETag"] = group.etag()
return UserGroupResponse.from_model(
usergroup=group,
self_base_hyperlink=f"{V3_API_PREFIX}/groups",
)

@handler(
path="/groups/{group_id}",
methods=["PUT"],
tags=TAGS,
responses={
200: {
"model": UserGroupResponse,
"headers": {"ETag": OPENAPI_ETAG_HEADER},
},
404: {"model": NotFoundBodyResponse},
},
response_model_exclude_none=True,
status_code=200,
dependencies=[
Depends(check_permissions(required_roles={UserRole.ADMIN}))
],
)
async def update_group(
self,
group_id: int,
group_request: UserGroupRequest,
response: Response,
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> UserGroupResponse:
group = await services.usergroups.update_by_id(
group_id, group_request.to_builder()
)
if not group:
raise NotFoundException()

response.headers["ETag"] = group.etag()
return UserGroupResponse.from_model(
usergroup=group,
self_base_hyperlink=f"{V3_API_PREFIX}/groups",
)

@handler(
path="/groups/{group_id}",
methods=["DELETE"],
tags=TAGS,
responses={
204: {},
404: {"model": NotFoundBodyResponse},
},
status_code=204,
dependencies=[
Depends(check_permissions(required_roles={UserRole.ADMIN}))
],
)
async def delete_group(
self,
group_id: int,
etag_if_match: Union[str, None] = Header(
alias="if-match", default=None
),
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> Response:
await services.usergroups.delete_by_id(group_id, etag_if_match)
return Response(status_code=status.HTTP_204_NO_CONTENT)
18 changes: 18 additions & 0 deletions src/maasapiserver/v3/api/public/models/requests/usergroups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2026 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

from typing import Optional

from pydantic import Field

from maasapiserver.v3.api.public.models.requests.base import NamedBaseModel
from maasservicelayer.builders.usergroups import UserGroupBuilder


class UserGroupRequest(NamedBaseModel):
description: Optional[str] = Field(
description="The description of the group.", default=None
)

def to_builder(self) -> UserGroupBuilder:
return UserGroupBuilder(name=self.name, description=self.description)
38 changes: 38 additions & 0 deletions src/maasapiserver/v3/api/public/models/responses/usergroups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Copyright 2026 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

from typing import Optional, Self

from maasapiserver.v3.api.public.models.responses.base import (
BaseHal,
BaseHref,
HalResponse,
PaginatedResponse,
)
from maasservicelayer.models.usergroups import UserGroup


class UserGroupResponse(HalResponse[BaseHal]):
kind = "UserGroup"
id: int
name: str
description: Optional[str]

@classmethod
def from_model(
cls, usergroup: UserGroup, self_base_hyperlink: str
) -> Self:
return cls(
id=usergroup.id,
name=usergroup.name,
description=usergroup.description,
hal_links=BaseHal( # pyright: ignore [reportCallIssue]
self=BaseHref(
href=f"{self_base_hyperlink.rstrip('/')}/{usergroup.id}"
)
),
)


class UserGroupsListResponse(PaginatedResponse[UserGroupResponse]):
kind = "UserGroupsList"
Loading
Loading