stapel-gdpr 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 (42) hide show
  1. stapel_gdpr-0.3.1/LICENSE +21 -0
  2. stapel_gdpr-0.3.1/PKG-INFO +55 -0
  3. stapel_gdpr-0.3.1/README.md +34 -0
  4. stapel_gdpr-0.3.1/__init__.py +53 -0
  5. stapel_gdpr-0.3.1/actions.py +38 -0
  6. stapel_gdpr-0.3.1/admin.py +44 -0
  7. stapel_gdpr-0.3.1/apps.py +33 -0
  8. stapel_gdpr-0.3.1/conf.py +29 -0
  9. stapel_gdpr-0.3.1/conftest.py +132 -0
  10. stapel_gdpr-0.3.1/dto.py +50 -0
  11. stapel_gdpr-0.3.1/errors.py +20 -0
  12. stapel_gdpr-0.3.1/management/__init__.py +0 -0
  13. stapel_gdpr-0.3.1/management/commands/__init__.py +0 -0
  14. stapel_gdpr-0.3.1/management/commands/consume_gdpr_completions.py +45 -0
  15. stapel_gdpr-0.3.1/migrations/0001_initial.py +106 -0
  16. stapel_gdpr-0.3.1/migrations/__init__.py +0 -0
  17. stapel_gdpr-0.3.1/models.py +203 -0
  18. stapel_gdpr-0.3.1/orchestrator.py +524 -0
  19. stapel_gdpr-0.3.1/py.typed +0 -0
  20. stapel_gdpr-0.3.1/pyproject.toml +46 -0
  21. stapel_gdpr-0.3.1/reregistration.py +89 -0
  22. stapel_gdpr-0.3.1/schemas/emits/user.deleted.json +14 -0
  23. stapel_gdpr-0.3.1/schemas/emits/user.deletion_initiated.json +13 -0
  24. stapel_gdpr-0.3.1/schemas/emits/user.export_ready.json +13 -0
  25. stapel_gdpr-0.3.1/serializers.py +17 -0
  26. stapel_gdpr-0.3.1/setup.cfg +4 -0
  27. stapel_gdpr-0.3.1/stapel_gdpr.egg-info/PKG-INFO +55 -0
  28. stapel_gdpr-0.3.1/stapel_gdpr.egg-info/SOURCES.txt +64 -0
  29. stapel_gdpr-0.3.1/stapel_gdpr.egg-info/dependency_links.txt +1 -0
  30. stapel_gdpr-0.3.1/stapel_gdpr.egg-info/requires.txt +8 -0
  31. stapel_gdpr-0.3.1/stapel_gdpr.egg-info/top_level.txt +1 -0
  32. stapel_gdpr-0.3.1/tasks.py +216 -0
  33. stapel_gdpr-0.3.1/tests/test_api.py +179 -0
  34. stapel_gdpr-0.3.1/tests/test_closure.py +253 -0
  35. stapel_gdpr-0.3.1/tests/test_consumer.py +92 -0
  36. stapel_gdpr-0.3.1/tests/test_export.py +140 -0
  37. stapel_gdpr-0.3.1/tests/test_orchestrator_edge.py +304 -0
  38. stapel_gdpr-0.3.1/tests/test_public_api.py +60 -0
  39. stapel_gdpr-0.3.1/tests/test_tasks.py +212 -0
  40. stapel_gdpr-0.3.1/tests/test_views_edge.py +292 -0
  41. stapel_gdpr-0.3.1/urls.py +31 -0
  42. stapel_gdpr-0.3.1/views.py +343 -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-gdpr
