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.
- stapel_workspaces-0.3.1/LICENSE +21 -0
- stapel_workspaces-0.3.1/PKG-INFO +55 -0
- stapel_workspaces-0.3.1/README.md +38 -0
- stapel_workspaces-0.3.1/__init__.py +54 -0
- stapel_workspaces-0.3.1/actions.py +23 -0
- stapel_workspaces-0.3.1/admin.py +27 -0
- stapel_workspaces-0.3.1/apps.py +22 -0
- stapel_workspaces-0.3.1/conftest.py +90 -0
- stapel_workspaces-0.3.1/conftest_urls.py +18 -0
- stapel_workspaces-0.3.1/dto.py +141 -0
- stapel_workspaces-0.3.1/errors.py +36 -0
- stapel_workspaces-0.3.1/events.py +34 -0
- stapel_workspaces-0.3.1/functions.py +67 -0
- stapel_workspaces-0.3.1/gdpr.py +64 -0
- stapel_workspaces-0.3.1/management/__init__.py +0 -0
- stapel_workspaces-0.3.1/management/commands/__init__.py +0 -0
- stapel_workspaces-0.3.1/management/commands/consume_auth_events.py +43 -0
- stapel_workspaces-0.3.1/migrations/0001_initial.py +103 -0
- stapel_workspaces-0.3.1/migrations/__init__.py +0 -0
- stapel_workspaces-0.3.1/models.py +116 -0
- stapel_workspaces-0.3.1/permissions.py +27 -0
- stapel_workspaces-0.3.1/py.typed +0 -0
- stapel_workspaces-0.3.1/pyproject.toml +58 -0
- stapel_workspaces-0.3.1/schemas/consumes/user.deleted.json +13 -0
- stapel_workspaces-0.3.1/schemas/consumes/user.deletion_initiated.json +13 -0
- stapel_workspaces-0.3.1/schemas/emits/workspace.created.json +14 -0
- stapel_workspaces-0.3.1/schemas/emits/workspace.member_joined.json +13 -0
- stapel_workspaces-0.3.1/schemas/emits/workspace.personal.created.json +12 -0
- stapel_workspaces-0.3.1/schemas/functions/workspaces.check_membership.json +12 -0
- stapel_workspaces-0.3.1/serializers.py +96 -0
- stapel_workspaces-0.3.1/services.py +172 -0
- stapel_workspaces-0.3.1/setup.cfg +4 -0
- stapel_workspaces-0.3.1/stapel_workspaces.egg-info/PKG-INFO +55 -0
- stapel_workspaces-0.3.1/stapel_workspaces.egg-info/SOURCES.txt +86 -0
- stapel_workspaces-0.3.1/stapel_workspaces.egg-info/dependency_links.txt +1 -0
- stapel_workspaces-0.3.1/stapel_workspaces.egg-info/requires.txt +3 -0
- stapel_workspaces-0.3.1/stapel_workspaces.egg-info/top_level.txt +1 -0
- stapel_workspaces-0.3.1/tests/__init__.py +0 -0
- stapel_workspaces-0.3.1/tests/test_api.py +112 -0
- stapel_workspaces-0.3.1/tests/test_api_invitations.py +220 -0
- stapel_workspaces-0.3.1/tests/test_api_members.py +215 -0
- stapel_workspaces-0.3.1/tests/test_api_workspaces_extra.py +162 -0
- stapel_workspaces-0.3.1/tests/test_comm.py +306 -0
- stapel_workspaces-0.3.1/tests/test_gdpr_actions.py +187 -0
- stapel_workspaces-0.3.1/tests/test_internal_api.py +118 -0
- stapel_workspaces-0.3.1/tests/test_models.py +13 -0
- stapel_workspaces-0.3.1/tests/test_public_api.py +87 -0
- stapel_workspaces-0.3.1/urls.py +51 -0
- 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
|
+
[](https://github.com/usestapel/stapel-workspaces/actions/workflows/ci.yml)
|
|
21
|
+
[](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
|
+
[](https://github.com/usestapel/stapel-workspaces/actions/workflows/ci.yml)
|
|
4
|
+
[](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)
|