From fce69c329ef629ffbdb674700492ba0ab2ebf950 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 23 Oct 2025 15:04:29 +0200 Subject: [PATCH] Correctly process weigh entry data as sent by powersync --- extras/docker/development/Dockerfile | 7 ++- uv.lock | 54 ++++++++++++++++++- wger/core/api/views.py | 27 ++++++++-- wger/weight/migrations/0005_add_uuid.py | 37 +++++++++++++ wger/weight/models.py | 26 ++++------ wger/weight/powersync.py | 69 +++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 wger/weight/migrations/0005_add_uuid.py create mode 100644 wger/weight/powersync.py diff --git a/extras/docker/development/Dockerfile b/extras/docker/development/Dockerfile index 41c437cf8..07a56ca03 100644 --- a/extras/docker/development/Dockerfile +++ b/extras/docker/development/Dockerfile @@ -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 diff --git a/uv.lock b/uv.lock index 539e1b2bb..4532a85bc 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/wger/core/api/views.py b/wger/core/api/views.py index 3bf8b292b..6fe2a0ec9 100644 --- a/wger/core/api/views.py +++ b/wger/core/api/views.py @@ -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, ) diff --git a/wger/weight/migrations/0005_add_uuid.py b/wger/weight/migrations/0005_add_uuid.py new file mode 100644 index 000000000..2b368958a --- /dev/null +++ b/wger/weight/migrations/0005_add_uuid.py @@ -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), + ), + ] diff --git a/wger/weight/models.py b/wger/weight/models.py index 13a4a73ec..0213bb9e5 100644 --- a/wger/weight/models.py +++ b/wger/weight/models.py @@ -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 . - - 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'), diff --git a/wger/weight/powersync.py b/wger/weight/powersync.py new file mode 100644 index 000000000..3c5c22100 --- /dev/null +++ b/wger/weight/powersync.py @@ -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 . + +# 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()