3
+ Version: 0.3.1
4
+ Summary: GDPR data export and account deletion Django app for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,gdpr,privacy,data-export,right-to-erasure
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
+ Requires-Dist: celery>=5.3
16
+ Provides-Extra: s3
17
+ Requires-Dist: boto3>=1.34; extra == "s3"
18
+ Provides-Extra: all
19
+ Requires-Dist: stapel-gdpr[s3]; extra == "all"
20
+ Dynamic: license-file
21
+
22
+ # stapel-gdpr
23
+
24
+ [![CI](https://github.com/usestapel/stapel-gdpr/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-gdpr/actions/workflows/ci.yml)
25
+
26
+ > GDPR compliance — data export (Art. 15/20), account deletion with grace period (Art. 17), inactivity closure, retention cleanup
27
+
28
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install stapel-gdpr
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ # settings.py
40
+ INSTALLED_APPS = [
41
+ ...
42
+ 'stapel_gdpr',
43
+ ]
44
+ ```
45
+
46
+ ## Bus events
47
+
48
+ ### Emits
49
+ | `user.deleted` | [schema](schemas/emits/user.deleted.json) | All user PII permanently deleted after grace period. Every package storing user |
50
+ | `user.deletion_initiated` | [schema](schemas/emits/user.deletion_initiated.json) | Account closure started. 30-day grace period begins; account is deactivated. |
51
+ | `user.export_ready` | [schema](schemas/emits/user.export_ready.json) | Data export archive is ready for download. |
52
+
53
+ ## License
54
+
55
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,34 @@
1
+ # stapel-gdpr
2
+
3
+ [![CI](https://github.com/usestapel/stapel-gdpr/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-gdpr/actions/workflows/ci.yml)
4
+
5
+ > GDPR compliance — data export (Art. 15/20), account deletion with grace period (Art. 17), inactivity closure, retention cleanup
6
+
7
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install stapel-gdpr
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ # settings.py
19
+ INSTALLED_APPS = [
20
+ ...
21
+ 'stapel_gdpr',
22
+ ]
23
+ ```
24
+
25
+ ## Bus events
26
+
27
+ ### Emits
28
+ | `user.deleted` | [schema](schemas/emits/user.deleted.json) | All user PII permanently deleted after grace period. Every package storing user |
29
+ | `user.deletion_initiated` | [schema](schemas/emits/user.deletion_initiated.json) | Account closure started. 30-day grace period begins; account is deactivated. |
30
+ | `user.export_ready` | [schema](schemas/emits/user.export_ready.json) | Data export archive is ready for download. |
31
+
32
+ ## License
33
+
34
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,53 @@
1
+ """Stapel GDPR — data export (Art. 15/20) and account deletion (Art. 17) for Django.
2
+
3
+ Public API (all attributes are lazily imported via PEP 562, so importing
4
+ ``stapel_gdpr`` itself never touches Django):
5
+
6
+ - ``gdpr_settings`` — the ``STAPEL_GDPR`` settings namespace (:mod:`stapel_gdpr.conf`).
7
+ - ``gdpr_orchestrator`` — the :class:`~stapel_gdpr.orchestrator.GDPROrchestrator`
8
+ singleton coordinating export and deletion across providers/services.
9
+ - ``is_reregistration`` — signup-time check: does an email/phone belong to a
10
+ previously deleted account? (:mod:`stapel_gdpr.reregistration`)
11
+ - ``store_hashes`` — persist salted identifier hashes before erasure
12
+ (:mod:`stapel_gdpr.reregistration`).
13
+ - ``LegalHold`` — Django model blocking closure/deletion while data must
14
+ be preserved (GDPR Art. 17(3)). Requires configured Django settings with
15
+ ``stapel_gdpr`` in ``INSTALLED_APPS``::
16
+
17
+ from stapel_gdpr import LegalHold
18
+ LegalHold.objects.create(user_id=user.pk, reason="litigation", created_by="legal")
19
+ """
20
+
21
+ __all__ = [
22
+ "LegalHold",
23
+ "gdpr_orchestrator",
24
+ "gdpr_settings",
25
+ "is_reregistration",
26
+ "store_hashes",
27
+ ]
28
+
29
+ # name -> (module, attribute); resolved on first access so that plain
30
+ # `import stapel_gdpr` stays free of Django (and Django-settings) imports.
31
+ _LAZY_EXPORTS = {
32
+ "gdpr_settings": ("stapel_gdpr.conf", "gdpr_settings"),
33
+ "gdpr_orchestrator": ("stapel_gdpr.orchestrator", "gdpr_orchestrator"),
34
+ "is_reregistration": ("stapel_gdpr.reregistration", "is_reregistration"),
35
+ "store_hashes": ("stapel_gdpr.reregistration", "store_hashes"),
36
+ "LegalHold": ("stapel_gdpr.models", "LegalHold"),
37
+ }
38
+
39
+
40
+ def __getattr__(name): # PEP 562
41
+ try:
42
+ module_path, attr = _LAZY_EXPORTS[name]
43
+ except KeyError:
44
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
45
+ from importlib import import_module
46
+
47
+ value = getattr(import_module(module_path), attr)
48
+ globals()[name] = value # cache so subsequent lookups skip __getattr__
49
+ return value
50
+
51
+
52
+ def __dir__():
53
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,38 @@
1
+ """Action subscriptions of the GDPR module.
2
+
3
+ Remote services confirm erasure of their data slice by emitting
4
+ ``gdpr.section.erased`` with the correlation_id they received in
5
+ ``user.deleted``. Handlers must be idempotent — delivery is at-least-once.
6
+ """
7
+ import logging
8
+
9
+ from stapel_core.comm import on_action
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SECTION_ERASED_SCHEMA = {
14
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
15
+ "title": "gdpr.section.erased",
16
+ "type": "object",
17
+ "required": ["user_id", "correlation_id", "service"],
18
+ "properties": {
19
+ "user_id": {"type": "string"},
20
+ "correlation_id": {"type": "string"},
21
+ "service": {"type": "string"},
22
+ },
23
+ "additionalProperties": False,
24
+ }
25
+
26
+
27
+ @on_action("gdpr.section.erased", schema=SECTION_ERASED_SCHEMA)
28
+ def handle_section_erased(event):
29
+ """Mark the remote deletion part done; finalize the closure when complete."""
30
+ correlation_id = event.payload.get("correlation_id")
31
+ service = event.payload.get("service") or event.service
32
+ if not correlation_id or not service:
33
+ logger.error("Malformed gdpr.section.erased event: %s", getattr(event, "event_id", "?"))
34
+ return
35
+
36
+ from .orchestrator import gdpr_orchestrator
37
+
38
+ gdpr_orchestrator.mark_section_erased(correlation_id, service)
@@ -0,0 +1,44 @@
1
+ from django.contrib import admin
2
+
3
+ from .models import (
4
+ AccountClosureRequest,
5
+ AccountDeletionPart,
6
+ DataExportRequest,
7
+ LegalHold,
8
+ ReRegistrationHash,
9
+ )
10
+
11
+
12
+ @admin.register(LegalHold)
13
+ class LegalHoldAdmin(admin.ModelAdmin):
14
+ list_display = ('user_id', 'reason', 'created_by', 'created_at', 'released_at')
15
+ list_filter = ('released_at',)
16
+ search_fields = ('user_id', 'reason', 'created_by')
17
+ readonly_fields = ('created_at',)
18
+
19
+
20
+ class AccountDeletionPartInline(admin.TabularInline):
21
+ model = AccountDeletionPart
22
+ extra = 0
23
+ readonly_fields = ('service', 'status', 'completed_at', 'error')
24
+
25
+
26
+ @admin.register(AccountClosureRequest)
27
+ class AccountClosureRequestAdmin(admin.ModelAdmin):
28
+ list_display = ('user_id', 'trigger', 'status', 'initiated_at', 'grace_ends_at', 'deleted_at')
29
+ list_filter = ('status', 'trigger')
30
+ search_fields = ('user_id', 'correlation_id')
31
+ inlines = [AccountDeletionPartInline]
32
+
33
+
34
+ @admin.register(DataExportRequest)
35
+ class DataExportRequestAdmin(admin.ModelAdmin):
36
+ list_display = ('user_id', 'status', 'created_at', 'deadline', 'download_expires_at')
37
+ list_filter = ('status',)
38
+ search_fields = ('user_id', 'correlation_id')
39
+
40
+
41
+ @admin.register(ReRegistrationHash)
42
+ class ReRegistrationHashAdmin(admin.ModelAdmin):
43
+ list_display = ('hash_type', 'user_id_was', 'created_at', 'expires_at')
44
+ list_filter = ('hash_type',)
@@ -0,0 +1,33 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class StapelGDPRConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'stapel_gdpr'
7
+ label = 'gdpr'
8
+ verbose_name = 'Stapel GDPR'
9
+
10
+ def ready(self):
11
+ from . import actions # noqa: F401 — register comm subscribers
12
+ self._register_gdpr_providers()
13
+
14
+ def _register_gdpr_providers(self):
15
+ """Load and register GDPR providers declared in settings.
16
+
17
+ Configure in Django settings which services this deployment collects data from:
18
+
19
+ GDPR_PROVIDERS = [
20
+ 'stapel_auth.gdpr.AuthGDPRProvider',
21
+ 'stapel_cdn.gdpr.CdnGDPRProvider',
22
+ ]
23
+
24
+ The providers are loaded dynamically — stapel_gdpr has no compile-time
25
+ dependency on any of the service packages.
26
+ """
27
+ from django.conf import settings
28
+ from django.utils.module_loading import import_string
29
+ from stapel_core.gdpr import gdpr_registry
30
+
31
+ for cls_path in getattr(settings, 'GDPR_PROVIDERS', []):
32
+ provider_cls = import_string(cls_path)
33
+ gdpr_registry.register(provider_cls())
@@ -0,0 +1,29 @@
1
+ """STAPEL_GDPR settings namespace.
2
+
3
+ Configure in Django settings:
4
+
5
+ STAPEL_GDPR = {
6
+ # Remote services (comm consumers) that must confirm erasure with a
7
+ # gdpr.section.erased action before a closure is marked DELETED.
8
+ "REMOTE_DELETION_SERVICES": ["profiles", "cdn"],
9
+ # Salt for re-registration hashes. Defaults to SECRET_KEY.
10
+ "REREG_SALT": "",
11
+ # Filesystem roots for export staging / final archives.
12
+ # Default: MEDIA_ROOT/gdpr/staging and MEDIA_ROOT/gdpr/exports.
13
+ "STAGING_ROOT": "",
14
+ "ARCHIVE_ROOT": "",
15
+ }
16
+ """
17
+ from stapel_core.conf import AppSettings
18
+
19
+ gdpr_settings = AppSettings(
20
+ "STAPEL_GDPR",
21
+ defaults={
22
+ "REMOTE_DELETION_SERVICES": [],
23
+ "REREG_SALT": "",
24
+ "STAGING_ROOT": "",
25
+ "ARCHIVE_ROOT": "",
26
+ },
27
+ )
28
+
29
+ __all__ = ["gdpr_settings"]
@@ -0,0 +1,132 @@
1
+ import tempfile
2
+ import uuid
3
+
4
+
5
+ def pytest_configure(config):
6
+ from django.conf import settings
7
+
8
+ if not settings.configured:
9
+ settings.configure(
10
+ SECRET_KEY="test-secret-key-not-for-production",
11
+ INSTALLED_APPS=[
12
+ "django.contrib.contenttypes",
13
+ "django.contrib.auth",
14
+ "django.contrib.sessions",
15
+ "django.contrib.messages",
16
+ "django.contrib.admin", # so stapel_gdpr.admin is importable/testable
17
+ "stapel_core.django.users",
18
+ "rest_framework",
19
+ "stapel_gdpr",
20
+ ],
21
+ AUTH_USER_MODEL="users.User",
22
+ DATABASES={
23
+ "default": {
24
+ "ENGINE": "django.db.backends.sqlite3",
25
+ "NAME": ":memory:",
26
+ }
27
+ },
28
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
29
+ USE_TZ=True,
30
+ APPEND_SLASH=False,
31
+ ROOT_URLCONF="tests.urls",
32
+ MEDIA_ROOT=tempfile.mkdtemp(prefix="gdpr-tests-"),
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 tables / relay needed
41
+ STAPEL_COMM={
42
+ "OUTBOX_ENABLED": False,
43
+ "ACTION_TRANSPORT": "inprocess",
44
+ },
45
+ MIDDLEWARE=[
46
+ "django.middleware.common.CommonMiddleware",
47
+ "stapel_core.django.jwt.middleware.ServiceAPIKeyMiddleware",
48
+ ],
49
+ SERVICE_API_KEY="test-service-key",
50
+ FRONTEND_URL="https://app.example.com",
51
+ # Skip migrations — create tables directly from models
52
+ MIGRATION_MODULES={
53
+ "users": None,
54
+ "gdpr": None,
55
+ },
56
+ )
57
+ import django
58
+
59
+ django.setup()
60
+
61
+ # Run celery tasks inline (.delay executes synchronously)
62
+ from celery import Celery
63
+
64
+ celery_app = Celery("stapel-gdpr-tests")
65
+ celery_app.conf.update(
66
+ task_always_eager=True,
67
+ task_eager_propagates=True,
68
+ broker_url="memory://",
69
+ result_backend="cache+memory://",
70
+ )
71
+ celery_app.set_default()
72
+
73
+
74
+ import pytest # noqa: E402
75
+
76
+
77
+ @pytest.fixture
78
+ def user(db):
79
+ from django.contrib.auth import get_user_model
80
+
81
+ User = get_user_model()
82
+ u = User.objects.create_user(
83
+ username=f"u-{uuid.uuid4().hex[:8]}",
84
+ email=f"{uuid.uuid4().hex[:8]}@example.com",
85
+ password="testpass-1234",
86
+ )
87
+ return u
88
+
89
+
90
+ @pytest.fixture
91
+ def api_client():
92
+ from rest_framework.test import APIClient
93
+
94
+ return APIClient()
95
+
96
+
97
+ @pytest.fixture
98
+ def authed_client(api_client, user):
99
+ api_client.force_authenticate(user=user)
100
+ return api_client
101
+
102
+
103
+ @pytest.fixture
104
+ def fake_provider():
105
+ """Register an in-process GDPR provider for the duration of a test."""
106
+ from stapel_core.gdpr import GDPRProvider, gdpr_registry
107
+
108
+ class FakeProvider(GDPRProvider):
109
+ section = "fake"
110
+
111
+ def __init__(self):
112
+ self.exported = []
113
+ self.deleted = []
114
+ self.anonymized = []
115
+ self.fail_delete = False
116
+
117
+ def export(self, user_id):
118
+ self.exported.append(str(user_id))
119
+ return {"user_id": str(user_id), "items": [1, 2, 3]}
120
+
121
+ def delete(self, user_id):
122
+ if self.fail_delete:
123
+ raise RuntimeError("boom")
124
+ self.deleted.append(str(user_id))
125
+
126
+ def anonymize(self, user_id):
127
+ self.anonymized.append(str(user_id))
128
+
129
+ provider = FakeProvider()
130
+ gdpr_registry.register(provider)
131
+ yield provider
132
+ gdpr_registry._providers.remove(provider)
@@ -0,0 +1,50 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class ExportRequestDTO:
7
+ """Response after initiating a data export request.
8
+
9
+ Attributes:
10
+ request_id: Unique export request ID. Example: 42
11
+ status: Current status. Example: pending
12
+ message: Human-readable status message. Example: Your archive will be ready within 48 hours.
13
+ """
14
+ request_id: int
15
+ status: str
16
+ message: str
17
+
18
+
19
+ @dataclass
20
+ class ExportStatusDTO:
21
+ """Status of a data export request.
22
+
23
+ Attributes:
24
+ request_id: Export request ID. Example: 42
25
+ status: One of pending, processing, ready, failed, expired. Example: ready
26
+ parts_done: Number of sections completed. Example: 4
27
+ parts_total: Total sections expected. Example: 5
28
+ download_available: Whether archive is ready to download. Example: true
29
+ expires_at: ISO datetime when download link expires, null if not ready. Example: 2026-07-01T12:00:00Z
30
+ """
31
+ request_id: int
32
+ status: str
33
+ parts_done: int
34
+ parts_total: int
35
+ download_available: bool
36
+ expires_at: Optional[str]
37
+
38
+
39
+ @dataclass
40
+ class ClosureStatusDTO:
41
+ """Status of an account closure request.
42
+
43
+ Attributes:
44
+ status: One of grace, deleting, deleted, cancelled. Example: grace
45
+ grace_ends_at: ISO datetime when grace period ends. Example: 2026-07-24T10:00:00Z
46
+ can_cancel: Whether the closure can still be cancelled. Example: true
47
+ """
48
+ status: str
49
+ grace_ends_at: str
50
+ can_cancel: bool
@@ -0,0 +1,20 @@
1
+ from stapel_core.django.api.errors import register_service_errors
2
+
3
+ ERR_409_EXPORT_COOLDOWN = 'error.409.gdpr.export_cooldown'
4
+ ERR_409_CLOSURE_PENDING = 'error.409.gdpr.closure_already_pending'
5
+ ERR_409_LEGAL_HOLD = 'error.409.gdpr.legal_hold'
6
+ ERR_404_NO_ACTIVE_CLOSURE = 'error.404.gdpr.no_active_closure'
7
+ ERR_404_EXPORT_NOT_FOUND = 'error.404.gdpr.export_not_found'
8
+ ERR_410_DOWNLOAD_EXPIRED = 'error.410.gdpr.download_expired'
9
+ ERR_425_EXPORT_NOT_READY = 'error.425.gdpr.export_not_ready'
10
+
11
+ _ERRORS = {
12
+ ERR_409_EXPORT_COOLDOWN: 'A data export was already requested in the last 30 days.',
13
+ ERR_409_CLOSURE_PENDING: 'Account closure is already in progress.',
14
+ ERR_409_LEGAL_HOLD: 'Account data is under a legal hold and cannot be deleted.',
15
+ ERR_404_NO_ACTIVE_CLOSURE: 'No pending account closure found.',
16
+ ERR_404_EXPORT_NOT_FOUND: 'Export request not found.',
17
+ ERR_410_DOWNLOAD_EXPIRED: 'Download link has expired.',
18
+ ERR_425_EXPORT_NOT_READY: 'Export is still being prepared.',
19
+ }
20
+ register_service_errors(_ERRORS)
File without changes
File without changes
@@ -0,0 +1,45 @@
1
+ """
2
+ Bus consumer for GDPR completion events published by individual services.
3
+
4
+ Run one instance per GDPR service deployment:
5
+
6
+ python manage.py consume_gdpr_completions
7
+ """
8
+ from stapel_core.bus.consumer import BaseBusConsumerCommand
9
+ from stapel_core.bus.event import Event
10
+ from stapel_core.gdpr import GDPR_DELETE_COMPLETED, GDPR_EXPORT_COMPLETED
11
+
12
+
13
+ class Command(BaseBusConsumerCommand):
14
+ help = 'Consume GDPR export/delete completion events from services'
15
+
16
+ topics = [GDPR_EXPORT_COMPLETED, GDPR_DELETE_COMPLETED]
17
+ consumer_group = 'gdpr-orchestrator'
18
+
19
+ def handle_event(self, event: Event) -> None:
20
+ if event.event_type == GDPR_EXPORT_COMPLETED:
21
+ self._on_export_completed(event)
22
+ elif event.event_type == GDPR_DELETE_COMPLETED:
23
+ self._on_delete_completed(event)
24
+
25
+ def _on_export_completed(self, event: Event) -> None:
26
+ from stapel_gdpr.orchestrator import gdpr_orchestrator
27
+ correlation_id = event.payload.get('correlation_id')
28
+ service = event.service
29
+ bucket_path = event.payload.get('bucket_path', '')
30
+
31
+ if not correlation_id or not service:
32
+ self.stderr.write(f'Malformed gdpr.export.completed event: {event.event_id}')
33
+ return
34
+
35
+ gdpr_orchestrator.mark_part_ready(correlation_id, service, bucket_path)
36
+
37
+ def _on_delete_completed(self, event: Event) -> None:
38
+ # Deletion is fire-and-forget — just log. Closure status is updated by sweep task
39
+ # once all services have confirmed (or deadline passes).
40
+ user_id = event.payload.get('user_id')
41
+ correlation_id = event.payload.get('correlation_id')
42
+ self.stdout.write(
43
+ f'GDPR delete completed: service={event.service} user={user_id} '
44
+ f'correlation={correlation_id}'
45
+ )
@@ -0,0 +1,106 @@
1
+ # Generated by Django 6.0.6 on 2026-07-02 10:26
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='AccountClosureRequest',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('user_id', models.UUIDField(db_index=True)),
20
+ ('trigger', models.CharField(choices=[('manual', 'Manual'), ('inactivity', 'Inactivity'), ('platform', 'Platform')], max_length=20)),
21
+ ('status', models.CharField(choices=[('grace', 'Grace Period'), ('deleting', 'Deleting'), ('deleted', 'Deleted'), ('cancelled', 'Cancelled')], default='grace', max_length=20)),
22
+ ('correlation_id', models.CharField(blank=True, db_index=True, max_length=36, null=True, unique=True)),
23
+ ('local_erasure_done', models.BooleanField(default=False)),
24
+ ('initiated_at', models.DateTimeField(auto_now_add=True)),
25
+ ('grace_ends_at', models.DateTimeField()),
26
+ ('deleted_at', models.DateTimeField(blank=True, null=True)),
27
+ ('cancelled_at', models.DateTimeField(blank=True, null=True)),
28
+ ],
29
+ ),
30
+ migrations.CreateModel(
31
+ name='DataExportRequest',
32
+ fields=[
33
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34
+ ('user_id', models.UUIDField(db_index=True)),
35
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('assembling', 'Assembling'), ('ready', 'Ready'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=20)),
36
+ ('correlation_id', models.CharField(blank=True, db_index=True, max_length=36, null=True, unique=True)),
37
+ ('expected_services', models.JSONField(default=list)),
38
+ ('archive_path', models.CharField(blank=True, max_length=500, null=True)),
39
+ ('download_token', models.CharField(blank=True, max_length=64, null=True, unique=True)),
40
+ ('created_at', models.DateTimeField(auto_now_add=True)),
41
+ ('deadline', models.DateTimeField()),
42
+ ('download_expires_at', models.DateTimeField(blank=True, null=True)),
43
+ ('error', models.TextField(blank=True, null=True)),
44
+ ],
45
+ options={
46
+ 'ordering': ['-created_at'],
47
+ },
48
+ ),
49
+ migrations.CreateModel(
50
+ name='LegalHold',
51
+ fields=[
52
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
53
+ ('user_id', models.UUIDField(db_index=True)),
54
+ ('reason', models.TextField()),
55
+ ('created_by', models.CharField(blank=True, default='', max_length=150)),
56
+ ('created_at', models.DateTimeField(auto_now_add=True)),
57
+ ('released_at', models.DateTimeField(blank=True, null=True)),
58
+ ],
59
+ options={
60
+ 'ordering': ['-created_at'],
61
+ },
62
+ ),
63
+ migrations.CreateModel(
64
+ name='ReRegistrationHash',
65
+ fields=[
66
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
67
+ ('hash_type', models.CharField(choices=[('email', 'Email'), ('phone', 'Phone')], max_length=10)),
68
+ ('hash_value', models.CharField(db_index=True, max_length=128)),
69
+ ('user_id_was', models.CharField(max_length=64)),
70
+ ('created_at', models.DateTimeField(auto_now_add=True)),
71
+ ('expires_at', models.DateTimeField()),
72
+ ],
73
+ options={
74
+ 'unique_together': {('hash_type', 'hash_value')},
75
+ },
76
+ ),
77
+ migrations.CreateModel(
78
+ name='AccountDeletionPart',
79
+ fields=[
80
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
81
+ ('service', models.CharField(max_length=50)),
82
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=20)),
83
+ ('completed_at', models.DateTimeField(blank=True, null=True)),
84
+ ('error', models.TextField(blank=True, null=True)),
85
+ ('closure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='gdpr.accountclosurerequest')),
86
+ ],
87
+ options={
88
+ 'unique_together': {('closure', 'service')},
89
+ },
90
+ ),
91
+ migrations.CreateModel(
92
+ name='DataExportPart',
93
+ fields=[
94
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
95
+ ('service', models.CharField(max_length=50)),
96
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=20)),
97
+ ('bucket_path', models.CharField(blank=True, max_length=500, null=True)),
98
+ ('completed_at', models.DateTimeField(blank=True, null=True)),
99
+ ('error', models.TextField(blank=True, null=True)),
100
+ ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='gdpr.dataexportrequest')),
101
+ ],
102
+ options={
103
+ 'unique_together': {('request', 'service')},
104
+ },
105
+ ),
106
+ ]