diff --git a/.gitignore b/.gitignore index 487653c..95d4ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ env/ .env .ipynb_checkpoints/ +*.db diff --git a/README.md b/README.md index 976b5ab..8b69dd9 100644 --- a/README.md +++ b/README.md @@ -45,16 +45,12 @@ Backend API for Redback Project 3 (Wearables for athletes), built with Python an pip install -r requirements.txt ``` -6. Initialize Database - ```bash - python scripts/create_db.py - ``` - -7. Run the Flask server +6. Run the Flask server ```bash python app.py ``` + The database will be automatically created and initialised on first run found in /instance. + Once running, the backend will be available at: `http://localhost:5000` -The Environment used in the backend are available for reference in the .env.example file located inside the Backend folder. diff --git a/api/activity.py b/api/activity.py new file mode 100644 index 0000000..5902a47 --- /dev/null +++ b/api/activity.py @@ -0,0 +1,55 @@ +from flask import Blueprint, request, jsonify +from models.activity import Activity, ActivityTimeSeries +from models import db +from datetime import datetime + +activity_bp = Blueprint('activity', __name__, url_prefix='/api/activity') + +@activity_bp.route('', methods=['POST']) +def create_activity(): + data = request.get_json() + + try: + activity = Activity( + user_id=data['user_id'], + begin_time=datetime.fromisoformat(data['begin_time']), + end_time=datetime.fromisoformat(data['end_time']), + activity_type=data['activity_type'], + average_speed=data['average_speed'], + max_speed=data['max_speed'], + average_heart_rate=data['average_heart_rate'], + max_heart_rate=data['max_heart_rate'], + calories=data['calories'], + duration=data['duration'], + moving_duration=data['moving_duration'], + average_moving_speed=data['average_moving_speed'], + distance=data['distance'], + elevation_gain=data['elevation_gain'], + elevation_loss=data['elevation_loss'], + max_elevation=data['max_elevation'], + min_elevation=data['min_elevation'] + ) + + # Handle optional time series data + time_series_list = data.get('time_series', []) + for point in time_series_list: + ts = ActivityTimeSeries( + timestamp=datetime.fromisoformat(point['timestamp']), + longitude=point['longitude'], + latitude=point['latitude'], + elevation=point['elevation'], + heart_rate=point['heart_rate'], + cadence=point['cadence'] + ) + activity.time_series.append(ts) + + db.session.add(activity) + db.session.commit() + + return jsonify({"message": "Activity and time series created", "activity_id": activity.id}), 201 + + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + + \ No newline at end of file diff --git a/api/body_insight.py b/api/body_insight.py new file mode 100644 index 0000000..c592d58 --- /dev/null +++ b/api/body_insight.py @@ -0,0 +1,86 @@ +#Use to POST and Get data to/from body_insight table +from flask import Blueprint, request, jsonify +from models.body_insight import BodyInsight +from models import db +from models.activity import Activity + +body_insight_bp = Blueprint('body_insight', __name__, url_prefix='/api/body_insight') + +@body_insight_bp.route('', methods=['POST']) +def add_body_insight(): + data = request.get_json() + + # Require either activity_id or user_id + activity_id = data.get('activity_id') + user_id = data.get('user_id') + + if not activity_id: + if not user_id: + return jsonify({"error": "Missing required field 'activity_id' or 'user_id'"}), 400 + # Find latest activity for user_id + latest_activity = ( + Activity.query + .filter_by(user_id=user_id) + .order_by(Activity.begin_time.desc()) + .first() + ) + if not latest_activity: + return jsonify({"error": "No activities found for user"}), 404 + activity_id = latest_activity.id + + try: + insight = BodyInsight( + activity_id=activity_id, + vo2_max=data.get('vo2_max'), + lactate_threshold=data.get('lactate_threshold'), + race_time_prediction=data.get('race_time_prediction'), + real_time_stamina=data.get('real_time_stamina'), + functional_threshold_power=data.get('functional_threshold_power'), + power_to_weight_ratio=data.get('power_to_weight_ratio'), + critical_power=data.get('critical_power'), + threshold_heart_rate=data.get('threshold_heart_rate'), + performance_index=data.get('performance_index'), + fatigue_index=data.get('fatigue_index'), + peak_power_5s=data.get('peak_power_5s'), + peak_power_1min=data.get('peak_power_1min'), + peak_power_5min=data.get('peak_power_5min'), + peak_power_20min=data.get('peak_power_20min'), + heat_acclimation=data.get('heat_acclimation'), + altitude_acclimation=data.get('altitude_acclimation'), + training_readiness=data.get('training_readiness'), + endurance_score=data.get('endurance_score'), + ) + db.session.add(insight) + db.session.commit() + return jsonify({ + "message": "BodyInsight created", + "id": insight.id, + "activity_id": activity_id # Return the actual activity_id linked + }), 201 + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + + + +@body_insight_bp.route('/', methods=['GET']) +def get_body_insight(activity_id): + insight = BodyInsight.query.filter_by(activity_id=activity_id).first() + if not insight: + return jsonify({"error": "BodyInsight not found for this activity_id"}), 404 + return jsonify(insight.as_dict()), 200 + +@body_insight_bp.route('/latest', methods=['GET']) +def get_latest_body_insight(): + # Get latest BodyInsight entry by most recent activity + latest = ( + BodyInsight.query + .join(Activity, Activity.id == BodyInsight.activity_id) + .order_by(Activity.begin_time.desc()) + .first() + ) + + if not latest: + return jsonify({"error": "No body insight data found"}), 404 + + return jsonify(latest.as_dict()), 200 diff --git a/api/profile.py b/api/profile.py index 0496bc3..34398de 100644 --- a/api/profile.py +++ b/api/profile.py @@ -48,4 +48,4 @@ def update_profile(): db.session.commit() # Save changes to the database - return jsonify({'message': 'Profile updated successfully'}), 200 \ No newline at end of file + return jsonify({'message': 'Profile updated successfully'}), 200 diff --git a/app.py b/app.py index f9f3767..ddacb32 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,8 @@ from api.goals import goals_bp from api.profile import api as profile_api from api.dashboard import dashboard_bp +from api.body_insight import body_insight_bp +from api.activity import activity_bp from models import db from dotenv import load_dotenv import os @@ -35,7 +37,7 @@ # Flask config app.secret_key = os.getenv("SECRET_KEY", "default_secret_key") -app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///goals.db") +app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///reflexionpro_backend.db") app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # Initialize database @@ -51,6 +53,8 @@ app.register_blueprint(goals_bp, url_prefix='/api/goals') app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') app.register_blueprint(profile_api, url_prefix='/api/profile') +app.register_blueprint(body_insight_bp, url_prefix='/api/body_insight') +app.register_blueprint(activity_bp, url_prefix='/api/activity') # Main index route (login + welcome) @app.route('/', methods=['GET', 'POST']) diff --git a/instance/goals.db b/instance/goals.db deleted file mode 100644 index bdb45dd..0000000 Binary files a/instance/goals.db and /dev/null differ diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..a6cc669 --- /dev/null +++ b/models/README.md @@ -0,0 +1,24 @@ +# Database Models and Local Development Guide + +This directory contains the SQLAlchemy ORM model definitions used by the Flask backend. These models map to tables in the SQLite database used during development. + +--- + +## 🛠️ Database Inspection via DB Browser for SQLite + +This project uses SQLite during development. You may inspect or modify the local database file using **DB Browser for SQLite**, a free and open-source visual tool. + +### ✅ Installation + +- Download DB Browser from: + https://sqlitebrowser.org/dl/ + +- Install it according to your operating system. + +--- + +## 📂 Locating the Database File + +By default, the SQLite database is created in the backend directory /redback-fit-backend/instance/your_database_name.db + +Your database name will be whatever you set DATABASE_URL to in your .env file. If no value is selected it defaults to database \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py index 589c64f..b25ac4f 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,2 +1,9 @@ from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() \ No newline at end of file +db = SQLAlchemy() + +from .user import UserProfile +from .goal import Goal +from .activity import Activity, ActivityTimeSeries +from .activity_summary import ActivitySummary +from .metadata import ActivityMetadata +from .body_insight import BodyInsight diff --git a/models/activity.py b/models/activity.py new file mode 100644 index 0000000..84505c5 --- /dev/null +++ b/models/activity.py @@ -0,0 +1,75 @@ +from models import db +from datetime import datetime + +class Activity(db.Model): + __tablename__ = 'activity' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user_profile.id'), nullable=False) + + # Summary data + begin_time = db.Column(db.DateTime, nullable=False) + end_time = db.Column(db.DateTime, nullable=False) + activity_type = db.Column(db.String(50), nullable=False) + average_speed = db.Column(db.Float, nullable=False) + max_speed = db.Column(db.Float, nullable=False) + average_heart_rate = db.Column(db.Integer, nullable=False) + max_heart_rate = db.Column(db.Integer, nullable=False) + calories = db.Column(db.Float, nullable=False) + duration = db.Column(db.String(20), nullable=False) + moving_duration = db.Column(db.String(20), nullable=False) + average_moving_speed = db.Column(db.Float, nullable=False) + distance = db.Column(db.Float, nullable=False) + elevation_gain = db.Column(db.Float, nullable=False) + elevation_loss = db.Column(db.Float, nullable=False) + max_elevation = db.Column(db.Float, nullable=False) + min_elevation = db.Column(db.Float, nullable=False) + + time_series = db.relationship('ActivityTimeSeries', backref='activity', cascade="all, delete-orphan") + + def as_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "begin_time": self.begin_time.isoformat(), + "end_time": self.end_time.isoformat(), + "activity_type": self.activity_type, + "average_speed": self.average_speed, + "max_speed": self.max_speed, + "average_heart_rate": self.average_heart_rate, + "max_heart_rate": self.max_heart_rate, + "calories": self.calories, + "duration": self.duration, + "moving_duration": self.moving_duration, + "average_moving_speed": self.average_moving_speed, + "distance": self.distance, + "elevation_gain": self.elevation_gain, + "elevation_loss": self.elevation_loss, + "max_elevation": self.max_elevation, + "min_elevation": self.min_elevation, + "time_series": [ts.as_dict() for ts in self.time_series] + } + + +class ActivityTimeSeries(db.Model): + __tablename__ = 'activity_time_series' + + id = db.Column(db.Integer, primary_key=True) + activity_id = db.Column(db.Integer, db.ForeignKey('activity.id'), nullable=False) + + timestamp = db.Column(db.DateTime, nullable=False) + longitude = db.Column(db.Float, nullable=False) + latitude = db.Column(db.Float, nullable=False) + elevation = db.Column(db.Float, nullable=False) + heart_rate = db.Column(db.Integer, nullable=False) + cadence = db.Column(db.Float, nullable=False) + + def as_dict(self): + return { + "timestamp": self.timestamp.isoformat(), + "longitude": self.longitude, + "latitude": self.latitude, + "elevation": self.elevation, + "heart_rate": self.heart_rate, + "cadence": self.cadence + } diff --git a/models/activity_summary.py b/models/activity_summary.py new file mode 100644 index 0000000..b13ba0d --- /dev/null +++ b/models/activity_summary.py @@ -0,0 +1,50 @@ +from models import db +import json + +class ActivitySummary(db.Model): + __tablename__ = 'activity_summary' + + id = db.Column(db.Integer, primary_key=True) + activity_id = db.Column(db.Integer, db.ForeignKey('activity.id'), nullable=False) + + training_status = db.Column(db.String(50), nullable=True) + body_battery = db.Column(db.Float, nullable=True) + recovery_time = db.Column(db.Float, nullable=True) + training_load = db.Column(db.Float, nullable=True) + training_load_focus = db.Column(db.String(50), nullable=True) + + training_effect_aerobic = db.Column(db.Float, nullable=True) + training_effect_anaerobic = db.Column(db.Float, nullable=True) + + hrv = db.Column(db.Text, nullable=True) # Stored as JSON string + hr_zones = db.Column(db.Text, nullable=True) # Stored as JSON string + + hydration_status = db.Column(db.Float, nullable=True) + epoc = db.Column(db.Float, nullable=True) + + def as_dict(self): + return { + "id": self.id, + "activity_id": self.activity_id, + "trainingStatus": self.training_status, + "bodyBattery": self.body_battery, + "recoveryTime": self.recovery_time, + "trainingLoad": self.training_load, + "trainingLoadFocus": self.training_load_focus, + "trainingEffect": { + "aerobic": self.training_effect_aerobic, + "anaerobic": self.training_effect_anaerobic + }, + "hrv": json.loads(self.hrv) if self.hrv else [], + "hrZones": json.loads(self.hr_zones) if self.hr_zones else {}, + "hydrationStatus": self.hydration_status, + "epoc": self.epoc + } + + def set_hrv(self, hrv_data): + """Accepts a list of HRV dicts and serialises them to JSON text.""" + self.hrv = json.dumps(hrv_data) + + def set_hr_zones(self, zones_data): + """Accepts a dict of HR zones and serialises them to JSON text.""" + self.hr_zones = json.dumps(zones_data) diff --git a/models/body_insight.py b/models/body_insight.py new file mode 100644 index 0000000..aac932d --- /dev/null +++ b/models/body_insight.py @@ -0,0 +1,57 @@ +from models import db + +class BodyInsight(db.Model): + __tablename__ = 'body_insight' + + id = db.Column(db.Integer, primary_key=True) + activity_id = db.Column(db.Integer, db.ForeignKey('activity.id'), nullable=False) + + # Performance Metrics + vo2_max = db.Column(db.Float, nullable=True) + lactate_threshold = db.Column(db.Float, nullable=True) + race_time_prediction = db.Column(db.String(100), nullable=True) + real_time_stamina = db.Column(db.Float, nullable=True) + functional_threshold_power = db.Column(db.Float, nullable=True) + power_to_weight_ratio = db.Column(db.Float, nullable=True) + critical_power = db.Column(db.Float, nullable=True) + threshold_heart_rate = db.Column(db.Integer, nullable=True) + performance_index = db.Column(db.Float, nullable=True) + fatigue_index = db.Column(db.Float, nullable=True) + + # Peak Power for durations + peak_power_5s = db.Column(db.Float, nullable=True) + peak_power_1min = db.Column(db.Float, nullable=True) + peak_power_5min = db.Column(db.Float, nullable=True) + peak_power_20min = db.Column(db.Float, nullable=True) + + # Acclimation and Readiness + heat_acclimation = db.Column(db.Float, nullable=True) + altitude_acclimation = db.Column(db.Float, nullable=True) + training_readiness = db.Column(db.Float, nullable=True) + endurance_score = db.Column(db.Float, nullable=True) + + def as_dict(self): + return { + "id": self.id, + "activity_id": self.activity_id, + "vo2_max": self.vo2_max, + "lactate_threshold": self.lactate_threshold, + "race_time_prediction": self.race_time_prediction, + "real_time_stamina": self.real_time_stamina, + "functional_threshold_power": self.functional_threshold_power, + "power_to_weight_ratio": self.power_to_weight_ratio, + "critical_power": self.critical_power, + "peak_power": { + "5s": self.peak_power_5s, + "1min": self.peak_power_1min, + "5min": self.peak_power_5min, + "20min": self.peak_power_20min + }, + "threshold_heart_rate": self.threshold_heart_rate, + "performance_index": self.performance_index, + "fatigue_index": self.fatigue_index, + "heat_acclimation": self.heat_acclimation, + "altitude_acclimation": self.altitude_acclimation, + "training_readiness": self.training_readiness, + "endurance_score": self.endurance_score + } diff --git a/models/goal.py b/models/goal.py index a6d2c47..d16554e 100644 --- a/models/goal.py +++ b/models/goal.py @@ -1,12 +1,11 @@ from models import db from datetime import datetime - class Goal(db.Model): __tablename__ = 'goals' id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, nullable=False) # Link to OAuth user later + user_id = db.Column(db.Integer, db.ForeignKey('user_profile.id'), nullable=False) # Link to OAuth user later start_date = db.Column(db.Date, nullable=False) end_date = db.Column(db.Date, nullable=False) steps = db.Column(db.Integer, default=0) @@ -15,7 +14,7 @@ class Goal(db.Model): minutes_swimming = db.Column(db.Integer, default=0) minutes_exercise = db.Column(db.Integer, default=0) calories = db.Column(db.Integer, default=0) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) def as_dict(self): return { @@ -30,4 +29,4 @@ def as_dict(self): "minutes_exercise": self.minutes_exercise, "calories": self.calories, "created_at": self.created_at.isoformat() - } \ No newline at end of file + } diff --git a/models/metadata.py b/models/metadata.py new file mode 100644 index 0000000..89b905f --- /dev/null +++ b/models/metadata.py @@ -0,0 +1,47 @@ +from models import db +from datetime import datetime + +class ActivityMetadata(db.Model): + __tablename__ = 'activity_metadata' + + id = db.Column(db.Integer, primary_key=True) + datetime = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user_profile.id'), nullable=False) + activity_id = db.Column(db.Integer, db.ForeignKey('activity.id'), unique=True, nullable=False) + activity_name = db.Column(db.String(100), nullable=False) + activity_type = db.Column(db.String(50), nullable=False) + description = db.Column(db.Text, nullable=True) + + temperature = db.Column(db.Float, nullable=True) + humidity = db.Column(db.Float, nullable=True) + wind_speed = db.Column(db.Float, nullable=True) + wind_direction = db.Column(db.String(20), nullable=True) + + begin_longitude = db.Column(db.Float, nullable=True) + end_longitude = db.Column(db.Float, nullable=True) + begin_latitude = db.Column(db.Float, nullable=True) + end_latitude = db.Column(db.Float, nullable=True) + + condition = db.Column(db.String(50), nullable=True) + rainfall = db.Column(db.String(50), nullable=True) + + def as_dict(self): + return { + "id": self.id, + "datetime": self.datetime.isoformat(), + "user_id": self.user_id, + "activity_id": self.activity_id, + "activity_name": self.activity_name, + "activity_type": self.activity_type, + "description": self.description, + "temperature": self.temperature, + "humidity": self.humidity, + "wind_speed": self.wind_speed, + "wind_direction": self.wind_direction, + "begin_longitude": self.begin_longitude, + "end_longitude": self.end_longitude, + "begin_latitude": self.begin_latitude, + "end_latitude": self.end_latitude, + "condition": self.condition, + "rainfall": self.rainfall + } diff --git a/models/user.py b/models/user.py index abb7d30..78f4e21 100644 --- a/models/user.py +++ b/models/user.py @@ -9,6 +9,8 @@ class UserProfile(db.Model): birthDate = db.Column(db.String(10), nullable=False) gender = db.Column(db.String(10), nullable=False) avatar = db.Column(db.String(200), nullable=True) + height = db.Column(db.Float, nullable=True) # Height in cm or m + weight = db.Column(db.Float, nullable=True) # Weight in kg def as_dict(self): return { @@ -17,5 +19,7 @@ def as_dict(self): "account": self.account, "birthDate": self.birthDate, "gender": self.gender, - "avatar": self.avatar + "avatar": self.avatar, + "height": self.height, + "weight": self.weight } diff --git a/scripts/create_db.py b/scripts/create_db.py deleted file mode 100644 index 52a5691..0000000 --- a/scripts/create_db.py +++ /dev/null @@ -1,32 +0,0 @@ -import sqlite3 - -# Connect to the SQLite database (it will create the file if it doesn't exist) -conn = sqlite3.connect('my_database.db') - -# Create a cursor object to interact with the database -cursor = conn.cursor() - -# Create a new table (you can change the schema as needed) -cursor.execute(''' -CREATE TABLE IF NOT EXISTS goals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - start_date TEXT NOT NULL, - end_date TEXT NOT NULL, - steps INTEGER DEFAULT 0, - minutes_running INTEGER DEFAULT 0, - minutes_cycling INTEGER DEFAULT 0, - minutes_swimming INTEGER DEFAULT 0, - minutes_exercise INTEGER DEFAULT 0, - calories INTEGER DEFAULT 0, - created_at TEXT DEFAULT CURRENT_TIMESTAMP -); -''') - -# Commit changes -conn.commit() - -# Close the connection -conn.close() - -print("Database and table created successfully.") \ No newline at end of file diff --git a/test_vul.py b/test_vul.py deleted file mode 100644 index be74eab..0000000 --- a/test_vul.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import flask -from flask import Flask, request -import sqlite3 - -app = Flask(__name__) - -# Hardcoded secret (should be flagged) -SECRET_KEY = "my_hardcoded_secret_key_12345" - -# Vulnerable SQL query (should be flagged) -@app.route('/login', methods=['POST']) -def login(): - username = request.form['username'] - password = request.form['password'] - query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" - conn = sqlite3.connect('users.db') - cursor = conn.cursor() - cursor.execute(query) - user = cursor.fetchone() - return "Logged in" if user else "Login failed" - -# XSS-like template rendering with unescaped input (should be flagged) -@app.route('/welcome') -def welcome(): - user_input = request.args.get('name') - return flask.render_template_string("

Welcome " + user_input + "

") - -# Debug mode enabled (should be flagged) -if __name__ == "__main__": - app.run(debug=True)