stapel-workspaces 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stapel_workspaces/__init__.py +54 -0
- stapel_workspaces/actions.py +23 -0
- stapel_workspaces/admin.py +27 -0
- stapel_workspaces/apps.py +22 -0
- stapel_workspaces/conftest.py +90 -0
- stapel_workspaces/conftest_urls.py +18 -0
- stapel_workspaces/dto.py +141 -0
- stapel_workspaces/errors.py +36 -0
- stapel_workspaces/events.py +34 -0
- stapel_workspaces/functions.py +67 -0
- stapel_workspaces/gdpr.py +64 -0
- stapel_workspaces/management/__init__.py +0 -0
- stapel_workspaces/management/commands/__init__.py +0 -0
- stapel_workspaces/management/commands/consume_auth_events.py +43 -0
- stapel_workspaces/migrations/0001_initial.py +103 -0
- stapel_workspaces/migrations/__init__.py +0 -0
- stapel_workspaces/models.py +116 -0
- stapel_workspaces/permissions.py +27 -0
- stapel_workspaces/py.typed +0 -0
- stapel_workspaces/schemas/consumes/user.deleted.json +13 -0
- stapel_workspaces/schemas/consumes/user.deletion_initiated.json +13 -0
- stapel_workspaces/schemas/emits/workspace.created.json +14 -0
- stapel_workspaces/schemas/emits/workspace.member_joined.json +13 -0
- stapel_workspaces/schemas/emits/workspace.personal.created.json +12 -0
- stapel_workspaces/schemas/functions/workspaces.check_membership.json +12 -0
- stapel_workspaces/serializers.py +96 -0
- stapel_workspaces/services.py +172 -0
- stapel_workspaces/tests/__init__.py +0 -0
- stapel_workspaces/tests/test_api.py +112 -0
- stapel_workspaces/tests/test_api_invitations.py +220 -0
- stapel_workspaces/tests/test_api_members.py +215 -0
- stapel_workspaces/tests/test_api_workspaces_extra.py +162 -0
- stapel_workspaces/tests/test_comm.py +306 -0
- stapel_workspaces/tests/test_gdpr_actions.py +187 -0
- stapel_workspaces/tests/test_internal_api.py +118 -0
- stapel_workspaces/tests/test_models.py +13 -0
- stapel_workspaces/tests/test_public_api.py +87 -0
- stapel_workspaces/urls.py +51 -0
- stapel_workspaces/views.py +460 -0
- stapel_workspaces-0.3.1.dist-info/METADATA +55 -0
- stapel_workspaces-0.3.1.dist-info/RECORD +44 -0
- stapel_workspaces-0.3.1.dist-info/WHEEL +5 -0
- stapel_workspaces-0.3.1.dist-info/licenses/LICENSE +21 -0
- stapel_workspaces-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Stapel Workspaces — team workspaces and RBAC Django app for Stapel.
|
|
2
|
+
|
|
3
|
+
Public API (see ``__all__``):
|
|
4
|
+
|
|
5
|
+
Service functions (``stapel_workspaces.services``):
|
|
6
|
+
- ``create_workspace`` — create a workspace and seed the owner membership.
|
|
7
|
+
- ``ensure_personal_workspace`` — get-or-create a user's personal workspace.
|
|
8
|
+
- ``create_invitation`` — invite an email address to a workspace.
|
|
9
|
+
- ``accept_invitation`` — resolve an invitation into a membership.
|
|
10
|
+
|
|
11
|
+
comm Function provider (``stapel_workspaces.functions``):
|
|
12
|
+
- ``CHECK_MEMBERSHIP`` — name of the ``workspaces.check_membership``
|
|
13
|
+
Function (call it via ``stapel_core.comm.call``).
|
|
14
|
+
- ``check_membership`` — the provider itself.
|
|
15
|
+
|
|
16
|
+
Events (``stapel_workspaces.events``):
|
|
17
|
+
- ``EVENT_WORKSPACE_PERSONAL_CREATED`` — comm action name emitted when a
|
|
18
|
+
personal workspace is bootstrapped.
|
|
19
|
+
|
|
20
|
+
GDPR:
|
|
21
|
+
- ``WorkspacesGDPRProvider`` — export/delete provider for workspace data.
|
|
22
|
+
|
|
23
|
+
Signal usage (``workspace_member_changed``) stays in ``stapel_core.signals``.
|
|
24
|
+
|
|
25
|
+
All exports are lazily imported (PEP 562): importing ``stapel_workspaces``
|
|
26
|
+
itself does not require Django to be configured.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_EXPORTS = {
|
|
30
|
+
"create_workspace": ".services",
|
|
31
|
+
"ensure_personal_workspace": ".services",
|
|
32
|
+
"create_invitation": ".services",
|
|
33
|
+
"accept_invitation": ".services",
|
|
34
|
+
"CHECK_MEMBERSHIP": ".functions",
|
|
35
|
+
"check_membership": ".functions",
|
|
36
|
+
"EVENT_WORKSPACE_PERSONAL_CREATED": ".events",
|
|
37
|
+
"WorkspacesGDPRProvider": ".gdpr",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
__all__ = list(_EXPORTS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def __getattr__(name):
|
|
44
|
+
if name in _EXPORTS:
|
|
45
|
+
import importlib
|
|
46
|
+
|
|
47
|
+
value = getattr(importlib.import_module(_EXPORTS[name], __name__), name)
|
|
48
|
+
globals()[name] = value # cache for subsequent lookups
|
|
49
|
+
return value
|
|
50
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __dir__():
|
|
54
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Action subscriptions of the workspaces module.
|
|
2
|
+
|
|
3
|
+
Handlers must be idempotent: delivery is at-least-once (outbox retries,
|
|
4
|
+
broker redelivery).
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from stapel_core.comm import on_action
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@on_action("user.deleted")
|
|
14
|
+
def handle_user_deleted(event):
|
|
15
|
+
"""Erase this module's PII when an account deletion is executed."""
|
|
16
|
+
from .gdpr import WorkspacesGDPRProvider
|
|
17
|
+
|
|
18
|
+
user_id = event.payload.get("user_id")
|
|
19
|
+
if not user_id:
|
|
20
|
+
logger.error("user.deleted event without user_id: %s", event.event_id)
|
|
21
|
+
return
|
|
22
|
+
WorkspacesGDPRProvider().delete(user_id)
|
|
23
|
+
logger.info("workspaces data erased for deleted user %s", user_id)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import Workspace, WorkspaceInvitation, WorkspaceMember
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(Workspace)
|
|
7
|
+
class WorkspaceAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = ["name", "slug", "type", "owner", "storage_used_bytes", "created_at"]
|
|
9
|
+
list_filter = ["type", "created_at"]
|
|
10
|
+
search_fields = ["name", "slug", "owner__email"]
|
|
11
|
+
readonly_fields = ["id", "created_at", "updated_at"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@admin.register(WorkspaceMember)
|
|
15
|
+
class WorkspaceMemberAdmin(admin.ModelAdmin):
|
|
16
|
+
list_display = ["workspace", "user", "role", "invited_at", "accepted_at"]
|
|
17
|
+
list_filter = ["role"]
|
|
18
|
+
search_fields = ["workspace__name", "user__email"]
|
|
19
|
+
readonly_fields = ["id", "invited_at"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@admin.register(WorkspaceInvitation)
|
|
23
|
+
class WorkspaceInvitationAdmin(admin.ModelAdmin):
|
|
24
|
+
list_display = ["workspace", "email", "role", "expires_at", "accepted_at", "revoked_at"]
|
|
25
|
+
list_filter = ["role", "accepted_at", "revoked_at"]
|
|
26
|
+
search_fields = ["workspace__name", "email"]
|
|
27
|
+
readonly_fields = ["id", "token", "created_at"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorkspacesConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "stapel_workspaces"
|
|
7
|
+
label = 'workspaces'
|
|
8
|
+
verbose_name = "Stapel Workspaces"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from stapel_core.gdpr import gdpr_registry
|
|
12
|
+
from .gdpr import WorkspacesGDPRProvider
|
|
13
|
+
gdpr_registry.register(WorkspacesGDPRProvider())
|
|
14
|
+
|
|
15
|
+
# Action subscriptions (in-process in a monolith, bus consumer in
|
|
16
|
+
# microservices — same code, transport chosen by STAPEL_COMM).
|
|
17
|
+
from . import actions # noqa: F401
|
|
18
|
+
|
|
19
|
+
# Function providers (workspaces.check_membership). register() is
|
|
20
|
+
# idempotent — ready() may run more than once.
|
|
21
|
+
from . import functions
|
|
22
|
+
functions.register()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pytest_configure(config):
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
if not settings.configured:
|
|
10
|
+
settings.configure(
|
|
11
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
12
|
+
INSTALLED_APPS=[
|
|
13
|
+
"django.contrib.contenttypes",
|
|
14
|
+
"django.contrib.auth",
|
|
15
|
+
"django.contrib.sessions",
|
|
16
|
+
"django.contrib.messages",
|
|
17
|
+
"django.contrib.admin",
|
|
18
|
+
"stapel_core.django.users",
|
|
19
|
+
"rest_framework",
|
|
20
|
+
"stapel_workspaces",
|
|
21
|
+
],
|
|
22
|
+
AUTH_USER_MODEL="users.User",
|
|
23
|
+
DATABASES={
|
|
24
|
+
"default": {
|
|
25
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
26
|
+
"NAME": ":memory:",
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
30
|
+
USE_TZ=True,
|
|
31
|
+
APPEND_SLASH=False,
|
|
32
|
+
ROOT_URLCONF="stapel_workspaces.conftest_urls",
|
|
33
|
+
CACHES={
|
|
34
|
+
"default": {
|
|
35
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
# In-memory bus — no Kafka/Redis broker needed
|
|
39
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
40
|
+
# Synchronous in-process comm: no outbox table needed, emitted
|
|
41
|
+
# actions reach in-process subscribers immediately.
|
|
42
|
+
STAPEL_COMM={
|
|
43
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
44
|
+
"FUNCTION_TRANSPORT": "inprocess",
|
|
45
|
+
"OUTBOX_ENABLED": False,
|
|
46
|
+
"VALIDATE_SCHEMAS": True,
|
|
47
|
+
},
|
|
48
|
+
SERVICE_NAME="workspaces",
|
|
49
|
+
FRONTEND_URL="https://app.example.com",
|
|
50
|
+
# Skip migrations — create tables directly from models
|
|
51
|
+
MIGRATION_MODULES={
|
|
52
|
+
"users": None,
|
|
53
|
+
"workspaces": None,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def api_client():
|
|
60
|
+
from rest_framework.test import APIClient
|
|
61
|
+
|
|
62
|
+
return APIClient()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def user(db):
|
|
67
|
+
from stapel_core.django.users.models import User
|
|
68
|
+
|
|
69
|
+
return User.objects.create_user(
|
|
70
|
+
username=f"u-{uuid.uuid4().hex[:8]}",
|
|
71
|
+
email=f"{uuid.uuid4().hex[:8]}@example.com",
|
|
72
|
+
password="testpass-1234",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def other_user(db):
|
|
78
|
+
from stapel_core.django.users.models import User
|
|
79
|
+
|
|
80
|
+
return User.objects.create_user(
|
|
81
|
+
username=f"u-{uuid.uuid4().hex[:8]}",
|
|
82
|
+
email=f"{uuid.uuid4().hex[:8]}@example.com",
|
|
83
|
+
password="testpass-1234",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def authed_client(api_client, user):
|
|
89
|
+
api_client.force_authenticate(user=user)
|
|
90
|
+
return api_client
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""URL configuration used only during tests.
|
|
2
|
+
|
|
3
|
+
Mounts the workspaces API at /workspaces/api/workspaces to match the test fixtures.
|
|
4
|
+
Tests call paths without a trailing slash on the collection (/workspaces/api/workspaces)
|
|
5
|
+
but with a slash before resource IDs (/workspaces/api/workspaces/{uuid}).
|
|
6
|
+
Two separate includes cover both cases:
|
|
7
|
+
- path with '/' strips 'workspaces/api/workspaces/' → passes bare '{uuid}' to sub-patterns
|
|
8
|
+
- path without '/' strips 'workspaces/api/workspaces' → passes '' to sub-patterns (list)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from django.urls import include, path
|
|
12
|
+
|
|
13
|
+
urlpatterns = [
|
|
14
|
+
# Detail/invite routes: URL has slash before UUID, so this prefix strips correctly.
|
|
15
|
+
path("workspaces/api/workspaces/", include("stapel_workspaces.urls")),
|
|
16
|
+
# List/create route: URL has no trailing slash; this prefix matches and passes '' to sub-patterns.
|
|
17
|
+
path("workspaces/api/workspaces", include("stapel_workspaces.urls")),
|
|
18
|
+
]
|
stapel_workspaces/dto.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Data Transfer Objects for workspaces API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class WorkspaceResponse:
|
|
10
|
+
"""Workspace details.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
id: Workspace UUID. Example: 0192f...
|
|
14
|
+
name: Display name. Example: Acme Engineering
|
|
15
|
+
slug: URL-safe identifier. Example: acme-eng
|
|
16
|
+
type: Workspace category. Example: work
|
|
17
|
+
owner_id: Owner user UUID. Example: 0192a...
|
|
18
|
+
settings: Workspace settings JSON.
|
|
19
|
+
storage_used_bytes: Bytes currently stored.
|
|
20
|
+
storage_limit_bytes: Plan-determined cap.
|
|
21
|
+
member_count: Number of members.
|
|
22
|
+
my_role: Role of the requesting user. Example: owner
|
|
23
|
+
created_at: ISO 8601 creation time. Example: 2026-05-20T10:00:00Z
|
|
24
|
+
updated_at: ISO 8601 last update time. Example: 2026-05-20T10:00:00Z
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
id: UUID
|
|
28
|
+
name: str
|
|
29
|
+
slug: str
|
|
30
|
+
type: str
|
|
31
|
+
owner_id: UUID
|
|
32
|
+
settings: dict
|
|
33
|
+
storage_used_bytes: int
|
|
34
|
+
storage_limit_bytes: int
|
|
35
|
+
member_count: int
|
|
36
|
+
my_role: Optional[str]
|
|
37
|
+
created_at: str
|
|
38
|
+
updated_at: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class WorkspaceListResponse:
|
|
43
|
+
workspaces: List[WorkspaceResponse] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class WorkspaceCreateRequest:
|
|
48
|
+
"""Create-workspace payload.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
name: Display name. Example: Acme Engineering
|
|
52
|
+
slug: URL-safe identifier (auto-generated when omitted). Example: acme-eng
|
|
53
|
+
type: personal or work. Example: work
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
slug: Optional[str] = None
|
|
58
|
+
type: str = "work"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class WorkspaceUpdateRequest:
|
|
63
|
+
name: Optional[str] = None
|
|
64
|
+
slug: Optional[str] = None
|
|
65
|
+
settings: Optional[dict] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class MemberResponse:
|
|
70
|
+
"""Workspace member.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
id: Membership UUID. Example: 0192...
|
|
74
|
+
workspace_id: Workspace UUID.
|
|
75
|
+
user_id: User UUID.
|
|
76
|
+
email: User email (best-effort, from JWT claim cache).
|
|
77
|
+
role: owner / admin / member / viewer. Example: admin
|
|
78
|
+
invited_at: ISO 8601 invite timestamp.
|
|
79
|
+
accepted_at: ISO 8601 acceptance timestamp; null while pending.
|
|
80
|
+
last_accessed_at: ISO 8601 last access; null if never accessed.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
id: UUID
|
|
84
|
+
workspace_id: UUID
|
|
85
|
+
user_id: UUID
|
|
86
|
+
email: Optional[str]
|
|
87
|
+
role: str
|
|
88
|
+
invited_at: str
|
|
89
|
+
accepted_at: Optional[str]
|
|
90
|
+
last_accessed_at: Optional[str]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class MemberListResponse:
|
|
95
|
+
members: List[MemberResponse] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class MemberInviteRequest:
|
|
100
|
+
"""Invite payload.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
emails: One or more emails to invite. Example: ["alice@example.com"]
|
|
104
|
+
role: Role to grant on acceptance. Example: member
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
emails: List[str]
|
|
108
|
+
role: str = "member"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class MemberInviteResponse:
|
|
113
|
+
invitations: List["InvitationResponse"] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class InvitationResponse:
|
|
118
|
+
id: UUID
|
|
119
|
+
workspace_id: UUID
|
|
120
|
+
email: str
|
|
121
|
+
role: str
|
|
122
|
+
expires_at: str
|
|
123
|
+
accepted_at: Optional[str]
|
|
124
|
+
revoked_at: Optional[str]
|
|
125
|
+
created_at: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class InvitationAcceptRequest:
|
|
130
|
+
"""Accept an invite.
|
|
131
|
+
|
|
132
|
+
Attributes:
|
|
133
|
+
token: Invite token from the email link.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
token: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class MemberUpdateRequest:
|
|
141
|
+
role: str
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Custom error keys for the workspaces service."""
|
|
2
|
+
|
|
3
|
+
from stapel_core.django.api.errors import ErrorKeysView, register_service_errors
|
|
4
|
+
|
|
5
|
+
ERR_404_WORKSPACE_NOT_FOUND = "error.404.workspace_not_found"
|
|
6
|
+
ERR_404_MEMBER_NOT_FOUND = "error.404.member_not_found"
|
|
7
|
+
ERR_404_INVITATION_NOT_FOUND = "error.404.invitation_not_found"
|
|
8
|
+
ERR_403_FORBIDDEN_WORKSPACE = "error.403.forbidden_workspace"
|
|
9
|
+
ERR_403_LAST_OWNER = "error.403.last_owner_cannot_be_removed"
|
|
10
|
+
ERR_400_SLUG_TAKEN = "error.400.workspace_slug_taken"
|
|
11
|
+
ERR_400_ALREADY_MEMBER = "error.400.already_workspace_member"
|
|
12
|
+
ERR_400_INVITATION_EXPIRED = "error.400.invitation_expired"
|
|
13
|
+
ERR_400_INVITATION_ALREADY_USED = "error.400.invitation_already_used"
|
|
14
|
+
ERR_400_INVITATION_REVOKED = "error.400.invitation_revoked"
|
|
15
|
+
ERR_400_INVALID_ROLE = "error.400.invalid_role"
|
|
16
|
+
|
|
17
|
+
WORKSPACES_ERRORS = {
|
|
18
|
+
ERR_404_WORKSPACE_NOT_FOUND: "Workspace not found",
|
|
19
|
+
ERR_404_MEMBER_NOT_FOUND: "Member not found in this workspace",
|
|
20
|
+
ERR_404_INVITATION_NOT_FOUND: "Invitation not found",
|
|
21
|
+
ERR_403_FORBIDDEN_WORKSPACE: "You do not have access to this workspace",
|
|
22
|
+
ERR_403_LAST_OWNER: "The last owner cannot be removed; transfer ownership first",
|
|
23
|
+
ERR_400_SLUG_TAKEN: "Workspace slug is already taken",
|
|
24
|
+
ERR_400_ALREADY_MEMBER: "User is already a member of this workspace",
|
|
25
|
+
ERR_400_INVITATION_EXPIRED: "Invitation has expired",
|
|
26
|
+
ERR_400_INVITATION_ALREADY_USED: "Invitation has already been used",
|
|
27
|
+
ERR_400_INVITATION_REVOKED: "Invitation has been revoked",
|
|
28
|
+
ERR_400_INVALID_ROLE: "Invalid role",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
register_service_errors(WORKSPACES_ERRORS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkspacesErrorKeysView(ErrorKeysView):
|
|
35
|
+
def get_service_errors(self):
|
|
36
|
+
return WORKSPACES_ERRORS
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Events published by stapel-workspaces.
|
|
2
|
+
|
|
3
|
+
Publishing goes through ``stapel_core.comm.emit`` (transactional outbox;
|
|
4
|
+
in-process in a monolith, bus in microservices) — see services.py. The
|
|
5
|
+
comm action name is ``workspace.personal.created``; its payload contract
|
|
6
|
+
lives in schemas/emits/workspace.personal.created.json.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
EVENT_WORKSPACE_PERSONAL_CREATED = "workspace.personal.created"
|
|
11
|
+
|
|
12
|
+
# On the bus transport the topic is the action name. The old Kafka topic
|
|
13
|
+
# ``stapel.workspaces.personal-created`` is retired; alias kept for any
|
|
14
|
+
# importer still referencing the old name.
|
|
15
|
+
TOPIC_WORKSPACE_PERSONAL_CREATED = EVENT_WORKSPACE_PERSONAL_CREATED
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class WorkspacePersonalCreatedPayload:
|
|
20
|
+
"""Payload for the workspace.personal.created event.
|
|
21
|
+
|
|
22
|
+
Fields:
|
|
23
|
+
user_id: UUID of the workspace owner.
|
|
24
|
+
workspace_id: UUID of the created personal workspace.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
user_id: str
|
|
28
|
+
workspace_id: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
EVENT_REGISTRY = {
|
|
32
|
+
EVENT_WORKSPACE_PERSONAL_CREATED: WorkspacePersonalCreatedPayload,
|
|
33
|
+
TOPIC_WORKSPACE_PERSONAL_CREATED: WorkspacePersonalCreatedPayload,
|
|
34
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""comm Function providers of the workspaces module.
|
|
2
|
+
|
|
3
|
+
Other modules check membership by name — no import of this app, no HTTP
|
|
4
|
+
client code (the transport is deployment configuration, see STAPEL_COMM):
|
|
5
|
+
|
|
6
|
+
from stapel_core.comm import call
|
|
7
|
+
|
|
8
|
+
result = call(
|
|
9
|
+
"workspaces.check_membership",
|
|
10
|
+
{"workspace_id": str(workspace_id), "user_id": str(user_id)},
|
|
11
|
+
)
|
|
12
|
+
# -> {"is_member": bool, "role": str | None}
|
|
13
|
+
|
|
14
|
+
The provider mirrors the internal HTTP endpoint
|
|
15
|
+
(:class:`stapel_workspaces.views.InternalMembershipView`): only *accepted*
|
|
16
|
+
memberships count.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from stapel_core.comm import register_function
|
|
20
|
+
|
|
21
|
+
CHECK_MEMBERSHIP = "workspaces.check_membership"
|
|
22
|
+
|
|
23
|
+
# Kept in sync with schemas/functions/workspaces.check_membership.json
|
|
24
|
+
# (the schemas/ autoloader registers the file too; passing it here makes
|
|
25
|
+
# validation work even without the autoloader).
|
|
26
|
+
CHECK_MEMBERSHIP_SCHEMA = {
|
|
27
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
28
|
+
"title": CHECK_MEMBERSHIP,
|
|
29
|
+
"type": "object",
|
|
30
|
+
"required": ["workspace_id", "user_id"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"workspace_id": {"type": "string", "format": "uuid"},
|
|
33
|
+
"user_id": {"type": "string", "format": "uuid"},
|
|
34
|
+
},
|
|
35
|
+
"additionalProperties": False,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_membership(payload: dict) -> dict:
|
|
40
|
+
"""Provider for ``workspaces.check_membership``.
|
|
41
|
+
|
|
42
|
+
Payload: ``{"workspace_id": str, "user_id": str}``
|
|
43
|
+
Returns: ``{"is_member": bool, "role": str | None}``
|
|
44
|
+
"""
|
|
45
|
+
from .models import WorkspaceMember
|
|
46
|
+
|
|
47
|
+
member = (
|
|
48
|
+
WorkspaceMember.objects.filter(
|
|
49
|
+
workspace_id=payload["workspace_id"],
|
|
50
|
+
user_id=payload["user_id"],
|
|
51
|
+
accepted_at__isnull=False,
|
|
52
|
+
)
|
|
53
|
+
.only("role")
|
|
54
|
+
.first()
|
|
55
|
+
)
|
|
56
|
+
if member is None:
|
|
57
|
+
return {"is_member": False, "role": None}
|
|
58
|
+
return {"is_member": True, "role": member.role}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def register() -> None:
|
|
62
|
+
"""Register this module's Function providers.
|
|
63
|
+
|
|
64
|
+
Idempotent: re-registering the *same* handler object is a no-op, so
|
|
65
|
+
AppConfig.ready() may run more than once without raising.
|
|
66
|
+
"""
|
|
67
|
+
register_function(CHECK_MEMBERSHIP, check_membership, schema=CHECK_MEMBERSHIP_SCHEMA)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from stapel_core.gdpr import GDPRProvider
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorkspacesGDPRProvider(GDPRProvider):
|
|
5
|
+
section = 'workspaces'
|
|
6
|
+
|
|
7
|
+
def export(self, user_id: int) -> dict:
|
|
8
|
+
from .models import Workspace, WorkspaceInvitation, WorkspaceMember
|
|
9
|
+
|
|
10
|
+
memberships = list(WorkspaceMember.objects.filter(user_id=user_id).select_related('workspace').values(
|
|
11
|
+
'workspace__name', 'workspace__slug', 'workspace__type',
|
|
12
|
+
'role', 'invited_at', 'accepted_at', 'last_accessed_at',
|
|
13
|
+
))
|
|
14
|
+
|
|
15
|
+
owned = list(Workspace.objects.filter(owner_id=user_id).values(
|
|
16
|
+
'name', 'slug', 'type', 'storage_used_bytes', 'created_at',
|
|
17
|
+
))
|
|
18
|
+
|
|
19
|
+
sent_invitations = list(WorkspaceInvitation.objects.filter(invited_by_id=user_id).values(
|
|
20
|
+
'workspace__name', 'role', 'created_at', 'accepted_at',
|
|
21
|
+
))
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
'memberships': _serialize_dates(memberships),
|
|
25
|
+
'owned_workspaces': _serialize_dates(owned),
|
|
26
|
+
'invitations_sent': _serialize_dates(sent_invitations),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def delete(self, user_id: int) -> None:
|
|
30
|
+
from .models import Workspace, WorkspaceInvitation, WorkspaceMember
|
|
31
|
+
|
|
32
|
+
# Remove memberships
|
|
33
|
+
WorkspaceMember.objects.filter(user_id=user_id).delete()
|
|
34
|
+
|
|
35
|
+
# Revoke pending invitations sent by this user
|
|
36
|
+
WorkspaceInvitation.objects.filter(
|
|
37
|
+
invited_by_id=user_id, accepted_at__isnull=True,
|
|
38
|
+
).delete()
|
|
39
|
+
|
|
40
|
+
# Owned workspaces: soft-delete (mark deleted_at).
|
|
41
|
+
# Hard deletion of workspace content is out of scope here —
|
|
42
|
+
# the platform should handle workspace transfer/deletion separately.
|
|
43
|
+
from django.utils import timezone
|
|
44
|
+
Workspace.objects.filter(owner_id=user_id, deleted_at__isnull=True).update(
|
|
45
|
+
deleted_at=timezone.now(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def anonymize(self, user_id: int) -> None:
|
|
49
|
+
from .models import WorkspaceInvitation
|
|
50
|
+
|
|
51
|
+
# Keep accepted invitation records but remove the invited_by link
|
|
52
|
+
WorkspaceInvitation.objects.filter(
|
|
53
|
+
invited_by_id=user_id, accepted_at__isnull=False,
|
|
54
|
+
).update(invited_by=None)
|
|
55
|
+
|
|
56
|
+
# Membership records that need to stay (e.g. for workspace history)
|
|
57
|
+
# are already removed in delete(); nothing to anonymise here.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _serialize_dates(rows: list[dict]) -> list[dict]:
|
|
61
|
+
return [
|
|
62
|
+
{k: v.isoformat() if hasattr(v, 'isoformat') else v for k, v in row.items()}
|
|
63
|
+
for row in rows
|
|
64
|
+
]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Consume events published by stapel-auth."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from stapel_core.bus import BaseBusConsumerCommand, Event
|
|
5
|
+
|
|
6
|
+
# stapel-auth emits the action through stapel_core.comm; on the bus transport
|
|
7
|
+
# the topic is the action name. Override via env for legacy topic layouts.
|
|
8
|
+
TOPIC_USER_REGISTERED = os.getenv("STAPEL_TOPIC_USER_REGISTERED", "user.registered")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseBusConsumerCommand):
|
|
12
|
+
help = "Listen for auth events and react (e.g. bootstrap personal workspaces)"
|
|
13
|
+
topics = [TOPIC_USER_REGISTERED]
|
|
14
|
+
consumer_group = "workspaces-auth-events"
|
|
15
|
+
|
|
16
|
+
def handle_event(self, event: Event) -> None:
|
|
17
|
+
if event.event_type == "user.registered":
|
|
18
|
+
self._on_user_registered(event.payload)
|
|
19
|
+
|
|
20
|
+
def _on_user_registered(self, payload: dict) -> None:
|
|
21
|
+
user_id = payload.get("user_id")
|
|
22
|
+
if not user_id:
|
|
23
|
+
self.stderr.write(f"user.registered event missing user_id: {payload}")
|
|
24
|
+
return
|
|
25
|
+
try:
|
|
26
|
+
from stapel_core.django.users.models import User
|
|
27
|
+
user = User.objects.get(id=user_id)
|
|
28
|
+
except User.DoesNotExist:
|
|
29
|
+
self.stderr.write(f"user.registered: user {user_id} not found, skipping")
|
|
30
|
+
return
|
|
31
|
+
from stapel_workspaces.services import ensure_personal_workspace
|
|
32
|
+
from stapel_workspaces.events import EVENT_WORKSPACE_PERSONAL_CREATED
|
|
33
|
+
from stapel_core.bus import publish, Event as BusEvent
|
|
34
|
+
workspace = ensure_personal_workspace(user)
|
|
35
|
+
publish(EVENT_WORKSPACE_PERSONAL_CREATED, BusEvent(
|
|
36
|
+
event_type="workspace.personal.created",
|
|
37
|
+
service="workspaces",
|
|
38
|
+
payload={
|
|
39
|
+
"user_id": user_id,
|
|
40
|
+
"workspace_id": str(workspace.id),
|
|
41
|
+
},
|
|
42
|
+
))
|
|
43
|
+
self.stdout.write(f"Bootstrapped personal workspace {workspace.id} for user {user_id}")
|