""" Per-user profile and preferences. Two unrelated things share one table because they share a lifetime and an edit screen: body measurements (height, weight, birth date, sex) that the rating bands and the fitness-age estimate read, and preferences (units, how often to sync, how far back to pull). Every value is optional. An account with an empty profile still works — it just falls back to general-population bands instead of personalised ones. """ import datetime from db import execute, query_one from config import DB_TYPE # Only these are accepted from a request body; anything else is ignored rather # than rejected, so an older client posting an unknown field still saves. FIELDS = ( "height_cm", "weight_kg", "birth_date", "sex", "units", "auto_sync", "auto_sync_minutes", "history_days", ) DEFAULTS = { "height_cm": None, "weight_kg": None, "birth_date": None, "sex": None, "units": "metric", "auto_sync": 1, "auto_sync_minutes": 60, # The range last chosen on the 数据同步 page, remembered so the picker # opens where the user left it. -1 is 自上次同步 and 0 is 全部历史. # # This is a *manual* sync setting and nothing else reads it — auto-sync # has its own fixed window (scheduler.SYNC_DAYS). It used to live in 设置 # as 历史范围 while only taking effect on another page, which is exactly # how "全部历史" ended up looking like it did nothing. "history_days": -1, } SEXES = ("male", "female", "other") UNITS = ("metric", "imperial") # Offered in the UI as a picker; anything else is snapped to the nearest. INTERVALS = (30, 60, 180, 360, 720, 1440) # 0 is 全部历史 (walk back to the start of the account) and -1 自上次同步. HISTORY = (7, 30, 90, 180, 365, 730, 1095, 1825, 0, -1) CAMEL = { "height_cm": "heightCm", "weight_kg": "weightKg", "birth_date": "birthDate", "sex": "sex", "units": "units", "auto_sync": "autoSync", "auto_sync_minutes": "autoSyncMinutes", "history_days": "historyDays", } class InvalidSetting(ValueError): """A value the UI should not have sent; the message is user-facing.""" def _number(value, low, high, label): if value is None or value == "": return None try: n = float(value) except (TypeError, ValueError): raise InvalidSetting(f"{label}必须是数字") if not low <= n <= high: raise InvalidSetting(f"{label}应在 {low:g}~{high:g} 之间") return n def _date(value, label): if not value: return None text = str(value)[:10] try: d = datetime.date.fromisoformat(text) except ValueError: raise InvalidSetting(f"{label}格式应为 YYYY-MM-DD") today = datetime.date.today() if not 0 < (today - d).days < 365 * 120: raise InvalidSetting(f"{label}看起来不对") return text def _choice(value, allowed, label, default=None): if value is None or value == "": return default if value not in allowed: raise InvalidSetting(f"{label}只能是 {'/'.join(map(str, allowed))}") return value def _snap(value, allowed, default): """Nearest allowed number rather than an error. The interval and history controls are pickers, so an off-list value means a stale client, not a user mistake — snapping keeps them working. """ if value is None or value == "": return default try: n = int(value) except (TypeError, ValueError): return default if n in allowed: return n if n <= 0: return 0 if 0 in allowed else default return min((a for a in allowed if a > 0), key=lambda a: abs(a - n)) def clean(patch): """Validate an incoming patch into storable columns.""" out = {} if "heightCm" in patch or "height_cm" in patch: out["height_cm"] = _number( patch.get("heightCm", patch.get("height_cm")), 80, 250, "身高" ) if "weightKg" in patch or "weight_kg" in patch: out["weight_kg"] = _number( patch.get("weightKg", patch.get("weight_kg")), 25, 300, "体重" ) if "birthDate" in patch or "birth_date" in patch: out["birth_date"] = _date( patch.get("birthDate", patch.get("birth_date")), "出生日期" ) if "sex" in patch: out["sex"] = _choice(patch.get("sex"), SEXES, "性别") if "units" in patch: out["units"] = _choice(patch.get("units"), UNITS, "单位", "metric") if "autoSync" in patch or "auto_sync" in patch: out["auto_sync"] = 1 if patch.get("autoSync", patch.get("auto_sync")) else 0 if "autoSyncMinutes" in patch or "auto_sync_minutes" in patch: out["auto_sync_minutes"] = _snap( patch.get("autoSyncMinutes", patch.get("auto_sync_minutes")), INTERVALS, DEFAULTS["auto_sync_minutes"], ) if "historyDays" in patch or "history_days" in patch: out["history_days"] = _snap( patch.get("historyDays", patch.get("history_days")), HISTORY, DEFAULTS["history_days"], ) return out def get_raw(user_id): """Settings as column names, with defaults filled in for missing rows.""" row = query_one("SELECT * FROM user_settings WHERE user_id = ?", [user_id]) merged = dict(DEFAULTS) if row: for key in FIELDS: value = row.get(key) if value is not None: merged[key] = value # DATE comes back as a date object from MariaDB and a string from SQLite. if merged["birth_date"] is not None: merged["birth_date"] = str(merged["birth_date"])[:10] return merged def get_settings(user_id): """Settings in the camelCase the UI consumes, plus derived age.""" raw = get_raw(user_id) out = {CAMEL[k]: raw[k] for k in FIELDS} out["autoSync"] = bool(raw["auto_sync"]) out["age"] = age_from(raw["birth_date"]) out["bmi"] = bmi_from(raw["height_cm"], raw["weight_kg"]) return out def save_settings(user_id, patch): changes = clean(patch) if not changes: return get_settings(user_id) current = get_raw(user_id) current.update(changes) current["updated_at"] = datetime.datetime.utcnow().isoformat(timespec="seconds") cols = ["user_id"] + list(FIELDS) + ["updated_at"] values = [user_id] + [current[k] for k in FIELDS] + [current["updated_at"]] placeholders = ", ".join(["?"] * len(cols)) updatable = [c for c in cols if c != "user_id"] if DB_TYPE == "mariadb": updates = ", ".join(f"{c}=VALUES({c})" for c in updatable) sql = (f"INSERT INTO user_settings ({', '.join(cols)}) " f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}") else: updates = ", ".join(f"{c}=excluded.{c}" for c in updatable) sql = (f"INSERT INTO user_settings ({', '.join(cols)}) " f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}") execute(sql, values) return get_settings(user_id) def age_from(birth_date): if not birth_date: return None try: born = datetime.date.fromisoformat(str(birth_date)[:10]) except ValueError: return None today = datetime.date.today() # Subtract a year when this year's birthday has not happened yet. return today.year - born.year - ( (today.month, today.day) < (born.month, born.day) ) def bmi_from(height_cm, weight_kg): if not height_cm or not weight_kg: return None metres = float(height_cm) / 100 return round(float(weight_kg) / (metres * metres), 1)