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