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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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,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)
|