[tasks] Add weigh-in task importer
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-05-21 09:09:41 -04:00
parent 2b88f89794
commit 9d3f7f434f
9 changed files with 307 additions and 0 deletions

2
data/scale-example.csv Normal file
View File

@ -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,,
1 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
2 2026-05-20 11:56:58.076 31.09 1706.74 29.084072 3.438837 33.07067 2645.46 54.445187 192.68378

View File

@ -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 = (

View File

@ -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

View File

@ -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",
},
),
]

View File

@ -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"

View File

@ -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/<slug:slug>/",
views.ScrobbleScaleCSVImportDetailView.as_view(),
name="scale-csv-import-detail",
),
path(
"long-plays/",
views.ScrobbleLongPlaysView.as_view(),

View File

@ -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):

View File

@ -129,4 +129,35 @@
</div>
</div>
{% endif %}
<div class="row">
<h3>Scale</h3>
{% if scale_csv_imports %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Started</th>
<th scope="col">Finished</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for obj in scale_csv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.processed_finished}}</td>
<td>{{obj.process_count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<form action="{% url 'scrobbles:scale-csv-upload' %}" method="post" enctype="multipart/form-data" style="margin-top: 10px;">
{% csrf_token %}
<input type="file" name="csv_file" accept=".csv" required>
<input type="submit" value="Import Scale CSV">
</form>
</div>
{% endblock %}

View File

@ -183,6 +183,18 @@
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
<form action="{% url 'scrobbles:scale-csv-upload' %}" method="post" enctype="multipart/form-data" style="margin-top: 10px;">
<div class="modal-body">
{% csrf_token %}
<div class="form-group">
<label for="id_csv_file" class="col-form-label">openScale CSV file:</label>
<input type="file" name="csv_file" class="form-control" id="id_csv_file" accept=".csv" required>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>
</div>
</div>