stapel-workspaces 0.3.1__tar.gz

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 (49) hide show
  1. stapel_workspaces-0.3.1/LICENSE +21 -0
  2. stapel_workspaces-0.3.1/PKG-INFO +55 -0
  3. stapel_workspaces-0.3.1/README.md +38 -0
  4. stapel_workspaces-0.3.1/__init__.py +54 -0
  5. stapel_workspaces-0.3.1/actions.py +23 -0
  6. stapel_workspaces-0.3.1/admin.py +27 -0
  7. stapel_workspaces-0.3.1/apps.py +22 -0
  8. stapel_workspaces-0.3.1/conftest.py +90 -0
  9. stapel_workspaces-0.3.1/conftest_urls.py +18 -0
  10. stapel_workspaces-0.3.1/dto.py +141 -0
  11. stapel_workspaces-0.3.1/errors.py +36 -0
  12. stapel_workspaces-0.3.1/events.py +34 -0
  13. stapel_workspaces-0.3.1/functions.py +67 -0
  14. stapel_workspaces-0.3.1/gdpr.py +64 -0
  15. stapel_workspaces-0.3.1/management/__init__.py +0 -0
  16. stapel_workspaces-0.3.1/management/commands/__init__.py +0 -0
  17. stapel_workspaces-0.3.1/management/commands/consume_auth_events.py +43 -0
  18. stapel_workspaces-0.3.1/migrations/0001_initial.py +103 -0
  19. stapel_workspaces-0.3.1/migrations/__init__.py +0 -0
  20. stapel_workspaces-0.3.1/models.py +116 -0
  21. stapel_workspaces-0.3.1/permissions.py +27 -0
  22. stapel_workspaces-0.3.1/py.typed +0 -0
  23. stapel_workspaces-0.3.1/pyproject.toml +58 -0
  24. stapel_workspaces-0.3.1/schemas/consumes/user.deleted.json +13 -0
  25. stapel_workspaces-0.3.1/schemas/consumes/user.deletion_initiated.json +13 -0
  26. stapel_workspaces-0.3.1/schemas/emits/workspace.created.json +14 -0
  27. stapel_workspaces-0.3.1/schemas/emits/workspace.member_joined.json +13 -0
  28. stapel_workspaces-0.3.1/schemas/emits/workspace.personal.created.json +12 -0
  29. stapel_workspaces-0.3.1/schemas/functions/workspaces.check_membership.json +12 -0
  30. stapel_workspaces-0.3.1/serializers.py +96 -0
  31. stapel_workspaces-0.3.1/services.py +172 -0
  32. stapel_workspaces-0.3.1/setup.cfg +4 -0
  33. stapel_workspaces-0.3.1/stapel_workspaces.egg-info/PKG-INFO +55 -0
  34. stapel_workspaces-0.3.1/stapel_workspaces.egg-info/SOURCES.txt +86 -0
  35. stapel_workspaces-0.3.1/stapel_workspaces.egg-info/dependency_links.txt +1 -0
  36. stapel_workspaces-0.3.1/stapel_workspaces.egg-info/requires.txt +3 -0
  37. stapel_workspaces-0.3.1/stapel_workspaces.egg-info/top_level.txt +1 -0
  38. stapel_workspaces-0.3.1/tests/__init__.py +0 -0
  39. stapel_workspaces-0.3.1/tests/test_api.py +112 -0
  40. stapel_workspaces-0.3.1/tests/test_api_invitations.py +220 -0
  41. stapel_workspaces-0.3.1/tests/test_api_members.py +215 -0
  42. stapel_workspaces-0.3.1/tests/test_api_workspaces_extra.py +162 -0
  43. stapel_workspaces-0.3.1/tests/test_comm.py +306 -0
  44. stapel_workspaces-0.3.1/tests/test_gdpr_actions.py +187 -0
  45. stapel_workspaces-0.3.1/tests/test_internal_api.py +118 -0
  46. stapel_workspaces-0.3.1/tests/test_models.py +13 -0
  47. stapel_workspaces-0.3.1/tests/test_public_api.py +87 -0
  48. stapel_workspaces-0.3.1/urls.py +51 -0
  49. stapel_workspaces-0.3.1/views.py +460 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usestapel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-workspaces
3
+ Version: 0.3.1
4
+ Summary: Team workspaces and RBAC Django app for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,workspaces,teams,rbac
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: stapel-core<0.4,>=0.3.0
15
+ Provides-Extra: all
16
+ Dynamic: license-file
17
+
18
+ # stapel-workspaces
19
+
20
+ [![CI](https://github.com/usestapel/stapel-workspaces/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-workspaces/actions/workflows/ci.yml)
21
+ [![codecov](https://codecov.io/gh/usestapel/stapel-workspaces/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-workspaces)
22
+
23
+ > Team workspaces and RBAC — roles, invitations, membership, storage quotas
24
+
25
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install stapel-workspaces
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ # settings.py
37
+ INSTALLED_APPS = [
38
+ ...
39
+ 'stapel_workspaces',
40
+ ]
41
+ ```
42
+
43
+ ## Bus events
44
+
45
+ ### Emits
46
+ | `workspace.created` | [schema](schemas/emits/workspace.created.json) | |
47
+ | `workspace.member_joined` | [schema](schemas/emits/workspace.member_joined.json) | |
48
+
49
+ ### Consumes
50
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
51
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
52
+
53
+ ## License
54
+
55
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,38 @@
1
+ # stapel-workspaces
2
+
3
+ [![CI](https://github.com/usestapel/stapel-workspaces/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-workspaces/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/usestapel/stapel-workspaces/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-workspaces)
5
+
6
+ > Team workspaces and RBAC — roles, invitations, membership, storage quotas
7
+
8
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install stapel-workspaces
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ # settings.py
20
+ INSTALLED_APPS = [
21
+ ...
22
+ 'stapel_workspaces',
23
+ ]
24
+ ```
25
+
26
+ ## Bus events
27
+
28
+ ### Emits
29
+ | `workspace.created` | [schema](schemas/emits/workspace.created.json) | |
30
+ | `workspace.member_joined` | [schema](schemas/emits/workspace.member_joined.json) | |
31
+
32
+ ### Consumes
33
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
34
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
35
+
36
+ ## License
37
+
38
+ MIT — see [LICENSE](LICENSE)
@@ -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)