Files
2026-06-22 00:49:38 +02:00

208 lines
7.9 KiB
Python

"""
History/diff rendering for the restock_diff (price / stock availability) processor.
A text diff is meaningless for restock watches - each history snapshot is just a short
string like "In Stock: True - Price: 12.34". So instead of a diff, this renders the whole
timeline as a simple smoothed line graph of price over time, coloured green where the item
was in stock and red where it was out of stock.
Conforms to processors.difference_base.DifferenceRenderer (module-level render()), and also
provides an optional get_data() hook served as JSON by /diff/<uuid>/processor-data so the
(potentially long) timeline stays out of the rendered HTML - same rationale as the preview
asset endpoint.
"""
import re
import statistics
import time
from flask_babel import gettext
from loguru import logger
# Snapshot format is written by processor.py: f"In Stock: {in_stock} - Price: {price}"
_RE_PRICE = re.compile(r"Price:\s*([\d.]+)", re.IGNORECASE)
_RE_INSTOCK = re.compile(r"In Stock:\s*(True|False)", re.IGNORECASE)
def _parse_restock_snapshot(text):
"""Parse a snapshot string into (price: float|None, in_stock: bool|None)."""
price = None
in_stock = None
if text:
m = _RE_PRICE.search(text)
if m:
try:
price = float(m.group(1))
except (TypeError, ValueError):
price = None
mi = _RE_INSTOCK.search(text)
if mi:
in_stock = mi.group(1).lower() == 'true'
return price, in_stock
def _currency(watch):
try:
return (watch.get('restock') or {}).get('currency') or ''
except Exception:
return ''
def _build_series(watch):
"""Read the full history into a [{timestamp, price, in_stock}] timeline (oldest -> newest)."""
series = []
for ts in list(watch.history.keys()):
try:
snapshot = watch.get_history_snapshot(timestamp=ts)
except Exception as e:
logger.error(f"Restock diff: unable to read snapshot {ts} for {watch.get('uuid')}: {e}")
continue
price, in_stock = _parse_restock_snapshot(snapshot)
series.append({'timestamp': int(ts), 'price': price, 'in_stock': in_stock})
return series
def compute_price_summary(series):
"""Compact price stats for the graph overlays + (future) watchlist deal-score badge.
This is the single source of truth for "is the current price low/typical/high". It works
off an already-built series (cheap, no extra I/O) so the same function can be reused at
check time to cache the result on the watch and avoid scanning history at render time.
Returns None when there are no prices.
"""
prices = [p['price'] for p in series if p.get('price') is not None]
if not prices:
return None
count = len(prices)
current = prices[-1] # series is oldest -> newest, so last priced value
ordered = sorted(prices)
mn, mx = ordered[0], ordered[-1]
avg = sum(prices) / count
if count >= 2:
try:
q = statistics.quantiles(prices, n=4, method='inclusive') # [p25, p50, p75]
p25, median, p75 = q[0], q[1], q[2]
except statistics.StatisticsError:
median, p25, p75 = statistics.median(prices), mn, mx
else:
median = p25 = p75 = current
# Two directional shares so the UI can phrase it naturally per status:
# cheaper_than_pct = share of records MORE expensive than now (good when price is low)
# pricier_than_pct = share of records CHEAPER than now (relevant when price is high)
cheaper_than_pct = round(100.0 * sum(1 for x in prices if x > current) / count)
pricier_than_pct = round(100.0 * sum(1 for x in prices if x < current) / count)
if current <= p25:
status = 'low'
elif current >= p75:
status = 'high'
else:
status = 'typical'
r2 = lambda v: round(v, 2)
return {
'count': count,
'min': r2(mn), 'max': r2(mx), 'avg': r2(avg), 'median': r2(median),
'p25': r2(p25), 'p75': r2(p75),
'current': r2(current),
'cheaper_than_pct': cheaper_than_pct,
'pricier_than_pct': pricier_than_pct,
'status': status,
'all_time_low': current <= mn,
}
def export_xlsx(watch, datastore):
"""Build an .xlsx of the full price/stock history. Returns (bytes, filename).
xlsx (not csv) so prices stay real numbers and dates stay real dates in the user's
spreadsheet, regardless of locale number formatting. Columns: Date, Stock status, Price,
Currency. Served by /diff/<uuid>/processor-export.xlsx.
"""
import datetime
from io import BytesIO
from openpyxl import Workbook
series = _build_series(watch)
currency = _currency(watch)
wb = Workbook()
ws = wb.active
ws.title = 'Price history'
ws.append([gettext('Date'), gettext('Stock status'), gettext('Price'), gettext('Currency')])
for p in series:
when = datetime.datetime.fromtimestamp(p['timestamp'])
if p['in_stock'] is None:
stock = ''
else:
stock = gettext('In stock') if p['in_stock'] else gettext('Out of stock')
cell_date = ws.cell(row=ws.max_row + 1, column=1, value=when)
cell_date.number_format = 'YYYY-MM-DD HH:MM:SS'
ws.cell(row=ws.max_row, column=2, value=stock)
# Real numeric price (None -> blank cell) so spreadsheet number formats apply.
ws.cell(row=ws.max_row, column=3, value=p['price'])
ws.cell(row=ws.max_row, column=4, value=currency)
# Sensible column widths.
for col, width in {'A': 20, 'B': 14, 'C': 12, 'D': 10}.items():
ws.column_dimensions[col].width = width
out = BytesIO()
wb.save(out)
return out.getvalue(), f"price-history-{watch.get('uuid')}.xlsx"
def get_data(watch, datastore, request):
"""JSON payload for the price/stock graph, fetched via /diff/<uuid>/processor-data.
Keeps the full timeline out of the HTML page."""
series = _build_series(watch)
priced = sum(1 for p in series if p['price'] is not None)
logger.info(f"Restock diff get_data for {watch.get('uuid')}: {len(series)} snapshots, {priced} with a price")
return {
'series': series,
'currency': _currency(watch),
'summary': compute_price_summary(series),
}
def render(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None):
"""Render the price/stock timeline page (shell + summary). The graph data is loaded
asynchronously from get_data() so this stays light regardless of history length."""
uuid = watch.get('uuid')
dates = list(watch.history.keys())
# Light render: read only the most recent snapshot for the summary badge/price.
latest = None
if dates:
try:
price, in_stock = _parse_restock_snapshot(watch.get_history_snapshot(timestamp=dates[-1]))
latest = {'timestamp': int(dates[-1]), 'price': price, 'in_stock': in_stock}
except Exception as e:
logger.error(f"Restock diff: unable to read latest snapshot for {uuid}: {e}")
# Opening the history page counts as viewing it (mirrors the text diff page).
datastore.set_last_viewed(uuid, time.time())
return render_template(
'restock_diff/difference.html',
uuid=uuid,
watch=watch,
current_diff_url=watch['url'],
extra_title=f" - {watch.label} - {gettext('Price history')}",
last_error=watch['last_error'],
screenshot=watch.get_screenshot(),
last_error_screenshot=watch.get_error_snapshot(),
last_error_text=watch.get_error_text(),
versions=dates,
from_version=str(dates[-2]) if len(dates) >= 2 else (str(dates[-1]) if dates else ''),
to_version=str(dates[-1]) if dates else '',
restock_latest=latest,
restock_currency=_currency(watch),
has_enough_history=len(dates) >= 2,
processor_data_url=url_for('ui.ui_diff.diff_history_page_processor_data', uuid=uuid),
)