Get project working locally

This commit is contained in:
Peter Stockings
2024-12-24 00:57:08 +11:00
parent d4fc1868ab
commit 11239fd4f5
22 changed files with 1214 additions and 14 deletions

40
app/__init__.py Normal file
View File

@@ -0,0 +1,40 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
# Initialize Flask extensions
db = SQLAlchemy()
migrate = Migrate()
bcrypt = Bcrypt()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info'
def create_app(config_class='app.config.DevelopmentConfig'):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
bcrypt.init_app(app)
login_manager.init_app(app)
# Import models here to avoid circular imports
from app.models import User # Import the User model
# Set up the user_loader function
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id)) # Query the User model by ID
# Register blueprints
from app.routes import main, auth, user
app.register_blueprint(main, url_prefix='/')
app.register_blueprint(auth, url_prefix='/auth')
app.register_blueprint(user, url_prefix='/user')
return app

31
app/config.py Normal file
View File

@@ -0,0 +1,31 @@
import os
class Config:
"""Base configuration."""
SECRET_KEY = os.environ.get('SECRET_KEY', '234234sdfsdfsdfsdf345345')
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
uri = os.environ.get('DATABASE_URL', 'postgresql://postgres:59fff56880e1bbb42e753d2a82ac21b6@peterstockings.com:15389/bloodpressure_db')
if uri and uri.startswith("postgres://"):
uri = uri.replace("postgres://", "postgresql://", 1)
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'postgresql://postgres:59fff56880e1bbb42e753d2a82ac21b6@peterstockings.com:15389/bloodpressure_db')
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
uri = os.environ.get('DATABASE_URL', 'postgresql://postgres:59fff56880e1bbb42e753d2a82ac21b6@peterstockings.com:15389/bloodpressure_db')
if uri and uri.startswith("postgres://"):
uri = uri.replace("postgres://", "postgresql://", 1)
SQLALCHEMY_DATABASE_URI = uri
class TestingConfig(Config):
"""Testing configuration."""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///test.db'

100
app/forms.py Normal file
View File

@@ -0,0 +1,100 @@
from typing import Optional
from flask_wtf import FlaskForm
from wtforms import BooleanField, FileField, StringField, PasswordField, SubmitField, IntegerField, DateTimeLocalField
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Email, Optional, NumberRange
from app.models import User
from datetime import datetime
class SignupForm(FlaskForm):
username = StringField(
'Username',
validators=[
DataRequired(),
Length(min=4, max=20, message="Username must be between 4 and 20 characters.")
]
)
password = PasswordField(
'Password',
validators=[
DataRequired(),
Length(min=6, message="Password must be at least 6 characters long.")
]
)
confirm_password = PasswordField(
'Confirm Password',
validators=[
DataRequired(),
EqualTo('password', message="Passwords must match.")
]
)
submit = SubmitField('Sign Up')
# Custom validator to check if username is already taken
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError("Username is already taken. Please choose a different one.")
class LoginForm(FlaskForm):
username = StringField(
'Username',
validators=[DataRequired(message="Username is required.")]
)
password = PasswordField(
'Password',
validators=[DataRequired(message="Password is required.")]
)
submit = SubmitField('Login')
class ReadingForm(FlaskForm):
timestamp = DateTimeLocalField(
'Timestamp',
format='%Y-%m-%dT%H:%M',
default=datetime.now, # Set default to current time
validators=[DataRequired(message="Timestamp is required.")]
)
systolic = IntegerField(
'Systolic (mmHg)',
validators=[
DataRequired(),
NumberRange(min=50, max=250, message="Systolic pressure must be between 50 and 250 mmHg.")
]
)
diastolic = IntegerField(
'Diastolic (mmHg)',
validators=[
DataRequired(),
NumberRange(min=30, max=150, message="Diastolic pressure must be between 30 and 150 mmHg.")
]
)
heart_rate = IntegerField(
'Heart Rate (bpm)',
validators=[
DataRequired(),
NumberRange(min=30, max=200, message="Heart rate must be between 30 and 200 bpm.")
]
)
submit = SubmitField('Save Reading')
class DeleteForm(FlaskForm):
submit = SubmitField('Delete')
class ProfileForm(FlaskForm):
name = StringField('Name', validators=[Optional()])
email = StringField('Email', validators=[Optional(), Email()])
profile_pic = FileField('Profile Picture (optional)')
systolic_threshold = IntegerField(
'Systolic Threshold (mmHg)',
validators=[Optional(), NumberRange(min=90, max=200)]
)
diastolic_threshold = IntegerField(
'Diastolic Threshold (mmHg)',
validators=[Optional(), NumberRange(min=60, max=120)]
)
dark_mode = BooleanField('Enable Dark Mode')
submit = SubmitField('Save Settings')

