diff --git a/extras/docker/development/Dockerfile b/extras/docker/development/Dockerfile
new file mode 100644
index 000000000..0f9861b38
--- /dev/null
+++ b/extras/docker/development/Dockerfile
@@ -0,0 +1,27 @@
+#
+# Installs some additional packages needed for development
+#
+# Note that this dockerfile is built from the corresponding docker-compose file
+#
+
+FROM wger/server:latest
+
+USER root
+
+WORKDIR /home/wger/src
+RUN apt-get update && \
+ apt-get install -y \
+ git \
+ vim \
+ yarnpkg \
+ sassc
+
+COPY ../../../requirements.txt /tmp/requirements.txt
+COPY ../../../requirements_dev.txt /tmp/requirements_dev.txt
+
+RUN ln -s /usr/bin/yarnpkg /usr/bin/yarn \
+ && ln -s /usr/bin/sassc /usr/bin/sass
+
+USER wger
+RUN pip3 install --break-system-packages --user -r /tmp/requirements.txt \
+ && pip3 install --break-system-packages --user -r /tmp/requirements_dev.txt
diff --git a/extras/docker/production/settings.py b/extras/docker/production/settings.py
index 6f005e2ea..81ea07ca5 100644
--- a/extras/docker/production/settings.py
+++ b/extras/docker/production/settings.py
@@ -187,3 +187,10 @@
#
EXPOSE_PROMETHEUS_METRICS = env.bool('EXPOSE_PROMETHEUS_METRICS', False)
PROMETHEUS_URL_PATH = env.str('PROMETHEUS_URL_PATH', 'super-secret-path')
+
+#
+# PowerSync configuration
+#
+POWERSYNC_JWKS_PUBLIC_KEY = env.str('POWERSYNC_JWKS_PUBLIC_KEY', '')
+POWERSYNC_JWKS_PRIVATE_KEY = env.str('POWERSYNC_JWKS_PRIVATE_KEY', '')
+POWERSYNC_URL = env.str('POWERSYNC_URL', 'http://powersync:8080')
diff --git a/manage.py b/manage.py
old mode 100644
new mode 100755
diff --git a/requirements.txt b/requirements.txt
index cdee5bea4..eda164859 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,7 +39,7 @@ tzdata==2024.2
django-cors-headers==4.4.0
django-filter==24.3
djangorestframework==3.15.2
-djangorestframework-simplejwt[crypto]==5.3.1
+djangorestframework-simplejwt[crypto,python-jose]==5.3.1
# Not used anymore, but needed because some modules are imported in DB migration
# files
diff --git a/wger/core/api/views.py b/wger/core/api/views.py
index 6a73e8ed1..4002db436 100644
--- a/wger/core/api/views.py
+++ b/wger/core/api/views.py
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
-
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
@@ -16,11 +15,18 @@
# along with Workout Manager. If not, see .
# Standard Library
+import json
import logging
+import time
+from base64 import urlsafe_b64decode
# Django
from django.conf import settings
from django.contrib.auth.models import User
+from django.http import (
+ HttpResponseForbidden,
+ JsonResponse,
+)
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
@@ -33,11 +39,17 @@
extend_schema,
inline_serializer,
)
+from jose.constants import ALGORITHMS
+from jose.exceptions import JWKError
+from jose.jwt import encode
from rest_framework import (
status,
viewsets,
)
-from rest_framework.decorators import action
+from rest_framework.decorators import (
+ action,
+ api_view,
+)
from rest_framework.fields import (
BooleanField,
CharField,
@@ -47,6 +59,7 @@
IsAuthenticated,
)
from rest_framework.response import Response
+from rest_framework_simplejwt.tokens import AccessToken
# wger
from wger import (
@@ -403,3 +416,65 @@ class RoutineWeightUnitViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = RoutineWeightUnitSerializer
ordering_fields = '__all__'
filterset_fields = ('name',)
+
+
+def create_jwt_token(user_id):
+ power_sync_private_key_bytes = urlsafe_b64decode(settings.POWERSYNC_JWKS_PRIVATE_KEY)
+ power_sync_private_key_json = json.loads(power_sync_private_key_bytes.decode('utf-8'))
+
+ try:
+ jwt_header = {
+ 'alg': power_sync_private_key_json['alg'],
+ 'kid': power_sync_private_key_json['kid'],
+ }
+
+ jwt_payload = {
+ 'sub': user_id,
+ 'iat': time.time(),
+ 'aud': 'powersync',
+ 'exp': int(time.time()) + 300, # 5 minutes expiration
+ }
+
+ token = encode(
+ jwt_payload, power_sync_private_key_json, algorithm=ALGORITHMS.RS256, headers=jwt_header
+ )
+
+ return token
+
+ except (JWKError, ValueError, KeyError) as e:
+ raise Exception(f'Error creating JWT token: {str(e)}')
+
+
+@api_view()
+def get_powersync_token(request):
+ if not request.user.is_authenticated:
+ return HttpResponseForbidden()
+
+ token = create_jwt_token(request.user.id)
+
+ try:
+ return JsonResponse({'token': token, 'powersync_url': settings.POWERSYNC_URL}, status=200)
+ except Exception as e:
+ return JsonResponse({'error': str(e)}, status=500)
+
+
+@api_view()
+def get_powersync_keys(request):
+ power_sync_public_key_bytes = urlsafe_b64decode(settings.POWERSYNC_JWKS_PUBLIC_KEY)
+
+ return JsonResponse(
+ {'keys': [json.loads(power_sync_public_key_bytes.decode('utf-8'))]},
+ status=200,
+ )
+
+
+@api_view()
+def upload_powersync_data(request):
+ if not request.user.is_authenticated:
+ return HttpResponseForbidden()
+
+ logger.debug(request.POST)
+ return JsonResponse(
+ {'ok!'},
+ status=200,
+ )
diff --git a/wger/core/migrations/0018_create_publication_add_ivm_extension.py b/wger/core/migrations/0018_create_publication_add_ivm_extension.py
new file mode 100644
index 000000000..959b6b35a
--- /dev/null
+++ b/wger/core/migrations/0018_create_publication_add_ivm_extension.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.2.13 on 2024-09-19 13:43
+
+from django.db import migrations
+
+from wger.utils.db import postgres_only
+
+
+@postgres_only
+def add_publication(apps, schema_editor):
+ # Note that "FOR ALL TABLES" applies for all tables created in the future as well
+ schema_editor.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication WHERE pubname = 'powersync'
+ ) THEN
+ CREATE PUBLICATION powersync FOR ALL TABLES;
+ END IF;
+ END $$;
+ """
+ )
+
+
+@postgres_only
+def remove_publication(apps, schema_editor):
+ schema_editor.execute('DROP PUBLICATION IF EXISTS powersync;')
+
+
+@postgres_only
+def add_ivm_extension(apps, schema_editor):
+ schema_editor.execute('CREATE EXTENSION IF NOT EXISTS pg_ivm;')
+
+
+@postgres_only
+def remove_ivm_extension(apps, schema_editor):
+ schema_editor.execute('DROP EXTENSION IF EXISTS pg_ivm;')
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('core', '0017_language_full_name_en'),
+ ]
+
+ operations = [
+ migrations.RunPython(add_publication, reverse_code=remove_publication),
+ migrations.RunPython(add_ivm_extension, reverse_code=remove_ivm_extension),
+ ]
diff --git a/wger/nutrition/management/commands/dummy-generator-nutrition.py b/wger/nutrition/management/commands/dummy-generator-nutrition.py
index 61eccc4bd..5b20cecac 100644
--- a/wger/nutrition/management/commands/dummy-generator-nutrition.py
+++ b/wger/nutrition/management/commands/dummy-generator-nutrition.py
@@ -107,11 +107,12 @@ def handle(self, **options):
self.stdout.write(f' created plan {plan.description}')
# Add meals
- for _ in range(0, meals_per_plan):
+ for i in range(0, meals_per_plan):
order = 1
meal = Meal(
plan=plan,
order=order,
+ name=f'Dummy meal {i}',
time=datetime.time(hour=randint(0, 23), minute=randint(0, 59)),
)
meal.save()
diff --git a/wger/nutrition/migrations/0025_add_uuids.py b/wger/nutrition/migrations/0025_add_uuids.py
new file mode 100644
index 000000000..695c0c314
--- /dev/null
+++ b/wger/nutrition/migrations/0025_add_uuids.py
@@ -0,0 +1,122 @@
+# Generated by Django 4.2.16 on 2024-10-19 21:04
+
+from django.db import migrations, models
+import uuid
+
+
+def gen_uuids(apps, schema_editor):
+ NutritionPlan = apps.get_model('nutrition', 'NutritionPlan')
+ Meal = apps.get_model('nutrition', 'Meal')
+ MealItem = apps.get_model('nutrition', 'MealItem')
+ LogItem = apps.get_model('nutrition', 'LogItem')
+
+ for item in LogItem.objects.all():
+ item.uuid = uuid.uuid4()
+ item.save(update_fields=['uuid'])
+
+ for item in MealItem.objects.all():
+ item.uuid = uuid.uuid4()
+ item.save(update_fields=['uuid'])
+
+ for meal in Meal.objects.all():
+ meal.uuid = uuid.uuid4()
+ meal.save(update_fields=['uuid'])
+
+ for plan in NutritionPlan.objects.all():
+ plan.uuid = uuid.uuid4()
+ plan.save(update_fields=['uuid'])
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('nutrition', '0024_remove_ingredient_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='nutritionplan',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=True,
+ unique=False,
+ ),
+ ),
+ migrations.AddField(
+ model_name='meal',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=True,
+ unique=False,
+ ),
+ ),
+ migrations.AddField(
+ model_name='mealitem',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=True,
+ unique=False,
+ ),
+ ),
+ migrations.AddField(
+ model_name='logitem',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=True,
+ unique=False,
+ ),
+ ),
+ # Generate UUIDs
+ migrations.RunPython(
+ gen_uuids,
+ reverse_code=migrations.RunPython.noop,
+ ),
+ # Set uuid fields to non-nullable
+ migrations.AlterField(
+ model_name='nutritionplan',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name='meal',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name='mealitem',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name='logitem',
+ name='uuid',
+ field=models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ ),
+ ),
+ ]
diff --git a/wger/nutrition/migrations/0026_create_ivm_entries.py b/wger/nutrition/migrations/0026_create_ivm_entries.py
new file mode 100644
index 000000000..e04339146
--- /dev/null
+++ b/wger/nutrition/migrations/0026_create_ivm_entries.py
@@ -0,0 +1,118 @@
+from django.db import migrations
+
+from wger.utils.db import postgres_only
+
+
+@postgres_only
+def add_ivm_views(apps, schema_editor):
+ """
+ Note: the select statements are written a bit weirdly because of this issue
+ https://github.com/sraoss/pg_ivm/issues/85
+
+ When this is resolved, we can remove the subqueries and write e.g.
+
+ SELECT m.*, p.user_id
+ FROM nutrition_meal AS m
+ JOIN nutrition_nutritionplan AS p ON m.plan_id = p.id;
+ """
+
+ schema_editor.execute(
+ """
+ SELECT create_immv(
+ 'ivm_nutrition_nutritionplan',
+ 'SELECT
+ p.uuid AS id,
+ p.id AS remote_id,
+ creation_date,
+ description,
+ has_goal_calories,
+ user_id,
+ only_logging,
+ goal_carbohydrates,
+ goal_energy,
+ goal_fat,
+ goal_protein,
+ goal_fiber
+ FROM nutrition_nutritionplan AS p;'
+ );
+ """
+ )
+
+ schema_editor.execute(
+ """
+ SELECT create_immv(
+ 'ivm_nutrition_meal',
+ 'SELECT
+ m.uuid AS id,
+ m.id AS remote_id,
+ "order",
+ time,
+ p.uuid AS plan_id,
+ name,
+ p.user_id
+ FROM (SELECT * FROM nutrition_meal) AS m
+ JOIN (SELECT id, uuid, user_id FROM nutrition_nutritionplan) AS p ON m.plan_id = p.id;'
+ );
+ """
+ )
+
+ schema_editor.execute(
+ """
+ SELECT create_immv(
+ 'ivm_nutrition_mealitem',
+ 'SELECT
+ mi.uuid AS id,
+ mi.id AS remote_id,
+ "order",
+ amount,
+ ingredient_id,
+ m.uuid AS meal_id,
+ weight_unit_id,
+ p.user_id
+ FROM (SELECT * FROM nutrition_mealitem) AS mi
+ JOIN (SELECT id, uuid, plan_id FROM nutrition_meal) AS m ON mi.meal_id = m.id
+ JOIN (SELECT id, user_id FROM nutrition_nutritionplan) AS p ON m.plan_id = p.id;'
+ );
+ """
+ )
+
+ schema_editor.execute(
+ """
+ SELECT create_immv(
+ 'ivm_nutrition_logitem',
+ 'SELECT
+ li.uuid as id,
+ li.id AS remote_id,
+ datetime,
+ comment,
+ amount,
+ ingredient_id,
+ p.uuid AS plan_id,
+ weight_unit_id,
+ m.uuid AS meal_id,
+ p.user_id
+ FROM (SELECT * FROM nutrition_logitem) AS li
+ JOIN (SELECT id, uuid FROM nutrition_meal) AS m ON li.meal_id = m.id
+ JOIN (SELECT id, uuid, user_id FROM nutrition_nutritionplan) AS p ON li.plan_id = p.id;'
+ );
+ """
+ )
+
+
+@postgres_only
+def remove_ivm_views(apps, schema_editor):
+ schema_editor.execute('DROP TABLE IF EXISTS ivm_nutrition_nutritionplan;')
+ schema_editor.execute('DROP TABLE IF EXISTS ivm_nutrition_meal;')
+ schema_editor.execute('DROP TABLE IF EXISTS ivm_nutrition_mealitem;')
+ schema_editor.execute('DROP TABLE IF EXISTS ivm_nutrition_logitem;')
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('nutrition', '0025_add_uuids'),
+ ('core', '0018_create_publication_add_ivm_extension'),
+ ]
+
+ operations = [
+ migrations.RunPython(add_ivm_views, reverse_code=remove_ivm_views),
+ ]
diff --git a/wger/nutrition/models/log.py b/wger/nutrition/models/log.py
index 0f51d3905..4ba14c475 100644
--- a/wger/nutrition/models/log.py
+++ b/wger/nutrition/models/log.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
# Standard Library
+import uuid
from decimal import Decimal
# Django
@@ -47,6 +48,13 @@ class Meta:
'-datetime',
]
+ uuid = models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ )
+
plan = models.ForeignKey(
NutritionPlan,
verbose_name=_('Nutrition plan'),
diff --git a/wger/nutrition/models/meal.py b/wger/nutrition/models/meal.py
index b32185c73..0a6dfd11a 100644
--- a/wger/nutrition/models/meal.py
+++ b/wger/nutrition/models/meal.py
@@ -16,6 +16,7 @@
# Standard Library
import logging
+import uuid
# Django
from django.db import models
@@ -43,6 +44,13 @@ class Meta:
'time',
]
+ uuid = models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ )
+
plan = models.ForeignKey(
NutritionPlan,
verbose_name=_('Nutrition plan'),
diff --git a/wger/nutrition/models/meal_item.py b/wger/nutrition/models/meal_item.py
index a2b64147b..0e266f4fe 100644
--- a/wger/nutrition/models/meal_item.py
+++ b/wger/nutrition/models/meal_item.py
@@ -16,6 +16,7 @@
# Standard Library
import logging
+import uuid
from decimal import Decimal
# Django
@@ -43,6 +44,13 @@ class MealItem(BaseMealItem, models.Model):
An item (component) of a meal
"""
+ uuid = models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ )
+
meal = models.ForeignKey(
Meal,
verbose_name=_('Nutrition plan'),
diff --git a/wger/nutrition/models/plan.py b/wger/nutrition/models/plan.py
index 8cea374fb..949b4d0af 100644
--- a/wger/nutrition/models/plan.py
+++ b/wger/nutrition/models/plan.py
@@ -17,7 +17,7 @@
# Standard Library
import datetime
import logging
-from decimal import Decimal
+import uuid
# Django
from django.contrib.auth.models import User
@@ -30,7 +30,6 @@
from wger.nutrition.consts import ENERGY_FACTOR
from wger.nutrition.helpers import NutritionalValues
from wger.utils.cache import cache_mapper
-from wger.utils.constants import TWOPLACES
from wger.weight.models import WeightEntry
@@ -49,6 +48,13 @@ class Meta:
'-creation_date',
]
+ uuid = models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ null=False,
+ unique=True,
+ )
+
user = models.ForeignKey(
User,
verbose_name=_('User'),
diff --git a/wger/urls.py b/wger/urls.py
index 9e83e30a9..8b16d06fd 100644
--- a/wger/urls.py
+++ b/wger/urls.py
@@ -277,6 +277,21 @@
core_api_views.RequiredApplicationVersionView.as_view({'get': 'get'}),
name='min_app_version',
),
+ path(
+ 'api/v2/powersync-token',
+ core_api_views.get_powersync_token,
+ name='get_token',
+ ),
+ path(
+ 'api/v2/powersync-keys',
+ core_api_views.get_powersync_keys,
+ name='powersync-keys',
+ ),
+ path(
+ 'api/v2/upload-powersync-data',
+ core_api_views.upload_powersync_data,
+ name='powersync-data',
+ ),
# Api documentation
path(
'api/v2/schema',
diff --git a/wger/utils/db.py b/wger/utils/db.py
index 2d15b5c74..3e12938b9 100644
--- a/wger/utils/db.py
+++ b/wger/utils/db.py
@@ -12,9 +12,25 @@
#
# You should have received a copy of the GNU Affero General Public License
+# Standard Library
+from functools import wraps
+
# Django
from django.conf import settings
def is_postgres_db():
return 'postgres' in settings.DATABASES['default']['ENGINE']
+
+
+def postgres_only(func):
+ """Decorator that runs the decorated function only if the database is PostgreSQL."""
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if is_postgres_db():
+ return func(*args, **kwargs)
+ else:
+ return
+
+ return wrapper