commit 315e731234cf70f5afb80b0f72b55b9e91d10d00 Author: Blake Ridgway Date: Sat Jul 5 15:29:33 2025 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a96156 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +ENV/ + +# Flask +instance/ +.webassets-cache + +# Data files +data/blog_posts.json + +# Uploads +static/uploads/* +!static/uploads/.gitkeep + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..396be4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim-buster + +# Install build dependencies (build-essential provides gcc and other tools) +RUN apt-get update && apt-get install -y build-essential + +WORKDIR /personalsite + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV FLASK_APP=app.py + +EXPOSE 5002 + +CMD ["gunicorn", "--bind", "0.0.0.0:5002", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..e17ed87 --- /dev/null +++ b/app.py @@ -0,0 +1,382 @@ +from flask import Flask, render_template, jsonify, request, redirect, url_for, flash +from flask_login import LoginManager, login_user, logout_user, login_required, current_user +import json +import os +from datetime import datetime, timedelta +from blog_manager import BlogManager +from forms import LoginForm, BlogPostForm +from user import User + +# Import traffic tracking components +from models import db +from traffic_tracker import TrafficTracker +from sqlalchemy import func, desc + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-key-change-in-production') + +# SQLite Configuration for traffic tracking +basedir = os.path.abspath(os.path.dirname(__file__)) +app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "traffic_analytics.db")}' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# Initialize extensions +db.init_app(app) +tracker = TrafficTracker(app) + +# Initialize Flask-Login +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'admin_login' +login_manager.login_message = 'Please log in to access the admin panel.' + +# Initialize Blog Manager +blog_manager = BlogManager() + + +@login_manager.user_loader +def load_user(user_id): + return User.get(user_id) + + +# Initialize Strava service (with error handling) +try: + from strava_service import StravaService + + strava = StravaService() + STRAVA_ENABLED = True +except Exception as e: + print(f"Strava service not available: {e}") + strava = None + STRAVA_ENABLED = False + + +# Load blog posts from BlogManager +def load_blog_posts(): + return blog_manager.load_posts() + + +@app.route('/') +def index(): + posts = load_blog_posts() + recent_posts = sorted(posts, key=lambda x: x['date'], reverse=True)[:3] + return render_template('index.html', recent_posts=recent_posts) + + +@app.route('/about') +def about(): + return render_template('about.html') + + +@app.route('/hardware') +def hardware(): + return render_template('hardware.html') + + +@app.route('/biking') +def biking(): + ytd_stats = None + recent_activities = [] + + if STRAVA_ENABLED and strava: + try: + ytd_stats = strava.get_ytd_stats() + recent_activities = strava.format_recent_activities() + except Exception as e: + print(f"Error fetching Strava data: {e}") + ytd_stats = { + 'distance': 0, + 'count': 0, + 'elevation': 0, + 'time': 0 + } + else: + ytd_stats = { + 'distance': 2450, + 'count': 127, + 'elevation': 45600, + 'time': 156 + } + recent_activities = [ + { + 'name': 'Morning Training Ride', + 'distance': 25.3, + 'elevation': 1200, + 'time': '1h 45m', + 'date': 'January 5, 2025' + } + ] + + current_time = datetime.now().strftime('%B %d, %Y at %I:%M %p') + + return render_template('biking.html', + ytd_stats=ytd_stats, + recent_activities=recent_activities, + strava_enabled=STRAVA_ENABLED, + last_updated=current_time) + + +@app.route('/blog') +def blog(): + posts = load_blog_posts() + posts = sorted(posts, key=lambda x: x['date'], reverse=True) + return render_template('blog.html', posts=posts) + + +@app.route('/blog/') +def blog_post(post_id): + post = blog_manager.get_post(post_id) + if not post: + return "Post not found", 404 + return render_template('blog_post.html', post=post) + + +# Admin Routes +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + if current_user.is_authenticated: + return redirect(url_for('admin_dashboard')) + + form = LoginForm() + if form.validate_on_submit(): + user = User.authenticate(form.username.data, form.password.data) + if user: + login_user(user) + flash('Logged in successfully!', 'success') + return redirect(url_for('admin_dashboard')) + else: + flash('Invalid username or password.', 'error') + + return render_template('admin/login.html', form=form) + + +@app.route('/admin/logout') +@login_required +def admin_logout(): + logout_user() + flash('You have been logged out.', 'info') + return redirect(url_for('index')) + + +@app.route('/admin') +@login_required +def admin_dashboard(): + posts = load_blog_posts() + posts = sorted(posts, key=lambda x: x['date'], reverse=True) + return render_template('admin/dashboard.html', posts=posts) + + +@app.route('/admin/post/new', methods=['GET', 'POST']) +@login_required +def admin_new_post(): + form = BlogPostForm() + if form.validate_on_submit(): + blog_manager.create_post( + title=form.title.data, + content=form.content.data, + category=form.category.data, + excerpt=form.excerpt.data or None + ) + flash('Post created successfully!', 'success') + return redirect(url_for('admin_dashboard')) + + return render_template('admin/edit_post.html', form=form, title='New Post') + + +@app.route('/admin/post//edit', methods=['GET', 'POST']) +@login_required +def admin_edit_post(post_id): + post = blog_manager.get_post(post_id) + if not post: + flash('Post not found.', 'error') + return redirect(url_for('admin_dashboard')) + + form = BlogPostForm(obj=post) + if form.validate_on_submit(): + blog_manager.update_post( + post_id=post_id, + title=form.title.data, + content=form.content.data, + category=form.category.data, + excerpt=form.excerpt.data or None + ) + flash('Post updated successfully!', 'success') + return redirect(url_for('admin_dashboard')) + + return render_template('admin/edit_post.html', form=form, post=post, title='Edit Post') + + +@app.route('/admin/post//delete', methods=['POST']) +@login_required +def admin_delete_post(post_id): + blog_manager.delete_post(post_id) + flash('Post deleted successfully!', 'success') + return redirect(url_for('admin_dashboard')) + + +# Traffic Analytics Admin Route +@app.route('/admin/traffic') +@login_required +def admin_traffic(): + from models import PageView, UniqueVisitor + + # Get date range from query params (default to last 30 days) + days = int(request.args.get('days', 30)) + end_date = datetime.utcnow().date() + start_date = end_date - timedelta(days=days) + + # Basic stats + total_views = PageView.query.filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).count() + + unique_visitors = db.session.query(func.count(func.distinct(PageView.ip_address))).filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).scalar() + + avg_response_time = db.session.query(func.avg(PageView.response_time)).filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).scalar() or 0 + + # Calculate bounce rate (sessions with only one page view) + single_page_sessions = db.session.query( + PageView.session_id + ).filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).group_by(PageView.session_id).having(func.count(PageView.id) == 1).count() + + total_sessions = db.session.query( + func.count(func.distinct(PageView.session_id)) + ).filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).scalar() + + bounce_rate = (single_page_sessions / total_sessions * 100) if total_sessions > 0 else 0 + + # Top pages + top_pages = db.session.query( + PageView.path, + func.count(PageView.id).label('views') + ).filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).group_by(PageView.path).order_by(desc('views')).limit(10).all() + + # Top referrers + top_referrers = db.session.query( + PageView.referrer, + func.count(PageView.id).label('views') + ).filter( + func.date(PageView.timestamp).between(start_date, end_date), + PageView.referrer.isnot(None), + PageView.referrer != '' + ).group_by(PageView.referrer).order_by(desc('views')).limit(10).all() + + # Daily views for chart - Fixed to handle SQLite date strings + daily_views_raw = db.session.query( + func.date(PageView.timestamp).label('date'), + func.count(PageView.id).label('views'), + func.count(func.distinct(PageView.ip_address)).label('unique_visitors') + ).filter( + func.date(PageView.timestamp).between(start_date, end_date) + ).group_by(func.date(PageView.timestamp)).order_by('date').all() + + # Recent activity (last 50 page views) + recent_activity = PageView.query.filter( + PageView.timestamp >= datetime.utcnow() - timedelta(hours=24) + ).order_by(desc(PageView.timestamp)).limit(50).all() + + stats = { + 'total_views': total_views, + 'unique_visitors': unique_visitors, + 'avg_response_time': round(avg_response_time, 2), + 'bounce_rate': round(bounce_rate, 2) + } + + # Format daily views for JSON - Handle both string and date objects + daily_views_json = [] + for day in daily_views_raw: + # Handle case where SQLite returns date as string + if isinstance(day.date, str): + date_str = day.date + else: + date_str = day.date.isoformat() + + daily_views_json.append({ + 'date': date_str, + 'views': day.views, + 'unique_visitors': day.unique_visitors + }) + + return render_template('admin/traffic.html', + stats=stats, + top_pages=top_pages, + top_referrers=top_referrers, + daily_views=daily_views_json, + recent_activity=recent_activity, + days=days) + +# Real-time traffic API for admin dashboard +@app.route('/admin/traffic/api/realtime') +@login_required +def realtime_traffic(): + from models import PageView + + # Last 5 minutes of traffic + five_min_ago = datetime.utcnow() - timedelta(minutes=5) + + recent_views = PageView.query.filter( + PageView.timestamp >= five_min_ago + ).order_by(desc(PageView.timestamp)).limit(50).all() + + active_users = db.session.query(func.count(func.distinct(PageView.session_id))).filter( + PageView.timestamp >= five_min_ago + ).scalar() + + return jsonify({ + 'active_users': active_users, + 'recent_views': [{ + 'path': view.path, + 'timestamp': view.timestamp.isoformat(), + 'ip_address': view.ip_address[:8] + '...', # Partial IP for privacy + 'country': view.country, + 'city': view.city + } for view in recent_views] + }) + + +@app.route('/api/strava-stats') +def strava_stats_api(): + if not STRAVA_ENABLED or not strava: + return jsonify({ + 'error': 'Strava service not available', + 'ytd_stats': None, + 'recent_activities': [] + }), 503 + + try: + ytd_stats = strava.get_ytd_stats() + recent_activities = strava.format_recent_activities() + + return jsonify({ + 'ytd_stats': ytd_stats, + 'recent_activities': recent_activities + }) + except Exception as e: + return jsonify({ + 'error': str(e), + 'ytd_stats': None, + 'recent_activities': [] + }), 500 + + +@app.route('/health') +def health_check(): + return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()} + + +# Create database tables on startup +with app.app_context(): + db.create_all() + +if __name__ == '__main__': + app.run(debug=True) \ No newline at end of file diff --git a/blog_manager.py b/blog_manager.py new file mode 100644 index 0000000..bd96c15 --- /dev/null +++ b/blog_manager.py @@ -0,0 +1,92 @@ +import json +import os +from datetime import datetime + + +class BlogManager: + def __init__(self, data_file='data/blog_posts.json'): + self.data_file = data_file + self.ensure_data_file() + + def ensure_data_file(self): + """Ensure the data file and directory exist""" + os.makedirs(os.path.dirname(self.data_file), exist_ok=True) + if not os.path.exists(self.data_file): + with open(self.data_file, 'w') as f: + json.dump([], f) + + def load_posts(self): + """Load all blog posts""" + try: + with open(self.data_file, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + + def save_posts(self, posts): + """Save all blog posts""" + with open(self.data_file, 'w') as f: + json.dump(posts, f, indent=2) + + def get_next_id(self): + """Get the next available ID""" + posts = self.load_posts() + if not posts: + return 1 + return max(post['id'] for post in posts) + 1 + + def create_post(self, title, content, category, excerpt=None): + """Create a new blog post""" + posts = self.load_posts() + + # Auto-generate excerpt if not provided + if not excerpt: + excerpt = content[:150] + "..." if len(content) > 150 else content + + new_post = { + 'id': self.get_next_id(), + 'title': title, + 'content': content, + 'excerpt': excerpt, + 'category': category, + 'date': datetime.now().strftime('%Y-%m-%d'), + 'created_at': datetime.now().isoformat() + } + + posts.append(new_post) + self.save_posts(posts) + return new_post + + def get_post(self, post_id): + """Get a specific post by ID""" + posts = self.load_posts() + return next((p for p in posts if p['id'] == post_id), None) + + def update_post(self, post_id, title, content, category, excerpt=None): + """Update an existing post""" + posts = self.load_posts() + post = next((p for p in posts if p['id'] == post_id), None) + + if not post: + return None + + if not excerpt: + excerpt = content[:150] + "..." if len(content) > 150 else content + + post.update({ + 'title': title, + 'content': content, + 'excerpt': excerpt, + 'category': category, + 'updated_at': datetime.now().isoformat() + }) + + self.save_posts(posts) + return post + + def delete_post(self, post_id): + """Delete a post""" + posts = self.load_posts() + posts = [p for p in posts if p['id'] != post_id] + self.save_posts(posts) + return True \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a1a768 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + web: + build: . + ports: + - "8000:8000" + environment: + - FLASK_ENV=development + - FLASK_DEBUG=1 + volumes: + - .:/app + command: flask run --host=0.0.0.0 --port=8000 + + # Optional: Add database service + # db: + # image: postgres:15 + # environment: + # POSTGRES_DB: myapp + # POSTGRES_USER: user + # POSTGRES_PASSWORD: password + # volumes: + # - postgres_data:/var/lib/postgresql/data + # ports: + # - "5432:5432" + +# volumes: +# postgres_data: \ No newline at end of file diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..5fa6945 --- /dev/null +++ b/forms.py @@ -0,0 +1,23 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, TextAreaField, SelectField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Length + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Login') + +class BlogPostForm(FlaskForm): + title = StringField('Title', validators=[DataRequired(), Length(max=200)]) + category = SelectField('Category', choices=[ + ('Technology', 'Technology'), + ('Hardware', 'Hardware'), + ('Biking', 'Biking'), + ('Cybersecurity', 'Cybersecurity'), + ('Personal', 'Personal'), + ('Tutorial', 'Tutorial') + ], validators=[DataRequired()]) + excerpt = TextAreaField('Excerpt (optional)', validators=[Length(max=300)]) + content = TextAreaField('Content', validators=[DataRequired()], + render_kw={"rows": 15}) + submit = SubmitField('Save Post') \ No newline at end of file diff --git a/get_strava_token.py b/get_strava_token.py new file mode 100644 index 0000000..d727a46 --- /dev/null +++ b/get_strava_token.py @@ -0,0 +1,68 @@ +import requests +from urllib.parse import urlencode + +# Replace with your actual values from Strava API settings +CLIENT_ID = '76528' +CLIENT_SECRET = 'de46e1ec96ad277ae0be94f949301483a4cd1a4d' +REDIRECT_URI = 'http://localhost' + +# Step 1: Get authorization URL +auth_url = f"https://www.strava.com/oauth/authorize?{urlencode({ + 'client_id': CLIENT_ID, + 'response_type': 'code', + 'redirect_uri': REDIRECT_URI, + 'approval_prompt': 'force', + 'scope': 'read,activity:read_all' +})}" + +print("=" * 60) +print("STRAVA API SETUP") +print("=" * 60) +print(f"1. Visit this URL in your browser:") +print(f"{auth_url}") +print() +print("2. Click 'Authorize' to allow access to your Strava data") +print("3. You'll be redirected to a localhost URL that won't load") +print("4. Copy the ENTIRE URL from your browser's address bar") +print("5. Look for the 'code=' parameter in that URL") +print() +print("Example: http://localhost/?state=&code=ABC123XYZ&scope=read,activity:read_all") +print("In this example, your code would be: ABC123XYZ") +print("=" * 60) + +code = input("Enter the authorization code from the URL: ").strip() + +if not code: + print("No code entered. Exiting.") + exit() + +# Step 2: Exchange code for tokens +print("\nExchanging code for tokens...") +token_url = 'https://www.strava.com/oauth/token' +data = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'code': code, + 'grant_type': 'authorization_code' +} + +response = requests.post(token_url, data=data) + +if response.status_code == 200: + tokens = response.json() + print("\n" + "=" * 60) + print("SUCCESS! Here are your tokens:") + print("=" * 60) + print(f"ACCESS_TOKEN: {tokens['access_token']}") + print(f"REFRESH_TOKEN: {tokens['refresh_token']}") + print(f"EXPIRES_AT: {tokens['expires_at']}") + print() + print("Add these to your .env file:") + print("=" * 60) + print(f"STRAVA_CLIENT_ID={CLIENT_ID}") + print(f"STRAVA_CLIENT_SECRET={CLIENT_SECRET}") + print(f"STRAVA_REFRESH_TOKEN={tokens['refresh_token']}") + print("=" * 60) +else: + print(f"Error: {response.status_code}") + print(response.text) \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..76c5cad --- /dev/null +++ b/models.py @@ -0,0 +1,39 @@ +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime +import json + +db = SQLAlchemy() + + +class PageView(db.Model): + __tablename__ = 'page_views' + + id = db.Column(db.Integer, primary_key=True) + ip_address = db.Column(db.String(45), index=True) + user_agent = db.Column(db.Text) + path = db.Column(db.String(255), index=True) + method = db.Column(db.String(10)) + referrer = db.Column(db.String(500)) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) + response_time = db.Column(db.Float) + status_code = db.Column(db.Integer, index=True) + country = db.Column(db.String(2), index=True) + city = db.Column(db.String(100)) + session_id = db.Column(db.String(255), index=True) + + +class UniqueVisitor(db.Model): + __tablename__ = 'unique_visitors' + + id = db.Column(db.Integer, primary_key=True) + ip_address = db.Column(db.String(45), index=True) + user_agent_hash = db.Column(db.String(64), index=True) + first_visit = db.Column(db.DateTime, default=datetime.utcnow) + last_visit = db.Column(db.DateTime, default=datetime.utcnow, index=True) + visit_count = db.Column(db.Integer, default=1) + country = db.Column(db.String(2)) + city = db.Column(db.String(100)) + + __table_args__ = ( + db.Index('idx_visitor_lookup', 'ip_address', 'user_agent_hash'), + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b969f9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Flask==2.3.3 +Werkzeug==2.3.7 +requests +python-dotenv +Flask-Login==0.6.3 +Flask-WTF==1.1.1 +WTForms==3.0.1 +Flask-SQLAlchemy==3.0.5 +gunicorn==21.2.0 \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b0d81a2 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,156 @@ +/* Wave animation for emoji */ +.wave-animation { + animation: wave 2s infinite; + transform-origin: 70% 70%; +} + +@keyframes wave { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-8deg); } + 30% { transform: rotate(14deg); } + 40% { transform: rotate(-4deg); } + 50% { transform: rotate(10deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } +} + +/* Hero section styles - MUCH SMALLER */ +.hero-section { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 10px; + padding: 2rem 0; /* Much smaller padding */ + margin-bottom: 2rem; /* Add some space below */ +} + +.hero-section .lead { + font-size: 1.1rem; + font-weight: 300; + margin-bottom: 0; +} + +.hero-section h1 { + font-weight: 700; + margin-bottom: 1rem; + font-size: 2.5rem; /* Control the heading size */ +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .hero-section .lead { + font-size: 1rem; + } + + .hero-section h1 { + font-size: 2rem; /* Smaller on mobile */ + } + + .hero-section { + padding: 1.5rem 0; /* Even less padding on mobile */ + } +} + +/* Rest of your styles... */ +.card { + transition: transform 0.2s; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.skill-card { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.skill-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important; +} + +.tech-icons img { + transition: transform 0.3s ease; +} + +.tech-icons img:hover { + transform: scale(1.2); +} + +.navbar-brand { + font-weight: bold; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +main { + flex: 1; +} + +footer { + margin-top: auto; +} + +.badge { + font-size: 0.9rem; + padding: 0.5rem 1rem; +} + +.bg-gradient { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; +} + +.rounded-circle { + aspect-ratio: 1 / 1; + object-fit: cover; +} + +/* Social Links Styling */ +.social-links { + font-size: 0; /* Remove whitespace between inline elements */ +} + +.social-link { + display: inline-block; + color: #fff; + text-decoration: none; + margin-right: 1rem; + font-size: 1.25rem; /* Reset font size for icons */ + transition: color 0.3s ease, transform 0.2s ease; +} + +.social-link:last-child { + margin-right: 0; +} + +.social-link:hover { + color: #007bff; + transform: translateY(-2px); + text-decoration: none; +} + +.social-link i { + display: block; + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; +} + +/* Alternative cleaner approach */ +.social-links-flex { + display: flex; + justify-content: center; + gap: 1rem; +} + +@media (min-width: 768px) { + .social-links-flex { + justify-content: flex-end; + } +} \ No newline at end of file diff --git a/static/images/profile.jpg b/static/images/profile.jpg new file mode 100644 index 0000000..a3579c4 Binary files /dev/null and b/static/images/profile.jpg differ diff --git a/strava_cache.json b/strava_cache.json new file mode 100644 index 0000000..7c3f63b --- /dev/null +++ b/strava_cache.json @@ -0,0 +1,461 @@ +{ + "access_token": [ + { + "token_type": "Bearer", + "access_token": "387110714399904dfb8430fe9b309410fad713c2", + "expires_at": 1751768062, + "expires_in": 21600, + "refresh_token": "c5e2d4b68ec8ff9acd2dfbd2451f724dc3e2009f" + }, + 1751746462.565112 + ], + "athlete_stats": [ + { + "biggest_ride_distance": 115397.0, + "biggest_climb_elevation_gain": 62.0, + "recent_ride_totals": { + "count": 9, + "distance": 264123.8994140625, + "moving_time": 34386, + "elapsed_time": 35962, + "elevation_gain": 832.5999984741211, + "achievement_count": 0 + }, + "all_ride_totals": { + "count": 756, + "distance": 18027711.82313776, + "moving_time": 2598611, + "elapsed_time": 2969272, + "elevation_gain": 49674.28379535675 + }, + "recent_run_totals": { + "count": 0, + "distance": 0, + "moving_time": 0, + "elapsed_time": 0, + "elevation_gain": 0, + "achievement_count": 0 + }, + "all_run_totals": { + "count": 8, + "distance": 5448.39013671875, + "moving_time": 7367, + "elapsed_time": 7397, + "elevation_gain": 6.239949941635132 + }, + "recent_swim_totals": { + "count": 0, + "distance": 0, + "moving_time": 0, + "elapsed_time": 0, + "elevation_gain": 0, + "achievement_count": 0 + }, + "all_swim_totals": { + "count": 0, + "distance": 0, + "moving_time": 0, + "elapsed_time": 0, + "elevation_gain": 0 + }, + "ytd_ride_totals": { + "count": 51, + "distance": 1586506, + "moving_time": 215634.0, + "elapsed_time": 239584.0, + "elevation_gain": 4510.799995422363 + }, + "ytd_run_totals": { + "count": 0, + "distance": 0, + "moving_time": 0, + "elapsed_time": 0, + "elevation_gain": 0 + }, + "ytd_swim_totals": { + "count": 0, + "distance": 0, + "moving_time": 0, + "elapsed_time": 0, + "elevation_gain": 0 + } + }, + 1751746463.1249552 + ], + "ytd_stats": [ + { + "distance": 985.8, + "elevation": 14799, + "time": 59.9, + "count": 51, + "avg_speed": 16.5 + }, + 1751746463.1257896 + ], + "recent_activities_5": [ + [ + { + "resource_state": 2, + "athlete": { + "id": 57387663, + "resource_state": 1 + }, + "name": "Morning Ride", + "distance": 65308.1, + "moving_time": 8555, + "elapsed_time": 8998, + "total_elevation_gain": 187.0, + "type": "Ride", + "sport_type": "Ride", + "workout_type": null, + "id": 15004705624, + "start_date": "2025-07-04T11:19:15Z", + "start_date_local": "2025-07-04T06:19:15Z", + "timezone": "(GMT-06:00) America/Chicago", + "utc_offset": -18000.0, + "location_city": null, + "location_state": null, + "location_country": null, + "achievement_count": 1, + "kudos_count": 11, + "comment_count": 0, + "athlete_count": 1, + "photo_count": 0, + "map": { + "id": "a15004705624", + "summary_polyline": "mbc}ExirtQFpDEvIDbEAxGCn@CFQDaAEaE@iBA{@?]DKDe@^QZa@~@UZc@d@wAjAWVq@bAKFYH{@CIDGNCn@@tB?jUEbJAPK^O\\IJKFMDkAAyAJi@Nc@XKFK@{@E_AA}VCcCJq@?O@KDIHIb@HhDCv\\?jEBtAEPGDSBc@Cy@@m@Eo@?u@DMGEGAKBsAAaDIk@OYWSYO{IgEWKWCc@@WDi@Xm@f@KNAVH\\p@hBFDPDXEp@SLAX@VLhAp@nCpATNFLBLElA?|@DRLLb@BnBApE@`ABHD@R@`SCh_@GfJENOD{@A}@Dog@Rgh@XaBDK@IDEHGn@BxLE|h@@du@GVCBS@yAEwt@KeKGgOCuq@B}\\CsMBmJC{CBoDCiJ@}ZD_JDuBAsC@iH?E@IHERCn@YpsETtoBCd@ENINIJMH]DqAEuHC_TAcJCgW?eAAwVAmQIsJ?mLCi~@EkxA?sjAEoa@GiSByCAgQ@_MA{D@iFA_XFyEA}a@B_D@kN?uLBiVCac@@QAMGIOEQAUFqA?qDA{\\EqHBkFEeq@@uICmID{LBMDELCpBDrAAPBNA\\F`BKvDBxB?bACh@BnL?bDOJKP_@HWB[@_MBq@H_@HKTUXGpC@bH?dECf@@dCCbBBzJA|AFJEDSBiOXsv@|@{aBIcyBC}HAqi@KioAAglAk@k}BKsnAC_C@eAAgDFSFERCxCBzrACnrDYbPDv^Pfi@PpSBlGAtDCtJSnFSrIMvj@?hDCnMB`f@CpFDjHNvH`@dELxDFhWB~OElTCdDDnRvBvDFbAEnBUt@OpD_ApBe@`AQdBOlAE~KAtHB|PALJDVEtG@zDC~CChRBxIRvBFLHHZHZ@dACbAIfB?xGDdU@tBBnG@fLLvQClB@r@BvBMzAApABhB?tTGtEGhDB~BExG?zIG~B@hGEdD@rAAvBMhP@rBChPD|CCXFbAE|CCNBDDFRCz@E~DGzWGjOEvABv@@nECfCDbAEfQDlG@pTDXFHNFZ?~BG\\@HBBF@VAxDBJNJXBnAC|CBjC?HBBD?~ICdL?pK", + "resource_state": 2 + }, + "trainer": false, + "commute": false, + "manual": false, + "private": false, + "visibility": "everyone", + "flagged": false, + "gear_id": "b13730672", + "start_latlng": [ + 36.392646, + -97.91032 + ], + "end_latlng": [ + 36.392489, + -97.909817 + ], + "average_speed": 7.634, + "max_speed": 15.0, + "average_cadence": 80.9, + "average_temp": 23, + "average_watts": 145.0, + "max_watts": 574, + "weighted_average_watts": 153, + "device_watts": true, + "kilojoules": 1238.0, + "has_heartrate": true, + "average_heartrate": 162.0, + "max_heartrate": 183.0, + "heartrate_opt_out": false, + "display_hide_heartrate_option": true, + "elev_high": 422.6, + "elev_low": 348.2, + "upload_id": 16017980571, + "upload_id_str": "16017980571", + "external_id": "470201470455676929.fit", + "from_accepted_tag": false, + "pr_count": 0, + "total_photo_count": 0, + "has_kudoed": false + }, + { + "resource_state": 2, + "athlete": { + "id": 57387663, + "resource_state": 1 + }, + "name": "TrainingPeaks Virtual - Workout: 1x45 @ 60%", + "distance": 28366.5, + "moving_time": 3631, + "elapsed_time": 3631, + "total_elevation_gain": 146.0, + "type": "VirtualRide", + "sport_type": "VirtualRide", + "id": 14999603655, + "start_date": "2025-07-03T22:38:49Z", + "start_date_local": "2025-07-03T17:38:49Z", + "timezone": "(GMT-05:00) America/Atikokan", + "utc_offset": -18000.0, + "location_city": null, + "location_state": null, + "location_country": null, + "achievement_count": 0, + "kudos_count": 6, + "comment_count": 0, + "athlete_count": 1, + "photo_count": 0, + "map": { + "id": "a14999603655", + "summary_polyline": "bpxGsedp[BsCf@OVpAGxHPt@r@N\\cAL_H`@UZZ`@fBRvC?fEQtA_@TwBIu@NyCtCeDbBmBlBc@fAUrDkCfDoB`EYtAArBe@~AeHfMQjFa@`Bk@r@{DpB}B`@yAkAy@U}Ju@}HyFaCcA}Ck@wDFmG|AuXUeAm@wDsGqAeDw@k@w@OuAZwAtBKbANtCc@hAgBl@yCc@gAqBBaBWu@uHiDwGsFYq@Cw@l@}CrA{CAwBk@o@qEQm@m@Ak@n@qAJcA_BmGnB{CF{C}AgGaCqDhAkDh@}Dr@_Cl@s@|AG`BXz@z@\\pA@dAkAbExB`H~AhDVzHXdBdAbCxAhBdBpAxCrAtBFjJaBbBgAtBkD|GmDj@gAZsBYuEaAkCoAmAs@Wu@Ds@`@_@r@]pBoAl@mJuD}Bo@cCAcClA{Es@oCmAgCqBjA}DI}Ai@mAaB}@uC@o@h@w@|Bc@pDqA`E?TrCvEjAdFKlC}ArBSp@`B|FG~@q@tAAn@l@v@nENt@x@?~AuAfDm@|C@p@`@bA`HvFdH|CTd@B~Br@vAx@d@nCNxAo@h@sAOwCL_AtAqBrASxAt@nAdDtDlGlAv@vXVvG}AnDIxF|A`JlGdKt@z@ZdA`ArBQxE_Ct@aA\\uATwF`H_Mb@yAD_CZuAbBqDlCeDT_AFgCTm@zAgBzDsB|CwClDEb@]TqDQuFq@mCe@S[LQbAGzFKb@YHa@{@DiI[kA_@EWPIbD{@r@}AOg@g@c@qAsAUg@RYp@?hE\\x@`ANx@g@J}ESw@i@Wy@Di@j@AdF\\l@f@Nt@Kb@k@@yEQk@g@Wk@?s@`@Kh@BnEn@r@v@Bn@a@|@eBxCy@Z}@AaCHOb@BRlAE`IJf@b@V`@QLg@LcHX]TDf@lAVdCFzDKnCm@j@oBI{@R{CtCaExBuA~A[`ACpBS`AmCfDeBpD[tAC`C[hAmHxMQ~E]bBi@v@}EbCcBRyAkA_AWmJo@eJoGyBy@iCe@sDHmG|AyXUeAq@sDkGqAeDi@g@eAWyAZmAfBSdAPzCa@jA{An@{BKaAi@q@uA?{BWc@qHgDaGqEc@q@Qw@Dy@j@oCfA_CNuAOcAe@a@kEQc@QSe@Dk@n@qADgAaBsFtBoDFqBQcBaAkDiAuBcAoAaD_Am@y@WcACaCn@aEwAeI\\sD|BoC|CKpCuA~AsAjAeB", + "resource_state": 2 + }, + "trainer": false, + "commute": false, + "manual": false, + "private": false, + "visibility": "everyone", + "flagged": false, + "gear_id": "b13730672", + "start_latlng": [ + -1.439482, + 149.612514 + ], + "end_latlng": [ + -1.41586, + 149.622902 + ], + "average_speed": 7.812, + "max_speed": 17.48, + "average_cadence": 86.7, + "average_watts": 125.3, + "max_watts": 162, + "weighted_average_watts": 128, + "device_watts": true, + "kilojoules": 455.0, + "has_heartrate": true, + "average_heartrate": 140.0, + "max_heartrate": 157.0, + "heartrate_opt_out": false, + "display_hide_heartrate_option": true, + "elev_high": 70.0, + "elev_low": 39.2, + "upload_id": 16012254216, + "upload_id_str": "16012254216", + "external_id": "file.dat.fit", + "from_accepted_tag": false, + "pr_count": 0, + "total_photo_count": 0, + "has_kudoed": false + }, + { + "resource_state": 2, + "athlete": { + "id": 57387663, + "resource_state": 1 + }, + "name": "Lunch Walk", + "distance": 880.0, + "moving_time": 690, + "elapsed_time": 722, + "total_elevation_gain": 3.0, + "type": "Walk", + "sport_type": "Walk", + "id": 14996947783, + "start_date": "2025-07-03T17:51:25Z", + "start_date_local": "2025-07-03T12:51:25Z", + "timezone": "(GMT-06:00) America/Chicago", + "utc_offset": -18000.0, + "location_city": null, + "location_state": null, + "location_country": null, + "achievement_count": 0, + "kudos_count": 4, + "comment_count": 0, + "athlete_count": 1, + "photo_count": 0, + "map": { + "id": "a14996947783", + "summary_polyline": "ied}E|_ltQ?qB@yEAk@?eAD[v@A^@`@C\\B`AADtBEnB", + "resource_state": 2 + }, + "trainer": false, + "commute": false, + "manual": false, + "private": false, + "visibility": "everyone", + "flagged": false, + "gear_id": null, + "start_latlng": [ + 36.399217, + -97.880887 + ], + "end_latlng": [ + 36.398729, + -97.880043 + ], + "average_speed": 1.275, + "max_speed": 2.5, + "average_cadence": 51.3, + "has_heartrate": true, + "average_heartrate": 99.9, + "max_heartrate": 108.0, + "heartrate_opt_out": false, + "display_hide_heartrate_option": true, + "elev_high": 387.0, + "elev_low": 384.0, + "upload_id": 16009366898, + "upload_id_str": "16009366898", + "external_id": "470182370203435011.fit", + "from_accepted_tag": false, + "pr_count": 0, + "total_photo_count": 0, + "has_kudoed": false + }, + { + "resource_state": 2, + "athlete": { + "id": 57387663, + "resource_state": 1 + }, + "name": "Get the movement in", + "distance": 1752.1, + "moving_time": 1447, + "elapsed_time": 1447, + "total_elevation_gain": 0.0, + "type": "Walk", + "sport_type": "Walk", + "id": 14992813188, + "start_date": "2025-07-03T10:22:50Z", + "start_date_local": "2025-07-03T05:22:50Z", + "timezone": "(GMT-06:00) America/Chicago", + "utc_offset": -18000.0, + "location_city": null, + "location_state": null, + "location_country": null, + "achievement_count": 0, + "kudos_count": 5, + "comment_count": 0, + "athlete_count": 1, + "photo_count": 0, + "map": { + "id": "a14992813188", + "summary_polyline": "a~b}E|grtQ_@@_@Co@@EBELBj@CnG@`CC~BFbFA`CEdBBn@CDKg@?cGB_AGoA?aFB}@Ay@@eBCq@@u@CqA@GDCZILILA", + "resource_state": 2 + }, + "trainer": false, + "commute": false, + "manual": false, + "private": false, + "visibility": "everyone", + "flagged": false, + "gear_id": null, + "start_latlng": [ + 36.39246, + -97.909735 + ], + "end_latlng": [ + 36.392535, + -97.909878 + ], + "average_speed": 1.211, + "max_speed": 3.06, + "average_cadence": 50.3, + "has_heartrate": true, + "average_heartrate": 84.1, + "max_heartrate": 93.0, + "heartrate_opt_out": false, + "display_hide_heartrate_option": true, + "elev_high": 384.0, + "elev_low": 383.0, + "upload_id": 16004847991, + "upload_id_str": "16004847991", + "external_id": "470175341223116808.fit", + "from_accepted_tag": false, + "pr_count": 0, + "total_photo_count": 0, + "has_kudoed": false + }, + { + "resource_state": 2, + "athlete": { + "id": 57387663, + "resource_state": 1 + }, + "name": "Afternoon Ride", + "distance": 8484.4, + "moving_time": 1478, + "elapsed_time": 1478, + "total_elevation_gain": 0, + "type": "Ride", + "sport_type": "Ride", + "workout_type": 10, + "id": 14946240425, + "start_date": "2025-06-28T21:05:49Z", + "start_date_local": "2025-06-28T16:05:49Z", + "timezone": "(GMT-05:00) America/Atikokan", + "utc_offset": -18000.0, + "location_city": null, + "location_state": null, + "location_country": null, + "achievement_count": 0, + "kudos_count": 1, + "comment_count": 0, + "athlete_count": 1, + "photo_count": 0, + "map": { + "id": "a14946240425", + "summary_polyline": "", + "resource_state": 2 + }, + "trainer": true, + "commute": false, + "manual": false, + "private": false, + "visibility": "everyone", + "flagged": false, + "gear_id": "b13730672", + "start_latlng": [], + "end_latlng": [], + "average_speed": 5.74, + "max_speed": 7.5, + "average_cadence": 91.6, + "average_watts": 117.2, + "max_watts": 198, + "weighted_average_watts": 123, + "device_watts": true, + "kilojoules": 178.0, + "has_heartrate": true, + "average_heartrate": 139.8, + "max_heartrate": 155.0, + "heartrate_opt_out": false, + "display_hide_heartrate_option": true, + "elev_high": 0.0, + "elev_low": 0.0, + "upload_id": 15953591302, + "upload_id_str": "15953591302", + "external_id": "470069740855918614.fit", + "from_accepted_tag": false, + "pr_count": 0, + "total_photo_count": 0, + "has_kudoed": false + } + ], + 1751746463.3906252 + ], + "formatted_activities": [ + [ + { + "name": "Morning Ride", + "distance": 40.6, + "elevation": 614, + "time": "2h 22m", + "date": "July 04, 2025", + "avg_speed": 17.1 + }, + { + "name": "TrainingPeaks Virtual - Workout: 1x45 @ 60%", + "distance": 17.6, + "elevation": 479, + "time": "1h 0m", + "date": "July 03, 2025", + "avg_speed": 17.5 + }, + { + "name": "Afternoon Ride", + "distance": 5.3, + "elevation": 0, + "time": "0h 24m", + "date": "June 28, 2025", + "avg_speed": 12.8 + } + ], + 1751746463.397715 + ] +} \ No newline at end of file diff --git a/strava_service.py b/strava_service.py new file mode 100644 index 0000000..3f45588 --- /dev/null +++ b/strava_service.py @@ -0,0 +1,352 @@ +import requests +import os +import time +import json +from datetime import datetime, timedelta +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + + +class StravaService: + def __init__(self, cache_file='strava_cache.json'): + self.client_id = os.getenv('STRAVA_CLIENT_ID') + self.client_secret = os.getenv('STRAVA_CLIENT_SECRET') + self.refresh_token = os.getenv('STRAVA_REFRESH_TOKEN') + self.access_token = None + self.base_url = 'https://www.strava.com/api/v3' + + # 12-hour cache duration (43200 seconds) + self.cache_duration = 43200 + self.cache_file = Path(cache_file) + self.cache = self._load_cache() + + # Check if credentials are available + if not all([self.client_id, self.client_secret, self.refresh_token]): + print("Warning: Strava credentials not found in .env file") + self.mock_mode = True + else: + self.mock_mode = False + print("Strava service initialized with API credentials") + + def _load_cache(self): + """Load cache from file""" + if self.cache_file.exists(): + try: + with open(self.cache_file, 'r') as f: + cache_data = json.load(f) + # Clean expired entries on load + return self._clean_expired_cache(cache_data) + except (json.JSONDecodeError, IOError) as e: + print(f"Error loading cache: {e}") + return {} + + def _save_cache(self): + """Save cache to file""" + try: + with open(self.cache_file, 'w') as f: + json.dump(self.cache, f, indent=2) + except IOError as e: + print(f"Error saving cache: {e}") + + def _clean_expired_cache(self, cache_data): + """Remove expired entries from cache""" + now = time.time() + cleaned_cache = {} + + for key, (data, timestamp) in cache_data.items(): + if now - timestamp < self.cache_duration: + cleaned_cache[key] = (data, timestamp) + + return cleaned_cache + + def _is_cache_valid(self, key): + """Check if cache entry is still valid""" + if key not in self.cache: + return False + + _, timestamp = self.cache[key] + return time.time() - timestamp < self.cache_duration + + def _get_cache_age(self, key): + """Get age of cache entry in hours""" + if key not in self.cache: + return None + + _, timestamp = self.cache[key] + age_seconds = time.time() - timestamp + return round(age_seconds / 3600, 1) + + def get_cached_or_fetch(self, key, fetch_function): + """Get data from cache or fetch fresh data""" + if self._is_cache_valid(key): + data, _ = self.cache[key] + cache_age = self._get_cache_age(key) + print(f"Using cached {key} (age: {cache_age}h)") + return data + + print(f"Cache miss or expired for {key}, fetching fresh data...") + data = fetch_function() + + if data: + self.cache[key] = (data, time.time()) + self._save_cache() + print(f"Cached fresh {key} data") + + return data + + def get_access_token(self): + """Get a fresh access token using refresh token""" + if self.mock_mode: + return None + + # Check if we have a cached valid token + if self._is_cache_valid('access_token'): + token_data, _ = self.cache['access_token'] + self.access_token = token_data['access_token'] + return self.access_token + + url = 'https://www.strava.com/oauth/token' + data = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'refresh_token': self.refresh_token, + 'grant_type': 'refresh_token' + } + + try: + response = requests.post(url, data=data, timeout=10) + if response.status_code == 200: + token_data = response.json() + self.access_token = token_data['access_token'] + + # Cache the token with shorter duration (1 hour) + self.cache['access_token'] = (token_data, time.time()) + self._save_cache() + + print("Successfully refreshed Strava access token") + return self.access_token + else: + print(f"Failed to refresh token: {response.status_code}") + return None + except Exception as e: + print(f"Error refreshing token: {e}") + return None + + def make_request(self, endpoint): + """Make authenticated request to Strava API""" + if self.mock_mode: + return None + + if not self.access_token: + if not self.get_access_token(): + return None + + headers = {'Authorization': f'Bearer {self.access_token}'} + + try: + response = requests.get(f'{self.base_url}{endpoint}', + headers=headers, timeout=10) + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + # Token expired, try to refresh + print("Token expired, refreshing...") + # Clear cached token + if 'access_token' in self.cache: + del self.cache['access_token'] + self._save_cache() + + if self.get_access_token(): + headers = {'Authorization': f'Bearer {self.access_token}'} + response = requests.get(f'{self.base_url}{endpoint}', + headers=headers, timeout=10) + if response.status_code == 200: + return response.json() + + print(f"API request failed: {response.status_code}") + return None + + except Exception as e: + print(f"Error making API request: {e}") + return None + + def calculate_avg_speed(self, distance_meters, moving_time_seconds): + """Calculate average speed in mph""" + if moving_time_seconds == 0: + return 0 + + # Convert distance from meters to miles + distance_miles = distance_meters * 0.000621371 + # Convert time from seconds to hours + time_hours = moving_time_seconds / 3600 + # Calculate speed in mph + avg_speed = distance_miles / time_hours + return round(avg_speed, 1) + + def get_athlete_stats(self): + """Get athlete statistics with caching""" + if self.mock_mode: + return self._mock_athlete_stats() + + def fetch_stats(): + athlete = self.make_request('/athlete') + if athlete: + athlete_id = athlete['id'] + return self.make_request(f'/athletes/{athlete_id}/stats') + return None + + return self.get_cached_or_fetch('athlete_stats', fetch_stats) + + def get_recent_activities(self, limit=10): + """Get recent activities with caching""" + if self.mock_mode: + return self._mock_recent_activities() + + def fetch_activities(): + return self.make_request(f'/athlete/activities?per_page={limit}') + + return self.get_cached_or_fetch(f'recent_activities_{limit}', fetch_activities) + + def get_ytd_stats(self): + """Get year-to-date statistics with caching""" + if self.mock_mode: + return self._mock_ytd_stats() + + def fetch_ytd(): + stats = self.get_athlete_stats() + if stats: + ytd_ride = stats.get('ytd_ride_totals', {}) + + # Calculate average speed for YTD + total_distance = ytd_ride.get('distance', 0) + total_time = ytd_ride.get('moving_time', 0) + avg_speed = self.calculate_avg_speed(total_distance, total_time) + + return { + 'distance': round(ytd_ride.get('distance', 0) * 0.000621371, 1), + 'elevation': round(ytd_ride.get('elevation_gain', 0) * 3.28084), + 'time': round(ytd_ride.get('moving_time', 0) / 3600, 1), + 'count': ytd_ride.get('count', 0), + 'avg_speed': avg_speed + } + return None + + result = self.get_cached_or_fetch('ytd_stats', fetch_ytd) + return result if result else self._mock_ytd_stats() + + def format_recent_activities(self): + """Format recent activities for display with caching""" + if self.mock_mode: + return self._mock_formatted_activities() + + def fetch_formatted(): + activities = self.get_recent_activities(5) + if not activities: + return None + + formatted = [] + for activity in activities: + if activity['type'] in ['Ride', 'VirtualRide']: + # Calculate average speed for this activity + avg_speed = self.calculate_avg_speed( + activity.get('distance', 0), + activity.get('moving_time', 0) + ) + + formatted.append({ + 'name': activity['name'], + 'distance': round(activity['distance'] * 0.000621371, 1), + 'elevation': round(activity['total_elevation_gain'] * 3.28084), + 'time': self.format_time(activity['moving_time']), + 'date': datetime.strptime( + activity['start_date'], + '%Y-%m-%dT%H:%M:%SZ' + ).strftime('%B %d, %Y'), + 'avg_speed': avg_speed + }) + + return formatted[:3] if formatted else None + + result = self.get_cached_or_fetch('formatted_activities', fetch_formatted) + return result if result else self._mock_formatted_activities() + + def get_cache_status(self): + """Get status of all cached items""" + status = {} + for key in self.cache: + if key != 'access_token': # Don't expose token info + age = self._get_cache_age(key) + valid = self._is_cache_valid(key) + status[key] = { + 'age_hours': age, + 'valid': valid, + 'expires_in_hours': round((self.cache_duration - (time.time() - self.cache[key][1])) / 3600, 1) + } + return status + + def clear_cache(self, key=None): + """Clear cache (specific key or all)""" + if key: + if key in self.cache: + del self.cache[key] + print(f"Cleared cache for {key}") + else: + print(f"No cache found for {key}") + else: + self.cache.clear() + print("Cleared all cache") + + self._save_cache() + + def format_time(self, seconds): + """Convert seconds to readable time format""" + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return f"{hours}h {minutes}m" + + # Mock data methods for fallback (unchanged) + def _mock_ytd_stats(self): + return { + 'distance': 2450.5, + 'count': 127, + 'elevation': 45600, + 'time': 156.2, + 'avg_speed': 15.7 + } + + def _mock_formatted_activities(self): + return [ + { + 'name': 'Morning Training Ride', + 'distance': 25.3, + 'elevation': 1200, + 'time': '1h 45m', + 'date': 'January 5, 2025', + 'avg_speed': 14.5 + }, + { + 'name': 'Weekend Century', + 'distance': 102.1, + 'elevation': 3500, + 'time': '5h 30m', + 'date': 'January 3, 2025', + 'avg_speed': 18.6 + }, + { + 'name': 'Recovery Spin', + 'distance': 15.2, + 'elevation': 400, + 'time': '45m', + 'date': 'January 1, 2025', + 'avg_speed': 20.3 + } + ] + + def _mock_recent_activities(self): + return [] + + def _mock_athlete_stats(self): + return None \ No newline at end of file diff --git a/templates/about.html b/templates/about.html new file mode 100644 index 0000000..5f8386b --- /dev/null +++ b/templates/about.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block title %}About - Blake Ridgway{% endblock %} + +{% block content %} +
+
+

