Skip to content

Commit 062299f

Browse files
committed
add auditer management apis and auditor client in sdk
1 parent 4bdffdf commit 062299f

File tree

5 files changed

+595
-1
lines changed

5 files changed

+595
-1
lines changed

api/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from api.views import (
1010
aimodel_detail,
1111
aimodel_execution,
12+
auditor,
1213
auth,
1314
download,
1415
generate_dynamic_chart,
@@ -32,6 +33,17 @@
3233
path("auth/keycloak/login/", auth.KeycloakLoginView.as_view(), name="keycloak_login"),
3334
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
3435
path("auth/user/info/", auth.UserInfoView.as_view(), name="user_info"),
36+
# Auditor management endpoints
37+
path(
38+
"organizations/<str:organization_id>/auditors/",
39+
auditor.OrganizationAuditorsView.as_view(),
40+
name="organization_auditors",
41+
),
42+
path(
43+
"users/search-by-email/",
44+
auditor.SearchUserByEmailView.as_view(),
45+
name="search_user_by_email",
46+
),
3547
# API endpoints
3648
path("search/dataset/", search_dataset.SearchDataset.as_view(), name="search_dataset"),
3749
path("search/usecase/", search_usecase.SearchUseCase.as_view(), name="search_usecase"),

api/views/auditor.py

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
"""REST API views for auditor management."""
2+
3+
import logging
4+
from typing import Any, Dict, List, Optional
5+
6+
from django.db import transaction
7+
from rest_framework import status, views
8+
from rest_framework.permissions import IsAuthenticated
9+
from rest_framework.request import Request
10+
from rest_framework.response import Response
11+
12+
from api.models import Organization
13+
from authorization.models import OrganizationMembership, Role, User
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class OrganizationAuditorsView(views.APIView):
19+
"""
20+
View for managing auditors in an organization.
21+
22+
GET: List all auditors for an organization
23+
POST: Add a user as auditor to an organization (by user_id or email)
24+
DELETE: Remove an auditor from an organization
25+
"""
26+
27+
permission_classes = [IsAuthenticated]
28+
29+
def _get_organization(self, organization_id: str) -> Optional[Organization]:
30+
"""Get organization by ID."""
31+
try:
32+
return Organization.objects.get(id=organization_id)
33+
except Organization.DoesNotExist:
34+
return None
35+
36+
def _check_admin_permission(self, user: User, organization: Organization) -> bool:
37+
"""Check if user has admin permission for the organization."""
38+
if user.is_superuser:
39+
return True
40+
try:
41+
membership = OrganizationMembership.objects.get(user=user, organization=organization)
42+
# Admin role has can_change permission
43+
return membership.role.can_change # type: ignore[return-value]
44+
except OrganizationMembership.DoesNotExist:
45+
return False
46+
47+
def _get_auditor_role(self) -> Optional[Role]:
48+
"""Get the auditor role."""
49+
try:
50+
return Role.objects.get(name="auditor")
51+
except Role.DoesNotExist:
52+
logger.error("Auditor role not found. Please run migrations.")
53+
return None
54+
55+
def get(self, request: Request, organization_id: str) -> Response:
56+
"""Get all auditors for an organization."""
57+
organization = self._get_organization(organization_id)
58+
if not organization:
59+
return Response(
60+
{"error": f"Organization with ID {organization_id} not found"},
61+
status=status.HTTP_404_NOT_FOUND,
62+
)
63+
64+
# Check if user has permission to view organization members
65+
if not self._check_admin_permission(request.user, organization): # type: ignore[arg-type]
66+
return Response(
67+
{"error": "You don't have permission to view auditors for this organization"},
68+
status=status.HTTP_403_FORBIDDEN,
69+
)
70+
71+
auditor_role = self._get_auditor_role()
72+
if not auditor_role:
73+
return Response(
74+
{"error": "Auditor role not configured"},
75+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
76+
)
77+
78+
# Get all auditors for this organization
79+
auditor_memberships = OrganizationMembership.objects.filter(
80+
organization=organization, role=auditor_role
81+
).select_related("user")
82+
83+
auditors: List[Dict[str, Any]] = []
84+
for membership in auditor_memberships: # type: OrganizationMembership
85+
user: User = membership.user # type: ignore[assignment]
86+
auditors.append(
87+
{
88+
"id": str(user.id),
89+
"username": user.username,
90+
"email": user.email,
91+
"first_name": user.first_name,
92+
"last_name": user.last_name,
93+
"profile_picture": user.profile_picture.url if user.profile_picture else None,
94+
"joined_at": (
95+
membership.created_at.isoformat() if membership.created_at else None
96+
),
97+
}
98+
)
99+
100+
return Response(
101+
{
102+
"organization_id": str(organization.id),
103+
"organization_name": organization.name,
104+
"auditors": auditors,
105+
"count": len(auditors),
106+
}
107+
)
108+
109+
@transaction.atomic
110+
def post(self, request: Request, organization_id: str) -> Response:
111+
"""
112+
Add a user as auditor to an organization.
113+
114+
Request body can contain either:
115+
- user_id: ID of an existing user
116+
- email: Email of a user to add (will look up user by email)
117+
"""
118+
organization = self._get_organization(organization_id)
119+
if not organization:
120+
return Response(
121+
{"error": f"Organization with ID {organization_id} not found"},
122+
status=status.HTTP_404_NOT_FOUND,
123+
)
124+
125+
# Check if user has admin permission
126+
if not self._check_admin_permission(request.user, organization): # type: ignore[arg-type]
127+
return Response(
128+
{"error": "You don't have permission to add auditors to this organization"},
129+
status=status.HTTP_403_FORBIDDEN,
130+
)
131+
132+
auditor_role = self._get_auditor_role()
133+
if not auditor_role:
134+
return Response(
135+
{"error": "Auditor role not configured"},
136+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
137+
)
138+
139+
user_id = request.data.get("user_id")
140+
email = request.data.get("email")
141+
142+
if not user_id and not email:
143+
return Response(
144+
{"error": "Either user_id or email is required"},
145+
status=status.HTTP_400_BAD_REQUEST,
146+
)
147+
148+
# Find the user
149+
target_user: Optional[User] = None
150+
if user_id:
151+
try:
152+
target_user = User.objects.get(id=user_id)
153+
except User.DoesNotExist:
154+
return Response(
155+
{"error": f"User with ID {user_id} not found"},
156+
status=status.HTTP_404_NOT_FOUND,
157+
)
158+
elif email:
159+
try:
160+
target_user = User.objects.get(email=email)
161+
except User.DoesNotExist:
162+
return Response(
163+
{
164+
"error": f"User with email {email} not found. The user must have an account in CivicDataSpace first."
165+
},
166+
status=status.HTTP_404_NOT_FOUND,
167+
)
168+
169+
if not target_user:
170+
return Response(
171+
{"error": "Could not find user"},
172+
status=status.HTTP_404_NOT_FOUND,
173+
)
174+
175+
# Check if user is already a member of the organization
176+
existing_membership = OrganizationMembership.objects.filter(
177+
user=target_user, organization=organization
178+
).first()
179+
180+
if existing_membership:
181+
if existing_membership.role == auditor_role:
182+
return Response(
183+
{"error": "User is already an auditor for this organization"},
184+
status=status.HTTP_400_BAD_REQUEST,
185+
)
186+
else:
187+
# User has a different role, update to auditor
188+
# Note: This might not be desired behavior - you may want to keep existing role
189+
# For now, we'll add them as auditor (they can have multiple roles in future)
190+
return Response(
191+
{"error": f"User is already a member of this organization with role '{existing_membership.role.name}'"}, # type: ignore[attr-defined]
192+
status=status.HTTP_400_BAD_REQUEST,
193+
)
194+
195+
# Create the membership
196+
membership = OrganizationMembership.objects.create(
197+
user=target_user,
198+
organization=organization,
199+
role=auditor_role,
200+
)
201+
202+
logger.info(
203+
f"Added user {target_user.username} as auditor to organization {organization.name}"
204+
)
205+
206+
return Response(
207+
{
208+
"success": True,
209+
"message": f"User {target_user.username} added as auditor",
210+
"auditor": {
211+
"id": target_user.id,
212+
"username": target_user.username,
213+
"email": target_user.email,
214+
"first_name": target_user.first_name,
215+
"last_name": target_user.last_name,
216+
"joined_at": membership.created_at.isoformat(),
217+
},
218+
},
219+
status=status.HTTP_201_CREATED,
220+
)
221+
222+
@transaction.atomic
223+
def delete(self, request: Request, organization_id: str) -> Response:
224+
"""Remove an auditor from an organization."""
225+
organization = self._get_organization(organization_id)
226+
if not organization:
227+
return Response(
228+
{"error": f"Organization with ID {organization_id} not found"},
229+
status=status.HTTP_404_NOT_FOUND,
230+
)
231+
232+
# Check if user has admin permission
233+
if not self._check_admin_permission(request.user, organization): # type: ignore[arg-type]
234+
return Response(
235+
{"error": "You don't have permission to remove auditors from this organization"},
236+
status=status.HTTP_403_FORBIDDEN,
237+
)
238+
239+
user_id = request.data.get("user_id")
240+
if not user_id:
241+
return Response(
242+
{"error": "user_id is required"},
243+
status=status.HTTP_400_BAD_REQUEST,
244+
)
245+
246+
auditor_role = self._get_auditor_role()
247+
if not auditor_role:
248+
return Response(
249+
{"error": "Auditor role not configured"},
250+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
251+
)
252+
253+
try:
254+
membership = OrganizationMembership.objects.get(
255+
user_id=user_id, organization=organization, role=auditor_role
256+
)
257+
username = membership.user.username # type: ignore[attr-defined]
258+
membership.delete()
259+
260+
logger.info(f"Removed auditor {username} from organization {organization.name}")
261+
262+
return Response(
263+
{
264+
"success": True,
265+
"message": f"Auditor {username} removed from organization",
266+
}
267+
)
268+
except OrganizationMembership.DoesNotExist:
269+
return Response(
270+
{"error": "User is not an auditor for this organization"},
271+
status=status.HTTP_404_NOT_FOUND,
272+
)
273+
274+
275+
class SearchUserByEmailView(views.APIView):
276+
"""
277+
Search for a user by email.
278+
Used to find users before adding them as auditors.
279+
"""
280+
281+
permission_classes = [IsAuthenticated]
282+
283+
def get(self, request: Request) -> Response:
284+
"""Search for a user by email."""
285+
email = request.query_params.get("email")
286+
if not email:
287+
return Response(
288+
{"error": "email query parameter is required"},
289+
status=status.HTTP_400_BAD_REQUEST,
290+
)
291+
292+
try:
293+
user = User.objects.get(email=email)
294+
return Response(
295+
{
296+
"found": True,
297+
"user": {
298+
"id": user.id,
299+
"username": user.username,
300+
"email": user.email,
301+
"first_name": user.first_name,
302+
"last_name": user.last_name,
303+
"profile_picture": (
304+
user.profile_picture.url if user.profile_picture else None
305+
),
306+
},
307+
}
308+
)
309+
except User.DoesNotExist:
310+
return Response(
311+
{
312+
"found": False,
313+
"message": f"No user found with email {email}",
314+
}
315+
)

dataspace_sdk/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from dataspace_sdk.auth import AuthClient
66
from dataspace_sdk.resources.aimodels import AIModelClient
7+
from dataspace_sdk.resources.auditors import AuditorClient
78
from dataspace_sdk.resources.datasets import DatasetClient
89
from dataspace_sdk.resources.sectors import SectorClient
910
from dataspace_sdk.resources.usecases import UseCaseClient
@@ -66,6 +67,7 @@ def __init__(
6667
self.aimodels = AIModelClient(self.base_url, self._auth)
6768
self.usecases = UseCaseClient(self.base_url, self._auth)
6869
self.sectors = SectorClient(self.base_url, self._auth)
70+
self.auditors = AuditorClient(self.base_url, self._auth)
6971

7072
def login(self, username: str, password: str) -> dict:
7173
"""
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Resource clients for DataSpace SDK."""
22

33
from dataspace_sdk.resources.aimodels import AIModelClient
4+
from dataspace_sdk.resources.auditors import AuditorClient
45
from dataspace_sdk.resources.datasets import DatasetClient
56
from dataspace_sdk.resources.sectors import SectorClient
67
from dataspace_sdk.resources.usecases import UseCaseClient
78

8-
__all__ = ["DatasetClient", "AIModelClient", "UseCaseClient", "SectorClient"]
9+
__all__ = ["DatasetClient", "AIModelClient", "UseCaseClient", "SectorClient", "AuditorClient"]

0 commit comments

Comments
 (0)