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()