About Me

+

Welcome! I'm Blake Ridgway, a Systems Administrator passionate about cybersecurity, technology, and ultra endurance cycling.

+ +

Background

+

+ As a Systems Administrator, I focus on ensuring the stability, integrity, and performance of critical IT infrastructure. + Through hands-on experience managing Linux, AWS, and Azure environments, I've learned that true system stability + is impossible without robust security. This realization has driven me to actively develop my skills in both + offensive and defensive security, with a particular focus on application security and secure infrastructure. +

+

+ I believe that understanding how to break systems is the best way to learn how to build unbreakable ones. + I'm currently documenting my entire cybersecurity learning journey, including all notes and projects, in my + + CyberSec Odyssey Repository + . +

+ +

Core Competencies

+
+
+
+
Systems Administration
+

Expertise in deploying, managing, and troubleshooting Linux and Windows server environments.

+
+
+
+
+
Cloud Technologies
+

Hands-on experience with AWS and Azure, including EC2, S3, Azure VMs, and virtual networking.

+
+
+
+
+
Security & Monitoring
+

Implementing monitoring solutions and security practices to proactively identify and resolve issues.

+
+
+
+
+
Network Management
+

Practical knowledge of Cisco, Fortigate, and Netgate solutions for network optimization.

+
+
+
+
+
Automation & Scripting
+

