• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SwissDataScienceCenter / renku-data-services / 19105345323

05 Nov 2025 02:29PM UTC coverage: 86.829% (-0.02%) from 86.85%
19105345323

Pull #1075

github

web-flow
Merge abc85e658 into 8505a95c9
Pull Request #1075: fix: sync search data when groups or projects are removed

39 of 39 new or added lines in 6 files covered. (100.0%)

19 existing lines in 5 files now uncovered.

22817 of 26278 relevant lines covered (86.83%)

1.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.56
/components/renku_data_services/namespace/blueprints.py
1
"""Group blueprint."""
2

3
from dataclasses import dataclass
2✔
4

5
from sanic import HTTPResponse, Request
2✔
6
from sanic.response import JSONResponse
2✔
7
from sanic_ext import validate
2✔
8

9
import renku_data_services.base_models as base_models
2✔
10
from renku_data_services.authz.models import Change, Role, UnsavedMember
2✔
11
from renku_data_services.base_api.auth import authenticate, only_authenticated, validate_path_user_id
2✔
12
from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint
2✔
13
from renku_data_services.base_api.misc import validate_body_root_model, validate_query
2✔
14
from renku_data_services.base_api.pagination import PaginationRequest, paginate
2✔
15
from renku_data_services.base_models.core import NamespaceSlug, ProjectPath, Slug
2✔
16
from renku_data_services.base_models.metrics import MetricsService
2✔
17
from renku_data_services.base_models.validation import validate_and_dump, validated_json
2✔
18
from renku_data_services.errors import errors
2✔
19
from renku_data_services.namespace import apispec, apispec_enhanced, models
2✔
20
from renku_data_services.namespace.core import validate_group_patch
2✔
21
from renku_data_services.namespace.db import GroupRepository
2✔
22

23

24
@dataclass(kw_only=True)
2✔
25
class GroupsBP(CustomBlueprint):
2✔
26
    """Handlers for manipulating groups."""
27

28
    group_repo: GroupRepository
2✔
29
    authenticator: base_models.Authenticator
2✔
30
    metrics: MetricsService
2✔
31

32
    def get_all(self) -> BlueprintFactoryResponse:
2✔
33
        """List all groups."""
34

35
        @authenticate(self.authenticator)
2✔
36
        @validate_query(query=apispec.GroupsGetParametersQuery)
2✔
37
        @paginate
2✔
38
        async def _get_all(
2✔
39
            _: Request,
40
            user: base_models.APIUser,
41
            pagination: PaginationRequest,
42
            query: apispec.GroupsGetParametersQuery,
43
        ) -> tuple[list[dict], int]:
44
            groups, rec_count = await self.group_repo.get_groups(
2✔
45
                user=user, pagination=pagination, direct_member=query.direct_member
46
            )
47
            return (
2✔
48
                validate_and_dump(apispec.GroupResponseList, groups),
49
                rec_count,
50
            )
51

52
        return "/groups", ["GET"], _get_all
2✔
53

54
    def post(self) -> BlueprintFactoryResponse:
2✔
55
        """Create a new group."""
56

57
        @authenticate(self.authenticator)
2✔
58
        @only_authenticated
2✔
59
        @validate(json=apispec.GroupPostRequest)
2✔
60
        async def _post(_: Request, user: base_models.APIUser, body: apispec.GroupPostRequest) -> JSONResponse:
2✔
61
            new_group = models.UnsavedGroup(**body.model_dump())
2✔
62
            result = await self.group_repo.insert_group(user=user, payload=new_group)
2✔
63
            await self.metrics.group_created(user)
2✔
64
            return validated_json(apispec.GroupResponse, result, 201)
2✔
65

66
        return "/groups", ["POST"], _post
2✔
67

68
    def get_one(self) -> BlueprintFactoryResponse:
2✔
69
        """Get a specific group."""
70

71
        @authenticate(self.authenticator)
2✔
72
        async def _get_one(_: Request, user: base_models.APIUser, slug: Slug) -> JSONResponse:
2✔
73
            result = await self.group_repo.get_group(user=user, slug=slug)
2✔
74
            return validated_json(apispec.GroupResponse, result)
2✔
75

76
        return "/groups/<slug:renku_slug>", ["GET"], _get_one
2✔
77

78
    def delete(self) -> BlueprintFactoryResponse:
2✔
79
        """Delete a specific group."""
80

81
        @authenticate(self.authenticator)
2✔
82
        @only_authenticated
2✔
83
        async def _delete(_: Request, user: base_models.APIUser, slug: Slug) -> HTTPResponse:
