simpleaudit-studio 0.1.0__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 (139) hide show
  1. accounts/__init__.py +0 -0
  2. accounts/admin.py +29 -0
  3. accounts/apps.py +7 -0
  4. accounts/auth_urls.py +9 -0
  5. accounts/migrations/0001_initial.py +201 -0
  6. accounts/migrations/0002_user_workos_user_id.py +17 -0
  7. accounts/migrations/__init__.py +0 -0
  8. accounts/models.py +83 -0
  9. accounts/project_urls.py +17 -0
  10. accounts/serializers.py +95 -0
  11. accounts/services.py +154 -0
  12. accounts/views.py +266 -0
  13. accounts/workos_auth.py +157 -0
  14. audits/__init__.py +0 -0
  15. audits/admin.py +16 -0
  16. audits/apps.py +7 -0
  17. audits/comparison.py +186 -0
  18. audits/events.py +149 -0
  19. audits/migrations/0001_initial.py +181 -0
  20. audits/migrations/0002_add_audit_run_archived.py +17 -0
  21. audits/migrations/0003_auditrun_updated_at.py +17 -0
  22. audits/migrations/__init__.py +0 -0
  23. audits/models.py +77 -0
  24. audits/serializers.py +47 -0
  25. audits/services.py +176 -0
  26. audits/urls.py +34 -0
  27. audits/views.py +293 -0
  28. config/__init__.py +1 -0
  29. config/asgi.py +8 -0
  30. config/settings.py +333 -0
  31. config/urls.py +150 -0
  32. config/wsgi.py +8 -0
  33. deploy/compose/Dockerfile +57 -0
  34. deploy/e2e_smoke.py +233 -0
  35. deploy/mock_openai_server.py +164 -0
  36. deploy/postgres-init.sql +8 -0
  37. deploy/seed_bulk.py +407 -0
  38. infra/__init__.py +0 -0
  39. infra/admin.py +1 -0
  40. infra/apps.py +7 -0
  41. infra/context_processors.py +65 -0
  42. infra/engine.py +616 -0
  43. infra/exceptions.py +44 -0
  44. infra/fixtures/demo_audit_results.json +830 -0
  45. infra/hashing.py +45 -0
  46. infra/health.py +406 -0
  47. infra/health_api.py +29 -0
  48. infra/logging.py +21 -0
  49. infra/management/__init__.py +0 -0
  50. infra/management/commands/__init__.py +0 -0
  51. infra/management/commands/bootstrap_platform.py +36 -0
  52. infra/management/commands/purge_test_data.py +117 -0
  53. infra/management/commands/run_worker.py +37 -0
  54. infra/management/commands/seed_demo_audits.py +231 -0
  55. infra/management/commands/seed_platform.py +119 -0
  56. infra/middleware.py +88 -0
  57. infra/minimal_config.py +78 -0
  58. infra/permissions.py +31 -0
  59. infra/readiness.py +59 -0
  60. infra/seed.py +189 -0
  61. infra/simpleaudit_package.py +147 -0
  62. infra/startup_checks.py +54 -0
  63. infra/tests/__init__.py +1 -0
  64. infra/tests/factories.py +174 -0
  65. infra/tests/test_api_e2e.py +214 -0
  66. infra/tests/test_audit_archive.py +95 -0
  67. infra/tests/test_audit_clone.py +85 -0
  68. infra/tests/test_audit_events.py +71 -0
  69. infra/tests/test_audit_poll_results.py +129 -0
  70. infra/tests/test_audit_rename.py +89 -0
  71. infra/tests/test_audit_run_freeze.py +163 -0
  72. infra/tests/test_audit_sse.py +91 -0
  73. infra/tests/test_audit_submission.py +88 -0
  74. infra/tests/test_audit_timing.py +62 -0
  75. infra/tests/test_auth_projects.py +137 -0
  76. infra/tests/test_bootstrap.py +48 -0
  77. infra/tests/test_comparison.py +126 -0
  78. infra/tests/test_engine_integration.py +517 -0
  79. infra/tests/test_health.py +20 -0
  80. infra/tests/test_health_panel.py +182 -0
  81. infra/tests/test_middleware.py +50 -0
  82. infra/tests/test_minimal_config.py +110 -0
  83. infra/tests/test_model_registry.py +89 -0
  84. infra/tests/test_purge_test_data.py +134 -0
  85. infra/tests/test_scenario_import_export.py +108 -0
  86. infra/tests/test_scenarios.py +159 -0
  87. infra/tests/test_seed_platform.py +156 -0
  88. infra/tests/test_smoke_all_pages.py +98 -0
  89. infra/tests/test_workspaces.py +402 -0
  90. infra/tests/test_workspaces_ui.py +76 -0
  91. infra/ui.py +1409 -0
  92. infra/worker.py +671 -0
  93. model_registry/__init__.py +0 -0
  94. model_registry/admin.py +25 -0
  95. model_registry/apps.py +7 -0
  96. model_registry/migrations/0001_initial.py +90 -0
  97. model_registry/migrations/__init__.py +0 -0
  98. model_registry/models.py +98 -0
  99. model_registry/serializers.py +95 -0
  100. model_registry/services.py +39 -0
  101. model_registry/urls.py +13 -0
  102. model_registry/views.py +78 -0
  103. scenarios/__init__.py +0 -0
  104. scenarios/admin.py +27 -0
  105. scenarios/apps.py +7 -0
  106. scenarios/migrations/0001_initial.py +278 -0
  107. scenarios/migrations/__init__.py +0 -0
  108. scenarios/models.py +113 -0
  109. scenarios/serializers.py +78 -0
  110. scenarios/services.py +148 -0
  111. scenarios/urls.py +37 -0
  112. scenarios/views.py +214 -0
  113. simpleaudit_studio/__init__.py +3 -0
  114. simpleaudit_studio/cli.py +182 -0
  115. simpleaudit_studio-0.1.0.dist-info/METADATA +159 -0
  116. simpleaudit_studio-0.1.0.dist-info/RECORD +139 -0
  117. simpleaudit_studio-0.1.0.dist-info/WHEEL +4 -0
  118. simpleaudit_studio-0.1.0.dist-info/entry_points.txt +2 -0
  119. static/favicon-16x16.svg +3 -0
  120. static/favicon-32x32.svg +3 -0
  121. static/favicon.svg +4 -0
  122. static/js/diff.min.js +1 -0
  123. static/logo.png +0 -0
  124. static/logo.svg +3 -0
  125. templates/404.html +9 -0
  126. templates/audit_detail.html +373 -0
  127. templates/auth/login.html +78 -0
  128. templates/auth/register.html +44 -0
  129. templates/auth/workos_login.html +37 -0
  130. templates/auth/workos_verify.html +42 -0
  131. templates/base.html +238 -0
  132. templates/compare.html +148 -0
  133. templates/dashboard.html +145 -0
  134. templates/health.html +278 -0
  135. templates/models.html +273 -0
  136. templates/new_audit.html +205 -0
  137. templates/scenario_result_detail.html +144 -0
  138. templates/scenarios.html +553 -0
  139. templates/workspaces.html +279 -0
