fix(formula): 导出文件安全、清理回收、撤权校验与定义库可靠性(F01/F02/F05-F08)
- F01: _safe_unlink_export 不跟随符号链接、逐层校验父目录,导出失败路径与 清理命令统一使用,只清理受控目录内本次创建的文件 - F02: 清理失败记录告警日志;清理命令回收 failed 任务残留文件 - F05: 定义保存事务内锁数据集行,配额原子检查;revision 冲突返回 409; 保留最近 20 版历史;支持个人/团队共享;审计与变更同事务 - F06: 工作簿参数页毛利率改为只读快照;可编辑单元格解锁并加数值校验 - F07: 对账按指标分容差(人数 0、比率 0.0001、金额 0.01);自定义列使用 服务端 Decimal 表达式求值参考 - F08: 导出任务执行/下载阶段重校验账号有效性与角色 - 对话草稿会话恢复;载入定义后追问携带原定义(F04);下载改浏览器直传 - 迁移 0006-0008;PostgreSQL 咨询锁语义修正(import_worker)
This commit is contained in:
+79
-17
@@ -3,12 +3,14 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from .analysis import METRICS, context, dimensions
|
||||
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
|
||||
from .models import FormulaDefinition, FormulaExport
|
||||
from .models import Dataset, FormulaDefinition, FormulaExport
|
||||
from .views import APIError, audit, body, dataset, endpoint, limit
|
||||
|
||||
|
||||
@@ -190,13 +192,23 @@ def formula_definitions(request, dataset_id):
|
||||
if request.member.departments:
|
||||
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
|
||||
if request.method == "GET":
|
||||
items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name")
|
||||
items = (
|
||||
FormulaDefinition.objects.filter(dataset=ds)
|
||||
.filter(Q(user=request.user) | Q(shared=True))
|
||||
.select_related("user")
|
||||
.order_by("normalized_name")
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": str(item.pk),
|
||||
"definition": item.definition,
|
||||
"revision": item.revision,
|
||||
"shared": item.shared,
|
||||
"owned": item.user_id == request.user.pk,
|
||||
"owner": item.user.username,
|
||||
"history": item.history,
|
||||
"updated_at": item.updated_at.isoformat(),
|
||||
}
|
||||
for item in items
|
||||
@@ -204,28 +216,74 @@ def formula_definitions(request, dataset_id):
|
||||
}
|
||||
)
|
||||
|
||||
definition = validate_column(body(request).get("definition"))
|
||||
data = body(request)
|
||||
definition = validate_column(data.get("definition"))
|
||||
shared = data.get("shared", False)
|
||||
if type(shared) is not bool:
|
||||
raise APIError("共享选项无效")
|
||||
try:
|
||||
ensure_available_metrics(ds, definition)
|
||||
except ValueError as exc:
|
||||
raise APIError(str(exc)) from exc
|
||||
normalized_name = definition["name"].strip().casefold()
|
||||
existing = FormulaDefinition.objects.filter(
|
||||
dataset=ds, user=request.user, normalized_name=normalized_name
|
||||
).first()
|
||||
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
|
||||
raise APIError("每个数据集最多保存 50 个公式定义")
|
||||
item, created = FormulaDefinition.objects.update_or_create(
|
||||
dataset=ds,
|
||||
user=request.user,
|
||||
normalized_name=normalized_name,
|
||||
defaults={"definition": definition},
|
||||
)
|
||||
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
|
||||
requested_revision = data.get("revision")
|
||||
with transaction.atomic():
|
||||
Dataset.objects.select_for_update().get(pk=ds.pk)
|
||||
existing = (
|
||||
FormulaDefinition.objects.select_for_update()
|
||||
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
if requested_revision is not None and (
|
||||
type(requested_revision) is not int or requested_revision != existing.revision
|
||||
):
|
||||
raise APIError("公式定义已在其他窗口更新,请重新载入", 409)
|
||||
# ponytail: retain 20 revisions; use a separate history table if long-term retention is required.
|
||||
existing.history = [
|
||||
*existing.history,
|
||||
{
|
||||
"revision": existing.revision,
|
||||
"definition": existing.definition,
|
||||
"updated_at": existing.updated_at.isoformat(),
|
||||
},
|
||||
][-20:]
|
||||
existing.definition = definition
|
||||
existing.shared = shared if "shared" in data else existing.shared
|
||||
existing.revision += 1
|
||||
existing.save(update_fields=["definition", "revision", "shared", "history", "updated_at"])
|
||||
item, created = existing, False
|
||||
else:
|
||||
if FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
|
||||
raise APIError("每个数据集最多保存 50 个公式定义")
|
||||
item = FormulaDefinition.objects.create(
|
||||
dataset=ds,
|
||||
user=request.user,
|
||||
normalized_name=normalized_name,
|
||||
definition=definition,
|
||||
shared=shared,
|
||||
)
|
||||
created = True
|
||||
audit(
|
||||
request,
|
||||
"formula_definition.save",
|
||||
item.pk,
|
||||
{
|
||||
"dataset": str(ds.pk),
|
||||
"name": definition["name"],
|
||||
"shared": item.shared,
|
||||
"revision": item.revision,
|
||||
},
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"id": str(item.pk),
|
||||
"definition": item.definition,
|
||||
"revision": item.revision,
|
||||
"shared": item.shared,
|
||||
"owned": True,
|
||||
"owner": request.user.username,
|
||||
"history": item.history,
|
||||
"updated_at": item.updated_at.isoformat(),
|
||||
},
|
||||
status=201 if created else 200,
|
||||
@@ -241,8 +299,10 @@ def formula_definition_delete(request, dataset_id, definition_id):
|
||||
if not item:
|
||||
raise APIError("公式定义不存在", 404)
|
||||
name = item.definition.get("name", "")
|
||||
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
|
||||
item.delete()
|
||||
with transaction.atomic():
|
||||
item = FormulaDefinition.objects.select_for_update().get(pk=item.pk)
|
||||
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
|
||||
item.delete()
|
||||
return JsonResponse({"deleted": True})
|
||||
|
||||
|
||||
@@ -279,6 +339,8 @@ def formula_export_create(request, dataset_id):
|
||||
|
||||
|
||||
def _owned_job(request, dataset_id, export_id):
|
||||
if not request.user.is_active or request.member.role not in ("admin", "analyst"):
|
||||
raise APIError("没有此导出任务的访问权限", 403)
|
||||
job = FormulaExport.objects.filter(
|
||||
pk=export_id,
|
||||
dataset_id=dataset_id,
|
||||
|
||||
Reference in New Issue
Block a user