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.
Files changed (44) hide show
  1. stapel_workspaces/__init__.py +54 -0
  2. stapel_workspaces/actions.py +23 -0
  3. stapel_workspaces/admin.py +27 -0
  4. stapel_workspaces/apps.py +22 -0
  5. stapel_workspaces/conftest.py +90 -0
  6. stapel_workspaces/conftest_urls.py +18 -0
  7. stapel_workspaces/dto.py +141 -0
  8. stapel_workspaces/errors.py +36 -0
  9. stapel_workspaces/events.py +34 -0
  10. stapel_workspaces/functions.py +67 -0
  11. stapel_workspaces/gdpr.py +64 -0
  12. stapel_workspaces/management/__init__.py +0 -0
  13. stapel_workspaces/management/commands/__init__.py +0 -0
  14. stapel_workspaces/management/commands/consume_auth_events.py +43 -0
  15. stapel_workspaces/migrations/0001_initial.py +103 -0
  16. stapel_workspaces/migrations/__init__.py +0 -0
  17. stapel_workspaces/models.py +116 -0
  18. stapel_workspaces/permissions.py +27 -0
  19. stapel_workspaces/py.typed +0 -0
  20. stapel_workspaces/schemas/consumes/user.deleted.json +13 -0
  21. stapel_workspaces/schemas/consumes/user.deletion_initiated.json +13 -0
  22. stapel_workspaces/schemas/emits/workspace.created.json +14 -0
  23. stapel_workspaces/schemas/emits/workspace.member_joined.json +13 -0
  24. stapel_workspaces/schemas/emits/workspace.personal.created.json +12 -0
  25. stapel_workspaces/schemas/functions/workspaces.check_membership.json +12 -0
  26. stapel_workspaces/serializers.py +96 -0
  27. stapel_workspaces/services.py +172 -0
  28. stapel_workspaces/tests/__init__.py +0 -0
  29. stapel_workspaces/tests/test_api.py +112 -0
  30. stapel_workspaces/tests/test_api_invitations.py +220 -0
  31. stapel_workspaces/tests/test_api_members.py +215 -0
  32. stapel_workspaces/tests/test_api_workspaces_extra.py +162 -0
  33. stapel_workspaces/tests/test_comm.py +306 -0
  34. stapel_workspaces/tests/test_gdpr_actions.py +187 -0
  35. stapel_workspaces/tests/test_internal_api.py +118 -0
  36. stapel_workspaces/tests/test_models.py +13 -0
  37. stapel_workspaces/tests/test_public_api.py +87 -0
  38. stapel_workspaces/urls.py +51 -0
  39. stapel_workspaces/views.py +460 -0
  40. stapel_workspaces-0.3.1.dist-info/METADATA +55 -0
  41. stapel_workspaces-0.3.1.dist-info/RECORD +44 -0
  42. stapel_workspaces-0.3.1.dist-info/WHEEL +5 -0
  43. stapel_workspaces-0.3.1.dist-info/licenses/LICENSE +21 -0
  44. 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
+ ]
@@ -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}")