Using Bash, PowerShell, and Python to automate tasks and improve efficiency.

+
+
+
+
+
Ultra Endurance Cycling
+

Combining passion for cycling with data-driven insights and secure development practices.

+
+
+
+ +

Technologies & Tools

+
+
+
+
OS & Cloud
+
+ Linux + Fedora + Debian + AWS + Azure +
+
+
+
+
+
DevOps & Automation
+
+ Docker + Kubernetes + Git + Bash +
+
+
+
+
+
Languages & Frameworks
+
+ C# + .NET + Python + Ruby +
+
+
+
+
+
Security & Monitoring
+
+ + + + +
+
+
+
+
+ +
+
+
+ Blake Ridgway Profile Picture +
Blake Ridgway
+

Systems Administrator & Cybersecurity Enthusiast

+
+ Systems Admin + Cybersecurity Enthusiast + Linux & Open Source Advocate + Ultra Endurance Cyclist +
+
+
+ +
+
+
Quick Links
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..67a03c7 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - Your Name{% endblock %} + +{% block content %} +
+

Admin Dashboard

+ +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} +{% endwith %} + +
+
+
Blog Posts ({{ posts|length }})
+
+
+ {% if posts %} +
+ + + + + + + + + + + {% for post in posts %} + + + + + + + {% endfor %} + +
TitleCategoryDateActions
+ {{ post.title }}
+ {{ post.excerpt[:100] }}... +
{{ post.category }}{{ post.date }} +
+ View + Edit +
+ +
+
+
+
+ {% else %} +
+