accounts/__init__.py ADDED
File without changes
accounts/admin.py ADDED
@@ -0,0 +1,29 @@
1
+ from django.contrib import admin
2
+ from django.contrib.auth.admin import UserAdmin
3
+
4
+ from .models import Project, ProjectMembership, User
5
+
6
+
7
+ @admin.register(User)
8
+ class SimpleAuditUserAdmin(UserAdmin):
9
+ pass
10
+
11
+
12
+ class ProjectMembershipInline(admin.TabularInline):
13
+ model = ProjectMembership
14
+ extra = 0
15
+
16
+
17
+ @admin.register(Project)
18
+ class ProjectAdmin(admin.ModelAdmin):
19
+ list_display = ("name", "slug", "created_at", "updated_at")
20
+ search_fields = ("name", "slug")
21
+ prepopulated_fields = {"slug": ("name",)}
22
+ inlines = [ProjectMembershipInline]
23
+
24
+
25
+ @admin.register(ProjectMembership)
26
+ class ProjectMembershipAdmin(admin.ModelAdmin):
27
+ list_display = ("user", "project", "role", "created_at")
28
+ list_filter = ("role", "project")
29
+ search_fields = ("user__username", "project__name")
accounts/apps.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AccountsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "accounts"
7
+ verbose_name = "Accounts & Projects"
accounts/auth_urls.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("register/", views.register, name="auth-register"),
7
+ path("token/", views.obtain_token, name="auth-token"),
8
+ path("me/", views.me, name="auth-me"),
9
+ ]
@@ -0,0 +1,201 @@
1
+ # Generated by Django 5.2.4 on 2026-09-23 12:05
2
+
3
+ import django.contrib.auth.models
4
+ import django.contrib.auth.validators
5
+ import django.db.models.deletion
6
+ import django.utils.timezone
7
+ from django.conf import settings
8
+ from django.db import migrations, models
9
+
10
+
11
+ class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ("auth", "0012_alter_user_first_name_max_length"),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name="Project",
21
+ fields=[
22
+ (
23
+ "id",
24
+ models.BigAutoField(
25
+ auto_created=True,
26
+ primary_key=True,
27
+ serialize=False,
28
+ verbose_name="ID",
29
+ ),
30
+ ),
31
+ ("name", models.CharField(max_length=200, unique=True)),
32
+ ("slug", models.SlugField(max_length=200, unique=True)),
33
+ ("description", models.TextField(blank=True)),
34
+ ("created_at", models.DateTimeField(auto_now_add=True)),
35
+ ("updated_at", models.DateTimeField(auto_now=True)),
36
+ ],
37
+ options={
38
+ "db_table": "core_project",
39
+ "ordering": ["name"],
40
+ },
41
+ ),
42
+ migrations.CreateModel(
43
+ name="User",
44
+ fields=[
45
+ (
46
+ "id",
47
+ models.BigAutoField(
48
+ auto_created=True,
49
+ primary_key=True,
50
+ serialize=False,
51
+ verbose_name="ID",
52
+ ),
53
+ ),
54
+ ("password", models.CharField(max_length=128, verbose_name="password")),
55
+ (
56
+ "last_login",
57
+ models.DateTimeField(
58
+ blank=True, null=True, verbose_name="last login"
59
+ ),
60
+ ),
61
+ (
62
+ "is_superuser",
63
+ models.BooleanField(
64
+ default=False,
65
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
66
+ verbose_name="superuser status",
67
+ ),
68
+ ),
69
+ (
70
+ "username",
71
+ models.CharField(
72
+ error_messages={
73
+ "unique": "A user with that username already exists."
74
+ },
75
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
76
+ max_length=150,
77
+ unique=True,
78
+ validators=[
79
+ django.contrib.auth.validators.UnicodeUsernameValidator()
80
+ ],
81
+ verbose_name="username",
82
+ ),
83
+ ),
84
+ (
85
+ "first_name",
86
+ models.CharField(
87
+ blank=True, max_length=150, verbose_name="first name"
88
+ ),
89
+ ),
90
+ (
91
+ "last_name",
92
+ models.CharField(
93
+ blank=True, max_length=150, verbose_name="last name"
94
+ ),
95
+ ),
96
+ (
97
+ "email",
98
+ models.EmailField(
99
+ blank=True, max_length=254, verbose_name="email address"
100
+ ),
101
+ ),
102
+ (
103
+ "is_staff",
104
+ models.BooleanField(
105
+ default=False,
106
+ help_text="Designates whether the user can log into this admin site.",
107
+ verbose_name="staff status",
108
+ ),
109
+ ),
110
+ (
111
+ "is_active",
112
+ models.BooleanField(
113
+ default=True,
114
+ help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
115
+ verbose_name="active",
116
+ ),
117
+ ),
118
+ (
119
+ "date_joined",
120
+ models.DateTimeField(
121
+ default=django.utils.timezone.now, verbose_name="date joined"
122
+ ),
123
+ ),
124
+ (
125
+ "groups",
126
+ models.ManyToManyField(
127
+ blank=True,
128
+ related_name="core_user_groups",
129
+ to="auth.group",
130
+ verbose_name="groups",
131
+ ),
132
+ ),
133
+ (
134
+ "user_permissions",
135
+ models.ManyToManyField(
136
+ blank=True,
137
+ related_name="core_user_permissions",
138
+ to="auth.permission",
139
+ verbose_name="user permissions",
140
+ ),
141
+ ),
142
+ ],
143
+ options={
144
+ "db_table": "core_user",
145
+ },
146
+ managers=[
147
+ ("objects", django.contrib.auth.models.UserManager()),
148
+ ],
149
+ ),
150
+ migrations.CreateModel(
151
+ name="ProjectMembership",
152
+ fields=[
153
+ (
154
+ "id",
155
+ models.BigAutoField(
156
+ auto_created=True,
157
+ primary_key=True,
158
+ serialize=False,
159
+ verbose_name="ID",
160
+ ),
161
+ ),
162
+ (
163
+ "role",
164
+ models.CharField(
165
+ choices=[
166
+ ("admin", "Admin"),
167
+ ("auditor", "Auditor"),
168
+ ("viewer", "Viewer"),
169
+ ],
170
+ max_length=20,
171
+ ),
172
+ ),
173
+ ("created_at", models.DateTimeField(auto_now_add=True)),
174
+ (
175
+ "project",
176
+ models.ForeignKey(
177
+ on_delete=django.db.models.deletion.CASCADE,
178
+ related_name="memberships",
179
+ to="accounts.project",
180
+ ),
181
+ ),
182
+ (
183
+ "user",
184
+ models.ForeignKey(
185
+ on_delete=django.db.models.deletion.CASCADE,
186
+ related_name="memberships",
187
+ to=settings.AUTH_USER_MODEL,
188
+ ),
189
+ ),
190
+ ],
191
+ options={
192
+ "db_table": "core_project_membership",
193
+ "ordering": ["project__name", "user__username"],
194
+ "constraints": [
195
+ models.UniqueConstraint(
196
+ fields=("project", "user"), name="unique_project_user"
197
+ )
198
+ ],
199
+ },
200
+ ),
201
+ ]
@@ -0,0 +1,17 @@
1
+ # Generated by Django 5.2.4 on 2026-09-24 13:27
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("accounts", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="user",
14
+ name="workos_user_id",
15
+ field=models.CharField(blank=True, max_length=64, null=True, unique=True),
16
+ ),
17
+ ]
File without changes
accounts/models.py ADDED
@@ -0,0 +1,83 @@
1
+ """Foundation models for users, projects, and project membership.
2
+
3
+ Phase 1 intentionally keeps the domain surface small. Scenario, model registry,
4
+ audit run, event, artifact, and comparison models are introduced in later phases
5
+ so migrations can be reviewed against the approved domain model.
6
+ """
7
+ from django.contrib.auth.models import AbstractUser
8
+ from django.db import models
9
+
10
+ # Django only auto-imports <app>/models.py. The domain models live in sibling
11
+ # modules, so they must be imported here to register with the app registry.
12
+ # Imported after the base classes below are defined to avoid circular imports.
13
+
14
+
15
+ class User(AbstractUser):
16
+ """Studio user with stable identity for audit attribution.
17
+
18
+ This is the AUTH_USER_MODEL. The inherited M2M fields are given explicit
19
+ related_names so they do not clash with auth.User's reverse accessors.
20
+ """
21
+
22
+ # Stable WorkOS AuthKit user id; null for locally-created accounts.
23
+ workos_user_id = models.CharField(max_length=64, unique=True, null=True, blank=True)
24
+
25
+ groups = models.ManyToManyField(
26
+ "auth.Group",
27
+ blank=True,
28
+ related_name="core_user_groups",
29
+ verbose_name="groups",
30
+ )
31
+ user_permissions = models.ManyToManyField(
32
+ "auth.Permission",
33
+ blank=True,
34
+ related_name="core_user_permissions",
35
+ verbose_name="user permissions",
36
+ )
37
+
38
+ class Meta:
39
+ db_table = "core_user"
40
+
41
+
42
+ class Project(models.Model):
43
+ """Tenant-like scope for scenarios, models, audits, and comparisons."""
44
+
45
+ name = models.CharField(max_length=200, unique=True)
46
+ slug = models.SlugField(max_length=200, unique=True)
47
+ description = models.TextField(blank=True)
48
+ created_at = models.DateTimeField(auto_now_add=True)
49
+ updated_at = models.DateTimeField(auto_now=True)
50
+
51
+ class Meta:
52
+ db_table = "core_project"
53
+ ordering = ["name"]
54
+
55
+ def __str__(self) -> str:
56
+ return self.name
57
+
58
+
59
+ class ProjectMembership(models.Model):
60
+ """Role-based access to a project."""
61
+
62
+ class Role(models.TextChoices):
63
+ ADMIN = "admin", "Admin"
64
+ AUDITOR = "auditor", "Auditor"
65
+ VIEWER = "viewer", "Viewer"
66
+
67
+ project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="memberships")
68
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="memberships")
69
+ role = models.CharField(max_length=20, choices=Role.choices)
70
+ created_at = models.DateTimeField(auto_now_add=True)
71
+
72
+ class Meta:
73
+ db_table = "core_project_membership"
74
+ constraints = [
75
+ models.UniqueConstraint(fields=["project", "user"], name="unique_project_user"),
76
+ ]
77
+ ordering = ["project__name", "user__username"]
78
+
79
+ def __str__(self) -> str:
80
+ return f"{self.user.username} -> {self.project.slug}:{self.role}"
81
+
82
+
83
+
@@ -0,0 +1,17 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("", views.list_workspaces, name="workspace-list"),
7
+ path("create/", views.create_workspace_view, name="workspace-create"),
8
+ path("switch/", views.switch_workspace, name="workspace-switch"),
9
+ path("<int:project_id>/", views.workspace_detail, name="workspace-detail"),
10
+ path("<int:project_id>/members/", views.list_members, name="workspace-members"),
11
+ path("<int:project_id>/members/add/", views.add_member, name="workspace-member-add"),
12
+ path(
13
+ "<int:project_id>/members/<int:user_id>/",
14
+ views.update_or_remove_member,
15
+ name="workspace-member-update",
16
+ ),
17
+ ]
@@ -0,0 +1,95 @@
1
+ from django.contrib.auth.password_validation import validate_password
2
+ from rest_framework import serializers
3
+
4
+ from accounts.models import Project, ProjectMembership, User
5
+
6
+
7
+ class UserSerializer(serializers.ModelSerializer):
8
+ class Meta:
9
+ model = User
10
+ fields = ("id", "username", "email", "first_name", "last_name", "is_active")
11
+ read_only_fields = ("id",)
12
+
13
+
14
+ class RegisterSerializer(serializers.Serializer):
15
+ username = serializers.CharField(max_length=150)
16
+ email = serializers.EmailField()
17
+ password = serializers.CharField(write_only=True, style={"input_type": "password"})
18
+ first_name = serializers.CharField(required=False, allow_blank=True, default="")
19
+ last_name = serializers.CharField(required=False, allow_blank=True, default="")
20
+
21
+ def validate_username(self, value):
22
+ if User.objects.filter(username__iexact=value).exists():
23
+ raise serializers.ValidationError("Username already exists.")
24
+ return value
25
+
26
+ def validate_email(self, value):
27
+ if User.objects.filter(email__iexact=value).exists():
28
+ raise serializers.ValidationError("Email already exists.")
29
+ return value
30
+
31
+ def validate_password(self, value):
32
+ validate_password(value)
33
+ return value
34
+
35
+ def create(self, validated_data):
36
+ return User.objects.create_user(**validated_data)
37
+
38
+
39
+ class ProjectSerializer(serializers.ModelSerializer):
40
+ class Meta:
41
+ model = Project
42
+ fields = ("id", "name", "slug", "description", "created_at", "updated_at")
43
+ read_only_fields = ("id", "created_at", "updated_at")
44
+
45
+
46
+ class WorkspaceItemSerializer(serializers.ModelSerializer):
47
+ """Workspace as shown in lists/detail, including whether the requesting
48
+ user can administer it (drives UI affordances)."""
49
+
50
+ is_admin = serializers.SerializerMethodField()
51
+
52
+ class Meta:
53
+ model = Project
54
+ fields = ("id", "name", "slug", "description", "is_admin", "created_at", "updated_at")
55
+ read_only_fields = fields
56
+
57
+ def get_is_admin(self, obj) -> bool:
58
+ request = self.context.get("request")
59
+ user = getattr(request, "user", None)
60
+ if user is None or not user.is_authenticated:
61
+ return False
62
+ if user.is_superuser:
63
+ return True
64
+ return ProjectMembership.objects.filter(
65
+ project=obj, user=user, role=ProjectMembership.Role.ADMIN
66
+ ).exists()
67
+
68
+
69
+ class WorkspaceCreateSerializer(serializers.Serializer):
70
+ name = serializers.CharField(max_length=200)
71
+ description = serializers.CharField(required=False, allow_blank=True, default="")
72
+
73
+
74
+ class WorkspaceUpdateSerializer(serializers.Serializer):
75
+ name = serializers.CharField(max_length=200, required=False)
76
+ description = serializers.CharField(required=False, allow_blank=True)
77
+
78
+
79
+ class MemberAddSerializer(serializers.Serializer):
80
+ username = serializers.CharField(max_length=150)
81
+ role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.VIEWER)
82
+
83
+
84
+ class MemberRoleSerializer(serializers.Serializer):
85
+ role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
86
+
87
+
88
+ class ProjectMembershipSerializer(serializers.ModelSerializer):
89
+ username = serializers.CharField(source="user.username", read_only=True)
90
+ email = serializers.EmailField(source="user.email", read_only=True)
91
+
92
+ class Meta:
93
+ model = ProjectMembership
94
+ fields = ("id", "project", "user", "username", "email", "role", "created_at")
95
+ read_only_fields = ("id", "created_at")
accounts/services.py ADDED
@@ -0,0 +1,154 @@
1
+ """Business logic for foundation operations.
2
+
3
+ Views should call these services rather than containing authorization or
4
+ bootstrap rules directly.
5
+ """
6
+ from django.contrib.auth import get_user_model
7
+ from django.db import IntegrityError, transaction
8
+ from django.utils.text import slugify
9
+
10
+ from accounts.models import Project, ProjectMembership
11
+ from infra.exceptions import StableAPIError
12
+
13
+ User = get_user_model()
14
+
15
+
16
+ @transaction.atomic
17
+ def bootstrap_admin_and_default_project(
18
+ *,
19
+ username: str,
20
+ email: str,
21
+ password: str,
22
+ project_name: str = "Default",
23
+ ) -> tuple[User, Project]:
24
+ """Idempotently create the initial admin user and default project."""
25
+ user, created = User.objects.get_or_create(
26
+ username=username,
27
+ defaults={"email": email, "is_staff": True, "is_superuser": True},
28
+ )
29
+ # Always ensure the admin password matches the configured value. This makes
30
+ # the bootstrap idempotent across container restarts where the Postgres data
31
+ # volume persists (e.g. HF Spaces with non-ephemeral storage). Without this,
32
+ # a pre-existing admin user created with a different password would never be
33
+ # corrected, breaking login on subsequent starts.
34
+ user.set_password(password)
35
+ user.save(update_fields=["password"])
36
+ if not user.is_active:
37
+ user.is_active = True
38
+ user.save(update_fields=["is_active"])
39
+ if not user.is_staff:
40
+ user.is_staff = True
41
+ user.save(update_fields=["is_staff"])
42
+ if not user.is_superuser:
43
+ user.is_superuser = True
44
+ user.save(update_fields=["is_superuser"])
45
+
46
+ slug = slugify(project_name) or "default"
47
+ project, _ = Project.objects.get_or_create(slug=slug, defaults={"name": project_name})
48
+ ProjectMembership.objects.get_or_create(
49
+ project=project,
50
+ user=user,
51
+ defaults={"role": ProjectMembership.Role.ADMIN},
52
+ )
53
+ return user, project
54
+
55
+
56
+ #: Slug of the shared workspace visible to every authenticated user.
57
+ DEFAULT_PROJECT_SLUG = "default"
58
+
59
+
60
+ def ensure_project_access(user, project) -> bool:
61
+ if not user or not user.is_authenticated:
62
+ return False
63
+ if user.is_superuser:
64
+ return True
65
+ # The Default workspace is always accessible to all users.
66
+ if project.slug == DEFAULT_PROJECT_SLUG:
67
+ return True
68
+ return ProjectMembership.objects.filter(project=project, user=user).exists()
69
+
70
+
71
+ def _require_workspace_admin(user, project) -> None:
72
+ """Raise 403 unless the user is a superuser or ADMIN member of THIS workspace."""
73
+ if not user or not user.is_authenticated:
74
+ raise StableAPIError(detail="Authentication required.", code="authentication_required", http_status=401)
75
+ if user.is_superuser:
76
+ return
77
+ if not ProjectMembership.objects.filter(
78
+ project=project, user=user, role=ProjectMembership.Role.ADMIN
79
+ ).exists():
80
+ raise StableAPIError(detail="Workspace admin role required.", code="workspace_admin_required", http_status=403)
81
+
82
+
83
+ @transaction.atomic
84
+ def create_workspace(*, user, name: str, description: str = "") -> Project:
85
+ """Create a workspace; the creator becomes its ADMIN member.
86
+
87
+ Any authenticated user may create a workspace — this is how new users
88
+ bootstrap their own space without needing an existing admin to invite them.
89
+ """
90
+ if not user or not user.is_authenticated:
91
+ raise StableAPIError(detail="Authentication required.", code="authentication_required", http_status=401)
92
+
93
+ clean_name = (name or "").strip()
94
+ if not clean_name:
95
+ raise StableAPIError(detail="Workspace name is required.", code="invalid_workspace_name")
96
+
97
+ base_slug = slugify(clean_name) or "workspace"
98
+ for attempt in range(5):
99
+ candidate = base_slug if attempt == 0 else f"{base_slug}-{attempt + 1}"
100
+ try:
101
+ with transaction.atomic():
102
+ project = Project.objects.create(name=clean_name, slug=candidate, description=(description or "").strip())
103
+ ProjectMembership.objects.create(project=project, user=user, role=ProjectMembership.Role.ADMIN)
104
+ return project
105
+ except IntegrityError:
106
+ if attempt == 4:
107
+ raise StableAPIError(detail="A workspace with this name already exists.", code="workspace_name_conflict", http_status=409)
108
+ continue
109
+ raise StableAPIError(detail="A workspace with this name already exists.", code="workspace_name_conflict", http_status=409)
110
+
111
+
112
+ @transaction.atomic
113
+ def update_workspace(*, user, project: Project, name: str | None = None, description: str | None = None) -> Project:
114
+ """Rename or re-describe a workspace. The slug is immutable — it is part
115
+ of frozen audit provenance and must never change after creation."""
116
+ _require_workspace_admin(user, project)
117
+ if name is not None:
118
+ clean_name = name.strip()
119
+ if not clean_name:
120
+ raise StableAPIError(detail="Workspace name cannot be empty.", code="invalid_workspace_name")
121
+ project.name = clean_name
122
+ if description is not None:
123
+ project.description = description.strip()
124
+ try:
125
+ project.save()
126
+ except IntegrityError as exc:
127
+ raise StableAPIError(detail="A workspace with this name already exists.", code="workspace_name_conflict", http_status=409) from exc
128
+ return project
129
+
130
+
131
+ def delete_workspace(*, user, project: Project) -> None:
132
+ """Delete an EMPTY workspace. Refuses while any audit content exists so a
133
+ mistaken click can never destroy experiment history."""
134
+ _require_workspace_admin(user, project)
135
+
136
+ from audits.models import AuditRun
137
+ from model_registry.models import ModelConnection, ModelEndpoint
138
+ from scenarios.models import Scenario, ScenarioSet
139
+
140
+ blockers = {
141
+ "scenarios": Scenario.objects.filter(project=project).exists(),
142
+ "scenario sets": ScenarioSet.objects.filter(project=project).exists(),
143
+ "model endpoints": ModelEndpoint.objects.filter(project=project).exists(),
144
+ "model connections": ModelConnection.objects.filter(project=project).exists(),
145
+ "audit runs": AuditRun.objects.filter(project=project).exists(),
146
+ }
147
+ present = [label for label, found in blockers.items() if found]
148
+ if present:
149
+ raise StableAPIError(
150
+ detail=f"This workspace still contains: {', '.join(present)}. Remove them first.",
151
+ code="workspace_not_empty",
152
+ http_status=409,
153
+ )
154
+ project.delete()