@kernel-sig/console 0.1.0
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.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""请求上下文:注入 request_id,绑定日志上下文,回写耗时头。"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import Response
|
|
10
|
+
|
|
11
|
+
REQUEST_ID_HEADER = "X-Request-ID"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RequestContextMiddleware(BaseHTTPMiddleware):
|
|
15
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
16
|
+
request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
|
|
17
|
+
request.state.request_id = request_id
|
|
18
|
+
|
|
19
|
+
structlog.contextvars.clear_contextvars()
|
|
20
|
+
structlog.contextvars.bind_contextvars(
|
|
21
|
+
request_id=request_id,
|
|
22
|
+
method=request.method,
|
|
23
|
+
path=request.url.path,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
started = time.perf_counter()
|
|
27
|
+
try:
|
|
28
|
+
response = await call_next(request)
|
|
29
|
+
finally:
|
|
30
|
+
structlog.contextvars.unbind_contextvars("method", "path")
|
|
31
|
+
|
|
32
|
+
duration_ms = (time.perf_counter() - started) * 1000
|
|
33
|
+
response.headers[REQUEST_ID_HEADER] = request_id
|
|
34
|
+
response.headers["X-Response-Time-MS"] = f"{duration_ms:.1f}"
|
|
35
|
+
|
|
36
|
+
structlog.get_logger(__name__).info(
|
|
37
|
+
"http_request",
|
|
38
|
+
status_code=response.status_code,
|
|
39
|
+
duration_ms=round(duration_ms, 1),
|
|
40
|
+
)
|
|
41
|
+
structlog.contextvars.clear_contextvars()
|
|
42
|
+
return response
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""ORM 模型集中导出。
|
|
2
|
+
|
|
3
|
+
Alembic 的 autogenerate 依赖此模块导入全部模型,否则新表不会被检测到。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from app.models.ai import (
|
|
7
|
+
AIAnalysis,
|
|
8
|
+
AIProviderKind,
|
|
9
|
+
AITask,
|
|
10
|
+
AnalysisStatus,
|
|
11
|
+
LLMProvider,
|
|
12
|
+
PromptTemplate,
|
|
13
|
+
TaskRouting,
|
|
14
|
+
)
|
|
15
|
+
from app.models.attention import AttentionItem, AttentionRule, AttentionSeverity
|
|
16
|
+
from app.models.attention_settings import AttentionRuleSetting
|
|
17
|
+
from app.models.audit import AuditLog, AuditResult
|
|
18
|
+
from app.models.base import Base, TimestampMixin
|
|
19
|
+
from app.models.classification import (
|
|
20
|
+
Classification,
|
|
21
|
+
ClassificationSource,
|
|
22
|
+
LabelSyncState,
|
|
23
|
+
PRKind,
|
|
24
|
+
SubjectType,
|
|
25
|
+
)
|
|
26
|
+
from app.models.credential import Credential, CredentialKind
|
|
27
|
+
from app.models.issue import Issue, IssueLinkSource, IssueType, PRIssueLink
|
|
28
|
+
from app.models.meeting import (
|
|
29
|
+
Meeting,
|
|
30
|
+
MeetingAgenda,
|
|
31
|
+
MeetingStatus,
|
|
32
|
+
ReleaseReport,
|
|
33
|
+
ReleaseReportPatch,
|
|
34
|
+
)
|
|
35
|
+
from app.models.pull_request import (
|
|
36
|
+
PRComment,
|
|
37
|
+
PRCommit,
|
|
38
|
+
PRFile,
|
|
39
|
+
PRState,
|
|
40
|
+
PullRequest,
|
|
41
|
+
ReviewEvent,
|
|
42
|
+
ReviewEventSource,
|
|
43
|
+
ReviewEventType,
|
|
44
|
+
)
|
|
45
|
+
from app.models.repository import (
|
|
46
|
+
DEFAULT_API_BASE_URL,
|
|
47
|
+
Repository,
|
|
48
|
+
SyncCursor,
|
|
49
|
+
SyncResource,
|
|
50
|
+
SyncRun,
|
|
51
|
+
SyncStatus,
|
|
52
|
+
SyncTrigger,
|
|
53
|
+
WebhookDelivery,
|
|
54
|
+
)
|
|
55
|
+
from app.models.sig import MemberRole, MemberSource, ReleaseBranch, SIGMember, SubsystemOwner
|
|
56
|
+
from app.models.user import User
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"DEFAULT_API_BASE_URL",
|
|
60
|
+
"AIAnalysis",
|
|
61
|
+
"AIProviderKind",
|
|
62
|
+
"AITask",
|
|
63
|
+
"AnalysisStatus",
|
|
64
|
+
"AttentionItem",
|
|
65
|
+
"AttentionRule",
|
|
66
|
+
"AttentionRuleSetting",
|
|
67
|
+
"AttentionSeverity",
|
|
68
|
+
"AuditLog",
|
|
69
|
+
"AuditResult",
|
|
70
|
+
"Base",
|
|
71
|
+
"Classification",
|
|
72
|
+
"ClassificationSource",
|
|
73
|
+
"Credential",
|
|
74
|
+
"CredentialKind",
|
|
75
|
+
"Issue",
|
|
76
|
+
"IssueLinkSource",
|
|
77
|
+
"IssueType",
|
|
78
|
+
"LLMProvider",
|
|
79
|
+
"LabelSyncState",
|
|
80
|
+
"Meeting",
|
|
81
|
+
"MeetingAgenda",
|
|
82
|
+
"MeetingStatus",
|
|
83
|
+
"MemberRole",
|
|
84
|
+
"MemberSource",
|
|
85
|
+
"PRComment",
|
|
86
|
+
"PRCommit",
|
|
87
|
+
"PRFile",
|
|
88
|
+
"PRIssueLink",
|
|
89
|
+
"PRKind",
|
|
90
|
+
"PRState",
|
|
91
|
+
"PromptTemplate",
|
|
92
|
+
"PullRequest",
|
|
93
|
+
"ReleaseBranch",
|
|
94
|
+
"ReleaseReport",
|
|
95
|
+
"ReleaseReportPatch",
|
|
96
|
+
"Repository",
|
|
97
|
+
"ReviewEvent",
|
|
98
|
+
"ReviewEventSource",
|
|
99
|
+
"ReviewEventType",
|
|
100
|
+
"SIGMember",
|
|
101
|
+
"SubjectType",
|
|
102
|
+
"SubsystemOwner",
|
|
103
|
+
"SyncCursor",
|
|
104
|
+
"SyncResource",
|
|
105
|
+
"SyncRun",
|
|
106
|
+
"SyncStatus",
|
|
107
|
+
"SyncTrigger",
|
|
108
|
+
"TaskRouting",
|
|
109
|
+
"TimestampMixin",
|
|
110
|
+
"User",
|
|
111
|
+
"WebhookDelivery",
|
|
112
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""AI 相关模型:模型配置、任务路由、Prompt 模板、分析结果。"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import (
|
|
8
|
+
Boolean,
|
|
9
|
+
DateTime,
|
|
10
|
+
Enum,
|
|
11
|
+
Float,
|
|
12
|
+
ForeignKey,
|
|
13
|
+
Integer,
|
|
14
|
+
String,
|
|
15
|
+
Text,
|
|
16
|
+
UniqueConstraint,
|
|
17
|
+
func,
|
|
18
|
+
)
|
|
19
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
20
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
21
|
+
|
|
22
|
+
from app.models.base import Base, TimestampMixin
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AIProviderKind(StrEnum):
|
|
26
|
+
"""目前统一走 OpenAI 兼容协议,保留字段以便后续接入其他协议。"""
|
|
27
|
+
|
|
28
|
+
OPENAI_COMPATIBLE = "openai_compatible"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AITask(StrEnum):
|
|
32
|
+
"""分析任务类型。取值同时用于任务路由配置的键。"""
|
|
33
|
+
|
|
34
|
+
CLASSIFY = "classify"
|
|
35
|
+
SUMMARIZE = "summarize"
|
|
36
|
+
RISK_REVIEW = "risk_review"
|
|
37
|
+
BACKPORT_VERIFY = "backport_verify"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AnalysisStatus(StrEnum):
|
|
41
|
+
PENDING = "pending"
|
|
42
|
+
RUNNING = "running"
|
|
43
|
+
SUCCEEDED = "succeeded"
|
|
44
|
+
FAILED = "failed"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LLMProvider(Base, TimestampMixin):
|
|
48
|
+
"""一个可用的模型端点。
|
|
49
|
+
|
|
50
|
+
按任务配置不同档位是刻意的:分类这类轻任务用便宜模型,
|
|
51
|
+
代码风险审查才用强模型。全部走大模型会让成本失控。
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__tablename__ = "ksp_llm_provider"
|
|
55
|
+
|
|
56
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
57
|
+
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
|
58
|
+
kind: Mapped[AIProviderKind] = mapped_column(
|
|
59
|
+
Enum(
|
|
60
|
+
AIProviderKind,
|
|
61
|
+
name="ai_provider_kind",
|
|
62
|
+
values_callable=lambda e: [m.value for m in e],
|
|
63
|
+
),
|
|
64
|
+
nullable=False,
|
|
65
|
+
default=AIProviderKind.OPENAI_COMPATIBLE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
69
|
+
# 指向凭据表,明文只在调用时解密
|
|
70
|
+
credential_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
71
|
+
UUID(as_uuid=True), ForeignKey("ksp_credential.id", ondelete="SET NULL"), nullable=True
|
|
72
|
+
)
|
|
73
|
+
model: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
74
|
+
|
|
75
|
+
temperature: Mapped[float] = mapped_column(Float, nullable=False, default=0.1)
|
|
76
|
+
max_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=4096)
|
|
77
|
+
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=180)
|
|
78
|
+
|
|
79
|
+
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
80
|
+
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
81
|
+
|
|
82
|
+
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
83
|
+
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return f"<LLMProvider {self.name} model={self.model}>"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaskRouting(Base, TimestampMixin):
|
|
90
|
+
"""任务 → 模型的路由。
|
|
91
|
+
|
|
92
|
+
没有为某任务配置时回落到 is_default 的 provider,
|
|
93
|
+
保证"刚部署还没配路由"时系统仍能工作。
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
__tablename__ = "ksp_task_routing"
|
|
97
|
+
|
|
98
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
99
|
+
task: Mapped[AITask] = mapped_column(
|
|
100
|
+
Enum(AITask, name="ai_task", values_callable=lambda e: [m.value for m in e]),
|
|
101
|
+
nullable=False,
|
|
102
|
+
unique=True,
|
|
103
|
+
)
|
|
104
|
+
provider_id: Mapped[uuid.UUID] = mapped_column(
|
|
105
|
+
UUID(as_uuid=True),
|
|
106
|
+
ForeignKey("ksp_llm_provider.id", ondelete="CASCADE"),
|
|
107
|
+
nullable=False,
|
|
108
|
+
)
|
|
109
|
+
fallback_provider_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
110
|
+
UUID(as_uuid=True), ForeignKey("ksp_llm_provider.id", ondelete="SET NULL"), nullable=True
|
|
111
|
+
)
|
|
112
|
+
system_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
113
|
+
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AIAnalysis(Base):
|
|
117
|
+
"""一次分析的结果。
|
|
118
|
+
|
|
119
|
+
input_hash 用于幂等:内容没变就不重复调用模型 ——
|
|
120
|
+
AI 调用是这套系统里唯一按次计费的部分。
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
__tablename__ = "ksp_ai_analysis"
|
|
124
|
+
__table_args__ = (
|
|
125
|
+
UniqueConstraint(
|
|
126
|
+
"subject_type", "subject_id", "task", name="uq_ksp_ai_analysis_subject_task"
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
131
|
+
repository_id: Mapped[uuid.UUID] = mapped_column(
|
|
132
|
+
UUID(as_uuid=True),
|
|
133
|
+
ForeignKey("ksp_repository.id", ondelete="CASCADE"),
|
|
134
|
+
nullable=False,
|
|
135
|
+
index=True,
|
|
136
|
+
)
|
|
137
|
+
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
138
|
+
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
|
139
|
+
|
|
140
|
+
task: Mapped[AITask] = mapped_column(
|
|
141
|
+
Enum(AITask, name="ai_task", values_callable=lambda e: [m.value for m in e]),
|
|
142
|
+
nullable=False,
|
|
143
|
+
index=True,
|
|
144
|
+
)
|
|
145
|
+
status: Mapped[AnalysisStatus] = mapped_column(
|
|
146
|
+
Enum(
|
|
147
|
+
AnalysisStatus,
|
|
148
|
+
name="ai_analysis_status",
|
|
149
|
+
values_callable=lambda e: [m.value for m in e],
|
|
150
|
+
),
|
|
151
|
+
nullable=False,
|
|
152
|
+
default=AnalysisStatus.PENDING,
|
|
153
|
+
index=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
provider_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
157
|
+
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
158
|
+
|
|
159
|
+
# 输入指纹:内容未变则跳过重复分析
|
|
160
|
+
input_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
161
|
+
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
162
|
+
completion_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
163
|
+
|
|
164
|
+
result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
165
|
+
# 原始响应保留供审计与调试,排查"模型到底说了什么"时是唯一依据
|
|
166
|
+
raw_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
167
|
+
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
168
|
+
|
|
169
|
+
triggered_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
170
|
+
UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
|
|
171
|
+
)
|
|
172
|
+
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
173
|
+
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
174
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
175
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def duration_seconds(self) -> float | None:
|
|
180
|
+
if self.started_at is None or self.finished_at is None:
|
|
181
|
+
return None
|
|
182
|
+
return (self.finished_at - self.started_at).total_seconds()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def total_tokens(self) -> int:
|
|
186
|
+
return self.prompt_tokens + self.completion_tokens
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PromptTemplate(Base, TimestampMixin):
|
|
190
|
+
"""领域 Prompt 模板。
|
|
191
|
+
|
|
192
|
+
版本化以支持"同一分析用不同 Prompt 重跑对比"。
|
|
193
|
+
内置模板从代码加载,此处保存的是人工调整后的覆盖版本。
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
__tablename__ = "ksp_prompt_template"
|
|
197
|
+
__table_args__ = (UniqueConstraint("task", "version", name="uq_ksp_prompt_task_version"),)
|
|
198
|
+
|
|
199
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
200
|
+
task: Mapped[AITask] = mapped_column(
|
|
201
|
+
Enum(AITask, name="ai_task", values_callable=lambda e: [m.value for m in e]),
|
|
202
|
+
nullable=False,
|
|
203
|
+
index=True,
|
|
204
|
+
)
|
|
205
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
206
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
207
|
+
|
|
208
|
+
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
|
209
|
+
user_template: Mapped[str] = mapped_column(Text, nullable=False)
|
|
210
|
+
|
|
211
|
+
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
212
|
+
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
213
|
+
UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
|
|
214
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""关注项。
|
|
2
|
+
|
|
3
|
+
跟踪子系统的输出:把散落在各个 PR/Issue 上的问题收敛成一份可认领的待办。
|
|
4
|
+
维护者不需要逐条翻 PR 才能发现"这个已经两周没人看了"。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import (
|
|
12
|
+
BigInteger,
|
|
13
|
+
DateTime,
|
|
14
|
+
Enum,
|
|
15
|
+
ForeignKey,
|
|
16
|
+
Index,
|
|
17
|
+
String,
|
|
18
|
+
Text,
|
|
19
|
+
UniqueConstraint,
|
|
20
|
+
func,
|
|
21
|
+
)
|
|
22
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
23
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
24
|
+
|
|
25
|
+
from app.models.base import Base
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AttentionSeverity(StrEnum):
|
|
29
|
+
BLOCKER = "blocker" # 必须处理才能推进
|
|
30
|
+
CRITICAL = "critical"
|
|
31
|
+
WARNING = "warning"
|
|
32
|
+
INFO = "info"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AttentionRule(StrEnum):
|
|
36
|
+
"""内置规则。
|
|
37
|
+
|
|
38
|
+
注意:规则的权威定义在领域层(``app.domain.attention.Rule``),
|
|
39
|
+
此处保持一致 —— 同一概念两处各写一份必然漂移,
|
|
40
|
+
表现为"规则算出的值写不进数据库"。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
STALE = "stale"
|
|
44
|
+
REVIEW_SLA_BREACH = "review_sla_breach"
|
|
45
|
+
CI_FAILED = "ci_failed"
|
|
46
|
+
MERGE_CONFLICT = "merge_conflict"
|
|
47
|
+
NEEDS_ISSUE = "needs_issue"
|
|
48
|
+
CLA_DENIED = "cla_denied"
|
|
49
|
+
CLA_PENDING_LONG = "cla_pending_long"
|
|
50
|
+
REJECTED = "rejected"
|
|
51
|
+
BINARY_FILE = "binary_file"
|
|
52
|
+
MISSING_SIGNED_OFF = "missing_signed_off"
|
|
53
|
+
CVE_AGING = "cve_aging"
|
|
54
|
+
ISSUE_UNASSIGNED = "issue_unassigned"
|
|
55
|
+
ISSUE_STALE = "issue_stale"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AttentionItem(Base):
|
|
59
|
+
"""一条待处理事项。
|
|
60
|
+
|
|
61
|
+
生命周期:每次 compute_attention 重新求值 ——
|
|
62
|
+
未解决的保留 first_detected_at 并刷新 detail;
|
|
63
|
+
已解决的置 resolved_at;已认领的不再重复提示。
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
__tablename__ = "ksp_attention_item"
|
|
67
|
+
__table_args__ = (
|
|
68
|
+
UniqueConstraint(
|
|
69
|
+
"subject_type", "subject_id", "rule", name="uq_ksp_attention_subject_rule"
|
|
70
|
+
),
|
|
71
|
+
Index("ix_ksp_attention_open", "resolved_at", "severity"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
75
|
+
repository_id: Mapped[uuid.UUID] = mapped_column(
|
|
76
|
+
UUID(as_uuid=True),
|
|
77
|
+
ForeignKey("ksp_repository.id", ondelete="CASCADE"),
|
|
78
|
+
nullable=False,
|
|
79
|
+
index=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
83
|
+
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
|
84
|
+
# 冗余一份编号与标题,让关注队列无需联表即可渲染
|
|
85
|
+
subject_number: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
86
|
+
subject_title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
87
|
+
subject_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
88
|
+
|
|
89
|
+
rule: Mapped[AttentionRule] = mapped_column(
|
|
90
|
+
Enum(
|
|
91
|
+
AttentionRule,
|
|
92
|
+
name="attention_rule",
|
|
93
|
+
values_callable=lambda e: [m.value for m in e],
|
|
94
|
+
),
|
|
95
|
+
nullable=False,
|
|
96
|
+
index=True,
|
|
97
|
+
)
|
|
98
|
+
severity: Mapped[AttentionSeverity] = mapped_column(
|
|
99
|
+
Enum(
|
|
100
|
+
AttentionSeverity,
|
|
101
|
+
name="attention_severity",
|
|
102
|
+
values_callable=lambda e: [m.value for m in e],
|
|
103
|
+
),
|
|
104
|
+
nullable=False,
|
|
105
|
+
index=True,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
109
|
+
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
110
|
+
# 规则求值时的原始数据,便于解释"为什么判定为超期"
|
|
111
|
+
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
112
|
+
|
|
113
|
+
first_detected_at: Mapped[datetime] = mapped_column(
|
|
114
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
115
|
+
)
|
|
116
|
+
last_evaluated_at: Mapped[datetime] = mapped_column(
|
|
117
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
118
|
+
)
|
|
119
|
+
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
120
|
+
|
|
121
|
+
acknowledged_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
122
|
+
UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
|
|
123
|
+
)
|
|
124
|
+
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_open(self) -> bool:
|
|
128
|
+
return self.resolved_at is None
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
return f"<AttentionItem {self.rule.value} #{self.subject_number}>"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""关注项的订阅设置。
|
|
2
|
+
|
|
3
|
+
背景:规则引擎对**每个命中对象**产出一条关注项。548 个未推进的 CVE PR 就是
|
|
4
|
+
463 条 cve_aging,3000 个陈旧 Issue 就是 3000 条 issue_stale —— 加起来四千多
|
|
5
|
+
条。那不是"需要关注",那是"全部清单"。使用者翻两页就会放弃这个功能。
|
|
6
|
+
|
|
7
|
+
两件事分开解决:
|
|
8
|
+
|
|
9
|
+
1. **订阅**(本表):哪些规则要提醒、阈值多少、只看 PR 还是只看 Issue。
|
|
10
|
+
团队节奏不同,阈值不该写死在代码里 —— 写死的结果是没人用,
|
|
11
|
+
因为第一条不符合自己节奏的提醒就会让人关掉整个队列。
|
|
12
|
+
|
|
13
|
+
2. **收敛**(见 attention_service):同一规则下的条目按主题聚合成分组,
|
|
14
|
+
队列显示分组,展开才是条目。分组是把"463 条"变成"3 类问题"的地方。
|
|
15
|
+
|
|
16
|
+
设置是全局的而非按用户:这是 SIG 的公共队列,不是个人待办。
|
|
17
|
+
按用户存会让"这个 PR 到底有没有人管"变得无法回答。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import (
|
|
24
|
+
Boolean,
|
|
25
|
+
DateTime,
|
|
26
|
+
Integer,
|
|
27
|
+
String,
|
|
28
|
+
Text,
|
|
29
|
+
UniqueConstraint,
|
|
30
|
+
)
|
|
31
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
32
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
33
|
+
|
|
34
|
+
from app.models.base import Base, TimestampMixin
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AttentionRuleSetting(Base, TimestampMixin):
|
|
38
|
+
"""单条规则的订阅设置。规则取值的权威定义在 ``domain.attention.Rule``。"""
|
|
39
|
+
|
|
40
|
+
__tablename__ = "ksp_attention_rule_setting"
|
|
41
|
+
__table_args__ = (UniqueConstraint("rule", name="uq_ksp_attention_rule_setting_rule"),)
|
|
42
|
+
|
|
43
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
44
|
+
|
|
45
|
+
rule: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
46
|
+
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
47
|
+
|
|
48
|
+
days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
49
|
+
"""阈值天数。为空表示沿用领域层的默认值。"""
|
|
50
|
+
|
|
51
|
+
subject_scope: Mapped[str] = mapped_column(String(16), nullable=False, default="all")
|
|
52
|
+
"""``all`` / ``pull_request`` / ``issue``。有些团队只关心 PR。"""
|
|
53
|
+
|
|
54
|
+
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
55
|
+
|
|
56
|
+
updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
57
|
+
effective_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
58
|
+
"""上次生效时间。界面据此说明"设置从下次重算开始生效"。"""
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"<AttentionRuleSetting {self.rule} enabled={self.enabled}>"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""审计日志。仅追加,不修改不删除。"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, String, Text, func
|
|
8
|
+
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
10
|
+
|
|
11
|
+
from app.models.base import Base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuditResult(StrEnum):
|
|
15
|
+
SUCCESS = "success"
|
|
16
|
+
FAILURE = "failure"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditLog(Base):
|
|
20
|
+
__tablename__ = "ksp_audit_log"
|
|
21
|
+
|
|
22
|
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
23
|
+
|
|
24
|
+
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
25
|
+
UUID(as_uuid=True),
|
|
26
|
+
ForeignKey("ksp_user.id", ondelete="SET NULL"),
|
|
27
|
+
nullable=True,
|
|
28
|
+
index=True,
|
|
29
|
+
)
|
|
30
|
+
actor_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
31
|
+
actor_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
|
|
32
|
+
|
|
33
|
+
action: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
34
|
+
target_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
35
|
+
target_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
36
|
+
|
|
37
|
+
before: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
38
|
+
after: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
39
|
+
|
|
40
|
+
result: Mapped[AuditResult] = mapped_column(
|
|
41
|
+
Enum(AuditResult, name="audit_result", values_callable=lambda e: [m.value for m in e]),
|
|
42
|
+
nullable=False,
|
|
43
|
+
default=AuditResult.SUCCESS,
|
|
44
|
+
)
|
|
45
|
+
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
46
|
+
request_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
47
|
+
|
|
48
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
49
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"<AuditLog {self.action} by={self.actor_username} result={self.result.value}>"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""SQLAlchemy 声明式基类与混入。"""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import DateTime, MetaData, func
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
7
|
+
|
|
8
|
+
# 统一约束命名,保证 Alembic 生成的迁移可读且可回滚
|
|
9
|
+
NAMING_CONVENTION = {
|
|
10
|
+
"ix": "ix_%(column_0_label)s",
|
|
11
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
12
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
13
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
14
|
+
"pk": "pk_%(table_name)s",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Base(DeclarativeBase):
|
|
19
|
+
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TimestampMixin:
|
|
23
|
+
"""创建/更新时间戳。"""
|
|
24
|
+
|
|
25
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
26
|
+
DateTime(timezone=True),
|
|
27
|
+
server_default=func.now(),
|
|
28
|
+
nullable=False,
|
|
29
|
+
)
|
|
30
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
31
|
+
DateTime(timezone=True),
|
|
32
|
+
server_default=func.now(),
|
|
33
|
+
onupdate=lambda: datetime.now(UTC),
|
|
34
|
+
nullable=False,
|
|
35
|
+
)
|