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.
- accounts/__init__.py +0 -0
- accounts/admin.py +29 -0
- accounts/apps.py +7 -0
- accounts/auth_urls.py +9 -0
- accounts/migrations/0001_initial.py +201 -0
- accounts/migrations/0002_user_workos_user_id.py +17 -0
- accounts/migrations/__init__.py +0 -0
- accounts/models.py +83 -0
- accounts/project_urls.py +17 -0
- accounts/serializers.py +95 -0
- accounts/services.py +154 -0
- accounts/views.py +266 -0
- accounts/workos_auth.py +157 -0
- audits/__init__.py +0 -0
- audits/admin.py +16 -0
- audits/apps.py +7 -0
- audits/comparison.py +186 -0
- audits/events.py +149 -0
- audits/migrations/0001_initial.py +181 -0
- audits/migrations/0002_add_audit_run_archived.py +17 -0
- audits/migrations/0003_auditrun_updated_at.py +17 -0
- audits/migrations/__init__.py +0 -0
- audits/models.py +77 -0
- audits/serializers.py +47 -0
- audits/services.py +176 -0
- audits/urls.py +34 -0
- audits/views.py +293 -0
- config/__init__.py +1 -0
- config/asgi.py +8 -0
- config/settings.py +333 -0
- config/urls.py +150 -0
- config/wsgi.py +8 -0
- deploy/compose/Dockerfile +57 -0
- deploy/e2e_smoke.py +233 -0
- deploy/mock_openai_server.py +164 -0
- deploy/postgres-init.sql +8 -0
- deploy/seed_bulk.py +407 -0
- infra/__init__.py +0 -0
- infra/admin.py +1 -0
- infra/apps.py +7 -0
- infra/context_processors.py +65 -0
- infra/engine.py +616 -0
- infra/exceptions.py +44 -0
- infra/fixtures/demo_audit_results.json +830 -0
- infra/hashing.py +45 -0
- infra/health.py +406 -0
- infra/health_api.py +29 -0
- infra/logging.py +21 -0
- infra/management/__init__.py +0 -0
- infra/management/commands/__init__.py +0 -0
- infra/management/commands/bootstrap_platform.py +36 -0
- infra/management/commands/purge_test_data.py +117 -0
- infra/management/commands/run_worker.py +37 -0
- infra/management/commands/seed_demo_audits.py +231 -0
- infra/management/commands/seed_platform.py +119 -0
- infra/middleware.py +88 -0
- infra/minimal_config.py +78 -0
- infra/permissions.py +31 -0
- infra/readiness.py +59 -0
- infra/seed.py +189 -0
- infra/simpleaudit_package.py +147 -0
- infra/startup_checks.py +54 -0
- infra/tests/__init__.py +1 -0
- infra/tests/factories.py +174 -0
- infra/tests/test_api_e2e.py +214 -0
- infra/tests/test_audit_archive.py +95 -0
- infra/tests/test_audit_clone.py +85 -0
- infra/tests/test_audit_events.py +71 -0
- infra/tests/test_audit_poll_results.py +129 -0
- infra/tests/test_audit_rename.py +89 -0
- infra/tests/test_audit_run_freeze.py +163 -0
- infra/tests/test_audit_sse.py +91 -0
- infra/tests/test_audit_submission.py +88 -0
- infra/tests/test_audit_timing.py +62 -0
- infra/tests/test_auth_projects.py +137 -0
- infra/tests/test_bootstrap.py +48 -0
- infra/tests/test_comparison.py +126 -0
- infra/tests/test_engine_integration.py +517 -0
- infra/tests/test_health.py +20 -0
- infra/tests/test_health_panel.py +182 -0
- infra/tests/test_middleware.py +50 -0
- infra/tests/test_minimal_config.py +110 -0
- infra/tests/test_model_registry.py +89 -0
- infra/tests/test_purge_test_data.py +134 -0
- infra/tests/test_scenario_import_export.py +108 -0
- infra/tests/test_scenarios.py +159 -0
- infra/tests/test_seed_platform.py +156 -0
- infra/tests/test_smoke_all_pages.py +98 -0
- infra/tests/test_workspaces.py +402 -0
- infra/tests/test_workspaces_ui.py +76 -0
- infra/ui.py +1409 -0
- infra/worker.py +671 -0
- model_registry/__init__.py +0 -0
- model_registry/admin.py +25 -0
- model_registry/apps.py +7 -0
- model_registry/migrations/0001_initial.py +90 -0
- model_registry/migrations/__init__.py +0 -0
- model_registry/models.py +98 -0
- model_registry/serializers.py +95 -0
- model_registry/services.py +39 -0
- model_registry/urls.py +13 -0
- model_registry/views.py +78 -0
- scenarios/__init__.py +0 -0
- scenarios/admin.py +27 -0
- scenarios/apps.py +7 -0
- scenarios/migrations/0001_initial.py +278 -0
- scenarios/migrations/__init__.py +0 -0
- scenarios/models.py +113 -0
- scenarios/serializers.py +78 -0
- scenarios/services.py +148 -0
- scenarios/urls.py +37 -0
- scenarios/views.py +214 -0
- simpleaudit_studio/__init__.py +3 -0
- simpleaudit_studio/cli.py +182 -0
- simpleaudit_studio-0.1.0.dist-info/METADATA +159 -0
- simpleaudit_studio-0.1.0.dist-info/RECORD +139 -0
- simpleaudit_studio-0.1.0.dist-info/WHEEL +4 -0
- simpleaudit_studio-0.1.0.dist-info/entry_points.txt +2 -0
- static/favicon-16x16.svg +3 -0
- static/favicon-32x32.svg +3 -0
- static/favicon.svg +4 -0
- static/js/diff.min.js +1 -0
- static/logo.png +0 -0
- static/logo.svg +3 -0
- templates/404.html +9 -0
- templates/audit_detail.html +373 -0
- templates/auth/login.html +78 -0
- templates/auth/register.html +44 -0
- templates/auth/workos_login.html +37 -0
- templates/auth/workos_verify.html +42 -0
- templates/base.html +238 -0
- templates/compare.html +148 -0
- templates/dashboard.html +145 -0
- templates/health.html +278 -0
- templates/models.html +273 -0
- templates/new_audit.html +205 -0
- templates/scenario_result_detail.html +144 -0
- templates/scenarios.html +553 -0
- 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
accounts/auth_urls.py
ADDED
|
@@ -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
|
+
|
accounts/project_urls.py
ADDED
|
@@ -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
|
+
]
|
accounts/serializers.py
ADDED
|
@@ -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()
|