[workouts] Add Flexify workout importer (96c9fbf5)
This commit is contained in:
@ -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)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
66
vrobbler/apps/workouts/management/commands/import_flexify.py
Normal file
66
vrobbler/apps/workouts/management/commands/import_flexify.py
Normal file
@ -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"
|
||||
)
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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()
|
||||
|
||||
@ -17,6 +17,23 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if object.routine_exercises.all %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Planned exercises</h3>
|
||||
<ol>
|
||||
{% for planned in object.routine_exercises.all %}
|
||||
<li>
|
||||
<a href="{{planned.exercise.get_absolute_url}}">{{planned.exercise.name}}</a>
|
||||
{% if planned.target_sets %}
|
||||
— {{planned.target_sets}} sets{% if planned.target_reps %} x {{planned.target_reps}} reps{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
|
||||
@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user