diff --git a/data/scale-example.csv b/data/scale-example.csv new file mode 100644 index 0000000..90dc0d0 --- /dev/null +++ b/data/scale-example.csv @@ -0,0 +1,2 @@ +DATE,TIME,BICEPS,BMI,BMR,BODY_FAT,BONE,CALIPER,CALIPER_1,CALIPER_2,CALIPER_3,CALORIES,CHEST,COMMENT,HEART_RATE,HIPS,LBM,MUSCLE,NECK,TDEE,THIGH,VISCERAL_FAT,WAIST,WATER,WEIGHT,WHR,WHTR +2026-05-20,11:56:58.076,,31.09,1706.74,29.084072,3.438837,,,,,,,,,,,33.07067,,2645.46,,,,54.445187,192.68378,, diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 275e7f9..5085f25 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/vrobbler/apps/scrobbles/admin.py @@ -5,6 +5,7 @@ from scrobbles.models import ( KoReaderImport, LastFmImport, RetroarchImport, + ScaleCSVImport, Scrobble, ) from scrobbles.mixins import Genre @@ -77,6 +78,11 @@ class RetroarchImportAdmin(ImportBaseAdmin): ... +@admin.register(ScaleCSVImport) +class ScaleCSVImportAdmin(ImportBaseAdmin): + ... + + @admin.register(Genre) class GenreAdmin(admin.ModelAdmin): list_display = ( diff --git a/vrobbler/apps/scrobbles/importers/scale.py b/vrobbler/apps/scrobbles/importers/scale.py new file mode 100644 index 0000000..9f5e113 --- /dev/null +++ b/vrobbler/apps/scrobbles/importers/scale.py @@ -0,0 +1,111 @@ +import csv +import logging +from datetime import datetime, timedelta + +from django.contrib.auth import get_user_model + +from scrobbles.models import Scrobble +from tasks.models import Task + +logger = logging.getLogger(__name__) + +FLOAT_COLUMNS = { + "WEIGHT": "weight_kg", + "BMI": "bmi", + "BODY_FAT": "body_fat_pct", + "BONE": "bone_kg", + "MUSCLE": "muscle_kg", + "WATER": "water_pct", + "VISCERAL_FAT": "visceral_fat", + "WAIST": "waist_cm", + "CALORIES": "calories", + "BMR": "bmr", + "TDEE": "tdee", + "HEART_RATE": "heart_rate", + "CHEST": "chest_cm", + "BICEPS": "biceps_cm", + "NECK": "neck_cm", + "THIGH": "thigh_cm", + "HIPS": "hips_cm", + "CALIPER": "caliper_mm", + "CALIPER_1": "caliper_1_mm", + "CALIPER_2": "caliper_2_mm", + "CALIPER_3": "caliper_3_mm", + "LBM": "lbm_kg", + "WHR": "whr", + "WHTR": "whtr", +} + + +def parse_float(value): + if not value: + return None + try: + return round(float(value), 2) + except (ValueError, TypeError): + return None + + +def import_scale_csv(file_path, user_id): + user = get_user_model().objects.get(id=user_id) + weigh_in = Task.find_or_create("Weigh-in") + new_scrobbles = [] + + with open(file_path, newline="", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + for row in reader: + date_str = (row.get("DATE") or "").strip() + time_str = (row.get("TIME") or "").strip() + if not date_str or not time_str: + logger.warning("Skipping row with no DATE or TIME") + continue + + try: + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S.%f") + except ValueError: + try: + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S") + except ValueError: + logger.warning(f"Could not parse date/time: {date_str} {time_str}") + continue + + dt = dt.replace(microsecond=0) + stop = user.profile.get_timestamp_with_tz(dt) + start = stop - timedelta(minutes=5) + + log_dict = {} + for csv_col, log_key in FLOAT_COLUMNS.items(): + val = parse_float(row.get(csv_col)) + if val is not None: + log_dict[log_key] = val + + comment = (row.get("COMMENT") or "").strip() + if comment: + log_dict["comment"] = comment + + existing = Scrobble.objects.filter( + timestamp=start, + task=weigh_in, + user=user, + ).first() + if existing: + logger.debug(f"Skipping existing scrobble for {start}") + continue + + new_scrobble = Scrobble( + user=user, + timestamp=start, + stop_timestamp=stop, + timezone=stop.tzinfo.name, + source="openScale CSV Import", + task=weigh_in, + log=log_dict, + played_to_completion=True, + in_progress=False, + media_type=Scrobble.MediaType.TASK, + ) + new_scrobbles.append(new_scrobble) + + created = Scrobble.objects.bulk_create(new_scrobbles) + logger.info(f"Created {len(created)} weigh-in scrobbles") + return created diff --git a/vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py b/vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py new file mode 100644 index 0000000..2837b60 --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.29 on 2026-05-21 12:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import scrobbles.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("scrobbles", "0076_scrobble_birding_location_alter_scrobble_media_type"), + ] + + operations = [ + migrations.CreateModel( + name="ScaleCSVImport", + 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)), + ( + "csv_file", + models.FileField( + blank=True, + null=True, + upload_to=scrobbles.models.ScaleCSVImport.get_path, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Scale CSV Import", + }, + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 38c8869..895c17f 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -260,6 +260,45 @@ class AudioScrobblerTSVImport(BaseFileImportMixin): self.mark_finished() +class ScaleCSVImport(BaseFileImportMixin): + class Meta: + verbose_name = "Scale CSV Import" + + @property + def import_type(self) -> str: + return "Scale" + + def get_absolute_url(self): + return reverse("scrobbles:scale-csv-import-detail", kwargs={"slug": self.uuid}) + + def get_path(instance, filename): + extension = filename.split(".")[-1] + uuid = instance.uuid + return f"scale-csv-uploads/{uuid}.{extension}" + + @property + def upload_file_path(self): + if getattr(settings, "USE_S3_STORAGE"): + path = self.csv_file.url + else: + path = self.csv_file.path + return path + + csv_file = models.FileField(upload_to=get_path, **BNULL) + + def process(self, force=False): + from scrobbles.importers.scale import import_scale_csv + + if self.processed_finished and not force: + logger.info(f"{self} already processed on {self.processed_finished}") + return + + self.mark_started() + scrobbles = import_scale_csv(self.upload_file_path, self.user.id) + self.record_log(scrobbles) + self.mark_finished() + + class LastFmImport(BaseFileImportMixin): class Meta: verbose_name = "Last.FM Import" diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index 9d1b5ba..8e62fba 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -57,6 +57,11 @@ urlpatterns = [ views.KoReaderImportCreateView.as_view(), name="koreader-file-upload", ), + path( + "upload/scale/", + views.ScaleCSVImportCreateView.as_view(), + name="scale-csv-upload", + ), path( "lastfm-import/", views.lastfm_import, @@ -116,6 +121,11 @@ urlpatterns = [ views.ScrobbleRetroarchImportDetailView.as_view(), name="retroarch-import-detail", ), + path( + "imports/scale//", + views.ScrobbleScaleCSVImportDetailView.as_view(), + name="scale-csv-import-detail", + ), path( "long-plays/", views.ScrobbleLongPlaysView.as_view(), diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 09dd38f..d9ed612 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -72,6 +72,7 @@ from scrobbles.models import ( KoReaderImport, LastFmImport, RetroarchImport, + ScaleCSVImport, Scrobble, ) from scrobbles.scrobblers import * @@ -461,6 +462,9 @@ class ScrobbleImportListView(TemplateView): ).objects.filter( user=self.request.user, ).order_by("-processing_started")[:10] + context_data["scale_csv_imports"] = ScaleCSVImport.objects.filter( + user=self.request.user, + ).order_by("-processing_started")[:10] return context_data @@ -482,6 +486,8 @@ class BaseScrobbleImportDetailView(DetailView): title = "LastFM Import" if self.model == RetroarchImport: title = "Retroarch Import" + if self.model == ScaleCSVImport: + title = "Scale CSV Import" context_data["title"] = title return context_data @@ -502,6 +508,10 @@ class ScrobbleRetroarchImportDetailView(BaseScrobbleImportDetailView): model = RetroarchImport +class ScrobbleScaleCSVImportDetailView(BaseScrobbleImportDetailView): + model = ScaleCSVImport + + class ManualScrobbleView(FormView): form_class = ScrobbleForm template_name = "scrobbles/manual_form.html" @@ -574,6 +584,22 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) +class ScaleCSVImportCreateView( + LoginRequiredMixin, JsonableResponseMixin, CreateView +): + model = ScaleCSVImport + fields = ["csv_file"] + template_name = "scrobbles/upload_form.html" + success_url = reverse_lazy("vrobbler-home") + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.user = self.request.user + self.object.save() + self.object.process() + return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) + + @api_view(["GET"]) @permission_classes([IsAuthenticated]) def lastfm_import(request): diff --git a/vrobbler/templates/scrobbles/import_list.html b/vrobbler/templates/scrobbles/import_list.html index 6d2e268..78b9c3d 100644 --- a/vrobbler/templates/scrobbles/import_list.html +++ b/vrobbler/templates/scrobbles/import_list.html @@ -129,4 +129,35 @@ {% endif %} + +
+

Scale

+ {% if scale_csv_imports %} +
+ + + + + + + + + + {% for obj in scale_csv_imports %} + + + + + + {% endfor %} + +
StartedFinishedScrobbles
{{obj.human_start}}{{obj.processed_finished}}{{obj.process_count}}
+
+ {% endif %} +
+ {% csrf_token %} + + +
+
{% endblock %} diff --git a/vrobbler/templates/scrobbles/scrobble_list.html b/vrobbler/templates/scrobbles/scrobble_list.html index 6199ac5..337f4df 100644 --- a/vrobbler/templates/scrobbles/scrobble_list.html +++ b/vrobbler/templates/scrobbles/scrobble_list.html @@ -183,6 +183,18 @@ +
+ + +