From b815ca4e80f6fc3e9c049f5aa6b99994c3e46bf1 Mon Sep 17 00:00:00 2001
From: Colin Powell
Date: Fri, 7 Aug 2026 12:46:32 -0400
Subject: [PATCH] [workouts] Add Flexify workout importer (96c9fbf5)
---
PROJECT.org | 24 +
data/flexify-example.sqlite | Bin 0 -> 53248 bytes
data/flexify-plans-example.csv | 4 +
tests/workouts_tests/test_flexify_importer.py | 307 +++++++++++++
vrobbler/apps/scrobbles/importers/webdav.py | 218 ++++++++-
vrobbler/apps/scrobbles/tasks.py | 10 +
vrobbler/apps/workouts/admin.py | 37 +-
vrobbler/apps/workouts/api/serializers.py | 41 +-
vrobbler/apps/workouts/api/views.py | 6 +
vrobbler/apps/workouts/importer.py | 434 +++++++++++++++++-
.../management/commands/import_flexify.py | 66 +++
vrobbler/apps/workouts/mcp.py | 15 +
...02_workoutroutineexercise_workoutimport.py | 134 ++++++
.../0003_workoutimport_file_hash.py | 18 +
vrobbler/apps/workouts/models.py | 68 +++
.../workouts/workoutroutine_detail.html | 17 +
vrobbler/urls.py | 2 +
17 files changed, 1386 insertions(+), 15 deletions(-)
create mode 100644 data/flexify-example.sqlite
create mode 100644 data/flexify-plans-example.csv
create mode 100644 tests/workouts_tests/test_flexify_importer.py
create mode 100644 vrobbler/apps/workouts/management/commands/import_flexify.py
create mode 100644 vrobbler/apps/workouts/migrations/0002_workoutroutineexercise_workoutimport.py
create mode 100644 vrobbler/apps/workouts/migrations/0003_workoutimport_file_hash.py
diff --git a/PROJECT.org b/PROJECT.org
index 42c90b4..8e80ed7 100644
--- a/PROJECT.org
+++ b/PROJECT.org
@@ -722,6 +722,30 @@ Added a `workouts` Django app so we can scrobble gym sessions.
- Templates, admin, DRF viewsets (`exercises`, `workout-routines`), MCP tools,
and `tests/workouts_tests/` (unit conversion, form round-trips, importer,
imperial POST) included.
+** TODO [#B] Add Flexify workout importer :workouts:importer:
+:PROPERTIES:
+:ID: 96c9fbf5-a0d5-4f09-a72c-bb491b7ece18
+:END:
+
+*** Description
+
+Import gym sessions and routines from a Flexify backup.
+
+- `import_flexify()` in `workouts/importer.py` reads a Flexify sqlite backup
+ (`data/flexify-example.sqlite`) plus an optional plans CSV
+ (`data/flexify-plans-example.csv`). Plans become `WorkoutRoutine`s with
+ `WorkoutRoutineExercise` planned-exercise rows; logged `gym_sets` are
+ clustered into sessions (gap > 60 min or plan change) and converted into
+ workout scrobbles.
+- A curated `FLEXIFY_EXERCISE_ALIASES` map resolves Flexify exercise names to
+ the wrkout catalog, falling back to `find_or_create` for unmatched names.
+- `python manage.py import_flexify --sqlite ... --csv ... --user ...` command.
+- `scan_webdav_for_flexify()` watches `var/workouts/` on WebDAV (via the
+ existing import_from_webdav sweep), pairing a plans CSV with the sqlite
+ backup, and queues `process_workout_import` celery tasks.
+- `WorkoutImport` model (`BaseFileImportMixin`, new `file_hash` field for
+ change detection), admin, DRF `workout-imports` viewset, planned-exercises
+ block on the routine detail template, and MCP routine dict update.
* Version 64.5 [1/1]
** DONE [#B] Clean up issues with org-mode notes :orgmode:notes:scrobbles:
:PROPERTIES:
diff --git a/data/flexify-example.sqlite b/data/flexify-example.sqlite
new file mode 100644
index 0000000000000000000000000000000000000000..dc6f927f12b91efd5565612714eecd5863b8460f
GIT binary patch
literal 53248
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
literal 0
HcmV?d00001
diff --git a/data/flexify-plans-example.csv b/data/flexify-plans-example.csv
new file mode 100644
index 0000000..a7c4bd6
--- /dev/null
+++ b/data/flexify-plans-example.csv
@@ -0,0 +1,4 @@
+id,days,title,sequence,exercises
+1,Monday,Middle Distance - Push Day,,Barbell bench press;Squat;Lat pull-down;Leg press
+2,Wednesday,Middle Distance - Pull Day,,Deadlift;Overhead triceps extension;Dumbbell biceps curl;Barbell bent-over row
+3,Friday,Middle Distance - Arm Day,,Leg press;Pull-up;Push-up;Crunch
diff --git a/tests/workouts_tests/test_flexify_importer.py b/tests/workouts_tests/test_flexify_importer.py
new file mode 100644
index 0000000..5cfc0d3
--- /dev/null
+++ b/tests/workouts_tests/test_flexify_importer.py
@@ -0,0 +1,307 @@
+import os
+
+import pytest
+from django.contrib.auth import get_user_model
+from scrobbles.importers.webdav import scan_webdav_for_flexify
+from scrobbles.models import Scrobble
+from workouts.importer import (
+ FLEXIFY_EXERCISE_ALIASES,
+ aggregate_bodyweight,
+ aggregate_workouts,
+ cluster_sessions,
+ import_flexify,
+ read_flexify_sqlite,
+ read_plans_csv,
+ resolve_exercise,
+)
+from workouts.models import Exercise, WorkoutRoutine, WorkoutRoutineExercise
+
+User = get_user_model()
+
+SAMPLE_SQLITE = os.path.join(
+ os.path.dirname(__file__), "..", "..", "data", "flexify-example.sqlite"
+)
+SAMPLE_CSV = os.path.join(
+ os.path.dirname(__file__), "..", "..", "data", "flexify-plans-example.csv"
+)
+
+
+@pytest.fixture
+def user(db):
+ return User.objects.create(email="lifter@example.com")
+
+
+def test_read_plans_csv():
+ plans = read_plans_csv(SAMPLE_CSV)
+ assert len(plans) == 3
+ assert plans[0]["days"] == "Monday"
+ assert plans[0]["title"] == "Middle Distance - Push Day"
+ assert plans[0]["exercises"] == [
+ "Barbell bench press",
+ "Squat",
+ "Lat pull-down",
+ "Leg press",
+ ]
+
+
+def test_read_flexify_sqlite():
+ data = read_flexify_sqlite(SAMPLE_SQLITE)
+ assert len(data["plans"]) == 3
+ assert len(data["plan_exercises"]) == 12
+ assert len(data["catalog"]) == 58
+ assert data["strength_unit"] == "lb"
+ assert len(data["sets"]) == 12
+ first = data["sets"][0]
+ assert first["name"] == "Leg press"
+ assert first["unit"] == "lb"
+ assert first["plan_id"] == 3
+
+
+def test_resolve_exercise_alias(db):
+ exercise, created = resolve_exercise("Barbell bench press")
+ assert exercise.name == "Barbell Bench Press - Medium Grip"
+ assert created is True
+
+
+def test_resolve_exercise_exact_name(db):
+ exercise, created = resolve_exercise("Leg press")
+ assert exercise.name == "Leg Press"
+ assert created is True
+
+
+def test_resolve_exercise_dry_run_does_not_create(db):
+ before = Exercise.objects.count()
+ exercise, created = resolve_exercise("Some Unknown Exercise", dry_run=True)
+ assert exercise is None
+ assert created is True
+ assert Exercise.objects.count() == before
+
+
+def test_resolve_exercise_reuses_existing(db):
+ first, _ = resolve_exercise("Push-up")
+ second, created = resolve_exercise("Push-up")
+ assert second.id == first.id
+ assert created is False
+
+
+def test_cluster_sessions_splits_on_time_gap():
+ sets = [
+ {"name": "A", "created": 1000, "id": 1, "plan_id": 1},
+ {"name": "B", "created": 1010, "id": 2, "plan_id": 1},
+ {"name": "C", "created": 1000 + 61 * 60, "id": 3, "plan_id": 1},
+ {"name": "D", "created": 1001 + 61 * 60, "id": 4, "plan_id": 1},
+ ]
+ sessions = cluster_sessions(sets, gap_minutes=60)
+ assert len(sessions) == 2
+ assert [s["name"] for s in sessions[0]] == ["A", "B"]
+ assert [s["name"] for s in sessions[1]] == ["C", "D"]
+
+
+def test_cluster_sessions_splits_on_plan_change():
+ sets = [
+ {"name": "A", "created": 1000, "id": 1, "plan_id": 1},
+ {"name": "B", "created": 1005, "id": 2, "plan_id": 2},
+ ]
+ sessions = cluster_sessions(sets, gap_minutes=60)
+ assert len(sessions) == 2
+ assert [s["name"] for s in sessions[0]] == ["A"]
+ assert [s["name"] for s in sessions[1]] == ["B"]
+
+
+def test_aggregate_workouts_weights(db):
+ session = read_flexify_sqlite(SAMPLE_SQLITE)["sets"][:3]
+ workouts = aggregate_workouts(session)
+ assert len(workouts) == 1
+ entry = workouts[0]
+ assert entry["sets"] == 3
+ assert entry["reps"] == 10
+ assert entry["weight_kg"] == pytest.approx(54.43, abs=0.01)
+
+
+def test_aggregate_bodyweight():
+ session = read_flexify_sqlite(SAMPLE_SQLITE)["sets"]
+ assert aggregate_bodyweight(session) == pytest.approx(83.91, abs=0.01)
+
+
+def test_import_flexify_dry_run(user):
+ result = import_flexify(
+ sqlite_path=SAMPLE_SQLITE,
+ csv_path=SAMPLE_CSV,
+ user_id=user.id,
+ dry_run=True,
+ )
+ assert result["routines_created"] == 3
+ assert result["routines_updated"] == 0
+ assert result["scrobbles_created"] == 1
+ assert Scrobble.objects.count() == 0
+ assert WorkoutRoutine.objects.count() == 0
+
+
+def test_import_flexify_full(user):
+ result = import_flexify(
+ sqlite_path=SAMPLE_SQLITE,
+ csv_path=SAMPLE_CSV,
+ user_id=user.id,
+ )
+ assert result["routines_created"] == 3
+ assert result["scrobbles_created"] == 1
+ assert result["scrobbles_skipped"] == 0
+
+ scrobble = Scrobble.objects.get(user=user)
+ assert scrobble.source == "Flexify Import"
+ assert scrobble.played_to_completion is True
+ assert scrobble.workout_routine.title == "Middle Distance - Arm Day"
+
+ entry = scrobble.logdata.workouts[0]
+ assert entry["exercise_id"] is not None
+ assert entry["sets"] == 3
+ assert entry["reps"] == 10
+ assert entry["weight_kg"] == pytest.approx(54.43, abs=0.01)
+ assert scrobble.logdata.bodyweight_kg == pytest.approx(83.91, abs=0.01)
+ assert scrobble.logdata.duration_minutes == 2
+
+ planned = WorkoutRoutineExercise.objects.filter(
+ routine=scrobble.workout_routine
+ ).order_by("sequence")
+ assert [pe.exercise.name for pe in planned] == [
+ "Leg Press",
+ "Pullups",
+ "Pushups",
+ "Crunches",
+ ]
+ assert result["exercises_created"] == 11
+
+
+def test_import_flexify_idempotent(user):
+ import_flexify(sqlite_path=SAMPLE_SQLITE, csv_path=SAMPLE_CSV, user_id=user.id)
+ result = import_flexify(
+ sqlite_path=SAMPLE_SQLITE,
+ csv_path=SAMPLE_CSV,
+ user_id=user.id,
+ )
+ assert result["routines_created"] == 0
+ assert result["routines_updated"] == 3
+ assert result["scrobbles_created"] == 0
+ assert result["scrobbles_existing"] == 1
+ assert Scrobble.objects.filter(user=user).count() == 1
+
+
+def test_import_flexify_csv_only(user):
+ result = import_flexify(
+ csv_path=SAMPLE_CSV,
+ user_id=user.id,
+ )
+ assert result["routines_created"] == 3
+ assert result["scrobbles_created"] == 0
+
+
+def test_import_flexify_sqlite_only(user):
+ result = import_flexify(
+ sqlite_path=SAMPLE_SQLITE,
+ user_id=user.id,
+ )
+ assert result["scrobbles_created"] == 1
+ routine_titles = set(WorkoutRoutine.objects.values_list("title", flat=True))
+ assert "Friday" in routine_titles
+
+
+def test_alias_targets_exist_in_wrkout_catalog():
+ assert FLEXIFY_EXERCISE_ALIASES["Pull-up"] == "Pullups"
+ assert FLEXIFY_EXERCISE_ALIASES["Push-up"] == "Pushups"
+ assert FLEXIFY_EXERCISE_ALIASES["Crunch"] == "Crunches"
+ assert FLEXIFY_EXERCISE_ALIASES["Deadlift"] == "Barbell Deadlift"
+ assert FLEXIFY_EXERCISE_ALIASES["Squat"] == "Barbell Squat"
+
+
+class FakeWebDAVClient:
+ def __init__(self, files):
+ self.files = {
+ "var/workouts/": None,
+ **{f"var/workouts/{name}": data for name, data in files.items()},
+ }
+
+ def info(self, path):
+ if not any(k.startswith(path) or k == path for k in self.files):
+ raise FileNotFoundError(path)
+
+ def list(self, path, get_info=False):
+ names = [k[len(path) :] for k in self.files if k.startswith(path) and k != path]
+ if get_info:
+ return [{"path": f"{path}{name}"} for name in names]
+ return names
+
+ def mkdir(self, path, recursive=False):
+ self.files.setdefault(path, None)
+
+ def download_sync(self, remote_path, local_path):
+ with open(local_path, "wb") as f:
+ f.write(self.files[remote_path])
+
+ def move(self, src, dst):
+ self.files[dst] = self.files.pop(src)
+
+
+def test_scan_webdav_for_flexify(user, monkeypatch, tmp_path):
+ with open(SAMPLE_SQLITE, "rb") as f:
+ sqlite_bytes = f.read()
+ with open(SAMPLE_CSV, "rb") as f:
+ csv_bytes = f.read()
+
+ client = FakeWebDAVClient(
+ {
+ "flexify-backup.sqlite": sqlite_bytes,
+ "flexify-plans.csv": csv_bytes,
+ }
+ )
+
+ import scrobbles.tasks as scrobbles_tasks
+ from workouts.models import WorkoutImport
+
+ queued = []
+ monkeypatch.setattr(
+ scrobbles_tasks,
+ "process_workout_import",
+ type("Task", (), {"delay": lambda self, pk: queued.append(pk)})(),
+ )
+
+ count = scan_webdav_for_flexify(client, user.id)
+ assert count == 1
+ assert len(queued) == 1
+ assert "var/workouts/flexify-backup.sqlite" not in client.files
+ assert "var/workouts/flexify-plans.csv" not in client.files
+ assert any(
+ k.startswith("var/workouts/processed/") and k.endswith(".sqlite")
+ for k in client.files
+ )
+
+ imp = WorkoutImport.objects.get(pk=queued[0])
+ assert imp.user_id == user.id
+ assert imp.original_filename == "flexify-backup.sqlite"
+ assert imp.file_hash
+ assert imp.csv_file
+
+
+def test_scan_webdav_for_flexify_skips_unchanged(user, monkeypatch, tmp_path):
+ with open(SAMPLE_SQLITE, "rb") as f:
+ sqlite_bytes = f.read()
+
+ client = FakeWebDAVClient({"flexify-backup.sqlite": sqlite_bytes})
+
+ import scrobbles.tasks as scrobbles_tasks
+ from workouts.models import WorkoutImport
+
+ queued = []
+ monkeypatch.setattr(
+ scrobbles_tasks,
+ "process_workout_import",
+ type("Task", (), {"delay": lambda self, pk: queued.append(pk)})(),
+ )
+
+ assert scan_webdav_for_flexify(client, user.id) == 1
+ assert len(queued) == 1
+
+ client.files["var/workouts/flexify-backup.sqlite"] = sqlite_bytes
+
+ count = scan_webdav_for_flexify(client, user.id)
+ assert count == 0
+ assert len(queued) == 1
diff --git a/vrobbler/apps/scrobbles/importers/webdav.py b/vrobbler/apps/scrobbles/importers/webdav.py
index e96c098..f2033da 100644
--- a/vrobbler/apps/scrobbles/importers/webdav.py
+++ b/vrobbler/apps/scrobbles/importers/webdav.py
@@ -20,6 +20,7 @@ DEFAULT_BGSTATS_PATH = "var/bgstats/"
DEFAULT_EBIRD_PATH = "var/ebird/"
DEFAULT_SCALE_PATH = "var/scale/"
DEFAULT_UDISC_PATH = "var/udisc/"
+DEFAULT_WORKOUTS_PATH = "var/workouts/"
def import_from_webdav_for_all_users(
@@ -50,6 +51,7 @@ def import_from_webdav_for_all_users(
ebird_count = 0
scale_count = 0
udisc_count = 0
+ workout_count = 0
for user_id in webdav_enabled_user_ids:
client = get_webdav_client(user_id)
@@ -84,9 +86,13 @@ def import_from_webdav_for_all_users(
udisc_count += scan_webdav_for_udisc(
client, user_id, include_processed=include_processed
)
+ logger.info("Scanning WebDAV workouts for user %s", user_id)
+ workout_count += scan_webdav_for_flexify(
+ client, user_id, include_processed=include_processed
+ )
logger.info(
- "Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale, %d uDisc WebDAV imports",
+ "Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale, %d uDisc, %d Workout WebDAV imports",
ko_count,
gpx_count,
retro_count,
@@ -94,6 +100,7 @@ def import_from_webdav_for_all_users(
ebird_count,
scale_count,
udisc_count,
+ workout_count,
extra={
"koreader": ko_count,
"trail_gpx": gpx_count,
@@ -102,9 +109,19 @@ def import_from_webdav_for_all_users(
"ebird": ebird_count,
"scale": scale_count,
"udisc": udisc_count,
+ "workouts": workout_count,
},
)
- return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count, udisc_count
+ return (
+ ko_count,
+ gpx_count,
+ retro_count,
+ bgstats_count,
+ ebird_count,
+ scale_count,
+ udisc_count,
+ workout_count,
+ )
def scan_webdav_for_koreader(
@@ -342,8 +359,8 @@ def scan_webdav_for_retroarch(
retroarch overwrites the same .lrtl filename.
"""
import hashlib
- import shutil
import re
+ import shutil
import zipfile
from datetime import datetime
@@ -379,8 +396,7 @@ def scan_webdav_for_retroarch(
f["name"] = os.path.basename(f["path"])
lrtl_files = [
- f for f in root_files
- if (f.get("name") or "").lower().endswith(".lrtl")
+ f for f in root_files if (f.get("name") or "").lower().endswith(".lrtl")
]
# Optionally include historical files from processed/
@@ -396,10 +412,13 @@ def scan_webdav_for_retroarch(
for f in processed_files:
if f.get("path") and not f.get("name"):
f["name"] = os.path.basename(f["path"])
- lrtl_files.extend([
- f for f in processed_files
- if (f.get("name") or "").lower().endswith(".lrtl")
- ])
+ lrtl_files.extend(
+ [
+ f
+ for f in processed_files
+ if (f.get("name") or "").lower().endswith(".lrtl")
+ ]
+ )
if not lrtl_files:
logger.info("No .lrtl files found on webdav", extra={"user_id": user_id})
@@ -408,6 +427,7 @@ def scan_webdav_for_retroarch(
# Sort chronologically: processed files (timestamp prefixed) first,
# current root files last.
ts_pattern = re.compile(r"^(\d{12})-")
+
def sort_key(f):
name = f.get("name") or ""
m = ts_pattern.match(name)
@@ -924,9 +944,183 @@ def scan_webdav_for_udisc(webdav_client, user_id, include_processed=False):
process_udisc_csv_import.delay(imp.id)
new_imports += 1
except Exception as e:
- logger.error(
- f"Failed to import processed uDisc CSV file {fname}: {e}"
- )
+ logger.error(f"Failed to import processed uDisc CSV file {fname}: {e}")
+ finally:
+ os.unlink(tmp.name)
+
+ return new_imports
+
+
+def scan_webdav_for_flexify(webdav_client, user_id, include_processed=False):
+ """Download Flexify sqlite backups from WebDAV var/workouts/ and queue imports.
+
+ A plans CSV next to the sqlite file (matching stem, else the first CSV in
+ the directory) is attached to the import so plans get their Flexify titles.
+ Files are moved to var/workouts/processed/ after downloading so they are
+ not re-imported on subsequent scans unless *include_processed* is True.
+ """
+ from scrobbles.tasks import process_workout_import
+ from workouts.models import WorkoutImport
+
+ workouts_path = DEFAULT_WORKOUTS_PATH
+ try:
+ webdav_client.info(workouts_path)
+ except:
+ logger.info("No var/workouts/ directory on webdav", extra={"user_id": user_id})
+ return 0
+
+ try:
+ files = webdav_client.list(workouts_path, get_info=True)
+ except Exception as e:
+ logger.warning(
+ "Could not list var/workouts/",
+ extra={"user_id": user_id, "error": str(e)},
+ )
+ return 0
+
+ processed_dir = f"{workouts_path}processed/"
+ try:
+ webdav_client.mkdir(processed_dir, recursive=True)
+ except Exception:
+ pass
+
+ def basename(f):
+ return os.path.basename(f.get("path") or f.get("name") or "")
+
+ sqlite_files = [
+ f for f in files if basename(f).lower().endswith((".sqlite", ".sqlite3", ".db"))
+ ]
+ csv_files = [basename(f) for f in files if basename(f).lower().endswith(".csv")]
+ if not sqlite_files:
+ logger.info(
+ "No Flexify sqlite files found on webdav", extra={"user_id": user_id}
+ )
+ return 0
+
+ if WorkoutImport.objects.filter(
+ user_id=user_id, processed_finished__isnull=True
+ ).exists():
+ logger.info(
+ "Workout import already pending for user", extra={"user_id": user_id}
+ )
+ return 0
+
+ new_imports = 0
+
+ for f in sqlite_files:
+ fname = basename(f)
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
+ try:
+ webdav_client.download_sync(
+ remote_path=f"{workouts_path}{fname}", local_path=tmp.name
+ )
+ new_hash = get_file_md5_hash(tmp.name)
+
+ last_import = (
+ WorkoutImport.objects.filter(
+ user_id=user_id,
+ original_filename=fname,
+ processed_finished__isnull=False,
+ )
+ .order_by("-processed_finished")
+ .first()
+ )
+ if last_import and last_import.file_hash == new_hash:
+ logger.debug(
+ "Workout sqlite %s unchanged (hash match), skipping",
+ fname,
+ extra={"user_id": user_id, "hash": new_hash},
+ )
+ continue
+
+ imp = WorkoutImport.objects.create(
+ user_id=user_id,
+ original_filename=fname,
+ file_hash=new_hash,
+ )
+ with open(tmp.name, "rb") as f:
+ imp.sqlite_file.save(fname, f, save=True)
+
+ stem = os.path.splitext(fname)[0]
+ csv_fname = next((c for c in csv_files if c.startswith(stem)), None)
+ if not csv_fname and csv_files:
+ csv_fname = csv_files[0]
+ if csv_fname:
+ csv_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=csv_fname)
+ try:
+ webdav_client.download_sync(
+ remote_path=f"{workouts_path}{csv_fname}",
+ local_path=csv_tmp.name,
+ )
+ with open(csv_tmp.name, "rb") as f:
+ imp.csv_file.save(csv_fname, f, save=True)
+ except Exception as e:
+ logger.error(f"Failed to fetch Flexify plans CSV {csv_fname}: {e}")
+ finally:
+ os.unlink(csv_tmp.name)
+
+ stem_ext, ext = os.path.splitext(fname)
+ ts = datetime.now().strftime("%Y%m%dT%H%M%S")
+ webdav_client.move(
+ f"{workouts_path}{fname}",
+ f"{processed_dir}{stem_ext}_{ts}{ext}",
+ )
+ if csv_fname:
+ try:
+ webdav_client.move(
+ f"{workouts_path}{csv_fname}",
+ f"{processed_dir}{csv_fname}",
+ )
+ except Exception:
+ pass
+
+ process_workout_import.delay(imp.id)
+ new_imports += 1
+ except Exception as e:
+ logger.error(f"Failed to import Flexify file {fname}: {e}")
+ finally:
+ os.unlink(tmp.name)
+
+ if include_processed:
+ try:
+ processed_files = webdav_client.list(processed_dir, get_info=True)
+ except Exception as e:
+ logger.warning(
+ "Could not list var/workouts/processed/",
+ extra={"user_id": user_id, "error": str(e)},
+ )
+ return new_imports
+
+ for f in processed_files:
+ fname = basename(f)
+ if not fname.lower().endswith((".sqlite", ".sqlite3", ".db")):
+ continue
+
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
+ try:
+ webdav_client.download_sync(
+ remote_path=f"{processed_dir}{fname}", local_path=tmp.name
+ )
+ new_hash = get_file_md5_hash(tmp.name)
+ if WorkoutImport.objects.filter(
+ user_id=user_id,
+ original_filename=fname,
+ file_hash=new_hash,
+ processed_finished__isnull=False,
+ ).exists():
+ continue
+
+ imp = WorkoutImport.objects.create(
+ user_id=user_id,
+ original_filename=fname,
+ file_hash=new_hash,
+ )
+ with open(tmp.name, "rb") as f:
+ imp.sqlite_file.save(fname, f, save=True)
+ process_workout_import.delay(imp.id)
+ new_imports += 1
+ except Exception as e:
+ logger.error(f"Failed to import processed Flexify file {fname}: {e}")
finally:
os.unlink(tmp.name)
diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py
index be3e7f7..1e6d12a 100644
--- a/vrobbler/apps/scrobbles/tasks.py
+++ b/vrobbler/apps/scrobbles/tasks.py
@@ -312,6 +312,16 @@ def process_trail_gpx_import(import_id):
trail_gpx_import.process()
+@shared_task
+def process_workout_import(import_id):
+ WorkoutImport = apps.get_model("workouts", "WorkoutImport")
+ workout_import = WorkoutImport.objects.filter(id=import_id).first()
+ if not workout_import:
+ logger.warn(f"WorkoutImport not found with id {import_id}")
+ return
+ workout_import.process()
+
+
@shared_task
def process_ebird_csv_import(import_id):
EBirdCSVImport = apps.get_model("scrobbles", "EBirdCSVImport")
diff --git a/vrobbler/apps/workouts/admin.py b/vrobbler/apps/workouts/admin.py
index 65f4c25..3e46dec 100644
--- a/vrobbler/apps/workouts/admin.py
+++ b/vrobbler/apps/workouts/admin.py
@@ -1,6 +1,11 @@
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
-from workouts.models import Exercise, WorkoutRoutine
+from workouts.models import (
+ Exercise,
+ WorkoutImport,
+ WorkoutRoutine,
+ WorkoutRoutineExercise,
+)
@admin.register(Exercise)
@@ -11,6 +16,11 @@ class ExerciseAdmin(admin.ModelAdmin):
search_fields = ("name", "primary_muscles", "secondary_muscles")
+class WorkoutRoutineExerciseInline(admin.TabularInline):
+ model = WorkoutRoutineExercise
+ extra = 0
+
+
@admin.register(WorkoutRoutine)
class WorkoutRoutineAdmin(admin.ModelAdmin):
date_hierarchy = "created"
@@ -19,4 +29,29 @@ class WorkoutRoutineAdmin(admin.ModelAdmin):
search_fields = ("title", "description")
inlines = [
ScrobbleInline,
+ WorkoutRoutineExerciseInline,
]
+
+
+@admin.register(WorkoutImport)
+class WorkoutImportAdmin(admin.ModelAdmin):
+ list_display = (
+ "uuid",
+ "user",
+ "original_filename",
+ "processing_started",
+ "processed_finished",
+ "process_count",
+ )
+ ordering = ("-created",)
+ readonly_fields = (
+ "uuid",
+ "created",
+ "modified",
+ "processing_started",
+ "processed_finished",
+ "process_log",
+ "process_count",
+ "error_log",
+ "file_hash",
+ )
diff --git a/vrobbler/apps/workouts/api/serializers.py b/vrobbler/apps/workouts/api/serializers.py
index 1f72621..07b767c 100644
--- a/vrobbler/apps/workouts/api/serializers.py
+++ b/vrobbler/apps/workouts/api/serializers.py
@@ -1,5 +1,10 @@
from rest_framework import serializers
-from workouts.models import Exercise, WorkoutRoutine
+from workouts.models import (
+ Exercise,
+ WorkoutImport,
+ WorkoutRoutine,
+ WorkoutRoutineExercise,
+)
class ExerciseSerializer(serializers.HyperlinkedModelSerializer):
@@ -8,7 +13,41 @@ class ExerciseSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__"
+class WorkoutRoutineExerciseSerializer(serializers.ModelSerializer):
+ exercise_name = serializers.CharField(source="exercise.name", read_only=True)
+
+ class Meta:
+ model = WorkoutRoutineExercise
+ fields = [
+ "id",
+ "exercise",
+ "exercise_name",
+ "sequence",
+ "target_sets",
+ "target_reps",
+ "target_weight_kg",
+ "enabled",
+ ]
+
+
class WorkoutRoutineSerializer(serializers.HyperlinkedModelSerializer):
+ routine_exercises = WorkoutRoutineExerciseSerializer(many=True, read_only=True)
+
class Meta:
model = WorkoutRoutine
fields = "__all__"
+
+
+class WorkoutImportSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = WorkoutImport
+ fields = "__all__"
+ read_only_fields = (
+ "uuid",
+ "processing_started",
+ "processed_finished",
+ "process_log",
+ "process_count",
+ "error_log",
+ "file_hash",
+ )
diff --git a/vrobbler/apps/workouts/api/views.py b/vrobbler/apps/workouts/api/views.py
index 504a5b9..fa8e6ec 100644
--- a/vrobbler/apps/workouts/api/views.py
+++ b/vrobbler/apps/workouts/api/views.py
@@ -13,3 +13,9 @@ class WorkoutRoutineViewSet(viewsets.ModelViewSet):
queryset = models.WorkoutRoutine.objects.all().order_by("-created")
serializer_class = serializers.WorkoutRoutineSerializer
permission_classes = [permissions.IsAuthenticated]
+
+
+class WorkoutImportViewSet(viewsets.ModelViewSet):
+ queryset = models.WorkoutImport.objects.all().order_by("-created")
+ serializer_class = serializers.WorkoutImportSerializer
+ permission_classes = [permissions.IsAuthenticated]
diff --git a/vrobbler/apps/workouts/importer.py b/vrobbler/apps/workouts/importer.py
index 609666c..17a00ec 100644
--- a/vrobbler/apps/workouts/importer.py
+++ b/vrobbler/apps/workouts/importer.py
@@ -1,13 +1,24 @@
+import csv
import json
import logging
import os
+import sqlite3
import tarfile
import tempfile
import zipfile
+from collections import Counter, defaultdict
+from datetime import datetime, timezone as dt_timezone
import requests
from django.core.files.base import ContentFile
-from workouts.models import Exercise
+from scrobbles.models import Scrobble
+from workouts.models import (
+ Exercise,
+ WorkoutRoutine,
+ WorkoutRoutineExercise,
+ WorkoutSetEntry,
+)
+from workouts.utils import lbs_to_kg
logger = logging.getLogger(__name__)
@@ -16,6 +27,9 @@ TARBALL_URL = (
)
IMAGES_DIR_NAME = "images"
+SESSION_GAP_MINUTES = 60
+DEFAULT_IMPORT_SOURCE = "Flexify Import"
+
def download_tarball(url=TARBALL_URL, destination=None):
if not destination:
@@ -142,3 +156,421 @@ def import_wrkout_exercises(tarball_path=None, import_images=True, dry_run=False
"skipped": skipped,
"image_failures": image_failures,
}
+
+
+FLEXIFY_EXERCISE_ALIASES = {
+ "Arnold press": "Arnold Dumbbell Press",
+ "Back extension": "Hyperextensions (Back Extensions)",
+ "Barbell bench press": "Barbell Bench Press - Medium Grip",
+ "Barbell bent-over row": "Bent Over Barbell Row",
+ "Barbell biceps curl": "Barbell Curl",
+ "Barbell shrug": "Barbell Shrug",
+ "Cable fly": "Flat Bench Cable Flyes",
+ "Cable lateral raise": "Cable Seated Lateral Raise",
+ "Cable pull-down": "Wide-Grip Lat Pulldown",
+ "Chest fly": "Dumbbell Flyes",
+ "Chin-up": "Chin-Up",
+ "Crunch": "Crunches",
+ "Deadlift": "Barbell Deadlift",
+ "Decline bench press": "Decline Barbell Bench Press",
+ "Dumbbell bench press": "Dumbbell Bench Press",
+ "Dumbbell bent-over row": "Bent Over Two-Dumbbell Row",
+ "Dumbbell biceps curl": "Dumbbell Bicep Curl",
+ "Dumbbell fly": "Dumbbell Flyes",
+ "Dumbbell lateral raise": "Side Lateral Raise",
+ "Dumbbell shoulder press": "Dumbbell Shoulder Press",
+ "Dumbbell shrug": "Dumbbell Shrug",
+ "Good morning": "Good Morning",
+ "Hanging leg raise": "Hanging Leg Raise",
+ "Hyperextension": "Hyperextensions (Back Extensions)",
+ "Incline bench press": "Barbell Incline Bench Press - Medium Grip",
+ "Lat pull-down": "Wide-Grip Lat Pulldown",
+ "Leg curl": "Seated Leg Curl",
+ "Leg extension": "Leg Extensions",
+ "Leg press": "Leg Press",
+ "Overhead triceps extension": "Cable Rope Overhead Triceps Extension",
+ "Pull-down": "Wide-Grip Lat Pulldown",
+ "Pull-up": "Pullups",
+ "Push-up": "Pushups",
+ "Reverse grip pull-down": "Underhand Cable Pulldowns",
+ "Reverse grip pushdown": "Reverse Grip Triceps Pushdown",
+ "Squat": "Barbell Squat",
+ "Standing calf raise": "Standing Calf Raises",
+ "T-bar row": "T-Bar Row with Handle",
+ "Triceps dip": "Dips - Triceps Version",
+ "Upright row": "Upright Barbell Row",
+}
+
+
+def resolve_exercise(name, dry_run=False):
+ """Map a Flexify exercise name to the closest wrkout Exercise.
+
+ Returns (exercise, created). When no good alias exists the Flexify name
+ itself is used, creating the Exercise on first import.
+ """
+ target = FLEXIFY_EXERCISE_ALIASES.get(name) or name
+ exercise = Exercise.objects.filter(name__iexact=target).first()
+ created = False
+ if not exercise:
+ created = True
+ if not dry_run:
+ exercise, _ = Exercise.objects.get_or_create(name=target)
+ return exercise, created
+
+
+def read_plans_csv(path):
+ plans = []
+ with open(path, newline="", encoding="utf-8-sig") as f:
+ for row in csv.DictReader(f):
+ exercises = [
+ e.strip() for e in (row.get("exercises") or "").split(";") if e.strip()
+ ]
+ plans.append(
+ {
+ "days": (row.get("days") or "").strip(),
+ "title": (row.get("title") or "").strip(),
+ "sequence": row.get("sequence") or None,
+ "exercises": exercises,
+ }
+ )
+ return plans
+
+
+def _available_columns(con, table):
+ return {row[1] for row in con.execute(f"PRAGMA table_info({table})")}
+
+
+def _select_rows(con, table, columns, where=None, order_by=None):
+ existing = [c for c in columns if c in _available_columns(con, table)]
+ if not existing:
+ return []
+ sql = f"SELECT {', '.join(existing)} FROM {table}"
+ if where:
+ sql += f" WHERE {where}"
+ if order_by:
+ sql += f" ORDER BY {order_by}"
+ return [dict(zip(existing, row)) for row in con.execute(sql)]
+
+
+def read_flexify_sqlite(path):
+ try:
+ con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
+ except sqlite3.Error as exc:
+ raise ValueError(f"Not a readable SQLite file: {path}") from exc
+ con.row_factory = sqlite3.Row
+ try:
+ plans = _select_rows(
+ con, "plans", ["id", "days", "sequence", "title"], order_by="id"
+ )
+ plan_exercises = _select_rows(
+ con,
+ "plan_exercises",
+ ["plan_id", "exercise", "sequence", "enabled"],
+ order_by="plan_id, sequence",
+ )
+ sets = _select_rows(
+ con,
+ "gym_sets",
+ [
+ "id",
+ "name",
+ "weight",
+ "reps",
+ "unit",
+ "created",
+ "plan_id",
+ "body_weight",
+ "notes",
+ "hidden",
+ "cardio",
+ "distance",
+ "duration",
+ ],
+ where="hidden = 0",
+ order_by="created, id",
+ )
+ catalog = [
+ r["name"]
+ for r in _select_rows(
+ con, "gym_sets", ["name"], where="hidden = 1", order_by="name"
+ )
+ ]
+ strength_unit = None
+ if "settings" in [
+ r[0]
+ for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ ]:
+ row = con.execute("SELECT strength_unit FROM settings LIMIT 1").fetchone()
+ if row and row[0]:
+ strength_unit = row[0]
+ return {
+ "plans": plans,
+ "plan_exercises": plan_exercises,
+ "sets": sets,
+ "catalog": catalog,
+ "strength_unit": strength_unit,
+ }
+ except sqlite3.Error as exc:
+ raise ValueError(f"Could not read Flexify data from {path}: {exc}") from exc
+ finally:
+ con.close()
+
+
+def cluster_sessions(sets, gap_minutes=SESSION_GAP_MINUTES):
+ """Group logged sets into sessions: a new session starts when the plan_id
+ changes or the gap between consecutive sets exceeds gap_minutes."""
+ sessions = []
+ current = None
+ for s in sorted(sets, key=lambda x: (x.get("created") or 0, x.get("id") or 0)):
+ if current is None:
+ current = []
+ sessions.append(current)
+ else:
+ gap = (s.get("created") or 0) - (current[-1].get("created") or 0)
+ if s.get("plan_id") != current[-1].get("plan_id") or gap > gap_minutes * 60:
+ current = []
+ sessions.append(current)
+ current.append(s)
+ return sessions
+
+
+def _to_kg(value, unit):
+ if value is None:
+ return None
+ try:
+ value = float(value)
+ except (TypeError, ValueError):
+ return None
+ if (unit or "").lower() == "lb":
+ return lbs_to_kg(value)
+ return round(value, 2)
+
+
+def _whole(value):
+ try:
+ value = float(value)
+ except (TypeError, ValueError):
+ return None
+ return int(value) if value.is_integer() else value
+
+
+def aggregate_workouts(session, dry_run=False):
+ by_name = defaultdict(list)
+ for s in session:
+ by_name[s["name"]].append(s)
+
+ workouts = []
+ for name, rows in by_name.items():
+ exercise, _ = resolve_exercise(name, dry_run=dry_run)
+ reps_counter = Counter(_whole(r.get("reps")) for r in rows if r.get("reps"))
+ weights = [
+ _to_kg(r.get("weight"), r.get("unit"))
+ for r in rows
+ if r.get("weight") not in (None, "")
+ ]
+ weight_kg = max(weights) if weights else None
+ if weight_kg == 0:
+ weight_kg = None
+ notes = (
+ "; ".join(str(r["notes"]).strip() for r in rows if r.get("notes")) or None
+ )
+ workouts.append(
+ WorkoutSetEntry(
+ exercise_id=exercise.id if exercise else None,
+ sets=len(rows),
+ reps=reps_counter.most_common(1)[0][0] if reps_counter else None,
+ weight_kg=weight_kg,
+ notes=notes,
+ ).asdict
+ )
+ return workouts
+
+
+def aggregate_bodyweight(session):
+ values = []
+ for r in session:
+ kg = _to_kg(r.get("body_weight"), r.get("unit"))
+ if kg:
+ values.append(round(kg, 2))
+ if not values:
+ return None
+ return Counter(values).most_common(1)[0][0]
+
+
+def _find_or_create_routine(title, counts):
+ routine = WorkoutRoutine.objects.filter(title__iexact=title).first()
+ if routine:
+ counts["routines_updated"] += 1
+ else:
+ routine = WorkoutRoutine.objects.create(title=title)
+ counts["routines_created"] += 1
+ return routine
+
+
+def _plan_title(plan_def, fallback="Workout"):
+ title = plan_def.get("title") or plan_def.get("days") or fallback
+ return title.strip() or fallback
+
+
+def import_flexify(
+ sqlite_path=None,
+ csv_path=None,
+ user_id=None,
+ dry_run=False,
+ source=DEFAULT_IMPORT_SOURCE,
+):
+ """Import Flexify plans and workout history.
+
+ Plans come from the CSV (preferred, has titles) and/or the sqlite
+ ``plans``/``plan_exercises`` tables. Logged ``gym_sets`` become workout
+ scrobbles, clustered into sessions by time gap and plan.
+ """
+ sqlite_data = read_flexify_sqlite(sqlite_path) if sqlite_path else None
+ csv_plans = read_plans_csv(csv_path) if csv_path else []
+ if sqlite_data is None and not csv_plans:
+ raise ValueError("Provide --sqlite and/or --csv")
+
+ counts = {
+ "routines_created": 0,
+ "routines_updated": 0,
+ "exercises_created": 0,
+ "scrobbles_created": 0,
+ "scrobbles_skipped": 0,
+ "scrobbles_existing": 0,
+ }
+ created_scrobbles = []
+
+ plan_defs = []
+ seen_days = set()
+ for p in csv_plans:
+ plan_defs.append(
+ {"days": p["days"], "title": p["title"], "exercises": p["exercises"]}
+ )
+ seen_days.add(p["days"].lower())
+ if sqlite_data:
+ by_plan = defaultdict(list)
+ for pe in sqlite_data["plan_exercises"]:
+ by_plan[pe["plan_id"]].append(pe["exercise"])
+ for p in sqlite_data["plans"]:
+ if p.get("days") and p["days"].lower() in seen_days:
+ continue
+ plan_defs.append(
+ {
+ "days": p.get("days") or "",
+ "title": p.get("title") or "",
+ "exercises": by_plan.get(p["id"], []),
+ }
+ )
+ if p.get("days"):
+ seen_days.add(p["days"].lower())
+
+ for plan_def in plan_defs:
+ title = _plan_title(plan_def)
+ if dry_run:
+ if not WorkoutRoutine.objects.filter(title__iexact=title).exists():
+ counts["routines_created"] += 1
+ else:
+ counts["routines_updated"] += 1
+ else:
+ _find_or_create_routine(title, counts)
+ for name in plan_def["exercises"]:
+ _, created = resolve_exercise(name, dry_run=dry_run)
+ if created:
+ counts["exercises_created"] += 1
+
+ if sqlite_data:
+ plans_by_id = {p["id"]: p for p in sqlite_data["plans"]}
+ routine_title_by_days = {}
+ for plan_def in plan_defs:
+ if plan_def["days"]:
+ routine_title_by_days[plan_def["days"].lower()] = _plan_title(plan_def)
+ for session in cluster_sessions(sqlite_data["sets"]):
+ if not session:
+ continue
+ plan_def = plans_by_id.get(session[0].get("plan_id"))
+ if plan_def and plan_def.get("days"):
+ title = routine_title_by_days.get(
+ plan_def["days"].lower(), _plan_title(plan_def)
+ )
+ elif plan_def:
+ title = _plan_title(plan_def)
+ else:
+ title = "Flexify Workout"
+ routine = None
+ if not dry_run:
+ routine = WorkoutRoutine.objects.filter(title__iexact=title).first()
+ if not routine:
+ routine = WorkoutRoutine.objects.create(title=title)
+ counts["routines_created"] += 1
+
+ workouts = aggregate_workouts(session, dry_run=dry_run)
+ if not workouts:
+ counts["scrobbles_skipped"] += 1
+ continue
+
+ timestamp = datetime.fromtimestamp(
+ session[0]["created"], tz=dt_timezone.utc
+ ).replace(microsecond=0)
+ if (
+ not dry_run
+ and routine
+ and Scrobble.objects.filter(
+ user_id=user_id,
+ workout_routine=routine,
+ timestamp=timestamp,
+ source=source,
+ ).exists()
+ ):
+ counts["scrobbles_existing"] += 1
+ continue
+
+ bodyweight_kg = aggregate_bodyweight(session)
+ duration_minutes = int(
+ (session[-1]["created"] - session[0]["created"]) / 60
+ )
+ log = {"workouts": workouts}
+ if bodyweight_kg:
+ log["bodyweight_kg"] = bodyweight_kg
+ if duration_minutes:
+ log["duration_minutes"] = duration_minutes
+
+ counts["scrobbles_created"] += 1
+ if dry_run:
+ continue
+ scrobble = Scrobble.objects.create(
+ user_id=user_id,
+ workout_routine=routine,
+ media_type=Scrobble.MediaType.WORKOUT,
+ timestamp=timestamp,
+ played_to_completion=True,
+ log=log,
+ source=source,
+ )
+ created_scrobbles.append(scrobble)
+
+ if not dry_run:
+ for plan_def in plan_defs:
+ routine = WorkoutRoutine.objects.filter(
+ title__iexact=_plan_title(plan_def)
+ ).first()
+ if not routine:
+ continue
+ for sequence, name in enumerate(plan_def["exercises"]):
+ exercise, _ = resolve_exercise(name)
+ we, _ = WorkoutRoutineExercise.objects.get_or_create(
+ routine=routine, exercise=exercise
+ )
+ we.sequence = sequence
+ we.enabled = True
+ we.save()
+
+ logger.info(f"Flexify import finished: {counts}")
+ return {
+ "routines_created": counts["routines_created"],
+ "routines_updated": counts["routines_updated"],
+ "exercises_created": counts["exercises_created"],
+ "scrobbles_created": counts["scrobbles_created"],
+ "scrobbles_skipped": counts["scrobbles_skipped"],
+ "scrobbles_existing": counts["scrobbles_existing"],
+ "scrobbles": created_scrobbles,
+ }
diff --git a/vrobbler/apps/workouts/management/commands/import_flexify.py b/vrobbler/apps/workouts/management/commands/import_flexify.py
new file mode 100644
index 0000000..280b85f
--- /dev/null
+++ b/vrobbler/apps/workouts/management/commands/import_flexify.py
@@ -0,0 +1,66 @@
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand, CommandError
+from workouts.importer import import_flexify
+
+User = get_user_model()
+
+
+class Command(BaseCommand):
+ help = "Import workouts and routines from a Flexify backup"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--sqlite",
+ dest="sqlite",
+ default=None,
+ help="Path to a Flexify sqlite backup file",
+ )
+ parser.add_argument(
+ "--csv",
+ dest="csv",
+ default=None,
+ help="Path to a Flexify plans CSV file",
+ )
+ parser.add_argument(
+ "--user",
+ dest="user",
+ required=True,
+ help="Email of the user to attach the scrobbles to",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Report what would be imported without writing anything",
+ )
+ parser.add_argument(
+ "--source",
+ dest="source",
+ default="Flexify Import",
+ help="Scrobble source label (default: Flexify Import)",
+ )
+
+ def handle(self, *args, **options):
+ user = User.objects.filter(email=options["user"]).first()
+ if not user:
+ raise CommandError(f"No user found with email {options['user']}")
+
+ if not options.get("sqlite") and not options.get("csv"):
+ raise CommandError("Provide --sqlite and/or --csv")
+
+ result = import_flexify(
+ sqlite_path=options.get("sqlite"),
+ csv_path=options.get("csv"),
+ user_id=user.id,
+ dry_run=options.get("dry_run"),
+ source=options.get("source"),
+ )
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Import finished: {result['routines_created']} routines created, "
+ f"{result['routines_updated']} routines updated, "
+ f"{result['exercises_created']} exercises created, "
+ f"{result['scrobbles_created']} scrobbles created, "
+ f"{result['scrobbles_existing']} scrobbles already present, "
+ f"{result['scrobbles_skipped']} skipped"
+ )
+ )
diff --git a/vrobbler/apps/workouts/mcp.py b/vrobbler/apps/workouts/mcp.py
index 5b12cfb..0b21277 100644
--- a/vrobbler/apps/workouts/mcp.py
+++ b/vrobbler/apps/workouts/mcp.py
@@ -66,4 +66,19 @@ def _routine_to_dict(r: WorkoutRoutine) -> dict:
result = {"uuid": str(r.uuid), "title": r.title}
if r.description:
result["description"] = r.description
+ exercises = []
+ for we in r.routine_exercises.all():
+ exercises.append(
+ {
+ "sequence": we.sequence,
+ "exercise_uuid": str(we.exercise.uuid),
+ "exercise_name": we.exercise.name,
+ "target_sets": we.target_sets,
+ "target_reps": we.target_reps,
+ "target_weight_kg": we.target_weight_kg,
+ "enabled": we.enabled,
+ }
+ )
+ if exercises:
+ result["planned_exercises"] = exercises
return result
diff --git a/vrobbler/apps/workouts/migrations/0002_workoutroutineexercise_workoutimport.py b/vrobbler/apps/workouts/migrations/0002_workoutroutineexercise_workoutimport.py
new file mode 100644
index 0000000..7997481
--- /dev/null
+++ b/vrobbler/apps/workouts/migrations/0002_workoutroutineexercise_workoutimport.py
@@ -0,0 +1,134 @@
+# Generated by Django 4.2.29 on 2026-08-07 16:27
+
+import uuid
+
+import django.db.models.deletion
+import django_extensions.db.fields
+import workouts.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("workouts", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="WorkoutRoutineExercise",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ django_extensions.db.fields.CreationDateTimeField(
+ auto_now_add=True, verbose_name="created"
+ ),
+ ),
+ (
+ "modified",
+ django_extensions.db.fields.ModificationDateTimeField(
+ auto_now=True, verbose_name="modified"
+ ),
+ ),
+ ("sequence", models.PositiveIntegerField(default=0)),
+ ("target_sets", models.PositiveIntegerField(blank=True, null=True)),
+ ("target_reps", models.PositiveIntegerField(blank=True, null=True)),
+ ("target_weight_kg", models.FloatField(blank=True, null=True)),
+ ("enabled", models.BooleanField(default=True)),
+ (
+ "exercise",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="routine_exercises",
+ to="workouts.exercise",
+ ),
+ ),
+ (
+ "routine",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="routine_exercises",
+ to="workouts.workoutroutine",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["routine", "sequence"],
+ },
+ ),
+ migrations.CreateModel(
+ name="WorkoutImport",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ django_extensions.db.fields.CreationDateTimeField(
+ auto_now_add=True, verbose_name="created"
+ ),
+ ),
+ (
+ "modified",
+ django_extensions.db.fields.ModificationDateTimeField(
+ auto_now=True, verbose_name="modified"
+ ),
+ ),
+ ("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
+ ("processing_started", models.DateTimeField(blank=True, null=True)),
+ ("processed_finished", models.DateTimeField(blank=True, null=True)),
+ ("process_log", models.TextField(blank=True, null=True)),
+ ("process_count", models.IntegerField(blank=True, null=True)),
+ ("error_log", models.TextField(blank=True, null=True)),
+ (
+ "sqlite_file",
+ models.FileField(
+ blank=True,
+ null=True,
+ upload_to=workouts.models.WorkoutImport.get_path,
+ ),
+ ),
+ (
+ "csv_file",
+ models.FileField(
+ blank=True,
+ null=True,
+ upload_to=workouts.models.WorkoutImport.get_path,
+ ),
+ ),
+ (
+ "original_filename",
+ models.CharField(blank=True, max_length=255, null=True),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Workout Import",
+ },
+ ),
+ ]
diff --git a/vrobbler/apps/workouts/migrations/0003_workoutimport_file_hash.py b/vrobbler/apps/workouts/migrations/0003_workoutimport_file_hash.py
new file mode 100644
index 0000000..7972101
--- /dev/null
+++ b/vrobbler/apps/workouts/migrations/0003_workoutimport_file_hash.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.29 on 2026-08-07 16:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("workouts", "0002_workoutroutineexercise_workoutimport"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="workoutimport",
+ name="file_hash",
+ field=models.CharField(blank=True, max_length=32, null=True),
+ ),
+ ]
diff --git a/vrobbler/apps/workouts/models.py b/vrobbler/apps/workouts/models.py
index 625f2e0..e399973 100644
--- a/vrobbler/apps/workouts/models.py
+++ b/vrobbler/apps/workouts/models.py
@@ -4,6 +4,7 @@ from functools import cached_property
from typing import Optional
from uuid import uuid4
+from django.conf import settings
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
@@ -11,6 +12,7 @@ from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
+from scrobbles.models import BaseFileImportMixin
from workouts.utils import display_weight
logger = logging.getLogger(__name__)
@@ -197,3 +199,69 @@ class WorkoutRoutine(ScrobblableMixin):
if not routine:
routine = cls.objects.create(title=title)
return routine
+
+
+class WorkoutRoutineExercise(TimeStampedModel):
+ routine = models.ForeignKey(
+ WorkoutRoutine, related_name="routine_exercises", on_delete=models.CASCADE
+ )
+ exercise = models.ForeignKey(
+ Exercise, related_name="routine_exercises", on_delete=models.CASCADE
+ )
+ sequence = models.PositiveIntegerField(default=0)
+ target_sets = models.PositiveIntegerField(**BNULL)
+ target_reps = models.PositiveIntegerField(**BNULL)
+ target_weight_kg = models.FloatField(**BNULL)
+ enabled = models.BooleanField(default=True)
+
+ class Meta:
+ ordering = ["routine", "sequence"]
+
+ def __str__(self):
+ return f"{self.routine.title}: {self.exercise.name}"
+
+
+class WorkoutImport(BaseFileImportMixin):
+ class Meta:
+ verbose_name = "Workout Import"
+
+ def get_path(instance, filename):
+ extension = filename.split(".")[-1]
+ uuid = instance.uuid
+ return f"workout-imports/{uuid}.{extension}"
+
+ @property
+ def import_type(self) -> str:
+ return "Flexify"
+
+ @property
+ def upload_file_path(self):
+ if getattr(settings, "USE_S3_STORAGE"):
+ return self.sqlite_file.url
+ return self.sqlite_file.path
+
+ sqlite_file = models.FileField(upload_to=get_path, **BNULL)
+ csv_file = models.FileField(upload_to=get_path, **BNULL)
+ original_filename = models.CharField(max_length=255, **BNULL)
+ file_hash = models.CharField(max_length=32, **BNULL)
+
+ def process(self, force=False):
+ from workouts.importer import import_flexify
+
+ if self.processed_finished and not force:
+ logger.info(f"{self} already processed on {self.processed_finished}")
+ return
+
+ self.mark_started()
+ try:
+ result = import_flexify(
+ sqlite_path=self.upload_file_path,
+ csv_path=self.csv_file.path if self.csv_file else None,
+ user_id=self.user_id,
+ )
+ self.record_log(result["scrobbles"])
+ except Exception as exc:
+ self.record_error(f"Import failed: {exc}")
+ logger.exception(f"Import failed for {self}")
+ finally:
+ self.mark_finished()
diff --git a/vrobbler/templates/workouts/workoutroutine_detail.html b/vrobbler/templates/workouts/workoutroutine_detail.html
index 69b9625..e0b3c1a 100644
--- a/vrobbler/templates/workouts/workoutroutine_detail.html
+++ b/vrobbler/templates/workouts/workoutroutine_detail.html
@@ -17,6 +17,23 @@
+{% if object.routine_exercises.all %}
+
+
+
Planned exercises
+
+ {% for planned in object.routine_exercises.all %}
+ -
+ {{planned.exercise.name}}
+ {% if planned.target_sets %}
+ — {{planned.target_sets}} sets{% if planned.target_reps %} x {{planned.target_reps}} reps{% endif %}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+{% endif %}
Last scrobbles
diff --git a/vrobbler/urls.py b/vrobbler/urls.py
index 47bdd18..6aff7f6 100644
--- a/vrobbler/urls.py
+++ b/vrobbler/urls.py
@@ -106,6 +106,7 @@ from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet
from vrobbler.apps.workouts import urls as workouts_urls
from vrobbler.apps.workouts.api.views import (
ExerciseViewSet,
+ WorkoutImportViewSet,
WorkoutRoutineViewSet,
)
@@ -173,6 +174,7 @@ router.register(r"observations", SpeciesObservationViewSet)
router.register(r"trails", TrailViewSet)
router.register(r"exercises", ExerciseViewSet)
router.register(r"workout-routines", WorkoutRoutineViewSet)
+router.register(r"workout-imports", WorkoutImportViewSet)
urlpatterns = [
path("api/v1/", include(router.urls)),
path("api/v1/auth", include("rest_framework.urls")),