diff --git a/cookbook/models.py b/cookbook/models.py index 3c9b69d1d..cf207c58c 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -126,7 +126,7 @@ class TreeModel(MP_Node): return None @property - def full_name(self): + def full_name(self) -> str: """ Returns a string representation of a tree node and it's ancestors, e.g. 'Cuisine > Asian > Chinese > Catonese'. @@ -1461,19 +1461,21 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis UNIT_REPLACE = 'UNIT_REPLACE' NAME_REPLACE = 'NAME_REPLACE' + automation_types = ( + (FOOD_ALIAS, _('Food Alias')), + (UNIT_ALIAS, _('Unit Alias')), + (KEYWORD_ALIAS, _('Keyword Alias')), + (DESCRIPTION_REPLACE, _('Description Replace')), + (INSTRUCTION_REPLACE, _('Instruction Replace')), + (NEVER_UNIT, _('Never Unit')), + (TRANSPOSE_WORDS, _('Transpose Words')), + (FOOD_REPLACE, _('Food Replace')), + (UNIT_REPLACE, _('Unit Replace')), + (NAME_REPLACE, _('Name Replace')), + ) + type = models.CharField(max_length=128, - choices=( - (FOOD_ALIAS, _('Food Alias')), - (UNIT_ALIAS, _('Unit Alias')), - (KEYWORD_ALIAS, _('Keyword Alias')), - (DESCRIPTION_REPLACE, _('Description Replace')), - (INSTRUCTION_REPLACE, _('Instruction Replace')), - (NEVER_UNIT, _('Never Unit')), - (TRANSPOSE_WORDS, _('Transpose Words')), - (FOOD_REPLACE, _('Food Replace')), - (UNIT_REPLACE, _('Unit Replace')), - (NAME_REPLACE, _('Name Replace')), - )) + choices=automation_types) name = models.CharField(max_length=128, default='') description = models.TextField(blank=True, null=True) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index e87284de9..de8658c94 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -4,7 +4,7 @@ from decimal import Decimal from gettext import gettext as _ from html import escape from smtplib import SMTPException - +from drf_spectacular.utils import extend_schema_field from django.forms.models import model_to_dict from django.contrib.auth.models import AnonymousUser, Group, User from django.core.cache import caches @@ -75,7 +75,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): images = None image = serializers.SerializerMethodField('get_image') - numrecipe = serializers.ReadOnlyField(source='recipe_count') + numrecipe = serializers.IntegerField(source='recipe_count', read_only=True) def get_fields(self, *args, **kwargs): fields = super().get_fields(*args, **kwargs) @@ -119,6 +119,7 @@ class OpenDataModelMixin(serializers.ModelSerializer): return super().update(instance, validated_data) +@extend_schema_field(float) class CustomDecimalField(serializers.Field): """ Custom decimal field to normalize useless decimal places @@ -142,6 +143,7 @@ class CustomDecimalField(serializers.Field): raise ValidationError('A valid number is required') +@extend_schema_field(bool) class CustomOnHandField(serializers.Field): def get_attribute(self, instance): return instance @@ -186,6 +188,7 @@ class SpaceFilterSerializer(serializers.ListSerializer): class UserSerializer(WritableNestedModelSerializer): display_name = serializers.SerializerMethodField('get_user_label') + @extend_schema_field(str) def get_user_label(self, obj): return obj.get_user_display_name() @@ -229,9 +232,11 @@ class UserFileSerializer(serializers.ModelSerializer): file_download = serializers.SerializerMethodField('get_download_link') preview = serializers.SerializerMethodField('get_preview_link') + @extend_schema_field(str) def get_download_link(self, obj): return self.context['request'].build_absolute_uri(reverse('api_download_file', args={obj.pk})) + @extend_schema_field(str) def get_preview_link(self, obj): try: Image.open(obj.file.file.file) @@ -277,9 +282,11 @@ class UserFileViewSerializer(serializers.ModelSerializer): file_download = serializers.SerializerMethodField('get_download_link') preview = serializers.SerializerMethodField('get_preview_link') + @extend_schema_field(str) def get_download_link(self, obj): return self.context['request'].build_absolute_uri(reverse('api_download_file', args={obj.pk})) + @extend_schema_field(str) def get_preview_link(self, obj): try: Image.open(obj.file.file.file) @@ -316,12 +323,15 @@ class SpaceSerializer(WritableNestedModelSerializer): logo_color_512 = UserFileViewSerializer(required=False, many=False, allow_null=True) logo_color_svg = UserFileViewSerializer(required=False, many=False, allow_null=True) + @extend_schema_field(int) def get_user_count(self, obj): return UserSpace.objects.filter(space=obj).count() + @extend_schema_field(int) def get_recipe_count(self, obj): return Recipe.objects.filter(space=obj).count() + @extend_schema_field(float) def get_file_size_mb(self, obj): try: return UserFile.objects.filter(space=obj).aggregate(Sum('file_size_kb'))['file_size_kb__sum'] / 1000 @@ -390,9 +400,11 @@ class UserPreferenceSerializer(WritableNestedModelSerializer): food_children_exist = serializers.SerializerMethodField('get_food_children_exist') image = UserFileViewSerializer(required=False, allow_null=True, many=False) + @extend_schema_field(FoodInheritFieldSerializer) def get_food_inherit_defaults(self, obj): return FoodInheritFieldSerializer(obj.user.get_active_space().food_inherit.all(), many=True).data + @extend_schema_field(bool) def get_food_children_exist(self, obj): space = getattr(self.context.get('request', None), 'space', None) return Food.objects.filter(depth__gt=0, space=space).exists() @@ -479,22 +491,24 @@ class SyncLogSerializer(SpacedModelSerializer): class KeywordLabelSerializer(serializers.ModelSerializer): label = serializers.SerializerMethodField('get_label') + @extend_schema_field(str) def get_label(self, obj): return str(obj) class Meta: list_serializer_class = SpaceFilterSerializer model = Keyword - fields = ( - 'id', 'label', - ) + fields = ('id', 'label') read_only_fields = ('id', 'label') class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): label = serializers.SerializerMethodField('get_label', allow_null=False) + parent = IntegerField(read_only=True) + recipe_filter = 'keywords' + @extend_schema_field(str) def get_label(self, obj): return str(obj) @@ -618,6 +632,7 @@ class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer): class RecipeSimpleSerializer(WritableNestedModelSerializer): url = serializers.SerializerMethodField('get_url') + @extend_schema_field(str) def get_url(self, obj): return reverse('view_recipe', args=[obj.id]) @@ -658,12 +673,13 @@ class FoodSimpleSerializer(serializers.ModelSerializer): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin, OpenDataModelMixin): supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) - shopping = serializers.ReadOnlyField(source='shopping_status') + shopping = serializers.CharField(source='shopping_status', read_only=True) inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) child_inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) food_onhand = CustomOnHandField(required=False, allow_null=True) substitute_onhand = serializers.SerializerMethodField('get_substitute_onhand') substitute = FoodSimpleSerializer(many=True, allow_null=True, required=False) + parent = IntegerField(read_only=True) properties = PropertySerializer(many=True, allow_null=True, required=False) properties_food_unit = UnitSerializer(allow_null=True, required=False) @@ -672,6 +688,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR recipe_filter = 'steps__ingredients__food' images = ['recipe__image'] + @extend_schema_field(bool) def get_substitute_onhand(self, obj): if not self.context["request"].user.is_authenticated: return [] @@ -769,12 +786,9 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR class Meta: model = Food fields = ( - 'id', 'name', 'plural_name', 'description', 'shopping', 'recipe', 'url', - 'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id', - 'food_onhand', 'supermarket_category', - 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping', - 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', - 'open_data_slug', + 'id', 'name', 'plural_name', 'description', 'shopping', 'recipe', 'url', 'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id', + 'food_onhand', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping', + 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug', ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') @@ -786,6 +800,7 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer): amount = CustomDecimalField() conversions = serializers.SerializerMethodField('get_conversions') + @extend_schema_field(list) def get_used_in_recipes(self, obj): used_in = [] for s in obj.step_set.all(): @@ -793,6 +808,7 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer): used_in.append({'id': r.id, 'name': r.name}) return used_in + @extend_schema_field(list) def get_conversions(self, obj): if obj.unit and obj.food: uch = UnitConversionHelper(self.context['request'].space) @@ -837,12 +853,16 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin): validated_data['space'] = self.context['request'].space return super().create(validated_data) + @extend_schema_field(str) def get_instructions_markdown(self, obj): return obj.get_instruction_render() + @extend_schema_field(serializers.ListField) def get_step_recipes(self, obj): return list(obj.recipe_set.values_list('id', flat=True).all()) + # couldn't set proper serializer StepRecipeSerializer because of circular reference + @extend_schema_field(serializers.JSONField) def get_step_recipe_data(self, obj): # check if root type is recipe to prevent infinite recursion # can be improved later to allow multi level embedding @@ -852,8 +872,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin): class Meta: model = Step fields = ( - 'id', 'name', 'instruction', 'ingredients', 'instructions_markdown', - 'time', 'order', 'show_as_header', 'file', 'step_recipe', + 'id', 'name', 'instruction', 'ingredients', 'instructions_markdown', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 'step_recipe_data', 'numrecipe', 'show_ingredients_table' ) @@ -863,9 +882,7 @@ class StepRecipeSerializer(WritableNestedModelSerializer): class Meta: model = Recipe - fields = ( - 'id', 'name', 'steps', - ) + fields = ('id', 'name', 'steps') class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin): @@ -876,6 +893,7 @@ class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin base_amount = CustomDecimalField() converted_amount = CustomDecimalField() + @extend_schema_field(str) def get_conversion_name(self, obj): text = f'{round(obj.base_amount)} {obj.base_unit} ' if obj.food: @@ -917,6 +935,7 @@ class NutritionInformationSerializer(serializers.ModelSerializer): class RecipeBaseSerializer(WritableNestedModelSerializer): # TODO make days of new recipe a setting + @extend_schema_field(bool) def is_recipe_new(self, obj): if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)): return True @@ -934,7 +953,7 @@ class CommentSerializer(serializers.ModelSerializer): class RecipeOverviewSerializer(RecipeBaseSerializer): keywords = KeywordLabelSerializer(many=True, read_only=True) new = serializers.SerializerMethodField('is_recipe_new', read_only=True) - recent = serializers.ReadOnlyField() + recent = serializers.CharField(read_only=True) rating = CustomDecimalField(required=False, allow_null=True, read_only=True) last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True) @@ -967,6 +986,7 @@ class RecipeSerializer(RecipeBaseSerializer): last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True) food_properties = serializers.SerializerMethodField('get_food_properties') + @extend_schema_field(serializers.JSONField) def get_food_properties(self, obj): fph = FoodPropertyHelper(obj.space) # initialize with object space since recipes might be viewed anonymously return fph.calculate_recipe_properties(obj) @@ -974,12 +994,9 @@ class RecipeSerializer(RecipeBaseSerializer): class Meta: model = Recipe fields = ( - 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', - 'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url', - 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', - 'file_path', 'servings_text', 'rating', - 'last_cooked', - 'private', 'shared', + 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url', + 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating', + 'last_cooked', 'private', 'shared' ) read_only_fields = ['image', 'created_by', 'created_at', 'food_properties'] @@ -1041,9 +1058,11 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer): book_content = serializers.SerializerMethodField(method_name='get_book_content', read_only=True) recipe_content = serializers.SerializerMethodField(method_name='get_recipe_content', read_only=True) + @extend_schema_field(RecipeBookSerializer) def get_book_content(self, obj): return RecipeBookSerializer(context={'request': self.context['request']}).to_representation(obj.book) + @extend_schema_field(RecipeOverviewSerializer) def get_recipe_content(self, obj): return RecipeOverviewSerializer(context={'request': self.context['request']}).to_representation(obj.recipe) @@ -1063,9 +1082,9 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer): class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): recipe = RecipeOverviewSerializer(required=False, allow_null=True) - recipe_name = serializers.ReadOnlyField(source='recipe.name') + recipe_name = serializers.CharField(source='recipe.name', read_only=True) meal_type = MealTypeSerializer() - meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed + meal_type_name = serializers.CharField(source='meal_type.name', read_only=True) # TODO deprecate once old meal plan was removed note_markdown = serializers.SerializerMethodField('get_note_markdown') servings = CustomDecimalField() shared = UserSerializer(many=True, required=False, allow_null=True) @@ -1073,9 +1092,11 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): to_date = serializers.DateField(required=False) + @extend_schema_field(str) def get_note_markdown(self, obj): return markdown(obj.note) + @extend_schema_field(bool) def in_shopping(self, obj): return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists() @@ -1119,6 +1140,7 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name') servings = CustomDecimalField() + @extend_schema_field(str) def get_name(self, obj): if not isinstance(value := obj.servings, Decimal): value = Decimal(value) @@ -1372,6 +1394,7 @@ class AccessTokenSerializer(serializers.ModelSerializer): validated_data['user'] = self.context['request'].user return super().create(validated_data) + @extend_schema_field(str) def get_token(self, obj): if (timezone.now() - obj.created).seconds < 15: return obj.token diff --git a/cookbook/views/api.py b/cookbook/views/api.py index c766d483c..c459c5f85 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -223,9 +223,12 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin): class MergeMixin(ViewSetMixin): + @extend_schema(parameters=[ + OpenApiParameter(name="target", description='The ID of the {obj} you want to merge with.', type=OpenApiTypes.INT, location=OpenApiParameter.PATH) + ]) @decorators.action(detail=True, url_path='merge/(?P[^/.]+)', methods=['PUT'], ) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) - def merge(self, request, pk, target): + def merge(self, request, pk, target: int): self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]." try: @@ -294,12 +297,16 @@ class MergeMixin(ViewSetMixin): return Response(content, status=status.HTTP_400_BAD_REQUEST) -@extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='root', description='Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.', type=int), - OpenApiParameter(name='tree', description='Return all self and children of {} with ID [int].', type=int), -])) +@extend_schema_view( + list=extend_schema(parameters=[ + OpenApiParameter(name='root', description='Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.', type=int), + OpenApiParameter(name='tree', description='Return all self and children of {obj} with ID [int].', type=int), + ]), + move=extend_schema(parameters=[ + OpenApiParameter(name="parent", description='The ID of the desired parent of the {obj}.', type=OpenApiTypes.INT, location=OpenApiParameter.PATH) + ]) +) class TreeMixin(MergeMixin, FuzzyFilterMixin): - # schema = TreeSchema() model = None def get_queryset(self): @@ -329,9 +336,10 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin): return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True) + @decorators.action(detail=True, url_path='move/(?P[^/.]+)', methods=['PUT'], ) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) - def move(self, request, pk, parent): + def move(self, request, pk, parent: int): self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root." if self.model.node_order_by: node_location = 'sorted' @@ -775,6 +783,7 @@ class MealPlanViewSet(viewsets.ModelViewSet): return meal_plans_to_ical(self.get_queryset(), f'meal_plan_{from_date}-{to_date}.ics') +# TODO create proper schema class AutoPlanViewSet(viewsets.ViewSet): http_method_names = ['post', 'options'] @@ -1273,25 +1282,6 @@ class UserFileViewSet(StandardFilterModelViewSet): class AutomationViewSet(StandardFilterModelViewSet): - """ - list: - optional parameters - - - **automation_type**: Return the Automations matching the automation type. Multiple values allowed. - - *Automation Types:* - - FS: Food Alias - - UA: Unit Alias - - KA: Keyword Alias - - DR: Description Replace - - IR: Instruction Replace - - NU: Never Unit - - TW: Transpose Words - - FR: Food Replace - - UR: Unit Replace - - NR: Name Replace - """ - queryset = Automation.objects serializer_class = AutomationSerializer permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] @@ -1311,15 +1301,20 @@ class AutomationViewSet(StandardFilterModelViewSet): } @extend_schema( - parameters=[OpenApiParameter(name='automation_type', description=_('Return the Automations matching the automation type. Multiple values allowed.'), type=str)] + parameters=[OpenApiParameter( + name='type', + description=_('Return the Automations matching the automation type. Multiple values allowed.'), + type=str, + enum=[a[0] for a in Automation.automation_types]) + ] ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) def get_queryset(self): - automation_type = self.request.query_params.getlist('automation_type', []) + automation_type = self.request.query_params.getlist('type', []) if automation_type: - self.queryset = self.queryset.filter(type__in=[self.auto_type[x.upper()] for x in automation_type]) + self.queryset = self.queryset.filter(type__in=automation_type) self.queryset = self.queryset.filter(space=self.request.space).all() return super().get_queryset() @@ -1397,6 +1392,7 @@ class CustomAuthToken(ObtainAuthToken): }) +# TODO implement proper schema https://drf-spectacular.readthedocs.io/en/latest/customization.html#replace-views-with-openapiviewextension class RecipeUrlImportView(APIView): throttle_classes = [RecipeImportThrottle] permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] @@ -1575,6 +1571,7 @@ def import_files(request): return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST) +# TODO implement schema https://drf-spectacular.readthedocs.io/en/latest/customization.html#replace-views-with-openapiviewextension class ImportOpenData(APIView): permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] @@ -1625,6 +1622,7 @@ def get_recipe_provider(recipe): raise Exception('Provider not implemented') +# TODO implement proper schema https://drf-spectacular.readthedocs.io/en/latest/customization.html#replace-views-with-openapiviewextension def update_recipe_links(recipe): if not recipe.link: # TODO response validation in apis @@ -1683,7 +1681,7 @@ def sync_all(request): @api_view(['GET']) -# @schema(AutoSchema()) #TODO add proper schema +# @schema(AutoSchema()) #TODO add proper schema https://drf-spectacular.readthedocs.io/en/latest/customization.html#replace-views-with-openapiviewextension @permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) def share_link(request, pk): if request.space.allow_sharing and has_group_permission(request.user, ('user',)): @@ -1714,6 +1712,7 @@ def log_cooking(request, recipe_id): return {'error': 'recipe does not exist'} +# TODO implement proper schema @api_view(['GET']) @permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) def get_plan_ical(request, from_date=datetime.date.today(), to_date=None): diff --git a/recipes/settings.py b/recipes/settings.py index c989865f2..2d5d8c52d 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -17,7 +17,6 @@ import re import socket import sys import traceback -import socket from django.contrib import messages from django.utils.translation import gettext_lazy as _ @@ -325,7 +324,7 @@ SPECTACULAR_SETTINGS = { } } }, - "SECURITY": [{ "ApiKeyAuth": [] }], + "SECURITY": [{"ApiKeyAuth": []}], 'SWAGGER_UI_DIST': 'SIDECAR', 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', 'REDOC_DIST': 'SIDECAR', @@ -473,7 +472,7 @@ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: if DEBUG: print("Vite Dev Server is running") DJANGO_VITE['default']['dev_mode'] = True - except: + except Exception: print("Running django-vite in production mode (no HMR)") # Internationalization