## 现象与根因
投顾工作台生成推荐方案后,草案停在 `pending_review` 且投顾无法推进:
- 服务层 `review` / `publish` 都带 **`admin=True` 角色闸门**
(`product_recommendation_service.py:286/317`),即使投顾角色**已经持有**
`product-recommendation:review` / `:publish` 两个权限码也一律 403;
- 审核/发布端点只注册在 **admin 路由**下(`/api/v1/admin/advisor/...`),
投顾侧根本没有对应入口;
- 投顾工作台也没有审核/发布按钮(`published-module.js` 原注释即写着
"发布动作要求管理员,投顾侧只读")。
于是业务上"让投顾自己审核"完全做不到,必须切管理员账号。
## 修法(三处配套,安全边界保留)
1. `app/service/product_recommendation_service.py`
- `review` / `publish` 去掉 `admin=True`,**只按权限码判定**
(`product-recommendation:review` / `:publish`,目前仅 advisor 与 admin 持有);
- `reviewer_user_id` 照旧如实落库,审计可追;
- 注释写明:若要回到"四眼原则/管理员专属",把 `admin=True` 加回即可。
2. `app/api/controllers/recommendations.py`
- 新增投顾侧路由 `POST /api/v1/advisor/recommendations/{id}/reviews`
与 `.../publications`(与 admin 路由调用同一服务方法)。
3. 前端
- `common/api-client.js`:注册 `ADVISOR_REVIEW_RECOMMENDATION` /
`ADVISOR_PUBLISH_RECOMMENDATION`;
- `employee-advisor/dashboard/actions-module.js`:结果区在拿到 `content_id` 后
给出「审核通过 / 驳回 / 发布给客户」按钮(结果区是 `innerHTML` 重建的,
所以每次渲染后重新绑定);审核通过后就地换成「发布给客户」;
- `published-module.js`:监听 `advisor:published-refresh`,发布成功后列表自动刷新。
## 未放宽的部分(有意保留)
- **管理面复核队列** `GET /api/v1/admin/advisor/pending-contents` 仍为
`admin=True` 专属 —— `tests/integration/test_advisor_review_queue_mysql.py`
里"投顾读不到该队列"的断言**未改动**;
- 客户/风控/运营角色不持有这两个权限码,因此不受影响。
## 验证(真实 HTTP,9020 身份)
```
① 生成推荐方案(客户 9001)→ content_id=19, pending_review
② 投顾自助审核通过 → HTTP 200 status=approved (改前 403)
③ 投顾自助发布 → HTTP 200 status=published
④ 已发布列表 → 含 id=19 ✅
```
新增回归测试 `test_advisor_can_review_and_publish_own_recommendation`
(客户缺测评/目标时 `pytest.skip` 并说明是数据前置,不误判为权限失败)。
## 门禁
- `pytest tests/unit tests/contract` → 1458 passed;
- `pytest tests/integration` → 111 passed + 1 例
`test_worker_runtime_mysql::...repeat[False]` 失败,**经复跑确认是 AGENTS.md 记载的
"常驻 Worker 抢队列",停掉常驻 Worker 后该用例 2 passed**,与本次改动无关;
- `ruff` 干净;三个 JS 文件 `node --check` 通过。
471 lines
22 KiB
Python
471 lines
22 KiB
Python
"""Constraint-first recommendations for the exchange-traded simulation domain."""
|
||
|
||
from collections.abc import Callable
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
|
||
from app.core.config import get_settings
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import GenericResourceNotFoundError, InvalidStateError
|
||
from app.core.product_recommendation_contracts import ProductRecommendationQuery
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
||
from app.repository.advisor_product_repository import (
|
||
AdvisorProductRepository,
|
||
AuthoritativeProductCandidate,
|
||
)
|
||
from app.service.api_transaction_service import ApiTransactionService
|
||
from app.service.authorization_service import AuthorizationService
|
||
from app.service.investment_goal_service import InvestmentGoalService
|
||
from app.service.product_governance_monitor_service import SALES_INSTITUTION
|
||
from app.service.profile_governance_service import ProfileGovernanceService
|
||
from app.service.relationship_service import RelationshipService
|
||
from app.service.suitability_service import SuitabilityService
|
||
|
||
#: 投资方案书的 `content_type`。它与推荐方案同处 `client_facing_content` 表,
|
||
#: 由 `InvestmentGoalService` 写入;两者的**后续动作用不同键寻址**:
|
||
#: 推荐方案用 `content_id`,方案书用 `goal_no`。
|
||
BOOK_CONTENT_TYPE = "investment_goal_book"
|
||
|
||
|
||
class ProductRecommendationService:
|
||
CONTENT_TYPE = "advisor_recommendation_plan"
|
||
#: 面向客户展示的投顾内容类型。**方案书(goal book)是本项目投顾交付的主产物**,
|
||
#: 由 `InvestmentGoalService` 写入同一张 `client_facing_content` 表,靠 `content_type`
|
||
#: 区分。投顾工作台只认 recommendation 时永远为空 —— 因为投顾给客户交付的是方案书。
|
||
CLIENT_CONTENT_TYPES: tuple[str, ...] = ("advisor_recommendation_plan", "investment_goal_book")
|
||
#: 两类内容的"已发布"在库里取值不同:recommendation 审核通过后置 `approved`
|
||
#: (`product_recommendation_service.review`),方案书发布后置 `published`
|
||
#: (`investment_goal_service.publish_book`)。只判 `approved` 会把方案书整类漏掉。
|
||
PUBLISHED_STATES: tuple[str, ...] = ("approved", "published")
|
||
#: 两类内容的"待审"取值同样不同:推荐方案生成时置 `pending_review`
|
||
#: (`ProductRecommendationService.generate`),方案书创建草稿时置 `pending`
|
||
#: (`InvestmentGoalService.create`)。只判其中一个会把另一类整类漏掉。
|
||
PENDING_STATES: tuple[str, ...] = ("pending", "pending_review")
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
session_factory: Callable[[], Any] = SessionFactory,
|
||
relationship_service: RelationshipService | None = None,
|
||
enforce_profile_governance: bool = False,
|
||
) -> None:
|
||
self.session_factory = session_factory
|
||
self.relationship_service = relationship_service or RelationshipService(
|
||
Neo4jGraphDriver(get_settings())
|
||
)
|
||
self.enforce_profile_governance = enforce_profile_governance
|
||
|
||
async def generate(
|
||
self, payload: ProductRecommendationQuery, context: RequestContext, key: str | None
|
||
) -> dict[str, object]:
|
||
# 代客:payload.customer_id 指定被分析客户;留空则以登录用户自身为对象(原行为)。
|
||
customer_id = payload.customer_id or int(context.user_id)
|
||
if customer_id == int(context.user_id):
|
||
await AuthorizationService.require(context, "product-recommendation:generate:self")
|
||
else:
|
||
await AuthorizationService.require_customer_scope(
|
||
context, "product-recommendation:generate:customer", customer_id
|
||
)
|
||
if self.enforce_profile_governance:
|
||
await ProfileGovernanceService().require_operable(customer_id)
|
||
authority = await SuitabilityService().authority_for_customer(customer_id)
|
||
if authority.customer_risk_level is None:
|
||
return {"status": "profile_required"}
|
||
goal = await InvestmentGoalService().current_for_customer(customer_id, context)
|
||
if goal is None:
|
||
return {"status": "investment_goal_required"}
|
||
candidates, excluded = await self._candidates(
|
||
authority.customer_risk_level, str(goal["liquidity_requirement"])
|
||
)
|
||
horizon = goal.get("investment_horizon_months")
|
||
if not isinstance(horizon, int):
|
||
return {"status": "recommendation_input_invalid"}
|
||
ranked = self._rank(
|
||
candidates, authority.customer_risk_level, horizon
|
||
)
|
||
selected = ranked[: payload.limit]
|
||
excluded.extend(self._ranking_exclusions(ranked[payload.limit :], payload.limit))
|
||
graph_context = await self._graph_context(customer_id)
|
||
products = [
|
||
self._view(item, index, goal, graph_context)
|
||
for index, item in enumerate(selected, start=1)
|
||
]
|
||
plan = {
|
||
"document_type": "advisor_recommendation_plan",
|
||
"document_version": "1.0",
|
||
"products": products,
|
||
"excluded_candidates": excluded,
|
||
"selection_summary": {
|
||
"candidate_count": len(candidates) + len(excluded),
|
||
"selected_count": len(products),
|
||
"excluded_count": len(excluded),
|
||
},
|
||
"graph_context": graph_context,
|
||
"disclosures": [
|
||
"推荐结果仅供场内基金模拟交易分析,不构成交易指令。",
|
||
"历史数据和风险等级不代表未来收益,收益目标不构成承诺。",
|
||
"推荐方案须经审核发布后方可对客户展示。",
|
||
],
|
||
}
|
||
if key is None:
|
||
return {"status": "ready", **plan, "analysis_only": True}
|
||
|
||
async def operation(session: Any) -> dict[str, object]:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
content = ClientFacingContent(
|
||
customer_id=customer_id,
|
||
content_type=self.CONTENT_TYPE,
|
||
draft_content=plan,
|
||
generated_by_portal=context.portal,
|
||
review_status="pending_review",
|
||
reviewer_user_id=None,
|
||
reviewed_at=None,
|
||
published_at=None,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
session.add(content)
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=int(context.user_id),
|
||
target_customer_id=customer_id,
|
||
portal=context.portal,
|
||
action_type="advisor.recommendation_created",
|
||
detail={
|
||
"content_type": self.CONTENT_TYPE,
|
||
"status": "pending_review",
|
||
"trace_id": context.trace_id,
|
||
},
|
||
created_at=now,
|
||
)
|
||
)
|
||
await session.flush()
|
||
return {
|
||
"data": {
|
||
"content_id": str(content.id),
|
||
"status": content.review_status,
|
||
"plan": plan,
|
||
},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{customer_id}",
|
||
key,
|
||
payload.model_dump(mode="json"),
|
||
operation,
|
||
)
|
||
|
||
async def _candidates(
|
||
self, customer_risk_level: int, liquidity_requirement: str
|
||
) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]:
|
||
async with self.session_factory() as session:
|
||
candidates = await AdvisorProductRepository(session).authoritative_tradable_products(
|
||
datetime.now(UTC).replace(tzinfo=None),
|
||
sales_institution=SALES_INSTITUTION,
|
||
liquidity_requirement=liquidity_requirement,
|
||
limit=50,
|
||
)
|
||
return AdvisorProductRepository.hard_suitability_filter(candidates, customer_risk_level)
|
||
|
||
@staticmethod
|
||
def _rank(
|
||
candidates: list[AuthoritativeProductCandidate], risk: int, horizon: int
|
||
) -> list[tuple[AuthoritativeProductCandidate, float]]:
|
||
def score(candidate: AuthoritativeProductCandidate) -> float:
|
||
level = int(candidate.suitability.risk_level.removeprefix("R"))
|
||
risk_score = 1 - abs(risk - level) / 4
|
||
liquidity = candidate.liquidity
|
||
if liquidity is None or liquidity.average_daily_turnover_amount is None:
|
||
liquidity_score = 0.5
|
||
else:
|
||
liquidity_score = min(
|
||
1.0, float(liquidity.average_daily_turnover_amount / 10_000_000)
|
||
)
|
||
term_score = (
|
||
0.8
|
||
if horizon >= 36 and candidate.product.product_category in {"ETF", "LOF"}
|
||
else 0.6
|
||
)
|
||
return 0.55 * risk_score + 0.25 * liquidity_score + 0.20 * term_score
|
||
|
||
return sorted(
|
||
((candidate, score(candidate)) for candidate in candidates),
|
||
key=lambda item: (-item[1], item[0].product.product_code),
|
||
)
|
||
|
||
@staticmethod
|
||
def _ranking_exclusions(
|
||
ranked: list[tuple[AuthoritativeProductCandidate, float]], limit: int
|
||
) -> list[dict[str, object]]:
|
||
return [
|
||
{
|
||
"product_code": candidate.product.product_code,
|
||
"product_name": candidate.product.product_name,
|
||
"stage": "ranking",
|
||
"reason_code": "RANKED_BELOW_SELECTION_LIMIT",
|
||
"reason": "产品通过硬性约束但排序低于本次选择数量。",
|
||
"ranking_score": round(score, 4),
|
||
"selection_limit": limit,
|
||
}
|
||
for candidate, score in ranked
|
||
]
|
||
|
||
@staticmethod
|
||
def _view(
|
||
item: tuple[AuthoritativeProductCandidate, float],
|
||
rank: int,
|
||
goal: dict[str, object],
|
||
graph_context: dict[str, object],
|
||
) -> dict[str, object]:
|
||
candidate, score = item
|
||
product = candidate.product
|
||
contract = candidate.contract
|
||
return {
|
||
"rank": rank,
|
||
"product_code": product.product_code,
|
||
"product_name": product.product_name,
|
||
"product_category": product.product_category,
|
||
"reason": "该产品已通过场内可交易、权威适当性和合同证据校验,"
|
||
"并与已确认投资目标的期限和流动性要求相匹配。",
|
||
"score": round(score, 4),
|
||
"recommendation_evidence_card": {
|
||
"card_version": "1.0",
|
||
"hard_constraints": [
|
||
"exchange_traded",
|
||
"suitability_verified",
|
||
"contract_verified",
|
||
],
|
||
"suitability": {
|
||
"risk_level": candidate.suitability.risk_level,
|
||
"source_url": candidate.suitability.source_url,
|
||
"document_title": candidate.suitability.document_title,
|
||
},
|
||
"contract": {
|
||
"fund_type": contract.fund_type,
|
||
"source_url": contract.source_url,
|
||
"document_title": contract.document_title,
|
||
},
|
||
"liquidity": {
|
||
"status": candidate.liquidity.status if candidate.liquidity else "unknown",
|
||
"average_daily_turnover_amount": str(
|
||
candidate.liquidity.average_daily_turnover_amount
|
||
)
|
||
if candidate.liquidity
|
||
and candidate.liquidity.average_daily_turnover_amount is not None
|
||
else None,
|
||
},
|
||
"goal_constraints": {
|
||
"liquidity_requirement": goal["liquidity_requirement"],
|
||
"investment_horizon_months": goal["investment_horizon_months"],
|
||
},
|
||
"graph_status": "degraded" if graph_context.get("degraded") else "available",
|
||
},
|
||
}
|
||
|
||
async def _graph_context(self, customer_id: int) -> dict[str, object]:
|
||
if self.relationship_service is None:
|
||
return {"degraded": True, "reason": "graph_not_configured"}
|
||
return await self.relationship_service.portfolio_industry_context(customer_id)
|
||
|
||
async def review(
|
||
self,
|
||
content_id: int,
|
||
decision: str,
|
||
comment: str,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, object]:
|
||
# 审核权**只按权限码**,不再额外要求 admin 角色(2026-09-14 业务要求:
|
||
# 投顾要能自己审核、发布自己生成的方案,否则草案永远停在 pending_review,
|
||
# 演示/生产都得切到管理员账号才能推进)。
|
||
# 安全边界仍在:`product-recommendation:review` 目前只授予 advisor 与 admin
|
||
# 两个角色(`tools/grant_advisor_role.py` + 种子的 ADMIN_PERMISSIONS),
|
||
# 且 `reviewer_user_id` 如实落库,审计可追。
|
||
# ⚠️ 若合规上要求"四眼原则",把 `admin=True` 加回本行即可恢复管理员专属
|
||
# (管理面复核队列 `pending_reviews` 仍保持 admin 专属,未放宽)。
|
||
await AuthorizationService.require(context, "product-recommendation:review")
|
||
|
||
async def operation(session: Any) -> dict[str, object]:
|
||
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||
if content is None or content.content_type != self.CONTENT_TYPE:
|
||
raise GenericResourceNotFoundError("推荐方案不存在")
|
||
if content.review_status != "pending_review":
|
||
raise InvalidStateError("推荐方案当前不能审核")
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
content.review_status = "approved" if decision == "approved" else "rejected"
|
||
content.reviewer_user_id = int(context.user_id)
|
||
content.reviewed_at = now
|
||
content.updated_at = now
|
||
content.draft_content = {**content.draft_content, "review_comment": comment}
|
||
await session.flush()
|
||
return {
|
||
"data": {"content_id": str(content.id), "status": content.review_status},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{content_id}:review",
|
||
key,
|
||
{"decision": decision, "comment": comment},
|
||
operation,
|
||
)
|
||
|
||
async def publish(
|
||
self, content_id: int, context: RequestContext, key: str | None
|
||
) -> dict[str, object]:
|
||
# 同上:发布权按权限码判定(advisor 与 admin 均持有),不再要求 admin 角色。
|
||
# 恢复到"管理员专属"只需把 `admin=True` 加回。
|
||
await AuthorizationService.require(context, "product-recommendation:publish")
|
||
|
||
async def operation(session: Any) -> dict[str, object]:
|
||
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||
if content is None or content.content_type != self.CONTENT_TYPE:
|
||
raise GenericResourceNotFoundError("推荐方案不存在")
|
||
if content.review_status != "approved":
|
||
raise InvalidStateError("推荐方案审核通过后才能发布")
|
||
content.review_status = "approved"
|
||
content.published_at = datetime.now(UTC).replace(tzinfo=None)
|
||
content.updated_at = content.published_at
|
||
await session.flush()
|
||
return {
|
||
"data": {"content_id": str(content.id), "status": "published"},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{content_id}:publish",
|
||
key,
|
||
{"publish": True},
|
||
operation,
|
||
)
|
||
|
||
@staticmethod
|
||
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
|
||
"""可查看的客户 id:本人 + 名下归属客户。
|
||
|
||
为什么不用 `data_scope`:投顾/运营因为持有 `promotion:*` 这类 all 级权限,
|
||
`IdentityService` 会把**整个身份**的 scope 抬到 `all`(`identity_repository` 取各授权
|
||
scope 的最大值)。按 scope 判定会让他们看到全部客户的方案,属过度开放。
|
||
归属关系来自 `sys_customer_assignment`(逐条授权,且带 assigned_at/unassigned_at
|
||
时间窗校验),比 scope 更窄,也更贴合"投顾只看自己服务的客户"这个业务口径。
|
||
"""
|
||
ids = {str(context.user_id), *(str(item) for item in context.customer_ids)}
|
||
return tuple(sorted({int(item) for item in ids if item.strip().isdigit()}))
|
||
|
||
async def published(self, context: RequestContext) -> dict[str, object]:
|
||
await AuthorizationService.require(context, "product-recommendation:read:self")
|
||
customer_ids = self._visible_customer_ids(context)
|
||
if not customer_ids:
|
||
return {"data": [], "meta": {"trace_id": context.trace_id}}
|
||
async with self.session_factory() as session:
|
||
rows = list(
|
||
await session.scalars(
|
||
select(ClientFacingContent)
|
||
.where(
|
||
ClientFacingContent.customer_id.in_(customer_ids),
|
||
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
|
||
ClientFacingContent.review_status.in_(self.PUBLISHED_STATES),
|
||
ClientFacingContent.published_at.is_not(None),
|
||
)
|
||
.order_by(ClientFacingContent.published_at.desc())
|
||
.limit(20)
|
||
)
|
||
)
|
||
return {
|
||
"data": [
|
||
{
|
||
"content_id": str(row.id),
|
||
"customer_id": str(row.customer_id),
|
||
"content_type": row.content_type,
|
||
"plan": row.draft_content,
|
||
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||
}
|
||
for row in rows
|
||
],
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
|
||
async def pending_reviews(self, context: RequestContext) -> dict[str, object]:
|
||
"""管理面复核队列:待审核的推荐方案与投资方案书。
|
||
|
||
## 为什么必须补这个入口
|
||
|
||
`review` / `publish` 都要求调用方**先知道 `content_id`**,而在此之前
|
||
**没有任何端点能列出待审内容** —— 管理员拿不到 id,整条审核链路实际不可达:
|
||
投顾生成草案后它会一直停在待审状态,没有人能推进它。
|
||
|
||
## 口径
|
||
|
||
- **不按客户归属过滤**:这是管理面的复核队列,管理员要看**全部**待审内容;
|
||
权限由 `product-recommendation:review`(`admin=True`)把关,
|
||
比 `published` 用的 `...:read:self` 更严。
|
||
- **一次返回两类内容**(推荐方案 + 方案书),前端按 `content_type` 区分。
|
||
它们同处 `client_facing_content` 表,只是 `review_status` 取值不同。
|
||
- 按 `created_at` **升序**:先提交的先审,避免新草案把旧的挤下去。
|
||
"""
|
||
await AuthorizationService.require(
|
||
context, "product-recommendation:review", admin=True
|
||
)
|
||
async with self.session_factory() as session:
|
||
rows = list(
|
||
await session.scalars(
|
||
select(ClientFacingContent)
|
||
.where(
|
||
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
|
||
ClientFacingContent.review_status.in_(self.PENDING_STATES),
|
||
)
|
||
.order_by(ClientFacingContent.created_at.asc())
|
||
.limit(50)
|
||
)
|
||
)
|
||
# ⚠️ 两类内容的后续动作用**不同的键**寻址:
|
||
# · 推荐方案:`content_id` → A045 / A046
|
||
# · 投资方案书:`goal_no` → AD006 / AD007
|
||
# 待审列表本身只有 `content_id`,所以这里为方案书一并查出 `goal_no`;
|
||
# 否则管理员拿到了列表也调不动那两个端点(缺的就是这个映射)。
|
||
book_ids = [row.id for row in rows if row.content_type == BOOK_CONTENT_TYPE]
|
||
goal_nos: dict[int, str] = {}
|
||
if book_ids:
|
||
pairs = await session.execute(
|
||
select(
|
||
AdvisorInvestmentGoal.goal_book_content_id,
|
||
AdvisorInvestmentGoal.goal_no,
|
||
).where(AdvisorInvestmentGoal.goal_book_content_id.in_(book_ids))
|
||
)
|
||
goal_nos = {int(content_id): str(no) for content_id, no in pairs.all()}
|
||
return {
|
||
"data": [
|
||
{
|
||
"content_id": str(row.id),
|
||
"customer_id": str(row.customer_id),
|
||
"content_type": row.content_type,
|
||
"review_status": row.review_status,
|
||
"plan": row.draft_content,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
# 仅方案书有值;推荐方案为 None(它按 content_id 寻址)
|
||
"goal_no": goal_nos.get(row.id),
|
||
}
|
||
for row in rows
|
||
],
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
|
||
async def product_recommendation_tool(
|
||
arguments: ProductRecommendationQuery, context: RequestContext
|
||
) -> dict[str, object]:
|
||
return await ProductRecommendationService(enforce_profile_governance=True).generate(
|
||
arguments, context, None
|
||
)
|