29
app/models.py Normal file
View File

@@ -0,0 +1,29 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from app import db
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False, unique=True)
password_hash = db.Column(db.Text, nullable=False)
profile = db.relationship('Profile', backref='user', uselist=False)
class Profile(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
name = db.Column(db.String(100))
email = db.Column(db.String(150), unique=True)
profile_pic = db.Column(db.Text) # Store image as a base64 string
systolic_threshold = db.Column(db.Integer, default=140)
diastolic_threshold = db.Column(db.Integer, default=90)
dark_mode = db.Column(db.Boolean, default=False)
class Reading(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
timestamp = db.Column(db.DateTime, nullable=False)
systolic = db.Column(db.Integer, nullable=False)
diastolic = db.Column(db.Integer, nullable=False)
heart_rate = db.Column(db.Integer, nullable=False)
user = db.relationship('User', backref='readings')

176
app/routes.py Normal file
View File

@@ -0,0 +1,176 @@
from flask import Blueprint, render_template, redirect, request, url_for, flash
from werkzeug.security import generate_password_hash, check_password_hash
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
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('/')
@login_required
def dashboard():
from datetime import datetime, timedelta
profile = current_user.profile
# Get all readings for the user
readings = Reading.query.filter_by(user_id=current_user.id).order_by(Reading.timestamp.desc()).all()
# Weekly summary (last 7 days)
one_week_ago = datetime.now() - timedelta(days=7)
weekly_readings = [r for r in readings if r.timestamp >= one_week_ago]
systolic_avg = round(sum(r.systolic for r in weekly_readings) / len(weekly_readings), 1) if weekly_readings else 0
diastolic_avg = round(sum(r.diastolic for r in weekly_readings) / len(weekly_readings), 1) if weekly_readings else 0
heart_rate_avg = round(sum(r.heart_rate for r in weekly_readings) / len(weekly_readings), 1) if weekly_readings else 0
# Progress badges
badges = []
if len(readings) >= 10:
badges.append("10 Readings Logged")
if len(readings) >= 100:
badges.append("100 Readings Milestone")
if len(weekly_readings) >= 7:
badges.append("Logged Readings for 7 Days")
# Pass the delete form to the template
delete_form = DeleteForm()
return render_template('dashboard.html', readings=readings, profile=profile, badges=badges,
systolic_avg=systolic_avg, diastolic_avg=diastolic_avg, heart_rate_avg=heart_rate_avg,
delete_form=delete_form)
@main.route('/dashboard/filter', methods=['POST'])
@login_required
def filter_dashboard():
from datetime import datetime
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
# Parse dates and filter readings
readings = Reading.query.filter(
Reading.user_id == current_user.id,
Reading.timestamp >= datetime.strptime(start_date, '%Y-%m-%d'),
Reading.timestamp <= datetime.strptime(end_date, '%Y-%m-%d')
).order_by(Reading.timestamp.desc()).all()
# Pass the delete form to the template
delete_form = DeleteForm()
return render_template('dashboard.html', readings=readings, delete_form=delete_form)
@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'))
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'))
form = ReadingForm(obj=reading) # Populate form with existing reading data
if form.validate_on_submit():
reading.timestamp = form.timestamp.data
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('/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
# Handle profile picture upload
if form.profile_pic.data:
file_data = form.profile_pic.data.read()
profile.profile_pic = base64.b64encode(file_data).decode('utf-8')
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)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BP Tracker{% endblock %}</title>
<script src="/static/js/tailwindcss@3.2.4.js"></script>
</head>
<body class="bg-gray-100 text-gray-800">
<!-- Navbar -->
<nav class="bg-blue-600 text-white p-4">
<div class="container mx-auto flex justify-between items-center">
<a href="/" class="text-2xl font-bold">BP Tracker</a>
<div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}" class="px-4 py-2 hover:underline">Dashboard</a>
<a href="{{ url_for('user.profile') }}" class="px-4 py-2 hover:underline">Profile</a>
<a href="{{ url_for('auth.logout') }}" class="px-4 py-2 bg-red-500 rounded hover:bg-red-600">Logout</a>
{% else %}
<a href="{{ url_for('auth.login') }}" class="px-4 py-2 hover:underline">Login</a>
<a href="{{ url_for('auth.signup') }}"
class="px-4 py-2 bg-white text-blue-600 rounded hover:bg-gray-200">Signup</a>
{% endif %}
</div>
</div>
</nav>
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div class="container mx-auto mt-4">
{% for category, message in messages %}
<div class="p-4 mb-4 rounded text-white bg-{{ 'red' if category == 'danger' else 'green' }}-500">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main class="container mx-auto mt-6">
{% block content %}
<!-- Content goes here -->
{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-4 mt-10">
<div class="container mx-auto text-center">
<p>&copy; 2024 BP Tracker. All rights reserved.</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,55 @@
{% extends "_layout.html" %}
{% block content %}
<div class="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-md">
<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.hidden_tag() }}
<!-- Timestamp Field -->
<div>
{{ form.timestamp.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.timestamp(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.timestamp.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Systolic Field -->
<div>
{{ form.systolic.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.systolic(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.systolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Diastolic Field -->
<div>
{{ form.diastolic.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.diastolic(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.diastolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Heart Rate Field -->
<div>
{{ form.heart_rate.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.heart_rate(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.heart_rate.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-500 to-blue-700 text-white py-3 rounded-lg
font-semibold hover:from-blue-600 hover:to-blue-800 shadow-lg") }}
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "_layout.html" %}
{% block content %}
<div class="max-w-5xl mx-auto p-4 space-y-6">
<!-- Header Section with "Add New Reading" Button -->
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1>
<a href="{{ url_for('main.add_reading') }}"
class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700">
+ Add New Reading
</a>
</div>
<!-- Weekly Summary -->
<div class="bg-gradient-to-r from-blue-500 to-blue-700 text-white p-6 rounded-lg shadow-md">
<h3 class="text-lg font-bold">Weekly Summary</h3>
<div class="flex justify-between mt-4">
<div>
<p class="text-sm font-semibold">Systolic Average</p>
<p class="text-2xl">{{ systolic_avg }} <span class="text-base">mmHg</span></p>
</div>
<div>
<p class="text-sm font-semibold">Diastolic Average</p>
<p class="text-2xl">{{ diastolic_avg }} <span class="text-base">mmHg</span></p>
</div>
<div>
<p class="text-sm font-semibold">Heart Rate Average</p>
<p class="text-2xl">{{ heart_rate_avg }} <span class="text-base">bpm</span></p>
</div>
</div>
</div>
<!-- Progress Badges -->
<div>
<h3 class="text-lg font-bold text-gray-800 mb-2">Progress Badges</h3>
<div class="flex flex-wrap gap-4">
{% for badge in badges %}
<div class="bg-green-100 text-green-800 px-4 py-2 rounded shadow text-sm font-medium">
{{ badge }}
</div>
{% endfor %}
</div>
</div>
<!-- Filter Form -->
<form method="POST" action="{{ url_for('main.filter_dashboard') }}" class="p-4 bg-white rounded-lg shadow-md">
<h3 class="text-lg font-bold text-gray-800 mb-4">Filter Readings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700">Start Date</label>
<input type="date" name="start_date" id="start_date" class="w-full p-2 border rounded">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700">End Date</label>
<input type="date" name="end_date" id="end_date" class="w-full p-2 border rounded">
</div>
</div>
<div class="mt-4">
<button type="submit" class="w-full md:w-auto bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700">
Apply Filters
</button>
</div>
</form>
<!-- Readings Table -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="w-full bg-white rounded shadow border-collapse">
<thead class="bg-gray-200">
<tr>
<th class="p-2 text-left">Timestamp</th>
<th class="p-2 text-left">Systolic</th>
<th class="p-2 text-left">Diastolic</th>
<th class="p-2 text-left">Heart Rate</th>
<th class="p-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{% for reading in readings %}
<tr class="border-t hover:bg-gray-100">
<td class="p-2">{{ reading.timestamp }}</td>
<td class="p-2">{{ reading.systolic }}</td>
<td class="p-2">{{ reading.diastolic }}</td>
<td class="p-2">{{ reading.heart_rate }}</td>
<td class="p-2">
<a href="{{ url_for('main.edit_reading', reading_id=reading.id) }}"
class="text-blue-600 hover:underline">Edit</a>
<form method="POST" action="{{ url_for('main.delete_reading', reading_id=reading.id) }}"
class="inline">
{{ delete_form.hidden_tag() }}
<button type="submit" class="text-red-600 hover:underline ml-2">
Delete
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="p-2 text-center text-gray-500">No readings found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "_layout.html" %}
{% block content %}
<div class="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-md">
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">Edit Reading</h1>
<form method="POST" action="{{ url_for('main.edit_reading', reading_id=reading.id) }}" novalidate>
{{ form.hidden_tag() }}
<!-- Timestamp Field -->
<div class="mb-4">
{{ form.timestamp.label(class="block text-sm font-medium text-gray-700") }}
{{ form.timestamp(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.timestamp.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Systolic Field -->
<div class="mb-4">
{{ form.systolic.label(class="block text-sm font-medium text-gray-700") }}
{{ form.systolic(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.systolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Diastolic Field -->
<div class="mb-4">
{{ form.diastolic.label(class="block text-sm font-medium text-gray-700") }}
{{ form.diastolic(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.diastolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Heart Rate Field -->
<div class="mb-4">
{{ form.heart_rate.label(class="block text-sm font-medium text-gray-700") }}
{{ form.heart_rate(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.heart_rate.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div>
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700">
Save Changes
</button>
</div>
</form>
</div>
{% endblock %}

32
app/templates/login.html Normal file
View File

@@ -0,0 +1,32 @@
{% extends "_layout.html" %}
{% block content %}
<div class="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-center mb-4">Login</h1>
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
{{ form.hidden_tag() }}
<!-- Username Field -->
<div class="mb-4">
{{ form.username.label(class="block text-sm font-medium text-gray-700") }}
{{ form.username(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% for error in form.username.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Password Field -->
<div class="mb-4">
{{ form.password.label(class="block text-sm font-medium text-gray-700") }}
{{ form.password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% for error in form.password.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div>
{{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }}
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "_layout.html" %}
{% block content %}
<div class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-center mb-6">Profile Settings</h1>
<div class="flex items-center justify-center mb-4">
{% if profile.profile_pic %}
<img src="data:image/jpeg;base64,{{ profile.profile_pic }}" alt="Profile Picture"
class="w-24 h-24 rounded-full border">
{% else %}
<img src="{{ url_for('static', filename='default.png') }}" alt="Default Profile Picture"
class="w-24 h-24 rounded-full border">
{% endif %}
</div>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="mb-4">
{{ form.name.label(class="block text-sm font-medium text-gray-700") }}
{{ form.name(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.email.label(class="block text-sm font-medium text-gray-700") }}
{{ form.email(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.profile_pic.label(class="block text-sm font-medium text-gray-700") }}
{{ form.profile_pic(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500")
}}
</div>
<div class="mb-4">
{{ form.systolic_threshold.label(class="block text-sm font-medium text-gray-700") }}
{{ form.systolic_threshold(class="w-full p-2 border rounded focus:outline-none focus:ring-2
focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.diastolic_threshold.label(class="block text-sm font-medium text-gray-700") }}
{{ form.diastolic_threshold(class="w-full p-2 border rounded focus:outline-none focus:ring-2
focus:ring-blue-500") }}
</div>
<div class="mb-4 flex items-center">
{{ form.dark_mode }}
{{ form.dark_mode.label(class="ml-2 text-sm font-medium text-gray-700") }}
</div>
<div>
{{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }}
</div>
</form>
</div>
{% endblock %}

42
app/templates/signup.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "_layout.html" %}
{% block content %}
<div class="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-center mb-4">Sign Up</h1>
<form method="POST" action="{{ url_for('auth.signup') }}" novalidate>
{{ form.hidden_tag() }}
<!-- Username Field -->
<div class="mb-4">
{{ form.username.label(class="block text-sm font-medium text-gray-700") }}
{{ form.username(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% for error in form.username.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Password Field -->
<div class="mb-4">
{{ form.password.label(class="block text-sm font-medium text-gray-700") }}
{{ form.password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% for error in form.password.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Confirm Password Field -->
<div class="mb-4">
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700") }}
{{ form.confirm_password(class="w-full p-2 border rounded focus:outline-none focus:ring-2
focus:ring-blue-500") }}
{% for error in form.confirm_password.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div>
{{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }}
</div>
</form>
</div>
{% endblock %}

18
manage.py Normal file
View File

@@ -0,0 +1,18 @@
from app import create_app, db
from flask_migrate import Migrate
from app.models import Profile, Reading, User
app = create_app()
# Initialize Flask-Migrate
migrate = Migrate(app, db)
# Add shell context for Flask CLI
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'User': User, 'Profile': Profile, 'Reading': Reading}
if __name__ == "__main__":
app.run()

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,38 @@
"""Change Password Hash column to text0
Revision ID: 59097bee8942
Revises: b409c5bae0bb
Create Date: 2024-12-24 00:35:11.199266
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '59097bee8942'
down_revision = 'b409c5bae0bb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password_hash',
existing_type=sa.VARCHAR(length=150),
type_=sa.Text(),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password_hash',
existing_type=sa.Text(),
type_=sa.VARCHAR(length=150),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Increase length of password_hash column
Revision ID: b409c5bae0bb
Revises: e276f9e056a2
Create Date: 2024-12-24 00:33:58.419416
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b409c5bae0bb'
down_revision = 'e276f9e056a2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('username',
existing_type=sa.VARCHAR(length=150),
type_=sa.String(length=255),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('username',
existing_type=sa.String(length=255),
type_=sa.VARCHAR(length=150),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,59 @@
"""Add tables
Revision ID: e276f9e056a2
Revises:
Create Date: 2024-12-24 00:28:49.674352
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e276f9e056a2'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=150), nullable=False),
sa.Column('password_hash', sa.String(length=150), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('profile',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=True),
sa.Column('email', sa.String(length=150), nullable=True),
sa.Column('profile_pic', sa.Text(), nullable=True),
sa.Column('systolic_threshold', sa.Integer(), nullable=True),
sa.Column('diastolic_threshold', sa.Integer(), nullable=True),
sa.Column('dark_mode', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('reading',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('systolic', sa.Integer(), nullable=False),
sa.Column('diastolic', sa.Integer(), nullable=False),
sa.Column('heart_rate', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('reading')
op.drop_table('profile')
op.drop_table('user')
# ### end Alembic commands ###

View File

@@ -1,14 +1,27 @@
Flask==2.2.2 alembic==1.14.0
gunicorn==19.7.1 bcrypt==4.2.1
Jinja2==3.1.0 blinker==1.9.0
jinja-partials==0.1.1 click==8.1.8
psycopg2-binary==2.9.3 colorama==0.4.6
flask-htmx==0.2.0 dnspython==2.7.0
python-dateutil==2.8.2 email-validator==2.2.0
minify-html==0.10.3 flask==3.1.0
jinja2-fragments==0.3.0 Flask-Bcrypt==1.0.1
Werkzeug==2.2.2 Flask-Login==0.6.3
numpy==1.19.5 Flask-Migrate==4.0.7
pandas==1.3.1 flask-sqlalchemy==3.1.1
python-dotenv==1.0.1 flask-wtf==1.2.2
plotly==5.24.1 greenlet==3.1.1
idna==3.10
importlib-metadata==8.5.0
itsdangerous==2.2.0
jinja2==3.1.5
Mako==1.3.8
MarkupSafe==3.0.2
psycopg2==2.9.10
psycopg2-binary==2.9.10
SQLAlchemy==2.0.36
typing-extensions==4.12.2
werkzeug==3.1.3
wtforms==3.2.1
zipp==3.21.0