2✔
84
            await self.group_repo.delete_group(user=user, slug=slug)
2✔
85
            return HTTPResponse(status=204)
2✔
86

87
        return "/groups/<slug:renku_slug>", ["DELETE"], _delete
2✔
88

89
    def patch(self) -> BlueprintFactoryResponse:
2✔
90
        """Partially update a specific group."""
91

92
        @authenticate(self.authenticator)
2✔
93
        @only_authenticated
2✔
94
        @validate(json=apispec.GroupPatchRequest)
2✔
95
        async def _patch(
2✔
96
            _: Request, user: base_models.APIUser, slug: Slug, body: apispec.GroupPatchRequest
97
        ) -> JSONResponse:
98
            group_patch = validate_group_patch(body)
2✔
99
            res = await self.group_repo.update_group(user=user, slug=slug, patch=group_patch)
2✔
100
            return validated_json(apispec.GroupResponse, res)
2✔
101

102
        return "/groups/<slug:renku_slug>", ["PATCH"], _patch
2✔
103

104
    def get_all_members(self) -> BlueprintFactoryResponse:
2✔
105
        """List all group members."""
106

107
        @authenticate(self.authenticator)
2✔
108
        async def _get_all_members(_: Request, user: base_models.APIUser, slug: Slug) -> JSONResponse:
2✔
109
            members = await self.group_repo.get_group_members(user, slug)
2✔
110
            return validated_json(
1✔
111
                apispec.GroupMemberResponseList,
112
                [
113
                    dict(
114
                        id=m.id,
115
                        first_name=m.first_name,
116
                        last_name=m.last_name,
117
                        role=apispec.GroupRole(m.role.value),
118
                        namespace=m.namespace,
119
                    )
120
                    for m in members
121
                ],
122
            )
123

124
        return "/groups/<slug:renku_slug>/members", ["GET"], _get_all_members
2✔
125

126
    def update_members(self) -> BlueprintFactoryResponse:
2✔
127
        """Update or add group members."""
128

129
        @authenticate(self.authenticator)
2✔
130
        @only_authenticated
2✔
131
        @validate_body_root_model(json=apispec.GroupMemberPatchRequestList)
2✔
132
        async def _update_members(
2✔
133
            _: Request, user: base_models.APIUser, slug: Slug, body: apispec.GroupMemberPatchRequestList
134
        ) -> JSONResponse:
135
            members = [UnsavedMember(Role.from_group_role(member.role), member.id) for member in body.root]
2✔
136
            res = await self.group_repo.update_group_members(
2✔
137
                user=user,
138
                slug=slug,
139
                members=members,
140
            )
141

142
            if any(m.change == Change.ADD for m in res):
1✔
143
                await self.metrics.group_member_added(user)
1✔
144

145
            return validated_json(
1✔
146
                apispec.GroupMemberPatchRequestList,
147
                [
148
                    dict(
149
                        id=m.member.user_id,
150
                        role=apispec.GroupRole(m.member.role.value),
151
                    )
152
                    for m in res
153
                ],
154
            )
155

156
        return "/groups/<slug:renku_slug>/members", ["PATCH"], _update_members
2✔
157

158
    def delete_member(self) -> BlueprintFactoryResponse:
2✔
159
        """Remove a specific user from the list of members of a group."""
160

161
        @authenticate(self.authenticator)
2✔
162
        @validate_path_user_id
2✔
163
        @only_authenticated
2✔
164
        async def _delete_member(_: Request, user: base_models.APIUser, slug: Slug, user_id: str) -> HTTPResponse:
2✔
165
            await self.group_repo.delete_group_member(user=user, slug=slug, user_id_to_delete=user_id)
1✔
166
            return HTTPResponse(status=204)
1✔
167

168
        return "/groups/<slug:renku_slug>/members/<user_id>", ["DELETE"], _delete_member
2✔
169

170
    def get_permissions(self) -> BlueprintFactoryResponse:
2✔
171
        """Get the permissions of the current user on the group."""
172

173
        @authenticate(self.authenticator)
2✔
174
        async def _get_permissions(_: Request, user: base_models.APIUser, slug: Slug) -> JSONResponse:
2✔
175
            permissions = await self.group_repo.get_group_permissions(user=user, slug=slug)
2✔
176
            return validated_json(apispec.GroupPermissions, permissions)
1✔
177

178
        return "/groups/<slug:renku_slug>/permissions", ["GET"], _get_permissions
2✔
179