No blog posts yet.

+ Create Your First Post +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/edit_post.html b/templates/admin/edit_post.html new file mode 100644 index 0000000..064b784 --- /dev/null +++ b/templates/admin/edit_post.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} - Admin{% endblock %} + +{% block content %} +
+

{{ title }}

+ Back to Dashboard +
+ +
+
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.title.label(class="form-label") }} + {{ form.title(class="form-control") }} + {% if form.title.errors %} +
+ {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+
+
+ {{ form.category.label(class="form-label") }} + {{ form.category(class="form-select") }} +
+
+
+ +
+ {{ form.excerpt.label(class="form-label") }} + {{ form.excerpt(class="form-control", rows="3", placeholder="Auto-generated if left blank") }} +
Brief description shown in blog listings
+
+ +
+ {{ form.content.label(class="form-label") }} + {{ form.content(class="form-control") }} + {% if form.content.errors %} +
+ {% for error in form.content.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + {% if post %} + Preview + {% endif %} +
+
+
+
+
+ +
+
+
+
Writing Tips
+
+
+
    +
  • Use clear, descriptive titles
  • +
  • Break up long paragraphs
  • +
  • Include code examples when relevant
  • +
  • Add personal insights and experiences
  • +
+
+
+ +
+
+
Markdown Support
+
+
+

You can use basic HTML in your posts:

+ <strong>bold</strong>
+ <em>italic</em>
+ <code>code</code>
+ <pre>code block</pre> +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..e020196 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Admin Login - Your Name{% endblock %} + +{% block content %} +
+
+
+
+

Admin Login

+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/traffic.html b/templates/admin/traffic.html new file mode 100644 index 0000000..bed9f79 --- /dev/null +++ b/templates/admin/traffic.html @@ -0,0 +1,216 @@ + + + + + + Traffic Analytics - Admin + + + + +
+
+

Traffic Analytics Dashboard

+
+ + +
+
+ + +
+
+
{{ "{:,}".format(stats.total_views) }}
+
Total Page Views
+
+
+
{{ "{:,}".format(stats.unique_visitors) }}
+
Unique Visitors
+
+
+
{{ stats.avg_response_time }}ms
+
Avg Response Time
+
+
+
{{ stats.bounce_rate }}%
+
Bounce Rate
+
+
+ + +
+

Daily Traffic (Last {{ days }} Days)

+ +
+ + +
+
+

Top Pages

+ + + + + + + + + {% for page, views in top_pages %} + + + + + {% endfor %} + +
PageViews
{{ page }}{{ views }}
+
+ +
+

Top Referrers

+ + + + + + + + + {% for referrer, views in top_referrers %} + + + + + {% endfor %} + +
ReferrerViews
{{ referrer[:50] }}{% if referrer|length > 50 %}...{% endif %}{{ views }}
+
+
+ + +
+

Recent Activity (Last 24 Hours)

+
+ {% for activity in recent_activity %} +
+ {{ activity.path }} + {% if activity.referrer %} + from {{ activity.referrer[:30] }}{% if activity.referrer|length > 30 %}...{% endif %} + {% endif %} +
{{ activity.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} - {{ activity.ip_address[:8] }}...
+
+ {% endfor %} +
+
+
+ + + + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a7e5c28 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,70 @@ + + + + + + {% block title %}Blake Ridgway - Personal Website{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
+
+
+

© 2025 Blake Ridgway. All rights reserved.

+
+
+ +
+
+
+
+ + + + + \ No newline at end of file diff --git a/templates/biking.html b/templates/biking.html new file mode 100644 index 0000000..a146704 --- /dev/null +++ b/templates/biking.html @@ -0,0 +1,354 @@ +{% extends "base.html" %} + +{% block title %}Biking Adventures - Blake Ridgway{% endblock %} + +{% block content %} +
+
+

Biking Adventures

+

Exploring the world on two wheels as an ultra endurance cyclist - powered by real-time Strava data!

+ +
+

Recent Adventures

+ +
+ +
+ {% if recent_activities %} + {% for activity in recent_activities %} +
+
+
+
+
+ {{ activity.name }} +
+

Latest ride from Strava - automatically synced!

+
+ {{ activity.date }} +
+
+
+
+ +
{{ activity.distance }}
+ Miles +
+
+
+
+ +
{{ activity.elevation }}
+ Feet +
+
+
+
+ +
{{ activity.time }}
+ Duration +
+
+
+
+ +
{{ activity.avg_speed or 'N/A' }}
+ Avg MPH +
+
+
+
+
+ {% endfor %} + {% else %} +
+ Loading Strava data... +
+ {% endif %} +
+ +

2025 Cycling Performance

+
+ {% if ytd_stats %} +
+
+
+ +
+

{{ ytd_stats.distance }}

+

Miles This Year

+
+
+
+
+
+ +
+

{{ ytd_stats.count }}

+

Rides Completed

+
+
+
+
+
+ +
+

{{ ytd_stats.elevation }}

+

Feet Climbed

+
+
+
+
+
+ +
+

{{ ytd_stats.time }}

+

Hours Riding

+
+
+ {% else %} +
+
+ Unable to load Strava stats. Please check your API connection. +
+
+ {% endif %} +
+
+ +
+
+
+
My Bikes
+
+
+
+
+ +
+
Road Bike
+

Giant TCR Advanced 2

+
+
+
+ Carbon Frame + Aero + Racing +
+
+ +
+
+ +
+
Gravel Bike
+

Canyon Endurace CF SL 8

+
+
+
+ Endurance + All-Road + Adventure +
+
+
+
+ +
+
+
Cycling Goals 2025
+
+
+
+
+ Annual Distance + 5,000 mi +
+
+
+
+ Elevation Gain + 250,000 ft +
+
+
+
+ Total Rides + 200 rides +
+
+
+
+ Ultra Events + 3 events +
+
+
+
+ +
+
+
Data Source
+
+
+

+ + Powered by Strava API +

+

+ Real-time cycling data automatically synced from my Strava account. +

+

+ Last updated: Just now +

+
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/blog.html b/templates/blog.html new file mode 100644 index 0000000..88d7c84 --- /dev/null +++ b/templates/blog.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Blog - Blake Ridgway{% endblock %} + +{% block content %} +
+
+

Blog

+

Thoughts on cybersecurity, systems administration, technology, cycling, and the journey of continuous learning.

+ + {% if posts %} + {% for post in posts %} +
+
+
+
+

+ + {{ post.title }} + +

+ +
+ {{ post.category }} +
+ +

{{ post.excerpt }}

+ +
+ + Read More + + +
+
+
+ {% endfor %} + + + {% else %} +
+
+ +
+

No posts yet!

+

I'm currently working on some exciting content about cybersecurity, systems administration, and my cycling adventures. Check back soon for new posts!

+
+
+
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/blog_post.html b/templates/blog_post.html new file mode 100644 index 0000000..45e42c9 --- /dev/null +++ b/templates/blog_post.html @@ -0,0 +1,287 @@ +{% extends "base.html" %} + +{% block title %}{{ post.title }} - Blake Ridgway{% endblock %} + +{% block content %} +
+
+
+
+ + +

{{ post.title }}

+ + +
+ +
+
+ {{ post.content|safe }} +
+
+ + + {% if post.tags %} + + {% endif %} +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ +
+ + + + +
+
+
Post Details
+
+
+
+
+ Category: + {{ post.category }} +
+
+
+
+ Published: + {{ post.date }} +
+
+
+
+ Reading Time: + ~5 min +
+
+
+
+ Author: + Blake Ridgway +
+
+
+
+ + +
+
+
Share This Post
+
+
+
+ + + +
+
+
+ + +
+
+
About the Author
+
+
+ Blake Ridgway +
Blake Ridgway
+

+ Systems Administrator & Cybersecurity Enthusiast passionate about secure infrastructure and continuous learning. +

+ +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/hardware.html b/templates/hardware.html new file mode 100644 index 0000000..2dfbcdc --- /dev/null +++ b/templates/hardware.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} + +{% block title %}Hardware - Blake Ridgway{% endblock %} + +{% block content %} +
+
+

My Hardware Setup

+

A showcase of my current tech setup and hardware projects focused on systems administration, cybersecurity, and development.

+ +

Current PC Build

+
+
+

Main Workstation

+
+
+
+
+
+ OS: Fedora 42 Workstation +
+
+
+
+ CPU: Intel Core i9-12900K +
+
+
+
+ GPU: Intel Arc A770 +
+
+
+
+ RAM: Corsair LPX 64GB (3200 MHz) +
+
+
+
+ NVMe: Samsung 980 PRO 1TB +
+
+
+
+ SSD: Samsung 860 EVO 500GB +
+
+
+
+ HDD: WD Green 1TB +
+
+
+
+ Storage: Seagate Constellation 4TB +
+
+
+
+ Mobo: ASRock B660M PRO +
+
+
+
+ PSU: Corsair CX750M +
+
+
+
+ Case: CORSAIR 4000D +
+
+
+
+
+ +

Peripherals & Accessories

+
+
+

Input & Output Devices

+
+
+
+
+
+ Primary: LG 34WQ500-B - 34" +
+
+
+
+ Secondary: ASUS VS248H - 24" +
+
+
+
+ Keyboard: System76 Launch +
+
+
+
+ Mouse: Logitech G305 +
+
+
+
+ Headset: CCZ DC01 PRO +
+
+
+
+ Mixer: Pupgsis T12 +
+
+
+
+ Webcam: Logitech C920x +
+
+
+
+
+ +

Current Hardware Projects

+
+
+
+
+ +
+
Home Server Re-setup
+

+ Reinstalling Proxmox on my PowerEdge R720 to host containers and VMs for expanding + knowledge in cybersecurity, systems administration, and development projects. +

+
+
+
+
+
+ +
+
Open Source SOC
+

+ Building a Security Operations Center using open source tools like ELK Stack, + Wazuh, and MISP for threat detection and incident response. +

+
+
+
+
+ +
+
+
+
System Stats
+
+
+
+
+ Total Storage: + 6.5TB +
+
+
+
+ RAM: + 64GB +
+
+
+
+ CPU Cores: + 16C/24T +
+
+
+
+ Display Area: + 58" Total +
+
+
+
+ +
+
+
Hardware Focus
+
+
+
    +
  • Enterprise Server Hardware
  • +
  • Network Infrastructure
  • +
  • Security Appliances
  • +
  • Virtualization Platforms
  • +
  • Linux Workstations
  • +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..cdc2d8b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ +
+

+ Greetings, I'm Blake Ridgway + Waving hand +

+
+ Systems Administrator + Cybersecurity Enthusiast + Linux & Open Source Advocate + Ultra Endurance Cyclist +
+
+ + +
+

+ As a Systems Administrator, my focus is on ensuring the stability, integrity, and performance of critical IT infrastructure. + Through hands-on experience managing Linux, AWS, and Azure environments, I've learned that + true system stability is impossible without robust security. +

+
+
+
+ + +
+
+
+
+
+

🚴 Personal Projects

+

+ Beyond my professional endeavors, I'm an avid cyclist and open-source enthusiast. + I'm currently developing a platform to help cyclists train smarter, combining + data-driven insights with a strong emphasis on + end-to-end data encryption, user privacy, and secure development practices. +

+ +
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/traffic_analytics.db b/traffic_analytics.db new file mode 100644 index 0000000..22a7e45 Binary files /dev/null and b/traffic_analytics.db differ diff --git a/traffic_tracker.py b/traffic_tracker.py new file mode 100644 index 0000000..c59413e --- /dev/null +++ b/traffic_tracker.py @@ -0,0 +1,83 @@ +from flask import request, g, session +from datetime import datetime +import time +import hashlib +from models import db, PageView, UniqueVisitor + + +class TrafficTracker: + def __init__(self, app=None): + self.app = app + if app is not None: + self.init_app(app) + + def init_app(self, app): + app.before_request(self.before_request) + app.after_request(self.after_request) + + def before_request(self): + g.start_time = time.time() + + if 'session_id' not in session: + session['session_id'] = hashlib.md5( + f"{request.remote_addr}{time.time()}".encode() + ).hexdigest() + + def after_request(self, response): + # Skip tracking for static files + if (request.endpoint and 'static' in request.endpoint): + return response + + try: + self.track_page_view(response) + self.update_unique_visitor() + except Exception as e: + print(f"Traffic tracking error: {e}") + + return response + + def track_page_view(self, response): + response_time = (time.time() - g.start_time) * 1000 + + page_view = PageView( + ip_address=self.get_real_ip(), + user_agent=request.headers.get('User-Agent', ''), + path=request.path, + method=request.method, + referrer=request.headers.get('Referer', ''), + response_time=response_time, + status_code=response.status_code, + session_id=session.get('session_id') + ) + + db.session.add(page_view) + db.session.commit() + + def update_unique_visitor(self): + ip = self.get_real_ip() + user_agent = request.headers.get('User-Agent', '') + user_agent_hash = hashlib.sha256(user_agent.encode()).hexdigest() + + visitor = UniqueVisitor.query.filter_by( + ip_address=ip, + user_agent_hash=user_agent_hash + ).first() + + if visitor: + visitor.last_visit = datetime.utcnow() + visitor.visit_count += 1 + else: + visitor = UniqueVisitor( + ip_address=ip, + user_agent_hash=user_agent_hash + ) + db.session.add(visitor) + + db.session.commit() + + def get_real_ip(self): + if request.headers.get('X-Forwarded-For'): + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + elif request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + return request.remote_addr \ No newline at end of file diff --git a/user.py b/user.py new file mode 100644 index 0000000..397d702 --- /dev/null +++ b/user.py @@ -0,0 +1,24 @@ +import os +from flask_login import UserMixin + + +class User(UserMixin): + def __init__(self, username): + self.id = username + self.username = username + + @staticmethod + def get(username): + admin_username = os.getenv('ADMIN_USERNAME', 'admin') + if username == admin_username: + return User(username) + return None + + @staticmethod + def authenticate(username, password): + admin_username = os.getenv('ADMIN_USERNAME', 'admin') + admin_password = os.getenv('ADMIN_PASSWORD', 'password') + + if username == admin_username and password == admin_password: + return User(username) + return None \ No newline at end of file