secondshift 0.1.0b12__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 (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,264 @@
1
+ from django.conf import settings
2
+ from django.http import HttpRequest
3
+ from django.shortcuts import get_object_or_404
4
+ from ninja import Router, Status
5
+ from ninja.errors import HttpError
6
+ from ninja.security import django_auth
7
+
8
+ from secondshift.workspaces.access import (
9
+ Action,
10
+ current_workspace,
11
+ require,
12
+ select_workspace,
13
+ user_memberships,
14
+ )
15
+ from secondshift.workspaces.dao import (
16
+ find_membership,
17
+ own_tokens,
18
+ pending_invites,
19
+ workspace_members,
20
+ )
21
+ from secondshift.workspaces.invites import (
22
+ InviteError,
23
+ create_invite,
24
+ forget_invite,
25
+ invite_path,
26
+ join_pending_invite,
27
+ pending_invite,
28
+ revoke_invite,
29
+ )
30
+ from secondshift.workspaces.limits import LimitError, save_overrides
31
+ from secondshift.workspaces.models import (
32
+ WorkspaceMembership,
33
+ )
34
+ from secondshift.workspaces.schemas import (
35
+ ApiTokenCreateSchema,
36
+ ApiTokenListSchema,
37
+ InviteCreateSchema,
38
+ InviteSchema,
39
+ MemberSchema,
40
+ NewApiTokenSchema,
41
+ NewInviteSchema,
42
+ PendingInviteSchema,
43
+ RoleChangeSchema,
44
+ RunLimitsUpdateSchema,
45
+ WorkspaceChoiceSchema,
46
+ WorkspaceCreateSchema,
47
+ WorkspaceSelectSchema,
48
+ WorkspaceSettingsSchema,
49
+ )
50
+ from secondshift.workspaces.serialization import (
51
+ limit_error_detail,
52
+ serialize_api_token,
53
+ serialize_choice,
54
+ serialize_invite,
55
+ serialize_member,
56
+ serialize_settings,
57
+ serialize_token_workspace,
58
+ )
59
+ from secondshift.workspaces.services import (
60
+ MembershipError,
61
+ WorkspaceCreationError,
62
+ WorkspaceCreationRefused,
63
+ change_role,
64
+ create_workspace_for,
65
+ members_in_order,
66
+ remove_member,
67
+ )
68
+ from secondshift.workspaces.tokens import TokenError, create_api_token, revoke_api_token
69
+
70
+ router = Router(auth=django_auth, tags=["workspaces"])
71
+
72
+
73
+ @router.get("/settings", response=WorkspaceSettingsSchema, auth=django_auth)
74
+ def workspace_settings(request: HttpRequest) -> WorkspaceSettingsSchema:
75
+ return serialize_settings(request, current_workspace(request))
76
+
77
+
78
+ @router.post("/settings/limits", response=WorkspaceSettingsSchema, auth=django_auth)
79
+ def set_workspace_limits(
80
+ request: HttpRequest, payload: RunLimitsUpdateSchema
81
+ ) -> WorkspaceSettingsSchema:
82
+ """Change the workspace's run limits. Runs already created keep theirs."""
83
+ workspace = require(request, Action.CHANGE_RUN_LIMITS)
84
+ try:
85
+ save_overrides(workspace, payload.model_dump(exclude_unset=True))
86
+ except LimitError as error:
87
+ raise HttpError(422, limit_error_detail(request, error)) from error
88
+ return serialize_settings(request, workspace)
89
+
90
+
91
+ @router.get("/workspaces", response=list[WorkspaceChoiceSchema])
92
+ def workspaces(request: HttpRequest) -> list[WorkspaceChoiceSchema]:
93
+ """Every workspace the person belongs to, the first one joined first."""
94
+ current = current_workspace(request)
95
+ current_id = current.pk if current else None
96
+ return [serialize_choice(item, current_id) for item in user_memberships(request)]
97
+
98
+
99
+ @router.post("/workspaces", response=WorkspaceChoiceSchema)
100
+ def new_workspace(
101
+ request: HttpRequest, payload: WorkspaceCreateSchema
102
+ ) -> WorkspaceChoiceSchema:
103
+ """Create a workspace owned by the person and switch to it."""
104
+ try:
105
+ workspace = create_workspace_for(request.user, payload.name)
106
+ except WorkspaceCreationRefused as error:
107
+ raise HttpError(403, str(error)) from error
108
+ except WorkspaceCreationError as error:
109
+ raise HttpError(400, str(error)) from error
110
+ membership = select_workspace(request, workspace.pk)
111
+ assert membership is not None
112
+ return serialize_choice(membership, workspace.pk)
113
+
114
+
115
+ @router.post("/workspaces/current", response=WorkspaceChoiceSchema)
116
+ def choose_workspace(
117
+ request: HttpRequest, payload: WorkspaceSelectSchema
118
+ ) -> WorkspaceChoiceSchema:
119
+ """Work in another workspace for the rest of this session."""
120
+ membership = select_workspace(request, payload.workspace_id)
121
+ if membership is None:
122
+ raise HttpError(404, "Not found")
123
+ return serialize_choice(membership, membership.workspace_id)
124
+
125
+
126
+ @router.get("/invites/pending", response=PendingInviteSchema | None)
127
+ def show_pending_invite(request: HttpRequest) -> PendingInviteSchema | None:
128
+ """The invite this browser opened, for the person to confirm or dismiss."""
129
+ invite = pending_invite(request)
130
+ if invite is None:
131
+ return None
132
+ if find_membership(invite.workspace_id, request.user.pk):
133
+ # Nothing to confirm: they already belong.
134
+ forget_invite(request)
135
+ return None
136
+ inviter = invite.created_by
137
+ return PendingInviteSchema(
138
+ workspace_name=invite.workspace.name,
139
+ role=invite.role,
140
+ invited_by=(inviter.email or inviter.get_username()) if inviter else None,
141
+ )
142
+
143
+
144
+ @router.post("/invites/pending/accept", response=WorkspaceChoiceSchema)
145
+ def accept_pending_invite(request: HttpRequest) -> WorkspaceChoiceSchema:
146
+ """Join the invite's workspace and switch to it."""
147
+ try:
148
+ membership = join_pending_invite(request)
149
+ except InviteError as error:
150
+ raise HttpError(409, str(error)) from error
151
+ select_workspace(request, membership.workspace_id)
152
+ return serialize_choice(membership, membership.workspace_id)
153
+
154
+
155
+ @router.post("/invites/pending/dismiss", response={204: None})
156
+ def dismiss_pending_invite(request: HttpRequest) -> Status[None]:
157
+ forget_invite(request)
158
+ return Status(204, None)
159
+
160
+
161
+ @router.get("/invites", response=list[InviteSchema])
162
+ def invites(request: HttpRequest) -> list[InviteSchema]:
163
+ """Invites in the current workspace that can still be accepted."""
164
+ workspace = require(request, Action.INVITE_PEOPLE)
165
+ queryset = pending_invites(workspace)
166
+ return [serialize_invite(invite) for invite in queryset]
167
+
168
+
169
+ @router.post("/invites", response=NewInviteSchema)
170
+ def invite_person(request: HttpRequest, payload: InviteCreateSchema) -> NewInviteSchema:
171
+ workspace = require(request, Action.INVITE_PEOPLE)
172
+ new = create_invite(workspace, payload.role, request.user, payload.note.strip())
173
+ return NewInviteSchema(
174
+ **serialize_invite(new.invite).model_dump(),
175
+ link=request.build_absolute_uri(invite_path(new.token)),
176
+ )
177
+
178
+
179
+ @router.delete("/invites/{invite_id}", response={204: None})
180
+ def cancel_invite(request: HttpRequest, invite_id: int) -> Status[None]:
181
+ workspace = require(request, Action.INVITE_PEOPLE)
182
+ revoke_invite(get_object_or_404(pending_invites(workspace), pk=invite_id))
183
+ return Status(204, None)
184
+
185
+
186
+ def membership_for_request(
187
+ request: HttpRequest, membership_id: int
188
+ ) -> WorkspaceMembership:
189
+ workspace = require(request, Action.MANAGE_MEMBERS)
190
+ return get_object_or_404(workspace_members(workspace), pk=membership_id)
191
+
192
+
193
+ @router.get("/members", response=list[MemberSchema])
194
+ def members(request: HttpRequest) -> list[MemberSchema]:
195
+ """Everyone in the current workspace. Any member can see who belongs."""
196
+ workspace = current_workspace(request)
197
+ if workspace is None:
198
+ return []
199
+ return [serialize_member(request, item) for item in members_in_order(workspace)]
200
+
201
+
202
+ @router.post("/members/{membership_id}/role", response=MemberSchema)
203
+ def set_member_role(
204
+ request: HttpRequest, membership_id: int, payload: RoleChangeSchema
205
+ ) -> MemberSchema:
206
+ membership = membership_for_request(request, membership_id)
207
+ try:
208
+ change_role(membership, payload.role)
209
+ except MembershipError as error:
210
+ raise HttpError(409, str(error)) from error
211
+ return serialize_member(request, membership)
212
+
213
+
214
+ @router.delete("/members/{membership_id}", response={204: None})
215
+ def remove_person(request: HttpRequest, membership_id: int) -> Status[None]:
216
+ membership = membership_for_request(request, membership_id)
217
+ try:
218
+ remove_member(membership)
219
+ except MembershipError as error:
220
+ raise HttpError(409, str(error)) from error
221
+ return Status(204, None)
222
+
223
+
224
+ # API tokens belong to the person and are managed from their account. Each
225
+ # works in one workspace, chosen when it is created. These endpoints accept
226
+ # only a signed-in browser session, so a token cannot create or extend another.
227
+
228
+
229
+ @router.get("/account/api-tokens", response=ApiTokenListSchema)
230
+ def api_tokens(request: HttpRequest) -> ApiTokenListSchema:
231
+ current = current_workspace(request)
232
+ current_id = current.pk if current else None
233
+ return ApiTokenListSchema(
234
+ items=[serialize_api_token(token) for token in own_tokens(request.user)],
235
+ workspaces=[
236
+ serialize_token_workspace(membership, current_id)
237
+ for membership in user_memberships(request)
238
+ ],
239
+ max_days=settings.API_TOKEN_MAX_DAYS,
240
+ )
241
+
242
+
243
+ @router.post("/account/api-tokens", response=NewApiTokenSchema)
244
+ def new_api_token(
245
+ request: HttpRequest, payload: ApiTokenCreateSchema
246
+ ) -> NewApiTokenSchema:
247
+ membership = get_object_or_404(
248
+ user_memberships(request), workspace_id=payload.workspace_id
249
+ )
250
+ try:
251
+ new = create_api_token(
252
+ membership, payload.name.strip(), payload.scope, payload.expires_in_days
253
+ )
254
+ except TokenError as error:
255
+ raise HttpError(422, str(error)) from error
256
+ return NewApiTokenSchema(
257
+ **serialize_api_token(new.token).model_dump(), token=new.secret
258
+ )
259
+
260
+
261
+ @router.delete("/account/api-tokens/{token_id}", response={204: None})
262
+ def revoke_token(request: HttpRequest, token_id: int) -> Status[None]:
263
+ revoke_api_token(get_object_or_404(own_tokens(request.user), pk=token_id))
264
+ return Status(204, None)
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class WorkspacesConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "secondshift.workspaces"
@@ -0,0 +1,11 @@
1
+ from django.http import HttpRequest
2
+
3
+ from secondshift.workspaces.invites import pending_invite
4
+ from secondshift.workspaces.models import WorkspaceInvite
5
+
6
+
7
+ def invite(request: HttpRequest) -> dict[str, WorkspaceInvite | None]:
8
+ """The invite this browser opened, so sign-in pages can name it."""
9
+ if not hasattr(request, "session"):
10
+ return {"pending_invite": None}
11
+ return {"pending_invite": pending_invite(request)}
@@ -0,0 +1,281 @@
1
+ """Queries and writes on people, workspaces, memberships, invites and API tokens."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from django.contrib.auth import get_user_model
7
+ from django.contrib.auth.models import AbstractBaseUser
8
+ from django.db import transaction
9
+ from django.db.models import F, Q, QuerySet
10
+ from django.utils import timezone
11
+
12
+ from secondshift.workspaces.models import (
13
+ ApiToken,
14
+ Workspace,
15
+ WorkspaceInvite,
16
+ WorkspaceMembership,
17
+ )
18
+
19
+ Role = WorkspaceMembership.Role
20
+
21
+
22
+ # People
23
+
24
+
25
+ def active_user(user_id: object) -> AbstractBaseUser | None:
26
+ """The active account with this id, if there is one."""
27
+ return get_user_model().objects.filter(pk=user_id, is_active=True).first()
28
+
29
+
30
+ def get_or_create_user(
31
+ username: str, defaults: dict[str, Any]
32
+ ) -> tuple[AbstractBaseUser, bool]:
33
+ return get_user_model().objects.get_or_create(username=username, defaults=defaults)
34
+
35
+
36
+ # Workspaces
37
+
38
+
39
+ @transaction.atomic
40
+ def create_workspace(name: str, slug: str, owner: AbstractBaseUser) -> Workspace:
41
+ """A new workspace, with this person as its owner."""
42
+ workspace = Workspace.objects.create(name=name, slug=slug)
43
+ WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=Role.OWNER)
44
+ return workspace
45
+
46
+
47
+ def get_or_create_workspace(slug: str, name: str) -> Workspace:
48
+ workspace, _ = Workspace.objects.get_or_create(slug=slug, defaults={"name": name})
49
+ return workspace
50
+
51
+
52
+ def get_workspace_by_slug(slug: str) -> Workspace:
53
+ """The workspace. Raises Workspace.DoesNotExist when there is none."""
54
+ return Workspace.objects.get(slug=slug)
55
+
56
+
57
+ def find_workspace_by_slug(slug: str) -> Workspace | None:
58
+ return Workspace.objects.filter(slug=slug).first()
59
+
60
+
61
+ def slug_taken(slug: str) -> bool:
62
+ return Workspace.objects.filter(slug=slug).exists()
63
+
64
+
65
+ # Memberships
66
+
67
+
68
+ def workspace_has_owner(workspace_id: int) -> bool:
69
+ return WorkspaceMembership.objects.filter(
70
+ workspace_id=workspace_id, role=Role.OWNER
71
+ ).exists()
72
+
73
+
74
+ def workspace_slug_has_owner(slug: str) -> bool:
75
+ return WorkspaceMembership.objects.filter(
76
+ workspace__slug=slug, role=Role.OWNER
77
+ ).exists()
78
+
79
+
80
+ def workspace_owner(workspace: Workspace) -> AbstractBaseUser:
81
+ """The workspace's first owner. Raises when it has none."""
82
+ return (
83
+ WorkspaceMembership.objects.select_related("user")
84
+ .filter(workspace=workspace, role=Role.OWNER)
85
+ .earliest("created_at", "pk")
86
+ .user
87
+ )
88
+
89
+
90
+ def is_owner(workspace_id: int, user_id: int) -> bool:
91
+ return WorkspaceMembership.objects.filter(
92
+ workspace_id=workspace_id, user_id=user_id, role=Role.OWNER
93
+ ).exists()
94
+
95
+
96
+ def find_membership(workspace_id: int, user_id: int) -> WorkspaceMembership | None:
97
+ return WorkspaceMembership.objects.filter(
98
+ workspace_id=workspace_id, user_id=user_id
99
+ ).first()
100
+
101
+
102
+ def active_member_role(workspace_id: int, user_id: int | None) -> str | None:
103
+ """The person's role in the workspace while their account is active."""
104
+ return (
105
+ WorkspaceMembership.objects.filter(
106
+ workspace_id=workspace_id, user_id=user_id, user__is_active=True
107
+ )
108
+ .values_list("role", flat=True)
109
+ .first()
110
+ )
111
+
112
+
113
+ def membership_in(user_id: int, workspace_id: int) -> WorkspaceMembership | None:
114
+ return memberships_for_user(user_id).filter(workspace_id=workspace_id).first()
115
+
116
+
117
+ def first_membership(user_id: int) -> WorkspaceMembership | None:
118
+ return memberships_for_user(user_id).first()
119
+
120
+
121
+ def memberships_for_user(user_id: int) -> QuerySet[WorkspaceMembership]:
122
+ """The person's memberships with their workspaces, the first one joined first."""
123
+ return (
124
+ WorkspaceMembership.objects.filter(user_id=user_id)
125
+ .select_related("workspace")
126
+ .order_by("created_at", "pk")
127
+ )
128
+
129
+
130
+ def workspace_members(workspace: Workspace) -> QuerySet[WorkspaceMembership]:
131
+ """The workspace's memberships with their people, in the order they joined."""
132
+ return (
133
+ WorkspaceMembership.objects.filter(workspace=workspace)
134
+ .select_related("user")
135
+ .order_by("created_at", "pk")
136
+ )
137
+
138
+
139
+ def create_membership(
140
+ workspace_id: int, user: AbstractBaseUser, role: str
141
+ ) -> WorkspaceMembership:
142
+ return WorkspaceMembership.objects.create(
143
+ workspace_id=workspace_id, user=user, role=role
144
+ )
145
+
146
+
147
+ def ensure_membership(
148
+ workspace: Workspace, user: AbstractBaseUser, role: str
149
+ ) -> WorkspaceMembership:
150
+ """The person's membership, created with this role when missing."""
151
+ membership, _ = WorkspaceMembership.objects.get_or_create(
152
+ workspace=workspace, user=user, defaults={"role": role}
153
+ )
154
+ return membership
155
+
156
+
157
+ def owned_workspace_count(user: AbstractBaseUser) -> int:
158
+ return WorkspaceMembership.objects.filter(user=user, role=Role.OWNER).count()
159
+
160
+
161
+ def lock_other_owners_exist(membership: WorkspaceMembership) -> bool:
162
+ """Lock the workspace's other owners and say whether there are any."""
163
+ return (
164
+ WorkspaceMembership.objects.select_for_update()
165
+ .filter(workspace_id=membership.workspace_id, role=Role.OWNER)
166
+ .exclude(pk=membership.pk)
167
+ .exists()
168
+ )
169
+
170
+
171
+ # Invites
172
+
173
+
174
+ def usable_invites() -> QuerySet[WorkspaceInvite]:
175
+ """Invites that were not accepted, revoked or left to expire."""
176
+ return WorkspaceInvite.objects.filter(
177
+ accepted_at__isnull=True,
178
+ revoked_at__isnull=True,
179
+ expires_at__gt=timezone.now(),
180
+ )
181
+
182
+
183
+ def pending_invites(workspace: Workspace) -> QuerySet[WorkspaceInvite]:
184
+ """Invites that can still be accepted, with who sent them."""
185
+ return usable_invites().filter(workspace=workspace).select_related("created_by")
186
+
187
+
188
+ def find_usable_invite(token_hash: str) -> WorkspaceInvite | None:
189
+ """The usable invite with this hash, with its workspace and inviter."""
190
+ return (
191
+ usable_invites()
192
+ .select_related("workspace", "created_by")
193
+ .filter(token_hash=token_hash)
194
+ .first()
195
+ )
196
+
197
+
198
+ def lock_usable_invite(token_hash: str) -> WorkspaceInvite | None:
199
+ """Lock the usable invite with this hash for this transaction."""
200
+ return usable_invites().select_for_update().filter(token_hash=token_hash).first()
201
+
202
+
203
+ def create_invite_record(
204
+ workspace: Workspace,
205
+ role: str,
206
+ note: str,
207
+ token_hash: str,
208
+ created_by: AbstractBaseUser | None,
209
+ expires_at: datetime,
210
+ ) -> WorkspaceInvite:
211
+ return WorkspaceInvite.objects.create(
212
+ workspace=workspace,
213
+ role=role,
214
+ note=note,
215
+ token_hash=token_hash,
216
+ created_by=created_by,
217
+ expires_at=expires_at,
218
+ )
219
+
220
+
221
+ # API tokens
222
+
223
+
224
+ def usable_tokens() -> QuerySet[ApiToken]:
225
+ """Tokens that were not revoked and have not expired."""
226
+ return ApiToken.objects.filter(
227
+ revoked_at__isnull=True, expires_at__gt=timezone.now()
228
+ )
229
+
230
+
231
+ def has_own_token(user: AbstractBaseUser, workspace: Workspace) -> bool:
232
+ return own_tokens(user).filter(workspace=workspace).exists()
233
+
234
+
235
+ def own_tokens(user: AbstractBaseUser) -> QuerySet[ApiToken]:
236
+ """The person's usable tokens, in workspaces they still belong to."""
237
+ return (
238
+ usable_tokens()
239
+ .filter(
240
+ created_by_id=user.pk,
241
+ created_by__workspace_memberships__workspace_id=F("workspace_id"),
242
+ )
243
+ .select_related("workspace")
244
+ )
245
+
246
+
247
+ def api_token_by_hash(token_hash: str) -> ApiToken | None:
248
+ """The usable token with this hash, while its creator is an active member."""
249
+ return (
250
+ usable_tokens()
251
+ .filter(
252
+ token_hash=token_hash,
253
+ created_by__is_active=True,
254
+ created_by__workspace_memberships__workspace_id=F("workspace_id"),
255
+ )
256
+ .select_related("created_by", "workspace")
257
+ .first()
258
+ )
259
+
260
+
261
+ def create_token_record(
262
+ membership: WorkspaceMembership,
263
+ name: str,
264
+ scope: str,
265
+ token_hash: str,
266
+ expires_at: datetime,
267
+ ) -> ApiToken:
268
+ return ApiToken.objects.create(
269
+ workspace_id=membership.workspace_id,
270
+ created_by_id=membership.user_id,
271
+ name=name,
272
+ scope=scope,
273
+ token_hash=token_hash,
274
+ expires_at=expires_at,
275
+ )
276
+
277
+
278
+ def touch_token(token: ApiToken, now: datetime, stale_before: datetime) -> None:
279
+ """Set the token's last use, unless it was already set after `stale_before`."""
280
+ stale = Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
281
+ ApiToken.objects.filter(stale, pk=token.pk).update(last_used_at=now)