feat: 添加审批系统和遗留查询功能支持

- 添加审批系统,包括审批请求模型、服务和路由,支持创建、批准和拒绝操作
- 实现审批API密钥验证机制,区分普通API和审批API访问权限
- 添加Alembic数据库迁移支持,更新初始schema版本并添加降级保护
- 配置遗留MySQL查询白名单机制,支持命名查询和参数化查询
- 更新业务服务以集成审批流程,高风险操作需要审批票证
- 调整安全认证使用常量定义的HTTP头,增强安全性比较
- 优化.gitignore配置,添加日志目录排除和文档文件包含规则
- 更新Dockerfile添加alembic依赖包,修复OpenClaw适配器错误处理
```
This commit is contained in:
2026-07-06 09:39:25 +08:00
parent ae5990eaef
commit e3a4a6d426
28 changed files with 536 additions and 86 deletions

View File

@@ -1,4 +1,6 @@
import json
from functools import lru_cache
from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -19,11 +21,14 @@ class Settings(BaseSettings):
api_prefix: str = "/api/v1"
api_key: str | None = None
api_actor: str = ActorValue.API
approval_api_key: str | None = None
approval_api_actor: str = ActorValue.APPROVER
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
legacy_database_url: str | None = None
legacy_project_query: str | None = None
legacy_allowed_queries: dict[str, str] = Field(default_factory=dict)
legacy_project_code_prefix: str = "LEGACY"
redis_url: str = "redis://127.0.0.1:6379/0"
@@ -71,6 +76,20 @@ class Settings(BaseSettings):
return value
return [item.strip() for item in value.split(",") if item.strip()]
@field_validator("legacy_allowed_queries", mode="before")
@classmethod
def parse_legacy_allowed_queries(cls, value: Any) -> dict[str, str]:
if value is None or value == "":
return {}
if isinstance(value, dict):
return {str(key): str(item) for key, item in value.items()}
if isinstance(value, str):
data = json.loads(value)
if not isinstance(data, dict):
raise ValueError("LEGACY_ALLOWED_QUERIES must be a JSON object")
return {str(key): str(item) for key, item in data.items()}
raise ValueError("LEGACY_ALLOWED_QUERIES must be a JSON object")
@lru_cache
def get_settings() -> Settings:

View File

@@ -3,6 +3,7 @@ from enum import StrEnum
class ActorValue(StrEnum):
API = "api"
APPROVER = "approver"
SYSTEM = "system"
SCHEDULER = "scheduler"
FEISHU = "feishu"
@@ -10,6 +11,8 @@ class ActorValue(StrEnum):
class HttpHeader(StrEnum):
AUTHORIZATION = "Authorization"
X_API_KEY = "X-API-Key"
X_APPROVAL_API_KEY = "X-Approval-API-Key"
BEARER_TOKEN_TEMPLATE = "Bearer {token}"

View File

@@ -1,15 +1,10 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
class Base(DeclarativeBase):
"""Base class for SQLAlchemy ORM models."""
pass
from app.core.db_base import Base
settings = get_settings()

7
app/core/db_base.py Normal file
View File

@@ -0,0 +1,7 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Base class for SQLAlchemy ORM models."""
pass

View File

@@ -1,8 +1,10 @@
from dataclasses import dataclass
from secrets import compare_digest
from fastapi import Header, HTTPException, status
from app.core.config import get_settings
from app.core.constants import HttpHeader
@dataclass(frozen=True)
@@ -12,7 +14,9 @@ class ApiPrincipal:
actor: str
def require_api_key(x_api_key: str | None = Header(default=None)) -> ApiPrincipal:
def require_api_key(
x_api_key: str | None = Header(default=None, alias=HttpHeader.X_API_KEY),
) -> ApiPrincipal:
"""Validate the internal API key header and return its service principal."""
settings = get_settings()
@@ -21,6 +25,31 @@ def require_api_key(x_api_key: str | None = Header(default=None)) -> ApiPrincipa
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="API_KEY is required",
)
if x_api_key != settings.api_key:
if not x_api_key or not compare_digest(x_api_key, settings.api_key):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
return ApiPrincipal(actor=settings.api_actor)
def require_approval_api_key(
x_approval_api_key: str | None = Header(
default=None,
alias=HttpHeader.X_APPROVAL_API_KEY,
),
) -> ApiPrincipal:
"""Validate the approval API key and return the approval principal."""
settings = get_settings()
if not settings.approval_api_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="APPROVAL_API_KEY is required",
)
if (
not x_approval_api_key
or not compare_digest(x_approval_api_key, settings.approval_api_key)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid approval API key",
)
return ApiPrincipal(actor=settings.approval_api_actor)

View File

@@ -263,8 +263,13 @@ class OpenClawHermesAdapter(AIAdapter):
or AIDefault.SESSION_KEY_MAIN
),
)
except HTTPException:
raise
except Exception as exc:
result[AIResponseKey.TOOL_ERROR] = _error_detail(exc)
raise HTTPException(
status_code=502,
detail={AIErrorKey.OPENCLAW: _error_detail(exc)},
) from exc
return result
def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]:

View File

@@ -5,8 +5,17 @@ class ApprovalStatus(StrEnum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
USED = "used"
class ApprovalActionValue(StrEnum):
CREATE = "create"
UPDATE = "update"
WILDCARD = "*"
class ApprovalErrorDetail(StrEnum):
NOT_FOUND = "Approval ticket not found"
ALREADY_DECIDED = "Approval ticket already decided"
SELF_APPROVAL = "Approval applicant cannot approve their own ticket"
NOT_APPROVED = "Approval ticket is not approved for this change"
PAYLOAD_MISMATCH = "Approval ticket payload does not match this change"

View File

@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue
from app.core.database import Base
from app.core.db_base import Base
from app.core.time import utc_now
from app.modules.approvals.constants import ApprovalStatus
@@ -23,6 +23,7 @@ class ApprovalRequest(Base):
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
used_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
@@ -30,3 +31,4 @@ class ApprovalRequest(Base):
onupdate=utc_now,
)
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.core.security import ApiPrincipal, require_api_key, require_approval_api_key
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
from app.modules.approvals.service import ApprovalService
@@ -37,7 +37,7 @@ def approve(
ticket_id: str,
payload: ApprovalDecision,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
principal: ApiPrincipal = Depends(require_approval_api_key),
):
return ApprovalService(db).decide(ticket_id, principal.actor, True, payload.comment)
@@ -47,6 +47,6 @@ def reject(
ticket_id: str,
payload: ApprovalDecision,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
principal: ApiPrincipal = Depends(require_approval_api_key),
):
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)

View File

@@ -34,6 +34,8 @@ class ApprovalRead(BaseModel):
reason: str | None
payload: str | None
decision_comment: str | None
used_by: str | None
created_at: datetime
updated_at: datetime
decided_at: datetime | None
used_at: datetime | None

View File

@@ -1,12 +1,19 @@
import json
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.time import utc_now
from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus
from app.modules.approvals.constants import (
ApprovalActionValue,
ApprovalErrorDetail,
ApprovalStatus,
)
from app.modules.approvals.models import ApprovalRequest
from app.modules.approvals.schemas import ApprovalCreate
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
@@ -61,7 +68,7 @@ class ApprovalService:
if ticket is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Approval ticket not found",
detail=ApprovalErrorDetail.NOT_FOUND,
)
return ticket
@@ -74,7 +81,15 @@ class ApprovalService:
) -> ApprovalRequest:
ticket = self.get_by_ticket(ticket_id)
if ticket.status != ApprovalStatus.PENDING:
raise HTTPException(status_code=409, detail="Approval ticket already decided")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=ApprovalErrorDetail.ALREADY_DECIDED,
)
if approved and approver == ticket.applicant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.SELF_APPROVAL,
)
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
ticket.approver = approver
ticket.decision_comment = comment
@@ -95,6 +110,33 @@ class ApprovalService:
)
return ticket
def consume_for(
self,
ticket_id: str,
domain: str,
record_id: str | int | None,
action: str,
payload: dict[str, Any],
actor: str,
) -> ApprovalRequest:
ticket = self.get_by_ticket(ticket_id)
if not self._is_ticket_scope_valid(ticket, domain, record_id, action):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.NOT_APPROVED,
)
if not _payload_matches(ticket.payload, payload):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
)
ticket.status = ApprovalStatus.USED
ticket.used_by = actor
ticket.used_at = utc_now()
if record_id is not None and not ticket.record_id:
ticket.record_id = str(record_id)
return ticket
def is_approved_for(
self,
ticket_id: str,
@@ -103,6 +145,15 @@ class ApprovalService:
action: str,
) -> bool:
ticket = self.get_by_ticket(ticket_id)
return self._is_ticket_scope_valid(ticket, domain, record_id, action)
@staticmethod
def _is_ticket_scope_valid(
ticket: ApprovalRequest,
domain: str,
record_id: str | int | None,
action: str,
) -> bool:
if ticket.status != ApprovalStatus.APPROVED:
return False
if ticket.domain != domain:
@@ -115,5 +166,28 @@ class ApprovalService:
action,
ApprovalActionValue.UPDATE,
f"{ApprovalActionValue.UPDATE}:{domain}",
ApprovalActionValue.WILDCARD,
}
def _payload_matches(approved_payload: str | None, requested_payload: dict[str, Any]) -> bool:
try:
parsed_payload = json.loads(approved_payload or "{}")
except json.JSONDecodeError:
parsed_payload = {}
return _canonical_payload(parsed_payload) == _canonical_payload(requested_payload)
def _canonical_payload(value: Any) -> str:
return json.dumps(_json_safe(value), ensure_ascii=False, sort_keys=True, default=str)
def _json_safe(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
return value

View File

@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue
from app.core.database import Base
from app.core.db_base import Base
from app.core.time import utc_now
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus

View File

@@ -5,7 +5,7 @@ from sqlalchemy import JSON, Date, DateTime, Integer, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue
from app.core.database import Base
from app.core.db_base import Base
from app.core.time import utc_now
from app.modules.business.constants import (
AccountType,

View File

@@ -48,14 +48,17 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
"""Filter unknown keys and coerce values according to model column types."""
"""Validate keys and coerce values according to model column types."""
columns = {column.name: column for column in model.__table__.columns if column.name != "id"}
payload: dict[str, Any] = {}
for key, value in data.items():
column = columns.get(key)
if column is None:
continue
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown field '{key}'",
)
try:
payload[key] = _coerce_column_value(column, value)
except (ValueError, TypeError, InvalidOperation) as exc:
@@ -104,12 +107,20 @@ class BusinessService:
actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
if domain in HIGH_RISK_DOMAINS:
self._ensure_approved(approval_ticket_id, domain, None, f"create:{domain}")
model = get_domain_model(domain)
payload = _model_payload(model, data)
record = model(**payload)
self.db.add(record)
if domain in HIGH_RISK_DOMAINS:
self.db.flush()
self._consume_approval(
approval_ticket_id,
domain,
record.id,
f"create:{domain}",
data,
actor,
)
self.db.commit()
self.db.refresh(record)
result = serialize_model(record)
@@ -137,8 +148,6 @@ class BusinessService:
actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
if domain in HIGH_RISK_DOMAINS:
self._ensure_approved(approval_ticket_id, domain, record_id, f"update:{domain}")
model = get_domain_model(domain)
record = self.db.get(model, record_id)
if record is None:
@@ -146,7 +155,17 @@ class BusinessService:
status_code=status.HTTP_404_NOT_FOUND,
detail="Record not found",
)
for key, value in _model_payload(model, data).items():
payload = _model_payload(model, data)
if domain in HIGH_RISK_DOMAINS:
self._consume_approval(
approval_ticket_id,
domain,
record_id,
f"update:{domain}",
data,
actor,
)
for key, value in payload.items():
setattr(record, key, value)
self.db.commit()
self.db.refresh(record)
@@ -167,25 +186,25 @@ class BusinessService:
)
return result
def _ensure_approved(
def _consume_approval(
self,
approval_ticket_id: str | None,
domain: str,
record_id: str | int | None,
action: str,
payload: dict[str, Any],
actor: str,
) -> None:
if not approval_ticket_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="High-risk domain change requires approval_ticket_id",
)
if not ApprovalService(self.db).is_approved_for(
ApprovalService(self.db).consume_for(
approval_ticket_id,
domain,
record_id,
action,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Approval ticket is not approved for this change",
)
payload,
actor,
)

View File

@@ -0,0 +1,10 @@
from enum import StrEnum
class LegacyQueryName(StrEnum):
PROJECTS = "projects"
class LegacyQueryError(StrEnum):
QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist"
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first."

View File

@@ -31,7 +31,10 @@ def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
@router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).execute_readonly(payload.sql, payload.params, payload.limit)
service = LegacyMySQLService(db)
if payload.sql:
return service.execute_readonly(payload.sql, payload.params, payload.limit)
return service.execute_allowed_query(payload.query_name, payload.params, payload.limit)
@router.get("/projects", response_model=QueryResult)
@@ -47,6 +50,7 @@ def sync_projects(
) -> dict:
return LegacyMySQLService(db).sync_projects(
source_query=payload.source_query,
source_query_name=payload.source_query_name,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,

View File

@@ -3,12 +3,20 @@ from typing import Any
from pydantic import BaseModel, Field
from app.core.constants import ActorValue
from app.modules.legacy_mysql.constants import LegacyQueryName
class ReadonlyQueryRequest(BaseModel):
"""Readonly SQL query request for the legacy MySQL connection."""
"""Allowlisted readonly query request for the legacy MySQL connection."""
sql: str = Field(..., description="Readonly SELECT statement.")
query_name: str | None = Field(
default=LegacyQueryName.PROJECTS,
description="Configured readonly query name.",
)
sql: str | None = Field(
default=None,
description="Deprecated: must exactly match a configured readonly query.",
)
params: dict[str, Any] = Field(default_factory=dict)
limit: int = Field(default=100, ge=1, le=500)
@@ -32,7 +40,11 @@ class LegacyProjectSyncRequest(BaseModel):
source_query: str | None = Field(
default=None,
description="Optional SELECT query for project sync.",
description="Deprecated: must exactly match a configured readonly query.",
)
source_query_name: str | None = Field(
default=LegacyQueryName.PROJECTS,
description="Configured readonly query name for project sync.",
)
field_map: dict[str, str] = Field(
default_factory=dict,

View File

@@ -19,6 +19,7 @@ from app.modules.audit.service import AuditService
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
from app.modules.business.models import LegacySyncRun, Project
from app.modules.business.service import serialize_model
from app.modules.legacy_mysql.constants import LegacyQueryError, LegacyQueryName
FORBIDDEN_SQL_TOKENS = {
"insert",
@@ -50,6 +51,18 @@ def _row_to_dict(row: RowMapping) -> dict[str, Any]:
return {key: _jsonable(value) for key, value in row.items()}
def _normalize_sql(sql: str) -> str:
return " ".join(sql.strip().rstrip(";").split()).lower()
def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
if query_name is None:
return LegacyQueryName.PROJECTS.value
if isinstance(query_name, LegacyQueryName):
return query_name.value
return str(query_name)
class LegacyMySQLService:
"""Read legacy MySQL data and sync projects into the internal ledger."""
@@ -74,6 +87,17 @@ class LegacyMySQLService:
if tokens & FORBIDDEN_SQL_TOKENS:
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
@staticmethod
def _allowed_queries() -> dict[str, str]:
settings = get_settings()
queries = {
_query_name_text(name): sql
for name, sql in settings.legacy_allowed_queries.items()
}
if settings.legacy_project_query:
queries.setdefault(LegacyQueryName.PROJECTS.value, settings.legacy_project_query)
return queries
def health(self) -> dict[str, str]:
engine = self._ensure_engine()
try:
@@ -113,6 +137,39 @@ class LegacyMySQLService:
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
"""Execute a readonly SQL statement only when it matches the allowlist."""
normalized_sql = _normalize_sql(sql)
for allowed_sql in self._allowed_queries().values():
if _normalize_sql(allowed_sql) == normalized_sql:
return self._execute_readonly_sql(allowed_sql, params, limit)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
)
def execute_allowed_query(
self,
query_name: str | None,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
queries = self._allowed_queries()
normalized_name = _query_name_text(query_name)
sql = queries.get(normalized_name)
if not sql:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
)
return self._execute_readonly_sql(sql, params, limit)
def _execute_readonly_sql(
self,
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
self._ensure_readonly(sql)
engine = self._ensure_engine()
@@ -132,9 +189,9 @@ class LegacyMySQLService:
if not settings.legacy_project_query:
raise HTTPException(
status_code=400,
detail="LEGACY_PROJECT_QUERY is not configured. Configure it in .env first.",
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED,
)
return self.execute_readonly(settings.legacy_project_query, {"limit": limit}, limit=limit)
return self.execute_allowed_query(LegacyQueryName.PROJECTS, {"limit": limit}, limit=limit)
@staticmethod
def _value(
@@ -187,6 +244,7 @@ class LegacyMySQLService:
def sync_projects(
self,
source_query: str | None = None,
source_query_name: str | None = None,
field_map: dict[str, str] | None = None,
limit: int = 100,
dry_run: bool = True,
@@ -198,12 +256,13 @@ class LegacyMySQLService:
detail="Application database session is not available",
)
settings = get_settings()
query = source_query or settings.legacy_project_query
if not query:
raise HTTPException(status_code=400, detail="Project sync query is not configured")
rows = self.execute_readonly(query, {"limit": limit}, limit=limit)["rows"]
query_name = source_query_name or LegacyQueryName.PROJECTS
if source_query:
rows = self.execute_readonly(source_query, {"limit": limit}, limit=limit)["rows"]
query_ref = "allowlisted_inline_sql"
else:
rows = self.execute_allowed_query(query_name, {"limit": limit}, limit=limit)["rows"]
query_ref = _query_name_text(query_name)
field_map = field_map or {}
created = 0
updated = 0
@@ -288,7 +347,7 @@ class LegacyMySQLService:
target_type=BusinessDomain.PROJECTS,
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
"source_query": source_query or "LEGACY_PROJECT_QUERY",
"source_query": query_ref,
"field_map": field_map,
"limit": limit,
"dry_run": dry_run,

View File

@@ -900,19 +900,13 @@ class ReportService:
WorkTask.due_date >= start,
WorkTask.due_date <= end,
]
start_at = datetime.combine(start, datetime.min.time())
end_at = datetime.combine(end, datetime.max.time())
project_filters = []
risk_filters = [RiskEvent.status == StatusValue.OPEN]
procurement_filters = [
Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES),
Procurement.created_at >= start_at,
Procurement.created_at <= end_at,
]
expense_filters = [
Expense.approval_status.in_(PENDING_APPROVAL_STATUSES),
Expense.created_at >= start_at,
Expense.created_at <= end_at,
]
attendance_filters = [
AttendanceRecord.work_date >= start,