Условие
Есть кусок исследовательского скрипта (см. ниже), который мы хотим поставить на регулярное выполнение. Перепишите код, отформатировав его для удобства дальнейшего переиспользования и исправив ошибки, если они есть. Напишите саммари того, что и почему изменили.
Исходный код (упрощённо):
import pandas as pd, numpy as np
from datetime import datetime, timedelta, date
from dateutil.relativedelta import relativedelta
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
today_dt = (datetime.today()).date()
today = today_dt.strftime('%Y-%m-%d')
yesterday_dt = today_dt - timedelta(days=1)
yesterday = yesterday_dt.strftime('%Y-%m-%d')
two_weeks_ago_start = (today_dt - timedelta(days=14)).strftime('%Y-%m-%d')
last_year_day = (yesterday_dt - relativedelta(years=1)).strftime('%Y-%m-%d')
total_aviasales_bookings = session.query(f"""
select to_date(booked_at) as period, count(booking_id) as total_bookings_aviasales
from bd.bookings
where to_date(booked_at) >= '{two_weeks_ago_start}' and to_date(booked_at) <= '{yesterday}'
group by 1 order by 1
""")
total_aviasales_gross_profit = session.query(f"""
select to_date(booked_at) as period, sum(profit) as total_profit_aviasales
from bd.bookings
where to_date(booked_at) >= '{two_weeks_ago_start}' and to_date(booked_at) <= '{yesterday}'
group by 1 order by 1
""")
# ... ещё две похожие выгрузки + 4 одинаковых по структуре графикаРешение
Подход
Скрипт — типичный «hand-written research notebook»: 4 почти одинаковых SQL-запроса, 4 однотипных графика, имена переменных непоследовательные, есть и реальные баги.
Для регулярного запуска нужно:
- Параметризовать даты (а не считать «сегодня - 14»).
- Вынести SQL в шаблоны/функции, не дублировать.
- Конфиг для путей/таблиц/цветов.
- Логирование вместо
print. - Обработка ошибок (что делать, если данных нет).
- Идемпотентность: запуск дважды не должен ломаться.
- Фикс багов в исходнике.
Найденные баги в исходнике
- Нерабочие переменные: в графике есть
df_bookings_megoиdf_bookings_mego_last_year, которых нет в коде — упадётNameError(видимо, в исходнике переименовали часть переменных, но не все). - Несовпадение полей: в SQL —
booked_atиperiod, а в графике обращаемся к'pdate'— не та колонка →KeyError. warnings.filterwarnings("ignore")глушит реальные предупреждения. Опасно в проде.- Подстановка дат через f-string — потенциальный SQL-injection и проблема с экранированием. Используйте параметризацию.
data_copy[...] * 2 + sqrt(...)— это не «forecast», это магическая формула без обоснования. Скорее всего, осталась с экспериментов.- Нет timezone:
datetime.today()возвращает локальное время. На сервере с другим tz расчёт «вчера» поедет.
Реализация — рефакторинг
"""
aviasales_daily_dashboard.py
Регулярный скрипт сборки графиков:
- бронирования и прибыль за последние 2 недели
- доля заказов с sender_id ∈ (10,20,30) — 2 недели и год
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
import pandas as pd
import matplotlib.pyplot as plt
from dateutil.relativedelta import relativedelta
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
# --- 1. Конфиг -----------------------------------------------------------
@dataclass(frozen=True)
class Config:
senders_of_interest: tuple = (10, 20, 30)
short_window_days: int = 14
long_window_years: int = 1
output_dir: Path = Path("artifacts")
last_marker_color: str = "red"
# --- 2. Даты и окна ------------------------------------------------------
def windows(today: date, cfg: Config):
yesterday = today - timedelta(days=1)
short_start = today - timedelta(days=cfg.short_window_days)
long_start = yesterday - relativedelta(years=cfg.long_window_years)
return {
"yesterday": yesterday,
"short_start": short_start,
"long_start": long_start,
}
# --- 3. Параметризованные SQL-выгрузки ----------------------------------
SQL_BOOKINGS = """
SELECT to_date(booked_at) AS period,
COUNT(booking_id) AS bookings,
SUM(profit) AS profit,
COUNT(IF(sender_id IN :senders, booking_id, NULL)) AS bookings_sender
FROM bd.bookings
WHERE to_date(booked_at) BETWEEN :start AND :end
GROUP BY 1 ORDER BY 1
"""
def fetch_period(session, start: date, end: date, senders: tuple) -> pd.DataFrame:
df = session.query(SQL_BOOKINGS, params={
"senders": senders, "start": start, "end": end
})
df["period"] = pd.to_datetime(df["period"])
df["share_of_sender"] = df["bookings_sender"] / df["bookings"] * 100
return df
# --- 4. Визуализация -----------------------------------------------------
def plot_panel(short: pd.DataFrame, long: pd.DataFrame, cfg: Config, out: Path):
fig, axs = plt.subplots(2, 2, figsize=(12, 8))
fig.suptitle("Daily metrics — Aviasales", fontsize=16)
panels = [
(axs[0, 0], short, "bookings", "Bookings (2 weeks)"),
(axs[0, 1], short, "profit", "Profit (2 weeks)"),
(axs[1, 0], short, "share_of_sender", "Sender share, % (2 weeks)"),
(axs[1, 1], long, "share_of_sender", "Sender share, % (year)"),
]
for ax, df, col, title in panels:
if df.empty:
log.warning("No data for %s", title)
ax.set_title(f"{title} — no data"); continue
ax.plot(df["period"], df[col], marker="o")
ax.scatter(df["period"].iloc[-1], df[col].iloc[-1],
color=cfg.last_marker_color, s=80, label="last day")
ax.set_title(title); ax.set_xlabel("date"); ax.legend()
fig.autofmt_xdate()
out.mkdir(parents=True, exist_ok=True)
path = out / f"daily_{date.today().isoformat()}.png"
fig.savefig(path, dpi=120, bbox_inches="tight")
log.info("Saved %s", path)
def main(session, today: date | None = None, cfg: Config = Config()):
today = today or date.today()
w = windows(today, cfg)
log.info("Windows: %s", w)
short = fetch_period(session, w["short_start"], w["yesterday"], cfg.senders_of_interest)
long_ = fetch_period(session, w["long_start"], w["yesterday"], cfg.senders_of_interest)
plot_panel(short, long_, cfg, cfg.output_dir)
if __name__ == "__main__":
from analytics_db import session # placeholder: ваш коннектор
main(session)Саммари изменений
| Что | Зачем |
|---|---|
| Один SQL-шаблон вместо 4 | DRY: исправление колонки в одном месте |
params={...} вместо f-string |
защита от инъекций, корректные типы дат |
dataclass Config |
один источник правды для констант |
| Логирование вместо print | в проде нужны структурированные логи |
today: date | None параметр |
возможность ручного бэкфилла |
output_dir.mkdir(parents=True, exist_ok=True) |
идемпотентность |
| Удалена «формула forecast» | была магической, без обоснования |
if df.empty: warning |
мягкая деградация при отсутствии данных |
Удалён warnings.filterwarnings("ignore") |
в проде нужно видеть реальные предупреждения |
| Имя файла-картинки с датой | не перезаписываем вчерашнюю |
Подводные камни
- Параметризация дат. Использование
f"{date}"в SQL — это и SQL-injection, и проблема с типами. Параметризуйте через драйвер. datetime.today()без tz. Локальное время. На сервере в UTC — расчёт «вчера» сместится. Используйтеdate.today()с явным tz черезpendulum/zoneinfo.- Дублирование SQL. Если в 4 запросах разные
WHERE— не пихайте всё в один; используйте функции с параметрами. - Графики молча падают на пустых данных. В рефакторе — guard
if df.empty. - «Магические числа» (10, 20, 30 в
sender_id). Вынесли в конфиг. - Имя файла без даты. Перезапись «вчерашнего» отчёта при ручном повторе. Добавили
today.isoformat().
Эталонный ответ
Главное в ответе — назвать конкретные баги (несуществующие переменные df_bookings_mego, разные имена колонок period vs pdate, magic-формула forecast, f-string SQL без параметров) и объяснить философию рефакторинга (DRY, конфиг, логи, идемпотентность). Не просто «причесать», а сделать код пригодным для cron / Airflow.