From 6095e76f10cbfc78a48be6d1f0c41e6d31525a70 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 30 Mar 2025 22:20:48 +1100 Subject: [PATCH] Here is a conventional commit message summarizing the refactoring work: ``` feat: Refactor calendar feature into blueprint - Moved calendar logic from `features/calendar.py` and `app.py` into a new blueprint at `routes/calendar.py`. - Removed the `Calendar` class and refactored logic into helper functions within the blueprint module for better organization and readability. - Eliminated the `pandas` dependency for date range generation, using standard `datetime` operations instead. - Resolved circular import issues between `db.py`, `extensions.py`, and `routes/calendar.py` by adjusting import locations. - Corrected `url_for` calls in templates (`calendar.html`, `partials/people_link.html`) to reference the new blueprint endpoint (`calendar.get_calendar`). - Fixed an `AttributeError` related to HTMX request checking in the calendar route. - Corrected `AttributeError` related to `.date()` calls on `datetime.date` objects in view processing functions. - Updated `templates/changelog/changelog.html` to document the refactoring and associated fixes. ``` --- app.py | 22 +-- db.py | 2 - features/calendar.py | 142 ------------------ routes/auth.py | 2 +- routes/calendar.py | 214 ++++++++++++++++++++++++++++ templates/calendar.html | 12 +- templates/changelog/changelog.html | 11 ++ templates/dashboard.html | 3 +- templates/notes.html | 4 +- templates/partials/people_link.html | 3 +- templates/person_overview.html | 2 +- templates/workout.html | 2 +- 12 files changed, 244 insertions(+), 175 deletions(-) delete mode 100644 features/calendar.py create mode 100644 routes/calendar.py diff --git a/app.py b/app.py index 51b2b9c..5c5db91 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ from jinja2_fragments import render_block from decorators import validate_person, validate_topset, validate_workout from routes.auth import auth, get_person_by_id from routes.changelog import changelog_bp +from routes.calendar import calendar_bp # Import the new calendar blueprint from extensions import db from utils import convert_str_to_date, generate_plot from flask_htmx import HTMX @@ -34,6 +35,7 @@ def load_user(person_id): app.register_blueprint(auth, url_prefix='/auth') app.register_blueprint(changelog_bp, url_prefix='/changelog') +app.register_blueprint(calendar_bp) # Register the calendar blueprint @app.after_request def response_minify(response): @@ -127,22 +129,6 @@ def person_overview(person_id): return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"} -@ app.route("/person//calendar") -def get_calendar(person_id): - selected_date = convert_str_to_date(request.args.get( - 'date'), '%Y-%m-%d') or date.today() - selected_view = request.args.get('view') or 'month' - - if selected_view == 'overview': - return redirect(url_for('person_overview', person_id=person_id)) - elif selected_view == 'notes': - return redirect(url_for('get_person_notes', person_id=person_id)) - - calendar_view = db.calendar.fetch_workouts_for_person(person_id, selected_date, selected_view) - - if htmx: - return render_block(app.jinja_env, 'calendar.html', 'content', **calendar_view), 200, {"HX-Push-Url": url_for('get_calendar', person_id=person_id, view=selected_view, date=selected_date), "HX-Trigger": "refreshStats"} - return render_template('calendar.html', **calendar_view), 200, {"HX-Push-Url": url_for('get_calendar', person_id=person_id, view=selected_view, date=selected_date), "HX-Trigger": "refreshStats"} @ app.route("/person//notes", methods=['GET']) @ validate_person @@ -166,8 +152,8 @@ def create_workout(person_id): @ validate_workout def delete_workout(person_id, workout_id): db.delete_workout(workout_id) - return redirect(url_for('get_calendar', person_id=person_id)) - #return "", 200, {"HX-Trigger": "updatedPeople", "HX-Push-Url": url_for('get_calendar', person_id=person_id)} + return redirect(url_for('calendar.get_calendar', person_id=person_id)) + #return "", 200, {"HX-Trigger": "updatedPeople", "HX-Push-Url": url_for('calendar.get_calendar', person_id=person_id)} @ app.route("/person//workout//start_date_edit_form", methods=['GET']) diff --git a/db.py b/db.py index 4e8f8ac..5b384ff 100644 --- a/db.py +++ b/db.py @@ -6,7 +6,6 @@ from dateutil.relativedelta import relativedelta from urllib.parse import urlparse from flask import g import pandas as pd -from features.calendar import Calendar from features.exercises import Exercises from features.people_graphs import PeopleGraphs from features.person_overview import PersonOverview @@ -19,7 +18,6 @@ from utils import get_exercise_graph_model class DataBase(): def __init__(self, app=None): - self.calendar = Calendar(self.execute) self.stats = Stats(self.execute) self.workout = Workout(self.execute) self.exercises = Exercises(self.execute) diff --git a/features/calendar.py b/features/calendar.py deleted file mode 100644 index d2c37bd..0000000 --- a/features/calendar.py +++ /dev/null @@ -1,142 +0,0 @@ -from datetime import datetime, timedelta -from dateutil.relativedelta import relativedelta -import pandas as pd - -class Calendar: - def __init__(self, db_connection_method): - self.execute = db_connection_method - - def fetch_workouts_for_person(self, person_id, date, view): - prev_date, next_date = None, None - if view == 'month': - first_day_of_month = date.replace(day=1) - days_to_subtract = (first_day_of_month.weekday() + 1) % 7 - start_date = first_day_of_month - timedelta(days=days_to_subtract) - end_date = start_date + timedelta(days=6 * 7 - 1) - prev_date = first_day_of_month - relativedelta(months=1) - next_date = first_day_of_month + relativedelta(months=1) - elif view == 'year': - start_date = date.replace(month=1, day=1) - end_date = date.replace(year=date.year + 1, month=1, day=1) - timedelta(days=1) - prev_date = date - relativedelta(years=1) - next_date = date + relativedelta(years=1) - else: - raise ValueError('Invalid view') - - query = """ - SELECT - w.workout_id, - w.start_date, - t.topset_id, - t.repetitions, - t.weight, - e.name AS exercise_name, - p.name AS person_name - FROM - person p - LEFT JOIN workout w ON p.person_id = w.person_id AND w.start_date BETWEEN %s AND %s - LEFT JOIN topset t ON w.workout_id = t.workout_id - LEFT JOIN exercise e ON t.exercise_id = e.exercise_id - WHERE - p.person_id = %s - ORDER BY - w.start_date, - t.topset_id; - """ - workouts_data = self.execute(query, [start_date, end_date, person_id]) - - # Assuming person_name is the same for all rows as we filter by person_id - person_name = workouts_data[0]['person_name'] if workouts_data else 'Unknown' - - calendar_view = {'prev_date': prev_date, 'next_date': next_date, 'person_id': person_id, 'person_name': person_name, 'view': view, 'date': date} - - if view == 'month': - calendar_view['days'] = [] - workouts_by_date = {} - - for row in workouts_data: - if row['workout_id'] is None: - continue # Skip rows that don't have workout data - - workout_date_str = row['start_date'].strftime("%Y-%m-%d") - workout_id = row['workout_id'] - - if workout_date_str not in workouts_by_date: - workouts_by_date[workout_date_str] = {} - - if workout_id not in workouts_by_date[workout_date_str]: - workouts_by_date[workout_date_str][workout_id] = { - 'workout_id': workout_id, - 'start_date': row['start_date'], - 'sets': [] - } - - if row['topset_id']: - workouts_by_date[workout_date_str][workout_id]['sets'].append({ - 'repetitions': row['repetitions'], - 'weight': row['weight'], - 'exercise_name': row['exercise_name'] - }) - - for current_date in pd.date_range(start_date, end_date, freq='D'): - date_str = current_date.strftime("%Y-%m-%d") - day_workouts = workouts_by_date.get(date_str, {}) - today = datetime.today().date() - - calendar_view['days'].append({ - 'day': current_date.day, - 'is_today': current_date == today, - 'is_in_current_month': current_date.month == date.month, # Ensure it compares with the selected month - 'has_workouts': len(day_workouts) > 0, - 'workouts': list(day_workouts.values()) - }) - - - elif view == 'year': - calendar_view['months'] = [] - workouts_by_date = {} - for row in workouts_data: - if row['start_date'] is None: - continue # Skip rows that don't have workout data - workout_date_str = row['start_date'].strftime("%Y-%m-%d") - if workout_date_str not in workouts_by_date: - workouts_by_date[workout_date_str] = [] - - workouts_by_date[workout_date_str].append({ - 'workout_id': row['workout_id'], - 'start_date': row['start_date'], - 'topset_id': row['topset_id'], - 'repetitions': row['repetitions'], - 'weight': row['weight'], - 'exercise_name': row['exercise_name'] - }) - - - for month in range(1, 13): - first_day_of_month = date.replace(month=month, day=1) - days_to_subtract = (first_day_of_month.weekday() + 1) % 7 - start_date = first_day_of_month - timedelta(days=days_to_subtract) - end_date = start_date + timedelta(days=6 * 7 - 1) - - month_data = {'name': first_day_of_month.strftime('%B'), 'first_day_of_month': first_day_of_month, 'days': []} - - current_day = start_date - while current_day <= end_date: - day_workouts = workouts_by_date.get(current_day.strftime('%Y-%m-%d'), []) - has_workouts = len(day_workouts) > 0 - first_workout_id = day_workouts[0]['workout_id'] if has_workouts else None - - day_data = { - 'day': current_day.day, - 'is_today': current_day == datetime.today().date(), - 'is_in_current_month': current_day.month == month, - 'workouts': day_workouts, - 'has_workouts': has_workouts, - 'first_workout_id': first_workout_id - } - month_data['days'].append(day_data) - current_day += timedelta(days=1) - - calendar_view['months'].append(month_data) - - return calendar_view diff --git a/routes/auth.py b/routes/auth.py index d185a14..c3f959a 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -110,7 +110,7 @@ def login(): if person and check_password_hash(person.password_hash, form.password.data): login_user(person) flash("Logged in successfully.", "success") - return redirect(url_for('get_calendar', person_id=person.id)) + return redirect(url_for('calendar.get_calendar', person_id=person.id)) else: flash("Invalid email or password.", "danger") return render_template('auth/login.html', form=form) diff --git a/routes/calendar.py b/routes/calendar.py new file mode 100644 index 0000000..a56a09e --- /dev/null +++ b/routes/calendar.py @@ -0,0 +1,214 @@ +from datetime import date, datetime, timedelta +from dateutil.relativedelta import relativedelta +from collections import defaultdict +from flask import Blueprint, render_template, redirect, url_for, request, current_app +from flask_htmx import HTMX +from jinja2_fragments import render_block +from utils import convert_str_to_date + +calendar_bp = Blueprint('calendar', __name__) +htmx = HTMX() + +# --- Helper Functions --- + +def _get_date_range_and_links(date_obj, view): + """Calculates start/end dates and prev/next links based on view.""" + start_date, end_date, prev_date, next_date = None, None, None, None + if view == 'month': + first_day_of_month = date_obj.replace(day=1) + # Day of week: Monday is 0 and Sunday is 6 + # Calculate the first day to display on the calendar grid (previous Sunday) + start_date = first_day_of_month - timedelta(days=first_day_of_month.weekday() + 1) + # Calculate the last day (start + 6 weeks - 1 day) + end_date = start_date + timedelta(days=41) # 6*7 - 1 = 41 days total in 6 weeks + prev_date = first_day_of_month - relativedelta(months=1) + next_date = first_day_of_month + relativedelta(months=1) + elif view == 'year': + start_date = date_obj.replace(month=1, day=1) + end_date = date_obj.replace(year=date_obj.year + 1, month=1, day=1) - timedelta(days=1) + prev_date = date_obj - relativedelta(years=1) + next_date = date_obj + relativedelta(years=1) + else: + raise ValueError('Invalid view type specified.') + return start_date, end_date, prev_date, next_date + +def _fetch_raw_workout_data(db_executor, person_id, start_date, end_date): + """Fetches workout data for a person within a date range.""" + query = """ + SELECT + w.workout_id, + w.start_date, + t.topset_id, + t.repetitions, + t.weight, + e.name AS exercise_name, + p.name AS person_name + FROM + person p + LEFT JOIN workout w ON p.person_id = w.person_id AND w.start_date BETWEEN %s AND %s + LEFT JOIN topset t ON w.workout_id = t.workout_id + LEFT JOIN exercise e ON t.exercise_id = e.exercise_id + WHERE + p.person_id = %s + ORDER BY + w.start_date, + t.topset_id; + """ + # Ensure dates are passed in a format the DB understands (e.g., YYYY-MM-DD strings) + return db_executor(query, [start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d'), person_id]) + +def _group_workouts_by_date(workouts_data): + """Groups workout data by date and workout ID.""" + # Structure: { 'YYYY-MM-DD': { workout_id: { workout_details..., 'sets': [...] } } } + workouts_by_date = defaultdict(lambda: defaultdict(lambda: {'sets': []})) + person_name = 'Unknown' + if workouts_data: + # Use .get() for safer access in case the key doesn't exist + person_name = workouts_data[0].get('person_name', 'Unknown') + + for row in workouts_data: + # Basic validation for row data + if not row or row.get('workout_id') is None or row.get('start_date') is None: + continue + + workout_date = row['start_date'] + workout_date_str = workout_date.strftime("%Y-%m-%d") + workout_id = row['workout_id'] + + # Initialize workout details if this workout_id hasn't been seen for this date + if workout_id not in workouts_by_date[workout_date_str]: + workouts_by_date[workout_date_str][workout_id].update({ + 'workout_id': workout_id, + 'start_date': workout_date, + # 'sets' is already initialized by defaultdict + }) + + # Add set details if topset_id exists + if row.get('topset_id'): + workouts_by_date[workout_date_str][workout_id]['sets'].append({ + 'repetitions': row.get('repetitions'), + 'weight': row.get('weight'), + 'exercise_name': row.get('exercise_name') + }) + + # Convert nested defaultdict to regular dict for easier handling/JSON serialization + processed_workouts = { + date_str: dict(workouts) + for date_str, workouts in workouts_by_date.items() + } + return processed_workouts, person_name + +def _process_workouts_for_month_view(grouped_workouts, start_date, end_date, selected_date): + """Formats grouped workout data for the monthly calendar view.""" + days_data = [] + today = datetime.today().date() + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + day_workouts_dict = grouped_workouts.get(date_str, {}) + day_workouts_list = list(day_workouts_dict.values()) # Convert workout dicts to list + + days_data.append({ + 'date_obj': current_date, # Pass the date object for easier template logic + 'day': current_date.day, + 'is_today': current_date == today, # Correct comparison: date object == date object + 'is_in_current_month': current_date.month == selected_date.month, + 'has_workouts': len(day_workouts_list) > 0, + 'workouts': day_workouts_list + }) + current_date += timedelta(days=1) + return days_data + +def _process_workouts_for_year_view(grouped_workouts, year_date): + """Formats grouped workout data for the yearly calendar view.""" + months_data = [] + today = datetime.today().date() + + for month_num in range(1, 13): + first_day_of_month = year_date.replace(month=month_num, day=1) + # Calculate the first day to display on the calendar month grid (previous Sunday) + start_date_month = first_day_of_month - timedelta(days=first_day_of_month.weekday() + 1) + # Calculate the last day of the grid (start + 6 weeks - 1 day) + end_date_month = start_date_month + timedelta(days=41) + + month_days_data = [] + current_day = start_date_month + while current_day <= end_date_month: + date_str = current_day.strftime('%Y-%m-%d') + day_workouts_dict = grouped_workouts.get(date_str, {}) + day_workouts_list = list(day_workouts_dict.values()) + has_workouts = len(day_workouts_list) > 0 + # Get first workout ID if workouts exist + first_workout_id = day_workouts_list[0]['workout_id'] if has_workouts else None + + month_days_data.append({ + 'date_obj': current_day, + 'day': current_day.day, + 'is_today': current_day == today, # Correct comparison: date object == date object + 'is_in_current_month': current_day.month == month_num, + 'has_workouts': has_workouts, + 'workouts': day_workouts_list, # Pass full workout details + 'first_workout_id': first_workout_id # Keep if template uses this + }) + current_day += timedelta(days=1) + + months_data.append({ + 'name': first_day_of_month.strftime('%B'), + 'first_day_of_month': first_day_of_month.strftime('%Y-%m-%d'), # Pass date string for URL + 'days': month_days_data + }) + return months_data + +# --- Route --- + +from extensions import db # Import db locally within the route's scope + +@calendar_bp.route("/person//calendar") +def get_calendar(person_id): + """Displays the workout calendar for a given person.""" + selected_date_str = request.args.get('date') # Get as string first + # Use today's date if 'date' param is missing or invalid + selected_date = convert_str_to_date(selected_date_str, '%Y-%m-%d') or date.today() + selected_view = request.args.get('view', 'month') # Default to month view + + # Redirect for non-calendar views + if selected_view == 'overview': + return redirect(url_for('person_overview', person_id=person_id)) + if selected_view == 'notes': + return redirect(url_for('get_person_notes', person_id=person_id)) + + try: + start_date, end_date, prev_date, next_date = _get_date_range_and_links(selected_date, selected_view) + except ValueError as e: + # Handle invalid view type gracefully (e.g., flash message and redirect, or error page) + # For now, returning a simple error response + return f"Error: Invalid view type '{selected_view}'.", 400 + + # Fetch and process data + raw_workouts = _fetch_raw_workout_data(db.execute, person_id, start_date, end_date) + grouped_workouts, person_name = _group_workouts_by_date(raw_workouts) + + # Prepare base context for the template + calendar_view_data = { + 'person_id': person_id, + 'person_name': person_name, + 'view': selected_view, + 'date': selected_date, # Pass the actual date object for display formatting + 'prev_date': prev_date.strftime('%Y-%m-%d') if prev_date else None, # Format dates for URL generation + 'next_date': next_date.strftime('%Y-%m-%d') if next_date else None, + } + + # Add view-specific data + if selected_view == 'month': + calendar_view_data['days'] = _process_workouts_for_month_view(grouped_workouts, start_date, end_date, selected_date) + elif selected_view == 'year': + calendar_view_data['months'] = _process_workouts_for_year_view(grouped_workouts, selected_date) + + # Format selected_date for URL generation consistently + selected_date_url_str = selected_date.strftime('%Y-%m-%d') + + # Render response (HTMX or full page) + if htmx: + # Use current_app imported from Flask + return render_block(current_app.jinja_env, 'calendar.html', 'content', **calendar_view_data), 200, {"HX-Push-Url": url_for('.get_calendar', person_id=person_id, view=selected_view, date=selected_date_url_str), "HX-Trigger": "refreshStats"} + return render_template('calendar.html', **calendar_view_data), 200, {"HX-Push-Url": url_for('.get_calendar', person_id=person_id, view=selected_view, date=selected_date_url_str), "HX-Trigger": "refreshStats"} \ No newline at end of file diff --git a/templates/calendar.html b/templates/calendar.html index f88c66b..aa53077 100644 --- a/templates/calendar.html +++ b/templates/calendar.html @@ -6,7 +6,7 @@
- -