Compare commits
10 Commits
1cd0b15201
...
acad2def92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acad2def92 | ||
|
|
b51f204d8d | ||
|
|
cfc61ca43b | ||
|
|
164b23e913 | ||
|
|
d3eba9ba5b | ||
|
|
68c8059466 | ||
|
|
ea0ad126ab | ||
|
|
6291c37820 | ||
|
|
91b3ef0e7e | ||
|
|
049e845d5b |
@@ -40,6 +40,9 @@ COPY . .
|
|||||||
# Copy the built TailwindCSS assets from the first stage
|
# Copy the built TailwindCSS assets from the first stage
|
||||||
COPY --from=tailwind-builder /app/app/static/css/tailwind.css ./app/static/css/tailwind.css
|
COPY --from=tailwind-builder /app/app/static/css/tailwind.css ./app/static/css/tailwind.css
|
||||||
|
|
||||||
|
# Run tests during the build process
|
||||||
|
RUN pytest --maxfail=5 --disable-warnings -v || (echo "Tests failed. Exiting." && exit 1)
|
||||||
|
|
||||||
# Expose the port Flask will run on
|
# Expose the port Flask will run on
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ def create_app():
|
|||||||
return User.query.get(int(user_id)) # Query the User model by ID
|
return User.query.get(int(user_id)) # Query the User model by ID
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import main, auth, user
|
from app.routes.auth import auth
|
||||||
|
from app.routes.data import data
|
||||||
|
from app.routes.main import main
|
||||||
|
from app.routes.user import user
|
||||||
|
from app.routes.reading import reading
|
||||||
app.register_blueprint(main, url_prefix='/')
|
app.register_blueprint(main, url_prefix='/')
|
||||||
app.register_blueprint(auth, url_prefix='/auth')
|
app.register_blueprint(auth, url_prefix='/auth')
|
||||||
app.register_blueprint(user, url_prefix='/user')
|
app.register_blueprint(user, url_prefix='/user')
|
||||||
|
app.register_blueprint(data, url_prefix="/data")
|
||||||
|
app.register_blueprint(reading, url_prefix="/reading")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
487
app/routes.py
487
app/routes.py
@@ -1,487 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
import csv
|
|
||||||
from io import StringIO
|
|
||||||
import io
|
|
||||||
from flask import Blueprint, Response, make_response, render_template, redirect, request, send_file, url_for, flash
|
|
||||||
import humanize
|
|
||||||
from pytz import timezone, utc
|
|
||||||
from sqlalchemy import func
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from werkzeug.http import http_date
|
|
||||||
from app.models import Profile, Reading, db, User
|
|
||||||
from app.forms import DeleteForm, LoginForm, ProfileForm, ReadingForm, SignupForm
|
|
||||||
from flask_login import login_user, login_required, current_user, logout_user
|
|
||||||
import base64
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
main = Blueprint('main', __name__)
|
|
||||||
auth = Blueprint('auth', __name__)
|
|
||||||
user = Blueprint('user', __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@auth.route('/signup', methods=['GET', 'POST'])
|
|
||||||
def signup():
|
|
||||||
form = SignupForm()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
hashed_password = generate_password_hash(form.password.data)
|
|
||||||
new_user = User(username=form.username.data, password_hash=hashed_password)
|
|
||||||
db.session.add(new_user)
|
|
||||||
db.session.commit()
|
|
||||||
flash("Account created successfully. Please log in.", "success")
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
return render_template('signup.html', form=form)
|
|
||||||
|
|
||||||
@auth.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
form = LoginForm()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
user = User.query.filter_by(username=form.username.data).first()
|
|
||||||
if user and check_password_hash(user.password_hash, form.password.data):
|
|
||||||
login_user(user)
|
|
||||||
flash("Logged in successfully.", "success")
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
flash("Invalid username or password.", "danger")
|
|
||||||
return render_template('login.html', form=form)
|
|
||||||
|
|
||||||
@auth.route('/logout')
|
|
||||||
@login_required
|
|
||||||
def logout():
|
|
||||||
logout_user() # Logs out the current user
|
|
||||||
flash('You have been logged out.', 'success')
|
|
||||||
return redirect(url_for('auth.login')) # Redirect to login page or home page
|
|
||||||
|
|
||||||
@main.route('/', methods=['GET'])
|
|
||||||
def landing():
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
return render_template('landing.html')
|
|
||||||
|
|
||||||
@main.route('/dashboard', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def dashboard():
|
|
||||||
# Helper function to get first and last reading timestamps
|
|
||||||
def get_reading_date_range(user_id):
|
|
||||||
result = (
|
|
||||||
db.session.query(
|
|
||||||
func.min(Reading.timestamp).label('first'),
|
|
||||||
func.max(Reading.timestamp).label('last')
|
|
||||||
)
|
|
||||||
.filter(Reading.user_id == user_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return result.first, result.last
|
|
||||||
|
|
||||||
# Helper function to calculate weekly summary averages
|
|
||||||
def calculate_weekly_summary(readings):
|
|
||||||
one_week_ago = datetime.now() - timedelta(days=7)
|
|
||||||
weekly_readings = [r for r in readings if r.timestamp >= one_week_ago]
|
|
||||||
if weekly_readings:
|
|
||||||
systolic_avg = round(sum(r.systolic for r in weekly_readings) / len(weekly_readings), 1)
|
|
||||||
diastolic_avg = round(sum(r.diastolic for r in weekly_readings) / len(weekly_readings), 1)
|
|
||||||
heart_rate_avg = round(sum(r.heart_rate for r in weekly_readings) / len(weekly_readings), 1)
|
|
||||||
else:
|
|
||||||
systolic_avg = diastolic_avg = heart_rate_avg = 0
|
|
||||||
return systolic_avg, diastolic_avg, heart_rate_avg
|
|
||||||
|
|
||||||
# Helper function to calculate progress badges
|
|
||||||
def calculate_progress_badges(readings):
|
|
||||||
"""Calculate badges based on user reading activity."""
|
|
||||||
badges = []
|
|
||||||
now = datetime.utcnow().date()
|
|
||||||
|
|
||||||
# Prepare sorted readings by timestamp
|
|
||||||
sorted_readings = sorted(readings, key=lambda r: r.timestamp)
|
|
||||||
|
|
||||||
# Incremental milestone badge
|
|
||||||
def highest_milestone(total_readings, milestones):
|
|
||||||
"""Determine the highest milestone achieved."""
|
|
||||||
for milestone in reversed(milestones):
|
|
||||||
if total_readings >= milestone:
|
|
||||||
return f"{milestone} Readings Logged"
|
|
||||||
return None
|
|
||||||
|
|
||||||
highest_milestone_badge = highest_milestone(len(readings), [10, 50, 100, 500, 1000, 5000, 10000])
|
|
||||||
if highest_milestone_badge:
|
|
||||||
badges.append(highest_milestone_badge)
|
|
||||||
|
|
||||||
# Streaks and calendar month badges
|
|
||||||
if sorted_readings:
|
|
||||||
streak_count = 1
|
|
||||||
daily_streak = True
|
|
||||||
monthly_tracker = defaultdict(int)
|
|
||||||
|
|
||||||
# Start with the first reading
|
|
||||||
previous_date = sorted_readings[0].timestamp.date()
|
|
||||||
|
|
||||||
for reading in sorted_readings[1:]:
|
|
||||||
current_date = reading.timestamp.date()
|
|
||||||
|
|
||||||
# Check for consecutive daily streaks
|
|
||||||
if (current_date - previous_date).days == 1:
|
|
||||||
streak_count += 1
|
|
||||||
elif (current_date - previous_date).days > 1:
|
|
||||||
daily_streak = False
|
|
||||||
|
|
||||||
# Track monthly activity
|
|
||||||
monthly_tracker[current_date.strftime('%Y-%m')] += 1
|
|
||||||
|
|
||||||
previous_date = current_date
|
|
||||||
|
|
||||||
# Add streak badges
|
|
||||||
if daily_streak and streak_count >= 1:
|
|
||||||
badges.append(f"Current Streak: {streak_count} Days")
|
|
||||||
if daily_streak and streak_count >= 7:
|
|
||||||
badges.append("Logged Every Day for a Week")
|
|
||||||
|
|
||||||
if daily_streak and streak_count >= 30 and previous_date == now:
|
|
||||||
badges.append("Monthly Streak")
|
|
||||||
|
|
||||||
# Add calendar month streak badges
|
|
||||||
for month, count in monthly_tracker.items():
|
|
||||||
if count >= 30:
|
|
||||||
badges.append(f"Full Month of Logging: {month}")
|
|
||||||
|
|
||||||
# Time-specific badges (morning/night logging)
|
|
||||||
def is_morning(reading_time):
|
|
||||||
return 5 <= reading_time.hour < 12
|
|
||||||
|
|
||||||
def is_night(reading_time):
|
|
||||||
return 18 <= reading_time.hour <= 23
|
|
||||||
|
|
||||||
if all(is_morning(r.timestamp) for r in sorted_readings[-7:]):
|
|
||||||
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
|
|
||||||
|
|
||||||
if all(is_night(r.timestamp) for r in sorted_readings[-7:]):
|
|
||||||
badges.append("Night Owl: Logged Readings Every Night for a Week")
|
|
||||||
|
|
||||||
return badges
|
|
||||||
|
|
||||||
def generate_monthly_calendar(readings, selected_date, local_timezone):
|
|
||||||
# Convert selected date to user's timezone and extract the start/end dates
|
|
||||||
today = datetime.now(local_timezone).date()
|
|
||||||
date = selected_date.astimezone(local_timezone).date()
|
|
||||||
first_day_of_month = date.replace(day=1)
|
|
||||||
days_to_subtract = (first_day_of_month.weekday() + 1) % 7
|
|
||||||
start_date = first_day_of_month - timedelta(days=days_to_subtract)
|
|
||||||
end_date = start_date + timedelta(days=6 * 7 - 1)
|
|
||||||
|
|
||||||
# Group readings by day
|
|
||||||
readings_by_day = {}
|
|
||||||
for reading in readings:
|
|
||||||
local_date = reading.timestamp.astimezone(local_timezone).date()
|
|
||||||
readings_by_day.setdefault(local_date, []).append(reading)
|
|
||||||
|
|
||||||
# Build calendar days
|
|
||||||
calendar = []
|
|
||||||
current_date = start_date
|
|
||||||
while current_date <= end_date:
|
|
||||||
calendar.append({
|
|
||||||
'day': current_date.day,
|
|
||||||
'is_today': current_date == today,
|
|
||||||
'is_in_current_month': current_date.month == date.month,
|
|
||||||
'readings': readings_by_day.get(current_date, []),
|
|
||||||
})
|
|
||||||
current_date += timedelta(days=1)
|
|
||||||
|
|
||||||
return calendar
|
|
||||||
|
|
||||||
# Get the first and last reading timestamps
|
|
||||||
first_reading_timestamp, last_reading_timestamp = get_reading_date_range(current_user.id)
|
|
||||||
|
|
||||||
# Set default start and end dates
|
|
||||||
start_date = first_reading_timestamp.strftime('%Y-%m-%d') if first_reading_timestamp else None
|
|
||||||
end_date = last_reading_timestamp.strftime('%Y-%m-%d') if last_reading_timestamp else None
|
|
||||||
|
|
||||||
# Handle filtering for POST request
|
|
||||||
readings_query = Reading.query.filter_by(user_id=current_user.id)
|
|
||||||
if request.method == 'POST':
|
|
||||||
start_date = request.form.get('start_date') or start_date
|
|
||||||
end_date = request.form.get('end_date') or end_date
|
|
||||||
if start_date and end_date:
|
|
||||||
readings_query = readings_query.filter(
|
|
||||||
Reading.timestamp >= datetime.strptime(start_date, '%Y-%m-%d'),
|
|
||||||
Reading.timestamp <= datetime.strptime(end_date, '%Y-%m-%d')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch readings
|
|
||||||
readings = readings_query.order_by(Reading.timestamp.desc()).all()
|
|
||||||
|
|
||||||
# Fetch the user's timezone (default to 'UTC' if none is set)
|
|
||||||
user_timezone = current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC'
|
|
||||||
local_tz = timezone(user_timezone)
|
|
||||||
|
|
||||||
# Add relative & local 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(local_tz)
|
|
||||||
|
|
||||||
month_view = generate_monthly_calendar(readings, now, local_tz)
|
|
||||||
|
|
||||||
# Calculate weekly summary and progress badges
|
|
||||||
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary(readings)
|
|
||||||
badges = calculate_progress_badges(readings)
|
|
||||||
|
|
||||||
# Prepare graph data
|
|
||||||
timestamps = [reading.timestamp.strftime('%b %d') for reading in readings]
|
|
||||||
systolic = [reading.systolic for reading in readings]
|
|
||||||
diastolic = [reading.diastolic for reading in readings]
|
|
||||||
heart_rate = [reading.heart_rate for reading in readings]
|
|
||||||
|
|
||||||
# Group readings by date
|
|
||||||
readings_by_date = {}
|
|
||||||
for reading in readings:
|
|
||||||
date_key = reading.timestamp.date()
|
|
||||||
if date_key not in readings_by_date:
|
|
||||||
readings_by_date[date_key] = []
|
|
||||||
readings_by_date[date_key].append(reading)
|
|
||||||
|
|
||||||
# Render template
|
|
||||||
return render_template(
|
|
||||||
'dashboard.html',
|
|
||||||
readings=readings,
|
|
||||||
profile=current_user.profile,
|
|
||||||
badges=badges,
|
|
||||||
systolic_avg=systolic_avg,
|
|
||||||
diastolic_avg=diastolic_avg,
|
|
||||||
heart_rate_avg=heart_rate_avg,
|
|
||||||
delete_form=DeleteForm(),
|
|
||||||
timestamps=timestamps,
|
|
||||||
systolic=systolic,
|
|
||||||
diastolic=diastolic,
|
|
||||||
heart_rate=heart_rate,
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
readings_by_date=readings_by_date,
|
|
||||||
month = month_view,
|
|
||||||
date=date,
|
|
||||||
timedelta=timedelta
|
|
||||||
)
|
|
||||||
|
|
||||||
@main.route('/add-reading', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def add_reading():
|
|
||||||
form = ReadingForm()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
new_reading = Reading(
|
|
||||||
user_id=current_user.id,
|
|
||||||
timestamp=form.timestamp.data,
|
|
||||||
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'))
|
|
||||||
|
|
||||||
# Fetch the user's timezone (default to 'UTC' if none is set)
|
|
||||||
user_timezone = current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC'
|
|
||||||
local_tz = timezone(user_timezone)
|
|
||||||
|
|
||||||
form.timestamp.data = utc.localize(datetime.utcnow()).astimezone(local_tz)
|
|
||||||
return render_template('add_reading.html', form=form)
|
|
||||||
|
|
||||||
@main.route('/reading/<int:reading_id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edit_reading(reading_id):
|
|
||||||
reading = Reading.query.get_or_404(reading_id)
|
|
||||||
|
|
||||||
# Ensure the reading belongs to the logged-in user
|
|
||||||
if reading.user_id != current_user.id:
|
|
||||||
flash('You are not authorized to edit this reading.', 'danger')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
# Fetch the user's timezone (default to 'UTC' if none is set)
|
|
||||||
user_timezone = current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC'
|
|
||||||
local_tz = timezone(user_timezone)
|
|
||||||
|
|
||||||
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(local_tz)
|
|
||||||
|
|
||||||
form = ReadingForm(obj=reading) # Populate form with existing reading data
|
|
||||||
form.timestamp.data = reading.local_timestamp
|
|
||||||
if form.validate_on_submit():
|
|
||||||
# Convert the local timestamp back to UTC for saving
|
|
||||||
local_timestamp = form.timestamp.data
|
|
||||||
# Ensure the local timestamp is naive before localizing
|
|
||||||
if local_timestamp.tzinfo is not None:
|
|
||||||
local_timestamp = local_timestamp.replace(tzinfo=None)
|
|
||||||
|
|
||||||
reading.timestamp = local_tz.localize(local_timestamp).astimezone(utc)
|
|
||||||
|
|
||||||
reading.systolic = form.systolic.data
|
|
||||||
reading.diastolic = form.diastolic.data
|
|
||||||
reading.heart_rate = form.heart_rate.data
|
|
||||||
db.session.commit()
|
|
||||||
flash('Reading updated successfully!', 'success')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
return render_template('edit_reading.html', form=form, reading=reading)
|
|
||||||
|
|
||||||
@main.route('/confirm_delete/<int:reading_id>', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def confirm_delete(reading_id):
|
|
||||||
# Fetch the reading to confirm deletion
|
|
||||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
# Handle deletion
|
|
||||||
db.session.delete(reading)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Reading deleted successfully!', 'success')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
return render_template('confirm_delete.html', reading=reading)
|
|
||||||
|
|
||||||
|
|
||||||
@main.route('/reading/<int:reading_id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def delete_reading(reading_id):
|
|
||||||
reading = Reading.query.get_or_404(reading_id)
|
|
||||||
|
|
||||||
# Ensure the reading belongs to the logged-in user
|
|
||||||
if reading.user_id != current_user.id:
|
|
||||||
flash('You are not authorized to delete this reading.', 'danger')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
db.session.delete(reading)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Reading deleted successfully!', 'success')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
|
|
||||||
@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((300, 300)) # Resize to a maximum of 300x300 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
|
|
||||||
response.headers.set('Last-Modified', http_date(datetime.utcnow().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')
|
|
||||||
|
|
||||||
@main.route('/data', 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
|
|
||||||
for row in csv_data:
|
|
||||||
timestamp, systolic, diastolic, heart_rate = row
|
|
||||||
reading = 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.add(reading)
|
|
||||||
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('main.manage_data'))
|
|
||||||
|
|
||||||
return render_template('data.html')
|
|
||||||
|
|
||||||
@main.route('/data/export', methods=['GET'])
|
|
||||||
@login_required
|
|
||||||
def export_data():
|
|
||||||
import io
|
|
||||||
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
|
|
||||||
# Write CSV header
|
|
||||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
|
||||||
|
|
||||||
# Write user readings to the CSV
|
|
||||||
readings = Reading.query.filter_by(user_id=current_user.id).all()
|
|
||||||
for reading in readings:
|
|
||||||
writer.writerow([
|
|
||||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
reading.systolic,
|
|
||||||
reading.diastolic,
|
|
||||||
reading.heart_rate,
|
|
||||||
])
|
|
||||||
|
|
||||||
# Convert text to bytes for `send_file`
|
|
||||||
output.seek(0)
|
|
||||||
response = io.BytesIO(output.getvalue().encode('utf-8'))
|
|
||||||
output.close()
|
|
||||||
|
|
||||||
return send_file(
|
|
||||||
response,
|
|
||||||
mimetype='text/csv',
|
|
||||||
as_attachment=True,
|
|
||||||
download_name='readings.csv'
|
|
||||||
)
|
|
||||||
|
|
||||||
@main.route('/health')
|
|
||||||
def health():
|
|
||||||
return "OK", 200
|
|
||||||
38
app/routes/auth.py
Normal file
38
app/routes/auth.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from app.models import db, User
|
||||||
|
from app.forms import LoginForm, SignupForm
|
||||||
|
from flask_login import login_user, login_required, logout_user
|
||||||
|
|
||||||
|
auth = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
@auth.route('/signup', methods=['GET', 'POST'])
|
||||||
|
def signup():
|
||||||
|
form = SignupForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
hashed_password = generate_password_hash(form.password.data)
|
||||||
|
new_user = User(username=form.username.data, password_hash=hashed_password)
|
||||||
|
db.session.add(new_user)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Account created successfully. Please log in.", "success")
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
return render_template('auth/signup.html', form=form)
|
||||||
|
|
||||||
|
@auth.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User.query.filter_by(username=form.username.data).first()
|
||||||
|
if user and check_password_hash(user.password_hash, form.password.data):
|
||||||
|
login_user(user)
|
||||||
|
flash("Logged in successfully.", "success")
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
flash("Invalid username or password.", "danger")
|
||||||
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
|
@auth.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user() # Logs out the current user
|
||||||
|
flash('You have been logged out.', 'success')
|
||||||
|
return redirect(url_for('auth.login')) # Redirect to login page or home page
|
||||||
71
app/routes/data.py
Normal file
71
app/routes/data.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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
|
||||||
|
for row in csv_data:
|
||||||
|
timestamp, systolic, diastolic, heart_rate = row
|
||||||
|
reading = 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.add(reading)
|
||||||
|
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
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Write CSV header
|
||||||
|
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||||
|
|
||||||
|
# Write user readings to the CSV
|
||||||
|
readings = Reading.query.filter_by(user_id=current_user.id).all()
|
||||||
|
for reading in readings:
|
||||||
|
writer.writerow([
|
||||||
|
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
reading.systolic,
|
||||||
|
reading.diastolic,
|
||||||
|
reading.heart_rate,
|
||||||
|
])
|
||||||
|
|
||||||
|
# Convert text to bytes for `send_file`
|
||||||
|
output.seek(0)
|
||||||
|
response = io.BytesIO(output.getvalue().encode('utf-8'))
|
||||||
|
output.close()
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
response,
|
||||||
|
mimetype='text/csv',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name='readings.csv'
|
||||||
|
)
|
||||||
213
app/routes/main.py
Normal file
213
app/routes/main.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
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__)
|
||||||
|
|
||||||
|
@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 with readings, stats, and calendar views."""
|
||||||
|
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||||
|
|
||||||
|
# Get date range for readings
|
||||||
|
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
|
||||||
|
start_date = request.form.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
|
||||||
|
end_date = request.form.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
|
# Fetch filtered readings
|
||||||
|
readings = fetch_readings(current_user.id, start_date, end_date, user_tz)
|
||||||
|
|
||||||
|
# Annotate readings with relative and localized timestamps
|
||||||
|
annotate_readings(readings, user_tz)
|
||||||
|
|
||||||
|
# Generate calendar view
|
||||||
|
week_view = generate_weekly_calendar(readings, datetime.now(user_tz), user_tz)
|
||||||
|
month_view = generate_monthly_calendar(readings, datetime.now(user_tz), user_tz)
|
||||||
|
|
||||||
|
# Calculate stats and badges
|
||||||
|
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary(readings)
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
|
||||||
|
# Prepare graph data
|
||||||
|
graph_data = prepare_graph_data(readings)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dashboard.html',
|
||||||
|
readings=readings,
|
||||||
|
profile=current_user.profile,
|
||||||
|
badges=badges,
|
||||||
|
systolic_avg=systolic_avg,
|
||||||
|
diastolic_avg=diastolic_avg,
|
||||||
|
heart_rate_avg=heart_rate_avg,
|
||||||
|
delete_form=DeleteForm(),
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
month=month_view,
|
||||||
|
week = week_view,
|
||||||
|
date=date,
|
||||||
|
**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(user_id, start_date, end_date, user_tz):
|
||||||
|
"""Retrieve readings filtered by date range."""
|
||||||
|
query = Reading.query.filter_by(user_id=user_id)
|
||||||
|
|
||||||
|
if start_date and end_date:
|
||||||
|
# Convert dates to the user's timezone
|
||||||
|
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()).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 calculate_weekly_summary(readings):
|
||||||
|
"""Calculate averages for the past week."""
|
||||||
|
one_week_ago = datetime.utcnow() - timedelta(days=7)
|
||||||
|
weekly_readings = [r for r in readings if r.timestamp >= one_week_ago]
|
||||||
|
if weekly_readings:
|
||||||
|
return (
|
||||||
|
round(sum(r.systolic for r in weekly_readings) / len(weekly_readings), 1),
|
||||||
|
round(sum(r.diastolic for r in weekly_readings) / len(weekly_readings), 1),
|
||||||
|
round(sum(r.heart_rate for r in weekly_readings) / len(weekly_readings), 1),
|
||||||
|
)
|
||||||
|
return 0, 0, 0
|
||||||
|
|
||||||
|
def generate_monthly_calendar(readings, selected_date, local_tz):
|
||||||
|
"""Generate a monthly calendar view."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
readings_by_day = defaultdict(list)
|
||||||
|
for reading in readings:
|
||||||
|
local_date = reading.timestamp.astimezone(local_tz).date()
|
||||||
|
readings_by_day[local_date].append(reading)
|
||||||
|
|
||||||
|
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, selected_date, local_tz):
|
||||||
|
"""Generate a weekly calendar view."""
|
||||||
|
# Get the start of the week (Monday) and the end of the week (Sunday)
|
||||||
|
today = datetime.now(local_tz).date()
|
||||||
|
start_of_week = selected_date - timedelta(days=selected_date.weekday())
|
||||||
|
end_of_week = start_of_week + timedelta(days=6)
|
||||||
|
|
||||||
|
# Group readings by day
|
||||||
|
readings_by_day = defaultdict(list)
|
||||||
|
for reading in readings:
|
||||||
|
local_date = reading.timestamp.astimezone(local_tz).date()
|
||||||
|
readings_by_day[local_date].append(reading)
|
||||||
|
|
||||||
|
# Build the weekly calendar view
|
||||||
|
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."""
|
||||||
|
return {
|
||||||
|
'timestamps': [r.timestamp.strftime('%b %d') for r in readings],
|
||||||
|
'systolic': [r.systolic for r in readings],
|
||||||
|
'diastolic': [r.diastolic for r in readings],
|
||||||
|
'heart_rate': [r.heart_rate for r in readings],
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_progress_badges(readings):
|
||||||
|
"""Generate badges based on user activity and milestones."""
|
||||||
|
now = datetime.utcnow().date()
|
||||||
|
streak_count, badges = 1, []
|
||||||
|
|
||||||
|
if not readings:
|
||||||
|
return badges
|
||||||
|
|
||||||
|
sorted_readings = sorted(readings, key=lambda r: r.timestamp.date())
|
||||||
|
streak_count = 1
|
||||||
|
daily_streak = True
|
||||||
|
|
||||||
|
# Start with the first reading
|
||||||
|
previous_date = sorted_readings[0].timestamp.date()
|
||||||
|
|
||||||
|
for reading in sorted_readings[1:]:
|
||||||
|
current_date = reading.timestamp.date()
|
||||||
|
|
||||||
|
# Check for consecutive daily streaks
|
||||||
|
if (current_date - previous_date).days == 1:
|
||||||
|
streak_count += 1
|
||||||
|
elif (current_date - previous_date).days > 1:
|
||||||
|
daily_streak = False
|
||||||
|
|
||||||
|
previous_date = current_date
|
||||||
|
|
||||||
|
# Add streak badges
|
||||||
|
if daily_streak and streak_count >= 1:
|
||||||
|
badges.append(f"Current Streak: {streak_count} Days")
|
||||||
|
if daily_streak and streak_count >= 7:
|
||||||
|
badges.append("Logged Every Day for a Week")
|
||||||
|
|
||||||
|
if daily_streak and streak_count >= 30 and previous_date == now:
|
||||||
|
badges.append("Monthly Streak")
|
||||||
|
|
||||||
|
if all(5 <= r.timestamp.hour < 12 for r in sorted_readings[-7:]):
|
||||||
|
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
|
||||||
|
|
||||||
|
if all(18 <= r.timestamp.hour <= 23 for r in sorted_readings[-7:]):
|
||||||
|
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 len(readings) >= m), default=None)
|
||||||
|
if highest_milestone:
|
||||||
|
badges.append(f"{highest_milestone} Readings Logged")
|
||||||
|
|
||||||
|
return badges
|
||||||
79
app/routes/reading.py
Normal file
79
app/routes/reading.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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'))
|
||||||
79
app/routes/user.py
Normal file
79
app/routes/user.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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((300, 300)) # Resize to a maximum of 300x300 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
|
||||||
|
response.headers.set('Last-Modified', http_date(datetime.utcnow().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')
|
||||||
@@ -51,11 +51,11 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="mr-3">
|
<li class="mr-3">
|
||||||
<a class="inline-block py-2 px-4 no-underline
|
<a class="inline-block py-2 px-4 no-underline
|
||||||
{% if request.path == url_for('main.manage_data') %}
|
{% if request.path == url_for('data.manage_data') %}
|
||||||
text-white
|
text-white
|
||||||
{% else %}
|
{% else %}
|
||||||
text-gray-600 hover:text-gray-200 hover:text-underline
|
text-gray-600 hover:text-gray-200 hover:text-underline
|
||||||
{% endif %}" href="{{ url_for('main.manage_data') }}">Data
|
{% endif %}" href="{{ url_for('data.manage_data') }}">Data
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="mr-3">
|
<li class="mr-3">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- Header Section with "Add New Reading" Button -->
|
<!-- Header Section with "Add New Reading" Button -->
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1>
|
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1>
|
||||||
<a rel="prefetch" href="{{ url_for('main.add_reading') }}"
|
<a rel="prefetch" href="{{ url_for('reading.add_reading') }}"
|
||||||
class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700">
|
class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700">
|
||||||
+ Add New Reading
|
+ Add New Reading
|
||||||
</a>
|
</a>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<!-- List -->
|
<!-- List -->
|
||||||
<div x-show="activeView === 'list'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div x-show="activeView === 'list'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{% for reading in readings %}
|
{% for reading in readings %}
|
||||||
<a href="{{ url_for('main.edit_reading', reading_id=reading.id) }}"
|
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
|
||||||
class="bg-white shadow-md rounded-lg p-4 flex flex-col justify-between relative hover:shadow-lg transition-shadow">
|
class="bg-white shadow-md rounded-lg p-4 flex flex-col justify-between relative hover:shadow-lg transition-shadow">
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
<div class="absolute top-2 right-2 flex items-center text-gray-400 text-xs">
|
<div class="absolute top-2 right-2 flex items-center text-gray-400 text-xs">
|
||||||
@@ -148,14 +148,12 @@
|
|||||||
|
|
||||||
<!-- Weekly View -->
|
<!-- Weekly View -->
|
||||||
<div x-show="activeView === 'weekly'" class="grid grid-cols-7 text-center">
|
<div x-show="activeView === 'weekly'" class="grid grid-cols-7 text-center">
|
||||||
{% set today = date.today() %}
|
{% for day in week %}
|
||||||
{% for i in range(7) %}
|
|
||||||
{% set day = today - timedelta(days=today.weekday() - i) %}
|
|
||||||
<div class="border p-1 md:p-4 bg-gray-50">
|
<div class="border p-1 md:p-4 bg-gray-50">
|
||||||
<div class="text-sm font-bold text-gray-500">{{ day.strftime('%a, %b %d') }}</div>
|
<div class="text-sm font-bold text-gray-500">{{ day.date }}</div>
|
||||||
{% if day in readings_by_date %}
|
{% if day.readings %}
|
||||||
{% for reading in readings_by_date[day]|sort(attribute="timestamp", reverse = True) %}
|
{% for reading in day.readings %}
|
||||||
<a href="{{ url_for('main.edit_reading', reading_id=reading.id) }}"
|
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
|
||||||
class="block mt-2 p-0 md:p-2 bg-green-100 rounded-lg shadow hover:bg-green-200 transition">
|
class="block mt-2 p-0 md:p-2 bg-green-100 rounded-lg shadow hover:bg-green-200 transition">
|
||||||
<p class="text-xs font-medium text-green-800">
|
<p class="text-xs font-medium text-green-800">
|
||||||
{{ reading.systolic }}/{{ reading.diastolic }} mmHg
|
{{ reading.systolic }}/{{ reading.diastolic }} mmHg
|
||||||
@@ -223,7 +221,7 @@
|
|||||||
<span class="text-gray-500 font-semibold">{{ day.day }}</span>
|
<span class="text-gray-500 font-semibold">{{ day.day }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% for reading in day.readings %}
|
{% for reading in day.readings %}
|
||||||
<a href="{{ url_for('main.edit_reading', reading_id=reading.id) }}"
|
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
|
||||||
class="block mt-2 p-0 md:p-2 bg-green-100 rounded-lg shadow hover:bg-green-200 transition">
|
class="block mt-2 p-0 md:p-2 bg-green-100 rounded-lg shadow hover:bg-green-200 transition">
|
||||||
<p class="text-xs font-medium text-green-800">
|
<p class="text-xs font-medium text-green-800">
|
||||||
{{ reading.systolic }}/{{ reading.diastolic }} mmHg
|
{{ reading.systolic }}/{{ reading.diastolic }} mmHg
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<!-- Import Data Section -->
|
<!-- Import Data Section -->
|
||||||
<div class="bg-white p-6 rounded-lg shadow-md mb-6">
|
<div class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">Import Data</h2>
|
<h2 class="text-lg font-semibold mb-4">Import Data</h2>
|
||||||
<form method="POST" action="{{ url_for('main.manage_data') }}" enctype="multipart/form-data">
|
<form method="POST" action="{{ url_for('data.manage_data') }}" enctype="multipart/form-data">
|
||||||
<label for="file" class="block text-sm font-medium text-gray-700 mb-2">Upload CSV File</label>
|
<label for="file" class="block text-sm font-medium text-gray-700 mb-2">Upload CSV File</label>
|
||||||
<input type="file" name="file" id="file"
|
<input type="file" name="file" id="file"
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<!-- Export Data Section -->
|
<!-- Export Data Section -->
|
||||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
<h2 class="text-lg font-semibold mb-4">Export Data</h2>
|
<h2 class="text-lg font-semibold mb-4">Export Data</h2>
|
||||||
<a href="{{ url_for('main.export_data') }}"
|
<a href="{{ url_for('data.export_data') }}"
|
||||||
class="bg-green-600 text-white px-6 py-2 rounded-lg shadow-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
|
class="bg-green-600 text-white px-6 py-2 rounded-lg shadow-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||||
Download CSV
|
Download CSV
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">Add Reading</h1>
|
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">Add Reading</h1>
|
||||||
<form method="POST" action="{{ url_for('main.add_reading') }}" novalidate class="space-y-6">
|
<form method="POST" action="{{ url_for('reading.add_reading') }}" novalidate class="space-y-6">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
<!-- Timestamp Field -->
|
<!-- Timestamp Field -->
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400">
|
class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400">
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="{{ url_for('main.confirm_delete', reading_id=reading.id) }}">
|
<form method="POST" action="{{ url_for('reading.delete_reading', reading_id=reading.id) }}">
|
||||||
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Delete Button (Top-Right) -->
|
<!-- Delete Button (Top-Right) -->
|
||||||
<a href="{{ url_for('main.confirm_delete', reading_id=reading.id) }}"
|
<a href="{{ url_for('reading.confirm_delete', reading_id=reading.id) }}"
|
||||||
class="absolute top-4 right-4 text-red-500 hover:text-red-700">
|
class="absolute top-4 right-4 text-red-500 hover:text-red-700">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||||
class="size-6">
|
class="size-6">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold mb-4 text-center">Edit Reading</h1>
|
<h1 class="text-2xl font-bold mb-4 text-center">Edit Reading</h1>
|
||||||
<form method="POST" action="{{ url_for('main.edit_reading', reading_id=reading.id) }}" novalidate
|
<form method="POST" action="{{ url_for('reading.edit_reading', reading_id=reading.id) }}" novalidate
|
||||||
class="bg-white p-8 rounded-lg shadow-md">
|
class="bg-white p-8 rounded-lg shadow-md">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
2
pytyest.ini
Normal file
2
pytyest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
@@ -5,6 +5,7 @@ click==8.1.8
|
|||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
dnspython==2.7.0
|
dnspython==2.7.0
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
|
exceptiongroup==1.2.2
|
||||||
flask==3.1.0
|
flask==3.1.0
|
||||||
Flask-Bcrypt==1.0.1
|
Flask-Bcrypt==1.0.1
|
||||||
Flask-Login==0.6.3
|
Flask-Login==0.6.3
|
||||||
@@ -16,16 +17,21 @@ gunicorn==23.0.0
|
|||||||
humanize==4.11.0
|
humanize==4.11.0
|
||||||
idna==3.10
|
idna==3.10
|
||||||
importlib-metadata==8.5.0
|
importlib-metadata==8.5.0
|
||||||
|
iniconfig==2.0.0
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
Mako==1.3.8
|
Mako==1.3.8
|
||||||
MarkupSafe==3.0.2
|
MarkupSafe==3.0.2
|
||||||
packaging==24.2
|
packaging==24.2
|
||||||
pillow==11.0.0
|
pillow==11.0.0
|
||||||
|
pluggy==1.5.0
|
||||||
psycopg2==2.9.10
|
psycopg2==2.9.10
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-flask==1.3.0
|
||||||
pytz==2024.2
|
pytz==2024.2
|
||||||
SQLAlchemy==2.0.36
|
SQLAlchemy==2.0.36
|
||||||
|
tomli==2.2.1
|
||||||
typing-extensions==4.12.2
|
typing-extensions==4.12.2
|
||||||
werkzeug==3.1.3
|
werkzeug==3.1.3
|
||||||
wtforms==3.2.1
|
wtforms==3.2.1
|
||||||
|
|||||||
71
tests/test_main.py
Normal file
71
tests/test_main.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Dynamically add the project directory to PYTHONPATH
|
||||||
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
from app.models import Reading
|
||||||
|
from app.routes.main import calculate_progress_badges
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_readings():
|
||||||
|
readings = []
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert badges == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_streak():
|
||||||
|
now = datetime.utcnow()
|
||||||
|
readings = [
|
||||||
|
Reading(timestamp=now - timedelta(days=i)) for i in range(3)
|
||||||
|
] # 3-day streak
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert "Current Streak: 3 Days" in badges
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_streak():
|
||||||
|
now = datetime.utcnow()
|
||||||
|
readings = [
|
||||||
|
Reading(timestamp=now - timedelta(days=i)) for i in range(7)
|
||||||
|
] # 7-day streak
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert "Logged Every Day for a Week" in badges
|
||||||
|
|
||||||
|
|
||||||
|
def test_morning_riser():
|
||||||
|
now = datetime.utcnow().replace(hour=8) # Morning readings
|
||||||
|
readings = [
|
||||||
|
Reading(timestamp=now - timedelta(days=i)) for i in range(7)
|
||||||
|
]
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert "Morning Riser: Logged Readings Every Morning for a Week" in badges
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_owl():
|
||||||
|
now = datetime.utcnow().replace(hour=20) # Night readings
|
||||||
|
readings = [
|
||||||
|
Reading(timestamp=now - timedelta(days=i)) for i in range(7)
|
||||||
|
]
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert "Night Owl: Logged Readings Every Night for a Week" in badges
|
||||||
|
|
||||||
|
|
||||||
|
def test_milestones():
|
||||||
|
readings = [
|
||||||
|
Reading(timestamp=datetime.utcnow()) for _ in range(50)
|
||||||
|
] # 50 readings
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert "50 Readings Logged" in badges
|
||||||
|
assert "100 Readings Logged" not in badges # Ensure only the highest milestone
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_streak_with_gaps():
|
||||||
|
now = datetime.utcnow()
|
||||||
|
readings = [
|
||||||
|
Reading(timestamp=now - timedelta(days=i * 2)) for i in range(3)
|
||||||
|
] # Gaps in dates
|
||||||
|
badges = calculate_progress_badges(readings)
|
||||||
|
assert "Current Streak" not in badges
|
||||||
Reference in New Issue
Block a user