Correctly process weigh entry data as sent by powersync

This commit is contained in:
Roland Geider
2025-10-23 15:04:29 +02:00
parent 241e5883ab
commit fce69c329e
6 changed files with 195 additions and 25 deletions

View File

@@ -10,15 +10,14 @@ USER root
WORKDIR /home/wger/src
RUN wget -O- https://deb.nodesource.com/setup_22.x | bash - \
apt update && \
apt install --no-install-recommends -y \
&& apt update \
&& apt install --no-install-recommends -y \
git \
vim \
nodejs \
sassc \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s /usr/bin/sassc /usr/bin/sass
&& npm install -g sass
USER wger
COPY --chown=wger:wger . /home/wger/src

54
uv.lock generated
View File

@@ -783,6 +783,9 @@ wheels = [
crypto = [
{ name = "cryptography" },
]
python-jose = [
{ name = "python-jose" },
]
[[package]]
name = "drf-spectacular"
@@ -831,6 +834,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/90/be137d0592501ec08d4a00c8430121e95dfe6c8114a1d4436b5cdd6b9803/easy_thumbnails-2.10.1-py3-none-any.whl", hash = "sha256:24462d63dd31543ef1585538b2bfefe0db96d3409bb431c70b81548fb2cfc5be", size = 79695, upload-time = "2025-08-17T20:49:09.119Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "faker"
version = "37.8.0"
@@ -1224,6 +1239,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/90/422ffbbeeb9418c795dae2a768db860401446af0c6768bc061ce22325f58/psycopg-3.2.10-py3-none-any.whl", hash = "sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3", size = 206586, upload-time = "2025-09-08T09:07:50.121Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
@@ -1365,6 +1389,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/bd/ccd7416fdb30f104ddf6cfd8ee9f699441c7d9880a26f9b3089438adee05/python_ipware-3.0.0-py3-none-any.whl", hash = "sha256:fc936e6e7ec9fcc107f9315df40658f468ac72f739482a707181742882e36b60", size = 10761, upload-time = "2024-04-19T20:00:57.171Z" },
]
[[package]]
name = "python-jose"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/19/b2c86504116dc5f0635d29f802da858404d77d930a25633d2e86a64a35b3/python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a", size = 129068, upload-time = "2021-06-05T03:30:40.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/2d/e94b2f7bab6773c70efc70a61d66e312e1febccd9e0db6b9e0adf58cbad1/python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a", size = 33530, upload-time = "2021-06-05T03:30:38.099Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
@@ -1659,6 +1697,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.13.2"
@@ -1912,7 +1962,7 @@ dependencies = [
{ name = "django-sortedm2m" },
{ name = "django-storages" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt", extra = ["crypto"] },
{ name = "djangorestframework-simplejwt", extra = ["crypto", "python-jose"] },
{ name = "drf-spectacular", extra = ["sidecar"] },
{ name = "easy-thumbnails" },
{ name = "flower" },
@@ -1969,7 +2019,7 @@ requires-dist = [
{ name = "django-sortedm2m", specifier = "==4.0.0" },
{ name = "django-storages", specifier = "==1.14.6" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "djangorestframework-simplejwt", extras = ["crypto"], specifier = "==5.5.1" },
{ name = "djangorestframework-simplejwt", extras = ["crypto", "python-jose"], specifier = "==5.5.1" },
{ name = "drf-spectacular", extras = ["sidecar"], specifier = "==0.28.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
{ name = "flower", specifier = "==2.0.1" },

View File

@@ -63,6 +63,7 @@ from rest_framework.response import Response
from rest_framework_simplejwt.tokens import AccessToken
# wger
import wger.weight.powersync as ps_weight
from wger.core.api.serializers import (
LanguageCheckSerializer,
LanguageSerializer,
@@ -484,13 +485,33 @@ def get_powersync_keys(request):
)
@api_view()
@api_view(['PUT', 'PATCH', 'DELETE'])
def upload_powersync_data(request):
if not request.user.is_authenticated:
return HttpResponseForbidden()
logger.debug(request.POST)
user_id = request.user.id
data = request.data
# get the http verb
http_verb = request.method
logger.info(f'Received PowerSync data: {data} via {http_verb} for user {user_id}')
match data['table']:
case 'weight_weightentry':
if http_verb == 'PUT':
ps_weight.handle_create(payload=data['data'], user_id=user_id)
elif http_verb == 'PATCH':
ps_weight.handle_update(payload=data['data'], user_id=user_id)
elif http_verb == 'DELETE':
ps_weight.handle_delete(payload=data['data'], user_id=user_id)
case _:
logger.warning('Received unknown PowerSync table')
raise ValueError('Unknown PowerSync table')
return JsonResponse(
{'ok!'},
{'status': 'ok!'},
status=200,
)

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.7 on 2025-10-21 10:11
import uuid
from django.db import migrations, models
def gen_uuids(apps, schema_editor):
WeightEntry = apps.get_model('weight', 'WeightEntry')
for entry in WeightEntry.objects.all():
entry.uuid = uuid.uuid4()
entry.save(update_fields=['uuid'])
class Migration(migrations.Migration):
dependencies = [
('weight', '0004_multiple_weight_entries_per_day'),
]
operations = [
migrations.AddField(
model_name='weightentry',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
# Generate UUIDs
migrations.RunPython(
gen_uuids,
reverse_code=migrations.RunPython.noop,
),
# Set uuid fields to non-nullable
migrations.AlterField(
model_name='weightentry',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False, null=False),
),
]

View File

@@ -16,6 +16,7 @@
# Standard Library
from decimal import Decimal
from uuid import uuid4
# Django
from django.contrib.auth.models import User
@@ -27,34 +28,27 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Workout Manager. If not, see <http://www.gnu.org/licenses/>.
class WeightEntry(models.Model):
"""
Model for a weight point
"""
date = models.DateTimeField(verbose_name=_('Date'))
uuid = models.UUIDField(
default=uuid4,
editable=False,
null=True,
unique=False,
)
weight = models.DecimalField(
verbose_name=_('Weight'),
max_digits=5,
decimal_places=2,
validators=[MinValueValidator(Decimal(30)), MaxValueValidator(Decimal(600))],
)
user = models.ForeignKey(
User,
verbose_name=_('User'),

69
wger/weight/powersync.py Normal file
View File

@@ -0,0 +1,69 @@
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Workout Manager. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import logging
# wger
from wger.weight.api.serializers import WeightEntrySerializer
from wger.weight.models import WeightEntry
logger = logging.getLogger(__name__)
def handle_update(payload: dict[str, any], user_id: int) -> None:
"""Handle a push event from PowerSync"""
logger.debug(
f'Received PowerSync payload for update: {payload}',
)
entry = WeightEntry.objects.get(uuid=payload['id'], user_id=user_id)
if not entry:
logger.warning(
f'WeightEntry with UUID {payload["id"]} and user {user_id} not found for update.'
)
return
serializer = WeightEntrySerializer(entry, data=payload, partial=True)
if serializer.is_valid():
serializer.save()
logger.info(f'Updated WeightEntry {entry.pk} (uuid={entry.uuid}) for user {user_id}')
else:
logger.warning(f'PowerSync update validation failed: {serializer.errors}')
def handle_create(payload: dict[str, any], user_id: int) -> None:
"""Handle a create event from PowerSync"""
logger.debug(
f'Received PowerSync payload for create: {payload}',
)
serializer = WeightEntrySerializer(data=payload)
if serializer.is_valid():
serializer.save()
else:
logger.warning(f'PowerSync create validation failed: {serializer.errors}')
def handle_delete(payload: dict[str, any], user_id: int) -> None:
"""Handle a delete event from PowerSync"""
logger.debug(
f'Received PowerSync payload for delete: {payload}',
)
entry = WeightEntry.objects.get(uuid=payload['id'], user_id=user_id)
if not entry:
logger.warning(f'WeightEntry with UUID {payload["uuid"]} not found for delete.')
return
entry.delete()