Redesign BP tracker with lightweight views and unobtrusive save confirmations
This commit is contained in:
+97
-77
@@ -1,77 +1,97 @@
|
||||
import csv
|
||||
from io import StringIO
|
||||
from flask import Blueprint, render_template, redirect, request, send_file, url_for, flash
|
||||
from app.models import Reading, db
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
data = Blueprint('data', __name__)
|
||||
|
||||
@data.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_data():
|
||||
if request.method == 'POST':
|
||||
# Handle CSV file upload
|
||||
file = request.files.get('file')
|
||||
if file and file.filename.endswith('.csv'):
|
||||
try:
|
||||
csv_data = csv.reader(StringIO(file.read().decode('utf-8')))
|
||||
next(csv_data) # Skip the header row
|
||||
readings_to_add = []
|
||||
for row in csv_data:
|
||||
timestamp, systolic, diastolic, heart_rate = row
|
||||
readings_to_add.append(Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S'),
|
||||
systolic=int(systolic),
|
||||
diastolic=int(diastolic),
|
||||
heart_rate=int(heart_rate),
|
||||
))
|
||||
db.session.bulk_save_objects(readings_to_add)
|
||||
db.session.commit()
|
||||
flash('Data imported successfully!', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Error importing data: {str(e)}', 'danger')
|
||||
else:
|
||||
flash('Please upload a valid CSV file.', 'danger')
|
||||
return redirect(url_for('data.manage_data'))
|
||||
|
||||
return render_template('data.html')
|
||||
|
||||
@data.route('/export', methods=['GET'])
|
||||
@login_required
|
||||
def export_data():
|
||||
import io
|
||||
from flask import Response
|
||||
|
||||
def generate_csv():
|
||||
"""Stream CSV rows to avoid loading all readings into memory."""
|
||||
# Write header
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Stream readings in chunks using yield_per
|
||||
readings = Reading.query.filter_by(user_id=current_user.id).order_by(
|
||||
Reading.timestamp
|
||||
).yield_per(500)
|
||||
|
||||
for reading in readings:
|
||||
writer.writerow([
|
||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.heart_rate,
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
return Response(
|
||||
generate_csv(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=readings.csv'}
|
||||
)
|
||||
import csv
|
||||
from io import StringIO
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, flash, stream_with_context
|
||||
from app.models import Reading, db
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
from app.forms import DeleteForm
|
||||
from app.insights import parse_range, readings_query, user_timezone
|
||||
|
||||
data = Blueprint('data', __name__)
|
||||
|
||||
@data.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_data():
|
||||
form = DeleteForm()
|
||||
if request.method == 'POST':
|
||||
if not form.validate_on_submit():
|
||||
flash('Your form expired. Please try again.', 'danger')
|
||||
return redirect(url_for('data.manage_data'))
|
||||
# Handle CSV file upload
|
||||
file = request.files.get('file')
|
||||
if file and file.filename.endswith('.csv'):
|
||||
try:
|
||||
payload = file.read(2_000_001)
|
||||
if len(payload) > 2_000_000:
|
||||
raise ValueError('CSV must be 2 MB or smaller.')
|
||||
csv_data = csv.reader(StringIO(payload.decode('utf-8-sig')))
|
||||
if next(csv_data, None) != ['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate']:
|
||||
raise ValueError('Use the column headings from a BP Tracker export.')
|
||||
readings_to_add = []
|
||||
for line, row in enumerate(csv_data, 2):
|
||||
timestamp, systolic, diastolic, heart_rate = row
|
||||
if not (50 <= int(systolic) <= 250 and 30 <= int(diastolic) <= 150 and 30 <= int(heart_rate) <= 200):
|
||||
raise ValueError(f'Check the measurement values on row {line}.')
|
||||
readings_to_add.append(Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S'),
|
||||
systolic=int(systolic),
|
||||
diastolic=int(diastolic),
|
||||
heart_rate=int(heart_rate),
|
||||
))
|
||||
db.session.bulk_save_objects(readings_to_add)
|
||||
db.session.commit()
|
||||
flash('Data imported successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error importing data: {str(e)}', 'danger')
|
||||
else:
|
||||
flash('Please upload a valid CSV file.', 'danger')
|
||||
return redirect(url_for('data.manage_data'))
|
||||
|
||||
return render_template('data.html', form=form)
|
||||
|
||||
@data.route('/export', methods=['GET'])
|
||||
@login_required
|
||||
def export_data():
|
||||
import io
|
||||
from flask import Response
|
||||
user_id = current_user.id
|
||||
selected = None
|
||||
if any(request.args.get(key) for key in ('start_date', 'end_date', 'days')):
|
||||
try:
|
||||
selected = parse_range(request.args, user_timezone(), 30)
|
||||
except ValueError as error:
|
||||
return render_template('error.html', message=str(error)), 400
|
||||
|
||||
def generate_csv():
|
||||
"""Stream CSV rows to avoid loading all readings into memory."""
|
||||
# Write header
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Stream readings in chunks using yield_per
|
||||
readings = readings_query(user_id, selected).order_by(
|
||||
Reading.timestamp, Reading.id
|
||||
).yield_per(500)
|
||||
|
||||
for reading in readings:
|
||||
writer.writerow([
|
||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.heart_rate,
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate_csv()),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=readings.csv'}
|
||||
)
|
||||
|
||||
+176
-347
@@ -1,347 +1,176 @@
|
||||
from collections import defaultdict
|
||||
from flask import Blueprint, render_template, redirect, request, url_for
|
||||
import humanize
|
||||
from pytz import timezone, utc
|
||||
from sqlalchemy import func
|
||||
from app.models import Reading, db
|
||||
from app.forms import DeleteForm
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
# Number of readings to show per page in list view
|
||||
PAGE_SIZE = 25
|
||||
|
||||
@main.route('/', methods=['GET'])
|
||||
def landing():
|
||||
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
|
||||
|
||||
@main.route('/health')
|
||||
def health():
|
||||
return "OK", 200
|
||||
|
||||
@main.route('/dashboard', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the dashboard shell and default list view."""
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
|
||||
# Calculate weekly averages via SQL
|
||||
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary_sql(current_user.id)
|
||||
|
||||
badges = calculate_progress_badges(current_user.id, user_tz)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
profile=current_user.profile,
|
||||
badges=badges,
|
||||
systolic_avg=systolic_avg,
|
||||
diastolic_avg=diastolic_avg,
|
||||
heart_rate_avg=heart_rate_avg,
|
||||
delete_form=DeleteForm(),
|
||||
active_view='list',
|
||||
)
|
||||
|
||||
@main.route('/dashboard/list', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_list():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
# List view is no longer constrained by date filter
|
||||
paginated = fetch_readings_paginated(current_user.id, None, None, user_tz, page, PAGE_SIZE)
|
||||
annotate_readings(paginated.items, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_list.html', readings=paginated.items, pagination=paginated)
|
||||
|
||||
@main.route('/dashboard/table', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_table():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
|
||||
|
||||
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
|
||||
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
paginated = fetch_readings_paginated(current_user.id, start_date, end_date, user_tz, page, PAGE_SIZE)
|
||||
annotate_readings(paginated.items, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_table.html', readings=paginated.items, pagination=paginated, start_date=start_date, end_date=end_date)
|
||||
|
||||
@main.route('/dashboard/weekly', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_weekly():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
week_offset = request.args.get('week_offset', 0, type=int)
|
||||
now = datetime.now(user_tz)
|
||||
|
||||
target_week_date = now + timedelta(weeks=week_offset)
|
||||
target_week_start = target_week_date - timedelta(days=target_week_date.weekday())
|
||||
target_week_start_utc = target_week_start.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
target_week_end_utc = (target_week_start + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, target_week_start_utc, target_week_end_utc)
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
|
||||
week_view = generate_weekly_calendar(readings_by_day, target_week_date, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_weekly.html', week=week_view, week_offset=week_offset)
|
||||
|
||||
@main.route('/dashboard/monthly', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_monthly():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
month_offset = request.args.get('month_offset', 0, type=int)
|
||||
now = datetime.now(user_tz)
|
||||
|
||||
target_month_year = now.year + (now.month + month_offset - 1) // 12
|
||||
target_month_month = (now.month + month_offset - 1) % 12 + 1
|
||||
target_month_date = now.replace(year=target_month_year, month=target_month_month, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
first_day = target_month_date
|
||||
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
|
||||
end_date = start_date + timedelta(days=42)
|
||||
|
||||
start_utc = start_date.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
end_utc = end_date.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, start_utc, end_utc)
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
|
||||
month_view = generate_monthly_calendar(readings_by_day, target_month_date, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_monthly.html', month=month_view, target_month_date=target_month_date, month_offset=month_offset)
|
||||
|
||||
@main.route('/dashboard/graph', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_graph():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
|
||||
|
||||
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
|
||||
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
|
||||
|
||||
if start_date and end_date:
|
||||
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
|
||||
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, start_dt, end_dt)
|
||||
else:
|
||||
now = datetime.now(user_tz)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_start_utc = month_start.astimezone(utc)
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, month_start_utc)
|
||||
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
|
||||
graph_data = prepare_graph_data(calendar_readings)
|
||||
graph_data['start_date'] = start_date
|
||||
graph_data['end_date'] = end_date
|
||||
|
||||
return render_template('partials/dashboard_graph.html', **graph_data)
|
||||
|
||||
def get_reading_date_range(user_id, user_tz):
|
||||
"""Fetch the earliest and latest reading timestamps for a user."""
|
||||
result = db.session.query(
|
||||
func.min(Reading.timestamp).label('first'),
|
||||
func.max(Reading.timestamp).label('last')
|
||||
).filter(Reading.user_id == user_id).first()
|
||||
|
||||
first = utc.localize(result.first).astimezone(user_tz) if result.first else None
|
||||
last = utc.localize(result.last).astimezone(user_tz) if result.last else None
|
||||
return first, last
|
||||
|
||||
|
||||
def fetch_readings_paginated(user_id, start_date, end_date, user_tz, page, per_page):
|
||||
"""Retrieve paginated readings filtered by date range."""
|
||||
query = Reading.query.filter_by(user_id=user_id)
|
||||
|
||||
if start_date and end_date:
|
||||
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
|
||||
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
|
||||
query = query.filter(
|
||||
Reading.timestamp >= start_dt,
|
||||
Reading.timestamp <= end_dt
|
||||
)
|
||||
|
||||
return query.order_by(Reading.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
|
||||
def fetch_readings_for_range(user_id, start_utc, end_utc=None):
|
||||
"""Fetch readings from a UTC start time onwards (for calendar/graph views)."""
|
||||
query = Reading.query.filter(
|
||||
Reading.user_id == user_id,
|
||||
Reading.timestamp >= start_utc
|
||||
)
|
||||
if end_utc:
|
||||
query = query.filter(Reading.timestamp <= end_utc)
|
||||
return query.order_by(Reading.timestamp.desc()).all()
|
||||
|
||||
|
||||
def annotate_readings(readings, user_tz):
|
||||
"""Add relative and localized timestamps to readings."""
|
||||
now = datetime.utcnow()
|
||||
for reading in readings:
|
||||
reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp)
|
||||
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(user_tz)
|
||||
|
||||
|
||||
def build_readings_by_day(readings, user_tz):
|
||||
"""Build a dict mapping dates to readings (single pass, shared by calendar views)."""
|
||||
readings_by_day = defaultdict(list)
|
||||
for reading in readings:
|
||||
local_date = reading.local_timestamp.date() if hasattr(reading, 'local_timestamp') else utc.localize(reading.timestamp).astimezone(user_tz).date()
|
||||
readings_by_day[local_date].append(reading)
|
||||
return readings_by_day
|
||||
|
||||
|
||||
def calculate_weekly_summary_sql(user_id):
|
||||
"""Calculate weekly averages using SQL aggregation (single DB query)."""
|
||||
one_week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
result = db.session.query(
|
||||
func.round(func.avg(Reading.systolic), 1).label('sys_avg'),
|
||||
func.round(func.avg(Reading.diastolic), 1).label('dia_avg'),
|
||||
func.round(func.avg(Reading.heart_rate), 1).label('hr_avg'),
|
||||
).filter(
|
||||
Reading.user_id == user_id,
|
||||
Reading.timestamp >= one_week_ago
|
||||
).first()
|
||||
|
||||
if result and result.sys_avg is not None:
|
||||
return float(result.sys_avg), float(result.dia_avg), float(result.hr_avg)
|
||||
return 0, 0, 0
|
||||
|
||||
|
||||
def generate_monthly_calendar(readings_by_day, selected_date, local_tz):
|
||||
"""Generate a monthly calendar view from pre-built readings_by_day."""
|
||||
today = datetime.now(local_tz).date()
|
||||
first_day = selected_date.replace(day=1)
|
||||
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
|
||||
end_date = start_date + timedelta(days=41)
|
||||
|
||||
return [
|
||||
{
|
||||
'day': current_date.day,
|
||||
'is_today': current_date == today,
|
||||
'is_in_current_month': current_date.month == selected_date.month,
|
||||
'readings': readings_by_day.get(current_date.date(), []),
|
||||
}
|
||||
for current_date in (start_date + timedelta(days=i) for i in range((end_date - start_date).days + 1))
|
||||
]
|
||||
|
||||
def generate_weekly_calendar(readings_by_day, selected_date, local_tz):
|
||||
"""Generate a weekly calendar view from pre-built readings_by_day."""
|
||||
today = datetime.now(local_tz).date()
|
||||
start_of_week = selected_date - timedelta(days=selected_date.weekday())
|
||||
|
||||
return [
|
||||
{
|
||||
'date': current_date.strftime('%a, %b %d'),
|
||||
'is_today': current_date == today,
|
||||
'readings': readings_by_day.get(current_date.date(), []),
|
||||
}
|
||||
for current_date in (start_of_week + timedelta(days=i) for i in range(7))
|
||||
]
|
||||
|
||||
def prepare_graph_data(readings):
|
||||
"""Prepare data for graph rendering, reversing so chronological order is left-to-right."""
|
||||
chronological_readings = list(reversed(readings))
|
||||
|
||||
n = len(chronological_readings)
|
||||
time_percentages = []
|
||||
|
||||
systolic_vals = [r.systolic for r in chronological_readings]
|
||||
diastolic_vals = [r.diastolic for r in chronological_readings]
|
||||
hr_vals = [r.heart_rate for r in chronological_readings]
|
||||
|
||||
sys_avg = round(sum(systolic_vals) / n, 1) if n > 0 else 0
|
||||
dia_avg = round(sum(diastolic_vals) / n, 1) if n > 0 else 0
|
||||
hr_avg = round(sum(hr_vals) / n, 1) if n > 0 else 0
|
||||
|
||||
if n == 0:
|
||||
pass
|
||||
elif n == 1:
|
||||
time_percentages = [0.5]
|
||||
else:
|
||||
first_time = chronological_readings[0].timestamp.timestamp()
|
||||
last_time = chronological_readings[-1].timestamp.timestamp()
|
||||
time_span = last_time - first_time
|
||||
|
||||
for r in chronological_readings:
|
||||
if time_span == 0:
|
||||
time_percentages.append(0.5)
|
||||
else:
|
||||
p = (r.timestamp.timestamp() - first_time) / time_span
|
||||
time_percentages.append(p)
|
||||
|
||||
return {
|
||||
'timestamps': [r.timestamp.strftime('%b %d\n%H:%M') for r in chronological_readings],
|
||||
'systolic': systolic_vals,
|
||||
'diastolic': diastolic_vals,
|
||||
'heart_rate': hr_vals,
|
||||
'time_percentages': time_percentages,
|
||||
'sys_avg': sys_avg,
|
||||
'dia_avg': dia_avg,
|
||||
'hr_avg': hr_avg,
|
||||
}
|
||||
|
||||
def calculate_progress_badges(user_id, user_tz):
|
||||
"""Generate badges based on user activity and milestones using optimized queries."""
|
||||
# Fetch only timestamps (index-only scan on the composite index)
|
||||
timestamps = db.session.query(Reading.timestamp).filter(Reading.user_id == user_id).order_by(Reading.timestamp.desc()).all()
|
||||
total_readings = len(timestamps)
|
||||
return _compute_badges(total_readings, timestamps, user_tz)
|
||||
|
||||
def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
|
||||
if now_local is None:
|
||||
now_local = datetime.now(user_tz).date()
|
||||
badges = []
|
||||
|
||||
if total_readings == 0:
|
||||
return badges
|
||||
|
||||
streak_count = 0
|
||||
if timestamps:
|
||||
distinct_dates = []
|
||||
last_date = None
|
||||
for (ts,) in timestamps:
|
||||
local_date = utc.localize(ts).astimezone(user_tz).date()
|
||||
if local_date != last_date:
|
||||
distinct_dates.append(local_date)
|
||||
last_date = local_date
|
||||
|
||||
if distinct_dates:
|
||||
most_recent_date = distinct_dates[0]
|
||||
if (now_local - most_recent_date).days <= 1:
|
||||
streak_count = 1
|
||||
current_check_date = most_recent_date
|
||||
|
||||
for d in distinct_dates[1:]:
|
||||
if (current_check_date - d).days == 1:
|
||||
streak_count += 1
|
||||
current_check_date = d
|
||||
else:
|
||||
break
|
||||
|
||||
if streak_count >= 1:
|
||||
badges.append(f"Current Streak: {streak_count} Days")
|
||||
if streak_count >= 7:
|
||||
badges.append("Logged Every Day for a Week")
|
||||
if streak_count >= 30:
|
||||
badges.append("Monthly Streak")
|
||||
|
||||
last_7_readings = timestamps[:7]
|
||||
if len(last_7_readings) == 7:
|
||||
if all(5 <= utc.localize(ts).astimezone(user_tz).hour < 12 for (ts,) in last_7_readings):
|
||||
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
|
||||
|
||||
if all(18 <= utc.localize(ts).astimezone(user_tz).hour <= 23 for (ts,) in last_7_readings):
|
||||
badges.append("Night Owl: Logged Readings Every Night for a Week")
|
||||
|
||||
milestones = [10, 50, 100, 500, 1000, 5000, 10000]
|
||||
highest_milestone = max((m for m in milestones if total_readings >= m), default=None)
|
||||
if highest_milestone:
|
||||
badges.append(f"{highest_milestone} Readings Logged")
|
||||
|
||||
return badges
|
||||
from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, request, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from pytz import utc
|
||||
from app.models import Reading
|
||||
from app.insights import DateRange, user_timezone, parse_range, readings_query, summary, annotate, chart_data
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
PAGE_SIZE = 25
|
||||
|
||||
@main.route('/')
|
||||
def landing():
|
||||
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
|
||||
|
||||
@main.route('/health')
|
||||
def health():
|
||||
return 'OK'
|
||||
|
||||
@main.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
view = request.args.get('view', 'overview')
|
||||
if view not in ('overview', 'history', 'trends'):
|
||||
view = 'overview'
|
||||
tz = user_timezone()
|
||||
mode = request.args.get('mode', 'readings') if view == 'history' else 'readings'
|
||||
calendar = None
|
||||
try:
|
||||
selected = parse_range(request.args, tz, 7 if view == 'overview' else 30)
|
||||
if mode == 'calendar':
|
||||
unit = request.args.get('unit', 'month')
|
||||
if unit not in ('week', 'month'):
|
||||
raise ValueError('Choose a week or month calendar.')
|
||||
anchor = date.fromisoformat(request.args.get('anchor', datetime.now(tz).date().isoformat()))
|
||||
if not 1900 <= anchor.year <= 2099:
|
||||
raise ValueError('Choose a calendar date between 1900 and 2099.')
|
||||
if unit == 'week':
|
||||
start = anchor - timedelta(days=anchor.weekday())
|
||||
end = start + timedelta(days=6)
|
||||
prev, following = start - timedelta(days=7), start + timedelta(days=7)
|
||||
else:
|
||||
start = anchor.replace(day=1)
|
||||
following = (start + timedelta(days=32)).replace(day=1)
|
||||
end = following - timedelta(days=1)
|
||||
prev = (start - timedelta(days=1)).replace(day=1)
|
||||
selected = DateRange(start, end, tz)
|
||||
calendar = dict(unit=unit, anchor=anchor.isoformat(), prev=prev.isoformat(), next=following.isoformat())
|
||||
except ValueError as error:
|
||||
return render_template('error.html', message=str(error)), 400
|
||||
query = readings_query(current_user.id, selected)
|
||||
stats = summary(query)
|
||||
pagination = None
|
||||
readings = []
|
||||
latest = None
|
||||
chart = None
|
||||
if view == 'overview':
|
||||
latest = readings_query(current_user.id).order_by(Reading.timestamp.desc(), Reading.id.desc()).first()
|
||||
if latest:
|
||||
annotate([latest], tz)
|
||||
readings = annotate(query.order_by(Reading.timestamp.desc(), Reading.id.desc()).limit(5).all(), tz)
|
||||
elif mode != 'calendar':
|
||||
if view == 'history':
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
pagination = query.order_by(Reading.timestamp.desc(), Reading.id.desc()).paginate(page=page, per_page=PAGE_SIZE, error_out=False)
|
||||
readings = annotate(pagination.items, tz)
|
||||
if view in ('overview', 'trends') or calendar:
|
||||
chart = chart_data(query, selected)
|
||||
if calendar:
|
||||
by_day = {p['date']: p for p in chart['points']}
|
||||
calendar['days'] = [dict(date=(selected.start + timedelta(days=i)), data=by_day.get((selected.start + timedelta(days=i)).isoformat()))
|
||||
for i in range((selected.end - selected.start).days + 1)]
|
||||
profile = current_user.profile
|
||||
active_days = (selected.end - selected.start).days + 1 if selected.end == datetime.now(tz).date() else None
|
||||
return render_template('dashboard.html', view=view, mode=mode, selected=selected, stats=stats,
|
||||
readings=readings, latest=latest, chart=chart, pagination=pagination,
|
||||
calendar=calendar, timezone_name=str(tz), active_days=active_days,
|
||||
sys_threshold=(profile.systolic_threshold or 140) if profile else 140,
|
||||
dia_threshold=(profile.diastolic_threshold or 90) if profile else 90)
|
||||
|
||||
@main.route('/dashboard/report')
|
||||
@login_required
|
||||
def report():
|
||||
tz = user_timezone()
|
||||
try:
|
||||
selected = parse_range(request.args, tz, 30)
|
||||
except ValueError as error:
|
||||
return render_template('error.html', message=str(error)), 400
|
||||
query = readings_query(current_user.id, selected)
|
||||
readings = annotate(query.order_by(Reading.timestamp, Reading.id).all(), tz)
|
||||
return render_template('report.html', selected=selected, stats=summary(query), readings=readings, timezone_name=str(tz))
|
||||
|
||||
# Preserve bookmarks to the former separate views.
|
||||
@main.route('/dashboard/list')
|
||||
@main.route('/dashboard/table')
|
||||
@login_required
|
||||
def dashboard_table():
|
||||
params = request.args.to_dict()
|
||||
params['view'] = 'history'
|
||||
return redirect(url_for('main.dashboard', **params))
|
||||
|
||||
@main.route('/dashboard/graph')
|
||||
@login_required
|
||||
def dashboard_graph():
|
||||
params = request.args.to_dict()
|
||||
params['view'] = 'trends'
|
||||
return redirect(url_for('main.dashboard', **params))
|
||||
|
||||
@main.route('/dashboard/weekly')
|
||||
@main.route('/dashboard/monthly')
|
||||
@login_required
|
||||
def dashboard_calendar():
|
||||
unit = 'week' if request.path.endswith('weekly') else 'month'
|
||||
anchor = datetime.now(user_timezone()).date()
|
||||
offset = request.args.get('week_offset' if unit == 'week' else 'month_offset', 0, type=int)
|
||||
try:
|
||||
if unit == 'week':
|
||||
anchor += timedelta(weeks=offset)
|
||||
else:
|
||||
month_index = anchor.year * 12 + anchor.month - 1 + offset
|
||||
anchor = date(month_index // 12, month_index % 12 + 1, 1)
|
||||
except (OverflowError, ValueError):
|
||||
return render_template('error.html', message='Choose a valid calendar date.'), 400
|
||||
return redirect(url_for('main.dashboard', view='history', mode='calendar', unit=unit, anchor=anchor.isoformat()))
|
||||
|
||||
def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
|
||||
if now_local is None:
|
||||
now_local = datetime.now(user_tz).date()
|
||||
badges = []
|
||||
|
||||
if total_readings == 0:
|
||||
return badges
|
||||
|
||||
streak_count = 0
|
||||
if timestamps:
|
||||
distinct_dates = []
|
||||
last_date = None
|
||||
for (ts,) in timestamps:
|
||||
local_date = utc.localize(ts).astimezone(user_tz).date()
|
||||
if local_date != last_date:
|
||||
distinct_dates.append(local_date)
|
||||
last_date = local_date
|
||||
|
||||
if distinct_dates:
|
||||
most_recent_date = distinct_dates[0]
|
||||
if (now_local - most_recent_date).days <= 1:
|
||||
streak_count = 1
|
||||
current_check_date = most_recent_date
|
||||
|
||||
for d in distinct_dates[1:]:
|
||||
if (current_check_date - d).days == 1:
|
||||
streak_count += 1
|
||||
current_check_date = d
|
||||
else:
|
||||
break
|
||||
|
||||
if streak_count >= 1:
|
||||
badges.append(f"Current Streak: {streak_count} Days")
|
||||
if streak_count >= 7:
|
||||
badges.append("Logged Every Day for a Week")
|
||||
if streak_count >= 30:
|
||||
badges.append("Monthly Streak")
|
||||
|
||||
last_7_readings = timestamps[:7]
|
||||
if len(last_7_readings) == 7:
|
||||
if all(5 <= utc.localize(ts).astimezone(user_tz).hour < 12 for (ts,) in last_7_readings):
|
||||
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
|
||||
|
||||
if all(18 <= utc.localize(ts).astimezone(user_tz).hour <= 23 for (ts,) in last_7_readings):
|
||||
badges.append("Night Owl: Logged Readings Every Night for a Week")
|
||||
|
||||
milestones = [10, 50, 100, 500, 1000, 5000, 10000]
|
||||
highest_milestone = max((m for m in milestones if total_readings >= m), default=None)
|
||||
if highest_milestone:
|
||||
badges.append(f"{highest_milestone} Readings Logged")
|
||||
|
||||
return badges
|
||||
|
||||
+86
-79
@@ -1,79 +1,86 @@
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, flash
|
||||
from pytz import timezone, utc
|
||||
from app.models import Reading, db
|
||||
from app.forms import ReadingForm
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
reading = Blueprint('reading', __name__)
|
||||
|
||||
def get_user_timezone():
|
||||
"""Fetch the user's timezone, defaulting to UTC."""
|
||||
return timezone(current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC')
|
||||
|
||||
def localize_timestamp(timestamp, user_tz):
|
||||
"""Convert a UTC timestamp to the user's local timezone."""
|
||||
return utc.localize(timestamp).astimezone(user_tz)
|
||||
|
||||
def save_reading_from_form(reading, form, user_tz):
|
||||
"""Update a reading with form data and convert the timestamp to UTC."""
|
||||
local_timestamp = form.timestamp.data
|
||||
reading.timestamp = user_tz.localize(local_timestamp.replace(tzinfo=None)).astimezone(utc)
|
||||
reading.systolic = form.systolic.data
|
||||
reading.diastolic = form.diastolic.data
|
||||
reading.heart_rate = form.heart_rate.data
|
||||
db.session.commit()
|
||||
|
||||
@reading.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_reading():
|
||||
form = ReadingForm()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
if form.validate_on_submit():
|
||||
new_reading = Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=user_tz.localize(form.timestamp.data.replace(tzinfo=None)).astimezone(utc),
|
||||
systolic=form.systolic.data,
|
||||
diastolic=form.diastolic.data,
|
||||
heart_rate=form.heart_rate.data,
|
||||
)
|
||||
db.session.add(new_reading)
|
||||
db.session.commit()
|
||||
flash("Reading added successfully.", "success")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
form.timestamp.data = localize_timestamp(datetime.utcnow(), user_tz)
|
||||
return render_template('reading/add_reading.html', form=form)
|
||||
|
||||
@reading.route('/<int:reading_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
form = ReadingForm(obj=reading)
|
||||
form.timestamp.data = localize_timestamp(reading.timestamp, user_tz)
|
||||
|
||||
if form.validate_on_submit():
|
||||
save_reading_from_form(reading, form, user_tz)
|
||||
flash('Reading updated successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('reading/edit_reading.html', form=form, reading=reading)
|
||||
|
||||
@reading.route('/<int:reading_id>/confirm_delete', methods=['GET'])
|
||||
@login_required
|
||||
def confirm_delete(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
return render_template('reading/confirm_delete.html', reading=reading)
|
||||
|
||||
@reading.route('/<int:reading_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
db.session.delete(reading)
|
||||
db.session.commit()
|
||||
flash('Reading deleted successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, flash
|
||||
from pytz import timezone, utc
|
||||
from app.models import Reading, db
|
||||
from app.forms import ReadingForm, DeleteForm
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
reading = Blueprint('reading', __name__)
|
||||
|
||||
def get_user_timezone():
|
||||
"""Fetch the user's timezone, defaulting to UTC."""
|
||||
return timezone(current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC')
|
||||
|
||||
def localize_timestamp(timestamp, user_tz):
|
||||
"""Convert a UTC timestamp to the user's local timezone."""
|
||||
return utc.localize(timestamp).astimezone(user_tz)
|
||||
|
||||
def save_reading_from_form(reading, form, user_tz):
|
||||
"""Update a reading with form data and convert the timestamp to UTC."""
|
||||
local_timestamp = form.timestamp.data
|
||||
reading.timestamp = user_tz.localize(local_timestamp.replace(tzinfo=None)).astimezone(utc)
|
||||
reading.systolic = form.systolic.data
|
||||
reading.diastolic = form.diastolic.data
|
||||
reading.heart_rate = form.heart_rate.data
|
||||
db.session.commit()
|
||||
|
||||
@reading.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_reading():
|
||||
form = ReadingForm()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
if form.validate_on_submit():
|
||||
new_reading = Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=user_tz.localize(form.timestamp.data.replace(tzinfo=None)).astimezone(utc),
|
||||
systolic=form.systolic.data,
|
||||
diastolic=form.diastolic.data,
|
||||
heart_rate=form.heart_rate.data,
|
||||
)
|
||||
db.session.add(new_reading)
|
||||
db.session.commit()
|
||||
flash("Reading added successfully.", "success")
|
||||
destination = 'reading.add_reading' if request.form.get('action') == 'another' else 'main.dashboard'
|
||||
return redirect(url_for(destination))
|
||||
|
||||
if request.method == 'GET':
|
||||
form.timestamp.data = localize_timestamp(datetime.utcnow(), user_tz)
|
||||
return render_template('reading/add_reading.html', form=form)
|
||||
|
||||
@reading.route('/<int:reading_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
form = ReadingForm(obj=reading)
|
||||
if request.method == 'GET':
|
||||
form.timestamp.data = localize_timestamp(reading.timestamp, user_tz)
|
||||
|
||||
if form.validate_on_submit():
|
||||
save_reading_from_form(reading, form, user_tz)
|
||||
flash('Reading updated successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('reading/edit_reading.html', form=form, reading=reading)
|
||||
|
||||
@reading.route('/<int:reading_id>/confirm_delete', methods=['GET'])
|
||||
@login_required
|
||||
def confirm_delete(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
reading.local_timestamp = localize_timestamp(reading.timestamp, get_user_timezone())
|
||||
return render_template('reading/confirm_delete.html', reading=reading, form=DeleteForm())
|
||||
|
||||
@reading.route('/<int:reading_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
if not DeleteForm().validate_on_submit():
|
||||
flash('Please try deleting the reading again.', 'danger')
|
||||
return redirect(url_for('reading.confirm_delete', reading_id=reading.id))
|
||||
db.session.delete(reading)
|
||||
db.session.commit()
|
||||
flash('Reading deleted successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
+85
-81
@@ -1,81 +1,85 @@
|
||||
import io
|
||||
from flask import Blueprint, make_response, render_template, redirect, url_for, flash
|
||||
from werkzeug.http import http_date
|
||||
from app.models import Profile, db
|
||||
from app.forms import ProfileForm
|
||||
from flask_login import login_required, current_user
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
|
||||
user = Blueprint('user', __name__)
|
||||
|
||||
@user.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
profile = current_user.profile or Profile(user_id=current_user.id)
|
||||
form = ProfileForm(obj=profile)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Update profile fields
|
||||
profile.name = form.name.data
|
||||
profile.email = form.email.data
|
||||
profile.systolic_threshold = form.systolic_threshold.data or profile.systolic_threshold
|
||||
profile.diastolic_threshold = form.diastolic_threshold.data or profile.diastolic_threshold
|
||||
profile.dark_mode = form.dark_mode.data
|
||||
profile.timezone = form.timezone.data
|
||||
|
||||
# Handle profile picture upload
|
||||
if form.profile_pic.data:
|
||||
file_data = form.profile_pic.data.read()
|
||||
|
||||
# Resize and compress the image
|
||||
try:
|
||||
image = Image.open(io.BytesIO(file_data))
|
||||
image = image.convert("RGB") # Ensure it's in RGB format
|
||||
image.thumbnail((200, 200)) # Resize to a maximum of 200x200 pixels
|
||||
|
||||
# Save the resized image to a buffer
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=80) # Compress with quality=80
|
||||
buffer.seek(0)
|
||||
|
||||
# Encode the compressed image as base64
|
||||
profile.profile_pic = base64.b64encode(buffer.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
flash(f"Error processing profile picture: {e}", 'danger')
|
||||
|
||||
|
||||
db.session.add(profile)
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('user.profile'))
|
||||
|
||||
return render_template('profile.html', form=form, profile=profile)
|
||||
|
||||
@user.route('/profile/image/<int:user_id>')
|
||||
def profile_image(user_id):
|
||||
# Ensure the reading belongs to the logged-in user
|
||||
if user_id != current_user.id:
|
||||
flash('You are not authorized to delete this reading.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
profile = Profile.query.filter_by(user_id=user_id).first()
|
||||
if profile and profile.profile_pic:
|
||||
image_data = base64.b64decode(profile.profile_pic)
|
||||
response = make_response(image_data)
|
||||
response.headers.set('Content-Type', 'image/jpeg')
|
||||
response.headers.set('Cache-Control', 'public, max-age=86400') # Cache for 1 day
|
||||
response.headers.set('ETag', str(hash(profile.profile_pic))) # Unique ETag for the image
|
||||
# Use actual profile update time instead of utcnow() which defeats caching
|
||||
last_modified = profile.updated_at or datetime.utcnow()
|
||||
response.headers.set('Last-Modified', http_date(last_modified.timestamp()))
|
||||
|
||||
return response
|
||||
else:
|
||||
# Serve the default SVG if no profile picture is found
|
||||
with open('app/static/images/default-profile.svg', 'r') as f:
|
||||
default_image = f.read()
|
||||
|
||||
response = make_response(default_image)
|
||||
response.headers.set('Content-Type', 'image/svg+xml')
|
||||
import io
|
||||
from flask import Blueprint, make_response, render_template, redirect, url_for, flash
|
||||
from werkzeug.http import http_date
|
||||
from app.models import Profile, db
|
||||
from app.forms import ProfileForm
|
||||
from flask_login import login_required, current_user
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
user = Blueprint('user', __name__)
|
||||
|
||||
@user.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@login_required
|
||||
def profile():
|
||||
profile = current_user.profile or Profile(user_id=current_user.id)
|
||||
form = ProfileForm(obj=profile)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Update profile fields
|
||||
profile.name = form.name.data
|
||||
profile.email = form.email.data
|
||||
profile.systolic_threshold = form.systolic_threshold.data or profile.systolic_threshold
|
||||
profile.diastolic_threshold = form.diastolic_threshold.data or profile.diastolic_threshold
|
||||
profile.dark_mode = form.dark_mode.data
|
||||
profile.timezone = form.timezone.data
|
||||
|
||||
# Handle profile picture upload
|
||||
if form.profile_pic.data:
|
||||
file_data = form.profile_pic.data.read()
|
||||
|
||||
# Resize and compress the image
|
||||
try:
|
||||
image = Image.open(io.BytesIO(file_data))
|
||||
image = image.convert("RGB") # Ensure it's in RGB format
|
||||
image.thumbnail((200, 200)) # Resize to a maximum of 200x200 pixels
|
||||
|
||||
# Save the resized image to a buffer
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=80) # Compress with quality=80
|
||||
buffer.seek(0)
|
||||
|
||||
# Encode the compressed image as base64
|
||||
profile.profile_pic = base64.b64encode(buffer.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
flash(f"Error processing profile picture: {e}", 'danger')
|
||||
|
||||
|
||||
db.session.add(profile)
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('user.profile'))
|
||||
|
||||
return render_template('profile.html', form=form, profile=profile)
|
||||
|
||||
@user.route('/profile/image/<int:user_id>')
|
||||
@login_required
|
||||
def profile_image(user_id):
|
||||
# Ensure the reading belongs to the logged-in user
|
||||
if user_id != current_user.id:
|
||||
flash('You are not authorized to delete this reading.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
profile = Profile.query.filter_by(user_id=user_id).first()
|
||||
if profile and profile.profile_pic:
|
||||
image_data = base64.b64decode(profile.profile_pic)
|
||||
response = make_response(image_data)
|
||||
response.headers.set('Content-Type', 'image/jpeg')
|
||||
response.headers.set('Cache-Control', 'public, max-age=86400') # Cache for 1 day
|
||||
response.headers.set('ETag', str(hash(profile.profile_pic))) # Unique ETag for the image
|
||||
# Use actual profile update time instead of utcnow() which defeats caching
|
||||
last_modified = profile.updated_at or datetime.utcnow()
|
||||
response.headers.set('Last-Modified', http_date(last_modified.timestamp()))
|
||||
|
||||
return response
|
||||
else:
|
||||
# Serve the default SVG if no profile picture is found
|
||||
with open('app/static/images/default-profile.svg', 'r') as f:
|
||||
default_image = f.read()
|
||||
|
||||
response = make_response(default_image)
|
||||
response.headers.set('Content-Type', 'image/svg+xml')
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user