180
    def get_namespaces(self) -> BlueprintFactoryResponse:
2✔
181
        """Get all namespaces."""
182

183
        @authenticate(self.authenticator)
2✔
184
        @only_authenticated
2✔
185
        @validate_query(query=apispec_enhanced.NamespacesGetParametersQuery)
2✔
186
        @paginate
2✔
187
        async def _get_namespaces(
2✔
188
            request: Request,
189
            user: base_models.APIUser,
190
            pagination: PaginationRequest,
191
            query: apispec.NamespacesGetParametersQuery,
192
        ) -> tuple[list[dict], int]:
193
            minimum_role = Role.from_group_role(query.minimum_role) if query.minimum_role is not None else None
2✔
194
            if query.kinds:
2✔
195
                kinds = [models.NamespaceKind(kind.value) for kind in query.kinds]
1✔
196
            else:
197
                # NOTE: This is for API backwards compatibility reasons, removing or modifying
198
                # this default will result in a breaking API change.
199
                kinds = [models.NamespaceKind.group, models.NamespaceKind.user]
2✔
200

201
            nss, total_count = await self.group_repo.get_namespaces(
2✔
202
                user=user, pagination=pagination, minimum_role=minimum_role, kinds=kinds
203
            )
204
            return validate_and_dump(
2✔
205
                apispec.NamespaceResponseList,
206
                [
207
                    dict(
208
                        id=ns.id,
209
                        name=ns.name,
210
                        slug=ns.latest_slug
211
                        if ns.latest_slug
212
                        else (ns.path.second.value if isinstance(ns, models.ProjectNamespace) else ns.path.first.value),
213
                        created_by=ns.created_by,
214
                        creation_date=ns.creation_date,
215
                        namespace_kind=apispec.NamespaceKind(ns.kind.value),
216
                        path=ns.path.serialize(),
217
                    )
218
                    for ns in nss
219
                ],
220
            ), total_count
221

222
        return "/namespaces", ["GET"], _get_namespaces
2✔
223

224
    def get_namespace(self) -> BlueprintFactoryResponse:
2✔
225
        """Get user or group namespace by slug."""
226

227
        @authenticate(self.authenticator)
2✔
228
        async def _get_namespace(_: Request, user: base_models.APIUser, slug: Slug) -> JSONResponse:
2✔
229
            ns = await self.group_repo.get_namespace_by_slug(user=user, slug=NamespaceSlug(slug.value))
2✔
230
            if not ns:
2✔
231
                raise errors.MissingResourceError(message=f"The namespace with slug {slug} does not exist")
2✔
232
            return validated_json(
1✔
233
                apispec.NamespaceResponse,
234
                dict(
235
                    id=ns.id,
236
                    name=ns.name,
237
                    slug=ns.latest_slug or ns.path.last().value,
238
                    created_by=ns.created_by,
239
                    creation_date=None,  # NOTE: we do not save creation date in the DB
240
                    namespace_kind=apispec.NamespaceKind(ns.kind.value),
241
                    path=ns.path.serialize(),
242
                ),
243
            )
244

245
        return "/namespaces/<slug:renku_slug>", ["GET"], _get_namespace
2✔
246

247
    def get_namespace_second_level(self) -> BlueprintFactoryResponse:
2✔
248
        """Get project namespaces by slug (i.e. user1/projec2)."""
249

250
        @authenticate(self.authenticator)
2✔
251
        async def _get_namespace_second_level(
2✔
252
            _: Request, user: base_models.APIUser, first_slug: Slug, second_slug: Slug
253
        ) -> JSONResponse:
254
            path = ProjectPath.from_strings(first_slug.value, second_slug.value)
2✔
255
            ns = await self.group_repo.get_namespace_by_path(user=user, path=path)
2✔
UNCOV
256
            if not ns:
×
257
                raise errors.MissingResourceError(message=f"The namespace with slug {path} does not exist")
×
UNCOV
258
            return validated_json(
×
259
                apispec.NamespaceResponse,
260
                dict(
261
                    id=ns.id,
262
                    name=ns.name,
263
                    slug=ns.latest_slug or ns.path.last().value,
264
                    created_by=ns.created_by,
265
                    creation_date=None,  # NOTE: we do not save creation date in the DB
266
                    namespace_kind=apispec.NamespaceKind(ns.kind.value),
267
                    path=ns.path.serialize(),
268
                ),
269
            )
270

271
        return "/namespaces/<first_slug:renku_slug>/<second_slug:renku_slug>", ["GET"], _get_namespace_second_level
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc