[tasks] Add weigh-in task importer
This commit is contained in:
2
data/scale-example.csv
Normal file
2
data/scale-example.csv
Normal 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,,
|
||||||
|
@ -5,6 +5,7 @@ from scrobbles.models import (
|
|||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
|
ScaleCSVImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
from scrobbles.mixins import Genre
|
||||||
@ -77,6 +78,11 @@ class RetroarchImportAdmin(ImportBaseAdmin):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ScaleCSVImport)
|
||||||
|
class ScaleCSVImportAdmin(ImportBaseAdmin):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Genre)
|
@admin.register(Genre)
|
||||||
class GenreAdmin(admin.ModelAdmin):
|
class GenreAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|||||||
111
vrobbler/apps/scrobbles/importers/scale.py
Normal file
111
vrobbler/apps/scrobbles/importers/scale.py
Normal 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
|
||||||
70
vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py
Normal file
70
vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py
Normal 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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -260,6 +260,45 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
|||||||
self.mark_finished()
|
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 LastFmImport(BaseFileImportMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Last.FM Import"
|
verbose_name = "Last.FM Import"
|
||||||
|
|||||||
@ -57,6 +57,11 @@ urlpatterns = [
|
|||||||
views.KoReaderImportCreateView.as_view(),
|
views.KoReaderImportCreateView.as_view(),
|
||||||
name="koreader-file-upload",
|
name="koreader-file-upload",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"upload/scale/",
|
||||||
|
views.ScaleCSVImportCreateView.as_view(),
|
||||||
|
name="scale-csv-upload",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"lastfm-import/",
|
"lastfm-import/",
|
||||||
views.lastfm_import,
|
views.lastfm_import,
|
||||||
@ -116,6 +121,11 @@ urlpatterns = [
|
|||||||
views.ScrobbleRetroarchImportDetailView.as_view(),
|
views.ScrobbleRetroarchImportDetailView.as_view(),
|
||||||
name="retroarch-import-detail",
|
name="retroarch-import-detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"imports/scale/<slug:slug>/",
|
||||||
|
views.ScrobbleScaleCSVImportDetailView.as_view(),
|
||||||
|
name="scale-csv-import-detail",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"long-plays/",
|
"long-plays/",
|
||||||
views.ScrobbleLongPlaysView.as_view(),
|
views.ScrobbleLongPlaysView.as_view(),
|
||||||
|
|||||||
@ -72,6 +72,7 @@ from scrobbles.models import (
|
|||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
|
ScaleCSVImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
)
|
)
|
||||||
from scrobbles.scrobblers import *
|
from scrobbles.scrobblers import *
|
||||||
@ -461,6 +462,9 @@ class ScrobbleImportListView(TemplateView):
|
|||||||
).objects.filter(
|
).objects.filter(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
).order_by("-processing_started")[:10]
|
).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
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
@ -482,6 +486,8 @@ class BaseScrobbleImportDetailView(DetailView):
|
|||||||
title = "LastFM Import"
|
title = "LastFM Import"
|
||||||
if self.model == RetroarchImport:
|
if self.model == RetroarchImport:
|
||||||
title = "Retroarch Import"
|
title = "Retroarch Import"
|
||||||
|
if self.model == ScaleCSVImport:
|
||||||
|
title = "Scale CSV Import"
|
||||||
context_data["title"] = title
|
context_data["title"] = title
|
||||||
return context_data
|
return context_data
|
||||||
|
|
||||||
@ -502,6 +508,10 @@ class ScrobbleRetroarchImportDetailView(BaseScrobbleImportDetailView):
|
|||||||
model = RetroarchImport
|
model = RetroarchImport
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleScaleCSVImportDetailView(BaseScrobbleImportDetailView):
|
||||||
|
model = ScaleCSVImport
|
||||||
|
|
||||||
|
|
||||||
class ManualScrobbleView(FormView):
|
class ManualScrobbleView(FormView):
|
||||||
form_class = ScrobbleForm
|
form_class = ScrobbleForm
|
||||||
template_name = "scrobbles/manual_form.html"
|
template_name = "scrobbles/manual_form.html"
|
||||||
@ -574,6 +584,22 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create
|
|||||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
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"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def lastfm_import(request):
|
def lastfm_import(request):
|
||||||
|
|||||||
@ -129,4 +129,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@ -183,6 +183,18 @@
|
|||||||
<button type="submit" class="btn btn-primary">Import</button>
|
<button type="submit" class="btn btn-primary">Import</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user