mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
242 lines
9.3 KiB
Python
242 lines
9.3 KiB
Python
"""Time range utilities for stats queries."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, model_validator
|
|
|
|
|
|
class TimeRangeParams(BaseModel):
|
|
"""
|
|
Time range parameters (local-date semantics).
|
|
|
|
Rules:
|
|
1) Inputs are user-local dates, backend converts to UTC datetime range.
|
|
2) Range is half-open: [start, end).
|
|
"""
|
|
|
|
start_date: date | None = None
|
|
end_date: date | None = None
|
|
|
|
preset: (
|
|
Literal[
|
|
"today",
|
|
"yesterday",
|
|
"last7days",
|
|
"last30days",
|
|
"last90days",
|
|
"this_week",
|
|
"last_week",
|
|
"this_month",
|
|
"last_month",
|
|
"this_year",
|
|
]
|
|
| None
|
|
) = None
|
|
|
|
granularity: Literal["hour", "day", "week", "month"] = "day"
|
|
|
|
timezone: str | None = None
|
|
tz_offset_minutes: int = 0
|
|
|
|
@model_validator(mode="after")
|
|
def validate_and_resolve(self) -> "TimeRangeParams":
|
|
"""Validate and resolve preset to concrete dates."""
|
|
if self.preset:
|
|
user_today = self._get_user_today()
|
|
match self.preset:
|
|
case "today":
|
|
self.start_date = self.end_date = user_today
|
|
case "yesterday":
|
|
self.start_date = self.end_date = user_today - timedelta(days=1)
|
|
case "last7days":
|
|
self.start_date = user_today - timedelta(days=6)
|
|
self.end_date = user_today
|
|
case "last30days":
|
|
self.start_date = user_today - timedelta(days=29)
|
|
self.end_date = user_today
|
|
case "last90days":
|
|
self.start_date = user_today - timedelta(days=89)
|
|
self.end_date = user_today
|
|
case "this_week":
|
|
self.start_date = user_today - timedelta(days=user_today.weekday())
|
|
self.end_date = user_today
|
|
case "last_week":
|
|
week_start = user_today - timedelta(days=user_today.weekday())
|
|
self.start_date = week_start - timedelta(days=7)
|
|
self.end_date = week_start - timedelta(days=1)
|
|
case "this_month":
|
|
self.start_date = user_today.replace(day=1)
|
|
self.end_date = user_today
|
|
case "last_month":
|
|
first_of_this_month = user_today.replace(day=1)
|
|
self.end_date = first_of_this_month - timedelta(days=1)
|
|
self.start_date = self.end_date.replace(day=1)
|
|
case "this_year":
|
|
self.start_date = user_today.replace(month=1, day=1)
|
|
self.end_date = user_today
|
|
|
|
if not self.preset and (self.start_date is None or self.end_date is None):
|
|
raise ValueError("Either preset or both start_date and end_date must be provided")
|
|
|
|
if self.start_date and self.end_date and self.start_date > self.end_date:
|
|
raise ValueError("start_date must be <= end_date")
|
|
|
|
if self.start_date and self.end_date:
|
|
max_days = 365
|
|
days = (self.end_date - self.start_date).days
|
|
if days > max_days:
|
|
raise ValueError(f"Query range cannot exceed {max_days} days")
|
|
|
|
if self.granularity == "hour":
|
|
if self.start_date != self.end_date:
|
|
raise ValueError("Hour granularity only supports single day query")
|
|
|
|
return self
|
|
|
|
def validate_for_time_series(self) -> "TimeRangeParams":
|
|
"""Extra validation for time series queries."""
|
|
if self.granularity == "hour" and self.start_date != self.end_date:
|
|
raise ValueError("Hour granularity only supports single day query")
|
|
if self.start_date and self.end_date:
|
|
days_inclusive = (self.end_date - self.start_date).days + 1
|
|
max_days_for_time_series = 90
|
|
if days_inclusive > max_days_for_time_series:
|
|
raise ValueError(
|
|
f"Time series query range cannot exceed {max_days_for_time_series} days "
|
|
f"(requested {days_inclusive} days). "
|
|
"For longer ranges, use aggregated statistics instead."
|
|
)
|
|
return self
|
|
|
|
def _get_user_today(self) -> date:
|
|
"""Get user-local 'today'."""
|
|
if self.timezone:
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
user_tz = ZoneInfo(self.timezone)
|
|
return datetime.now(user_tz).date()
|
|
except Exception:
|
|
pass
|
|
|
|
user_now = datetime.now(timezone.utc) + timedelta(minutes=self.tz_offset_minutes)
|
|
return user_now.date()
|
|
|
|
def _get_tz_offset_for_date(self, local_date: date) -> timedelta:
|
|
"""Get timezone offset for a local date (DST-aware if timezone is provided)."""
|
|
if self.timezone:
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
user_tz = ZoneInfo(self.timezone)
|
|
local_midnight = datetime.combine(local_date, time.min)
|
|
local_aware = local_midnight.replace(tzinfo=user_tz)
|
|
return local_aware.utcoffset() or timedelta(0)
|
|
except Exception:
|
|
pass
|
|
return timedelta(minutes=self.tz_offset_minutes)
|
|
|
|
def to_utc_datetime_range(self) -> tuple[datetime, datetime]:
|
|
"""Convert to UTC datetime range (half-open)."""
|
|
start_offset = self._get_tz_offset_for_date(self.start_date)
|
|
end_offset = self._get_tz_offset_for_date(self.end_date + timedelta(days=1))
|
|
|
|
local_start = datetime.combine(self.start_date, time.min)
|
|
local_end = datetime.combine(self.end_date + timedelta(days=1), time.min)
|
|
|
|
start_utc = (local_start - start_offset).replace(tzinfo=timezone.utc)
|
|
end_utc = (local_end - end_offset).replace(tzinfo=timezone.utc)
|
|
return start_utc, end_utc
|
|
|
|
def get_complete_utc_dates(
|
|
self,
|
|
) -> tuple[list[date], tuple[datetime, datetime] | None, tuple[datetime, datetime] | None]:
|
|
"""Split into complete UTC days + head/tail boundaries."""
|
|
start_utc, end_utc = self.to_utc_datetime_range()
|
|
|
|
if (
|
|
start_utc.hour == 0
|
|
and start_utc.minute == 0
|
|
and start_utc.second == 0
|
|
and start_utc.microsecond == 0
|
|
):
|
|
first_complete_date = start_utc.date()
|
|
head_boundary = None
|
|
else:
|
|
first_complete_date = start_utc.date() + timedelta(days=1)
|
|
head_boundary = (
|
|
start_utc,
|
|
datetime.combine(first_complete_date, time.min, tzinfo=timezone.utc),
|
|
)
|
|
|
|
if (
|
|
end_utc.hour == 0
|
|
and end_utc.minute == 0
|
|
and end_utc.second == 0
|
|
and end_utc.microsecond == 0
|
|
):
|
|
last_complete_date = end_utc.date() - timedelta(days=1)
|
|
tail_boundary = None
|
|
else:
|
|
last_complete_date = end_utc.date() - timedelta(days=1)
|
|
tail_start = datetime.combine(end_utc.date(), time.min, tzinfo=timezone.utc)
|
|
tail_boundary = (tail_start, end_utc)
|
|
|
|
complete_dates = []
|
|
if first_complete_date <= last_complete_date:
|
|
current = first_complete_date
|
|
while current <= last_complete_date:
|
|
complete_dates.append(current)
|
|
current += timedelta(days=1)
|
|
|
|
return complete_dates, head_boundary, tail_boundary
|
|
|
|
def get_local_day_hours(self) -> list[tuple[date, datetime, datetime]]:
|
|
"""Return local-day mapped UTC ranges for time-series."""
|
|
result = []
|
|
current_date = self.start_date
|
|
while current_date <= self.end_date:
|
|
offset = self._get_tz_offset_for_date(current_date)
|
|
local_start = datetime.combine(current_date, time.min)
|
|
local_end = datetime.combine(current_date + timedelta(days=1), time.min)
|
|
day_start_utc = (local_start - offset).replace(tzinfo=timezone.utc)
|
|
day_end_utc = (
|
|
local_end - self._get_tz_offset_for_date(current_date + timedelta(days=1))
|
|
).replace(tzinfo=timezone.utc)
|
|
result.append((current_date, day_start_utc, day_end_utc))
|
|
current_date += timedelta(days=1)
|
|
return result
|
|
|
|
|
|
def split_time_range_for_hourly(start_utc: datetime, end_utc: datetime) -> tuple[
|
|
tuple[datetime, datetime] | None,
|
|
list[datetime],
|
|
tuple[datetime, datetime] | None,
|
|
]:
|
|
"""Split into head fragment, complete hours, tail fragment."""
|
|
first_hour = start_utc.replace(minute=0, second=0, microsecond=0)
|
|
if start_utc > first_hour:
|
|
first_hour += timedelta(hours=1)
|
|
head_fragment = (start_utc, first_hour) if first_hour <= end_utc else None
|
|
else:
|
|
head_fragment = None
|
|
first_hour = start_utc
|
|
|
|
last_hour = end_utc.replace(minute=0, second=0, microsecond=0)
|
|
if end_utc > last_hour:
|
|
tail_fragment = (last_hour, end_utc) if last_hour >= first_hour else None
|
|
else:
|
|
tail_fragment = None
|
|
last_hour = end_utc
|
|
|
|
complete_hours = []
|
|
current = first_hour
|
|
while current < last_hour:
|
|
complete_hours.append(current)
|
|
current += timedelta(hours=1)
|
|
|
|
return head_fragment, complete_hours, tail_fragment
|