744 lines
28 KiB
Python
744 lines
28 KiB
Python
"""
|
|
Medications API - medication scheduling, logging, and adherence tracking
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, date, timedelta, timezone
|
|
|
|
import flask
|
|
import jwt
|
|
from psycopg2.extras import Json
|
|
import core.auth as auth
|
|
import core.postgres as postgres
|
|
import core.tz as tz
|
|
|
|
|
|
def _get_user_uuid(token):
|
|
try:
|
|
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
|
return payload.get("sub")
|
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
|
return None
|
|
|
|
|
|
def _auth(request):
|
|
"""Extract and verify token. Returns user_uuid or None."""
|
|
header = request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return None
|
|
token = header[7:]
|
|
user_uuid = _get_user_uuid(token)
|
|
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
|
|
return None
|
|
return user_uuid
|
|
|
|
|
|
_JSON_COLS = {"times", "days_of_week"}
|
|
|
|
|
|
def _wrap_json(data):
|
|
"""Wrap list/dict values in Json() for psycopg2 when targeting JSON columns."""
|
|
return {
|
|
k: Json(v) if k in _JSON_COLS and isinstance(v, (list, dict)) else v
|
|
for k, v in data.items()
|
|
}
|
|
|
|
|
|
def _filter_times_in_range(times, start, end):
|
|
"""Return times (HH:MM strings) that fall within [start, end] inclusive."""
|
|
return [t for t in times if start <= t <= end]
|
|
|
|
|
|
def _is_med_due_today(med, today, current_day):
|
|
"""Check if a medication is scheduled for today based on its frequency."""
|
|
freq = med.get("frequency", "daily")
|
|
if freq in ("daily", "twice_daily"):
|
|
return True
|
|
if freq == "as_needed":
|
|
return True # always show PRN, but with no scheduled times
|
|
if freq == "specific_days":
|
|
days = med.get("days_of_week", [])
|
|
return current_day in days
|
|
if freq == "every_n_days":
|
|
start = med.get("start_date")
|
|
interval = med.get("interval_days")
|
|
if start and interval:
|
|
start_d = (
|
|
start
|
|
if isinstance(start, date)
|
|
else datetime.strptime(str(start), "%Y-%m-%d").date()
|
|
)
|
|
days_since = (today - start_d).days
|
|
return days_since >= 0 and days_since % interval == 0
|
|
return False
|
|
return True
|
|
|
|
|
|
def _get_nearest_scheduled_time(med):
|
|
"""Find the nearest scheduled time for a medication.
|
|
|
|
Looks at the medication's scheduled times and finds the one closest to now.
|
|
Returns the time as HH:MM string, or None if medication is as_needed.
|
|
"""
|
|
freq = med.get("frequency", "daily")
|
|
|
|
# PRN medications don't have scheduled times
|
|
if freq == "as_needed":
|
|
return None
|
|
|
|
times = med.get("times", [])
|
|
if not times:
|
|
return None
|
|
|
|
# Get current time
|
|
now = datetime.now().time()
|
|
now_minutes = now.hour * 60 + now.minute
|
|
|
|
# Find the time closest to now (within ±4 hours window)
|
|
best_time = None
|
|
best_diff = float("inf")
|
|
window_minutes = 4 * 60 # 4 hour window
|
|
|
|
for time_str in times:
|
|
try:
|
|
# Parse time string (e.g., "12:00")
|
|
hour, minute = map(int, time_str.split(":"))
|
|
time_minutes = hour * 60 + minute
|
|
|
|
# Calculate difference (handle wrap-around for times near midnight)
|
|
diff = abs(time_minutes - now_minutes)
|
|
# Also check if it's much earlier/later due to day boundary
|
|
alt_diff = abs((time_minutes + 24 * 60) - now_minutes)
|
|
diff = min(diff, alt_diff)
|
|
|
|
# Only consider times within the window
|
|
if diff <= window_minutes and diff < best_diff:
|
|
best_diff = diff
|
|
best_time = time_str
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
|
|
# If no time within window, use the most recent past time
|
|
if not best_time:
|
|
for time_str in times:
|
|
try:
|
|
hour, minute = map(int, time_str.split(":"))
|
|
time_minutes = hour * 60 + minute
|
|
|
|
# If this time was earlier today, it's a candidate
|
|
if time_minutes <= now_minutes:
|
|
diff = now_minutes - time_minutes
|
|
if diff < best_diff:
|
|
best_diff = diff
|
|
best_time = time_str
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
|
|
# If still no time found, use the first scheduled time
|
|
if not best_time and times:
|
|
best_time = times[0]
|
|
|
|
return best_time
|
|
|
|
|
|
def _compute_next_dose_date(med):
|
|
"""Compute the next dose date for every_n_days medications."""
|
|
interval = med.get("interval_days")
|
|
if not interval:
|
|
return None
|
|
return (tz.user_today() + timedelta(days=interval)).isoformat()
|
|
|
|
|
|
def _count_expected_doses(med, period_start, days):
|
|
"""Count expected doses in a period based on frequency and schedule."""
|
|
freq = med.get("frequency", "daily")
|
|
times_per_day = len(med.get("times", [])) or 1
|
|
|
|
if freq == "as_needed":
|
|
return 0 # PRN has no expected doses
|
|
if freq in ("daily", "twice_daily"):
|
|
return days * times_per_day
|
|
if freq == "specific_days":
|
|
dow = med.get("days_of_week", [])
|
|
count = 0
|
|
for d in range(days):
|
|
check_date = period_start + timedelta(days=d)
|
|
if check_date.strftime("%a").lower() in dow:
|
|
count += times_per_day
|
|
return count
|
|
if freq == "every_n_days":
|
|
interval = med.get("interval_days", 1)
|
|
start = med.get("start_date")
|
|
if not start:
|
|
return 0
|
|
start_d = (
|
|
start
|
|
if isinstance(start, date)
|
|
else datetime.strptime(str(start), "%Y-%m-%d").date()
|
|
)
|
|
count = 0
|
|
for d in range(days):
|
|
check_date = period_start + timedelta(days=d)
|
|
diff = (check_date - start_d).days
|
|
if diff >= 0 and diff % interval == 0:
|
|
count += times_per_day
|
|
return count
|
|
return days * times_per_day
|
|
|
|
|
|
def _log_local_date(created_at, user_tz):
|
|
"""Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD."""
|
|
if created_at is None:
|
|
return ""
|
|
if isinstance(created_at, datetime):
|
|
# Treat naive datetimes as UTC
|
|
if created_at.tzinfo is None:
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
return created_at.astimezone(user_tz).date().isoformat()
|
|
# Fallback: already a string
|
|
return str(created_at)[:10]
|
|
|
|
|
|
def _count_logs_in_period(logs, period_start_str, action, user_tz=None):
|
|
"""Count logs of a given action where created_at (local date) >= period_start."""
|
|
return sum(
|
|
1
|
|
for log in logs
|
|
if log.get("action") == action
|
|
and (
|
|
_log_local_date(log.get("created_at"), user_tz)
|
|
if user_tz
|
|
else str(log.get("created_at", ""))[:10]
|
|
)
|
|
>= period_start_str
|
|
)
|
|
|
|
|
|
def register(app):
|
|
# ── Medications CRUD ──────────────────────────────────────────
|
|
|
|
@app.route("/api/medications", methods=["GET"])
|
|
def api_listMedications():
|
|
"""List all medications for the logged-in user."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
meds = postgres.select(
|
|
"medications", where={"user_uuid": user_uuid}, order_by="name"
|
|
)
|
|
return flask.jsonify(meds), 200
|
|
|
|
@app.route("/api/medications", methods=["POST"])
|
|
def api_addMedication():
|
|
"""Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
required = ["name", "dosage", "unit", "frequency"]
|
|
missing = [f for f in required if not data.get(f)]
|
|
if missing:
|
|
return flask.jsonify(
|
|
{"error": f"missing required fields: {', '.join(missing)}"}
|
|
), 400
|
|
|
|
row = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_uuid": user_uuid,
|
|
"name": data["name"],
|
|
"dosage": data["dosage"],
|
|
"unit": data["unit"],
|
|
"frequency": data["frequency"],
|
|
"times": data.get("times", []),
|
|
"days_of_week": data.get("days_of_week", []),
|
|
"interval_days": data.get("interval_days"),
|
|
"start_date": data.get("start_date"),
|
|
"notes": data.get("notes"),
|
|
"active": True,
|
|
}
|
|
|
|
# Compute next_dose_date for interval meds
|
|
if (
|
|
data.get("frequency") == "every_n_days"
|
|
and data.get("start_date")
|
|
and data.get("interval_days")
|
|
):
|
|
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
|
|
today = tz.user_today()
|
|
if start > today:
|
|
row["next_dose_date"] = data["start_date"]
|
|
else:
|
|
days_since = (today - start).days
|
|
remainder = days_since % data["interval_days"]
|
|
if remainder == 0:
|
|
row["next_dose_date"] = today.isoformat()
|
|
else:
|
|
row["next_dose_date"] = (
|
|
today + timedelta(days=data["interval_days"] - remainder)
|
|
).isoformat()
|
|
|
|
med = postgres.insert("medications", _wrap_json(row))
|
|
return flask.jsonify(med), 201
|
|
|
|
@app.route("/api/medications/<med_id>", methods=["GET"])
|
|
def api_getMedication(med_id):
|
|
"""Get a single medication with its schedule."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
return flask.jsonify(med), 200
|
|
|
|
@app.route("/api/medications/<med_id>", methods=["PUT"])
|
|
def api_updateMedication(med_id):
|
|
"""Update medication details."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
existing = postgres.select_one(
|
|
"medications", {"id": med_id, "user_uuid": user_uuid}
|
|
)
|
|
if not existing:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
allowed = [
|
|
"name",
|
|
"dosage",
|
|
"unit",
|
|
"frequency",
|
|
"times",
|
|
"notes",
|
|
"active",
|
|
"days_of_week",
|
|
"interval_days",
|
|
"start_date",
|
|
"next_dose_date",
|
|
]
|
|
updates = {k: v for k, v in data.items() if k in allowed}
|
|
if not updates:
|
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
|
result = postgres.update(
|
|
"medications", _wrap_json(updates), {"id": med_id, "user_uuid": user_uuid}
|
|
)
|
|
return flask.jsonify(result[0] if result else {}), 200
|
|
|
|
@app.route("/api/medications/<med_id>", methods=["DELETE"])
|
|
def api_deleteMedication(med_id):
|
|
"""Delete a medication and its logs."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
existing = postgres.select_one(
|
|
"medications", {"id": med_id, "user_uuid": user_uuid}
|
|
)
|
|
if not existing:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
postgres.delete("med_logs", {"medication_id": med_id})
|
|
postgres.delete("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
return flask.jsonify({"deleted": True}), 200
|
|
|
|
# ── Medication Logging (take / skip / snooze) ─────────────────
|
|
|
|
@app.route("/api/medications/<med_id>/take", methods=["POST"])
|
|
def api_takeMedication(med_id):
|
|
"""Log that a dose was taken. Body: {scheduled_time?, notes?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json() or {}
|
|
|
|
# Determine scheduled_time
|
|
scheduled_time = data.get("scheduled_time")
|
|
if not scheduled_time:
|
|
# Auto-determine from medication schedule
|
|
scheduled_time = _get_nearest_scheduled_time(med)
|
|
|
|
log_entry = {
|
|
"id": str(uuid.uuid4()),
|
|
"medication_id": med_id,
|
|
"user_uuid": user_uuid,
|
|
"action": "taken",
|
|
"scheduled_time": scheduled_time,
|
|
"notes": data.get("notes"),
|
|
}
|
|
log = postgres.insert("med_logs", log_entry)
|
|
# Advance next_dose_date for interval meds
|
|
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
|
next_date = _compute_next_dose_date(med)
|
|
if next_date:
|
|
postgres.update(
|
|
"medications", {"next_dose_date": next_date}, {"id": med_id}
|
|
)
|
|
return flask.jsonify(log), 201
|
|
|
|
@app.route("/api/medications/<med_id>/skip", methods=["POST"])
|
|
def api_skipMedication(med_id):
|
|
"""Log a skipped dose. Body: {scheduled_time?, reason?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json() or {}
|
|
|
|
# Determine scheduled_time
|
|
scheduled_time = data.get("scheduled_time")
|
|
if not scheduled_time:
|
|
# Auto-determine from medication schedule
|
|
scheduled_time = _get_nearest_scheduled_time(med)
|
|
|
|
log_entry = {
|
|
"id": str(uuid.uuid4()),
|
|
"medication_id": med_id,
|
|
"user_uuid": user_uuid,
|
|
"action": "skipped",
|
|
"scheduled_time": scheduled_time,
|
|
"notes": data.get("reason"),
|
|
}
|
|
log = postgres.insert("med_logs", log_entry)
|
|
# Advance next_dose_date for interval meds
|
|
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
|
next_date = _compute_next_dose_date(med)
|
|
if next_date:
|
|
postgres.update(
|
|
"medications", {"next_dose_date": next_date}, {"id": med_id}
|
|
)
|
|
return flask.jsonify(log), 201
|
|
|
|
@app.route("/api/medications/<med_id>/snooze", methods=["POST"])
|
|
def api_snoozeMedication(med_id):
|
|
"""Snooze a reminder. Body: {minutes: 15}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json() or {}
|
|
minutes = data.get("minutes", 15)
|
|
return flask.jsonify({"snoozed_until_minutes": minutes}), 200
|
|
|
|
# ── Medication Log / History ──────────────────────────────────
|
|
|
|
@app.route("/api/medications/<med_id>/log", methods=["GET"])
|
|
def api_getMedLog(med_id):
|
|
"""Get dose log for a medication. Query: ?days=30"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
days = flask.request.args.get("days", 30, type=int)
|
|
logs = postgres.select(
|
|
"med_logs",
|
|
where={"medication_id": med_id},
|
|
order_by="created_at DESC",
|
|
limit=days * 10,
|
|
)
|
|
return flask.jsonify(logs), 200
|
|
|
|
@app.route("/api/medications/today", methods=["GET"])
|
|
def api_todaysMeds():
|
|
"""Get today's medication schedule with taken/pending status.
|
|
|
|
Includes cross-midnight lookahead:
|
|
- After 22:00: includes next-day meds scheduled 00:00-02:00 (is_next_day)
|
|
- Before 02:00: includes previous-day meds scheduled 22:00-23:59 (is_previous_day)
|
|
"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
|
|
meds = postgres.select(
|
|
"medications", where={"user_uuid": user_uuid, "active": True}
|
|
)
|
|
now = tz.user_now()
|
|
user_tz = now.tzinfo
|
|
today = now.date()
|
|
today_str = today.isoformat()
|
|
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
|
current_hour = now.hour
|
|
|
|
result = []
|
|
seen_med_ids = set()
|
|
|
|
# Main pass: today's meds
|
|
for med in meds:
|
|
if not _is_med_due_today(med, today, current_day):
|
|
continue
|
|
|
|
freq = med.get("frequency", "daily")
|
|
is_prn = freq == "as_needed"
|
|
|
|
all_logs = postgres.select(
|
|
"med_logs",
|
|
where={"medication_id": med["id"]},
|
|
)
|
|
today_taken = [
|
|
log.get("scheduled_time") or ""
|
|
for log in all_logs
|
|
if log.get("action") == "taken"
|
|
and _log_local_date(log.get("created_at"), user_tz) == today_str
|
|
]
|
|
today_skipped = [
|
|
log.get("scheduled_time") or ""
|
|
for log in all_logs
|
|
if log.get("action") == "skipped"
|
|
and _log_local_date(log.get("created_at"), user_tz) == today_str
|
|
]
|
|
|
|
result.append(
|
|
{
|
|
"medication": med,
|
|
"scheduled_times": [] if is_prn else med.get("times", []),
|
|
"taken_times": today_taken,
|
|
"skipped_times": today_skipped,
|
|
"is_prn": is_prn,
|
|
}
|
|
)
|
|
seen_med_ids.add(med["id"])
|
|
|
|
# Late night pass (22:00+): include next-day meds scheduled 00:00-02:00
|
|
if current_hour >= 22:
|
|
tomorrow = today + timedelta(days=1)
|
|
tomorrow_str = tomorrow.isoformat()
|
|
tomorrow_day = tomorrow.strftime("%a").lower()
|
|
for med in meds:
|
|
if med["id"] in seen_med_ids:
|
|
continue
|
|
if not _is_med_due_today(med, tomorrow, tomorrow_day):
|
|
continue
|
|
freq = med.get("frequency", "daily")
|
|
if freq == "as_needed":
|
|
continue
|
|
times = med.get("times", [])
|
|
early_times = _filter_times_in_range(times, "00:00", "02:00")
|
|
if not early_times:
|
|
continue
|
|
|
|
all_logs = postgres.select(
|
|
"med_logs",
|
|
where={"medication_id": med["id"]},
|
|
)
|
|
tomorrow_taken = [
|
|
log.get("scheduled_time") or ""
|
|
for log in all_logs
|
|
if log.get("action") == "taken"
|
|
and _log_local_date(log.get("created_at"), user_tz) == tomorrow_str
|
|
]
|
|
tomorrow_skipped = [
|
|
log.get("scheduled_time") or ""
|
|
for log in all_logs
|
|
if log.get("action") == "skipped"
|
|
and _log_local_date(log.get("created_at"), user_tz) == tomorrow_str
|
|
]
|
|
|
|
result.append(
|
|
{
|
|
"medication": med,
|
|
"scheduled_times": early_times,
|
|
"taken_times": tomorrow_taken,
|
|
"skipped_times": tomorrow_skipped,
|
|
"is_prn": False,
|
|
"is_next_day": True,
|
|
}
|
|
)
|
|
seen_med_ids.add(med["id"])
|
|
|
|
# Early morning pass (<02:00): include previous-day meds scheduled 22:00-23:59
|
|
if current_hour < 2:
|
|
yesterday = today - timedelta(days=1)
|
|
yesterday_str = yesterday.isoformat()
|
|
yesterday_day = yesterday.strftime("%a").lower()
|
|
for med in meds:
|
|
if med["id"] in seen_med_ids:
|
|
continue
|
|
if not _is_med_due_today(med, yesterday, yesterday_day):
|
|
continue
|
|
freq = med.get("frequency", "daily")
|
|
if freq == "as_needed":
|
|
continue
|
|
times = med.get("times", [])
|
|
late_times = _filter_times_in_range(times, "22:00", "23:59")
|
|
if not late_times:
|
|
continue
|
|
|
|
all_logs = postgres.select(
|
|
"med_logs",
|
|
where={"medication_id": med["id"]},
|
|
)
|
|
yesterday_taken = [
|
|
log.get("scheduled_time") or ""
|
|
for log in all_logs
|
|
if log.get("action") == "taken"
|
|
and _log_local_date(log.get("created_at"), user_tz) == yesterday_str
|
|
]
|
|
yesterday_skipped = [
|
|
log.get("scheduled_time") or ""
|
|
for log in all_logs
|
|
if log.get("action") == "skipped"
|
|
and _log_local_date(log.get("created_at"), user_tz) == yesterday_str
|
|
]
|
|
|
|
result.append(
|
|
{
|
|
"medication": med,
|
|
"scheduled_times": late_times,
|
|
"taken_times": yesterday_taken,
|
|
"skipped_times": yesterday_skipped,
|
|
"is_prn": False,
|
|
"is_previous_day": True,
|
|
}
|
|
)
|
|
seen_med_ids.add(med["id"])
|
|
|
|
return flask.jsonify(result), 200
|
|
|
|
# ── Adherence Stats ───────────────────────────────────────────
|
|
|
|
@app.route("/api/medications/adherence", methods=["GET"])
|
|
def api_adherenceStats():
|
|
"""Get adherence stats across all meds. Query: ?days=30"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
num_days = flask.request.args.get("days", 30, type=int)
|
|
meds = postgres.select(
|
|
"medications", where={"user_uuid": user_uuid, "active": True}
|
|
)
|
|
now = tz.user_now()
|
|
user_tz = now.tzinfo
|
|
today = now.date()
|
|
period_start = today - timedelta(days=num_days)
|
|
period_start_str = period_start.isoformat()
|
|
|
|
result = []
|
|
for med in meds:
|
|
freq = med.get("frequency", "daily")
|
|
is_prn = freq == "as_needed"
|
|
expected = _count_expected_doses(med, period_start, num_days)
|
|
|
|
logs = postgres.select("med_logs", where={"medication_id": med["id"]})
|
|
taken = _count_logs_in_period(logs, period_start_str, "taken", user_tz)
|
|
skipped = _count_logs_in_period(logs, period_start_str, "skipped", user_tz)
|
|
|
|
if is_prn:
|
|
adherence_pct = None
|
|
elif expected > 0:
|
|
adherence_pct = round(min(taken / expected * 100, 100), 1)
|
|
else:
|
|
adherence_pct = 0
|
|
|
|
result.append(
|
|
{
|
|
"medication_id": med["id"],
|
|
"name": med["name"],
|
|
"taken": taken,
|
|
"skipped": skipped,
|
|
"expected": expected,
|
|
"adherence_percent": adherence_pct,
|
|
"is_prn": is_prn,
|
|
}
|
|
)
|
|
return flask.jsonify(result), 200
|
|
|
|
@app.route("/api/medications/<med_id>/adherence", methods=["GET"])
|
|
def api_medAdherence(med_id):
|
|
"""Get adherence stats for a single medication. Query: ?days=30"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
num_days = flask.request.args.get("days", 30, type=int)
|
|
now = tz.user_now()
|
|
user_tz = now.tzinfo
|
|
today = now.date()
|
|
period_start = today - timedelta(days=num_days)
|
|
period_start_str = period_start.isoformat()
|
|
|
|
freq = med.get("frequency", "daily")
|
|
is_prn = freq == "as_needed"
|
|
expected = _count_expected_doses(med, period_start, num_days)
|
|
|
|
logs = postgres.select("med_logs", where={"medication_id": med_id})
|
|
taken = _count_logs_in_period(logs, period_start_str, "taken", user_tz)
|
|
skipped = _count_logs_in_period(logs, period_start_str, "skipped", user_tz)
|
|
|
|
if is_prn:
|
|
adherence_pct = None
|
|
elif expected > 0:
|
|
adherence_pct = round(min(taken / expected * 100, 100), 1)
|
|
else:
|
|
adherence_pct = 0
|
|
|
|
return flask.jsonify(
|
|
{
|
|
"medication_id": med_id,
|
|
"name": med["name"],
|
|
"taken": taken,
|
|
"skipped": skipped,
|
|
"expected": expected,
|
|
"adherence_percent": adherence_pct,
|
|
"is_prn": is_prn,
|
|
}
|
|
), 200
|
|
|
|
# ── Refills ───────────────────────────────────────────────────
|
|
|
|
@app.route("/api/medications/<med_id>/refill", methods=["PUT"])
|
|
def api_setRefill(med_id):
|
|
"""Set refill info. Body: {quantity_remaining, refill_date?, pharmacy_notes?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
|
if not med:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
allowed = ["quantity_remaining", "refill_date", "pharmacy_notes"]
|
|
updates = {k: v for k, v in data.items() if k in allowed}
|
|
if not updates:
|
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
|
result = postgres.update(
|
|
"medications", updates, {"id": med_id, "user_uuid": user_uuid}
|
|
)
|
|
return flask.jsonify(result[0] if result else {}), 200
|
|
|
|
@app.route("/api/medications/refills-due", methods=["GET"])
|
|
def api_refillsDue():
|
|
"""Get medications that need refills soon. Query: ?days_ahead=7"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
days_ahead = flask.request.args.get("days_ahead", 7, type=int)
|
|
cutoff = (tz.user_today() + timedelta(days=days_ahead)).isoformat()
|
|
meds = postgres.select(
|
|
"medications",
|
|
where={"user_uuid": user_uuid},
|
|
)
|
|
due = []
|
|
for med in meds:
|
|
qty = med.get("quantity_remaining")
|
|
refill_date = med.get("refill_date")
|
|
if qty is not None and qty <= 7:
|
|
due.append(med)
|
|
elif refill_date and str(refill_date) <= cutoff:
|
|
due.append(med)
|
|
return flask.jsonify(due), 200
|