ensure that all schema fields are typed correctly

This commit is contained in:
smilerz
2024-04-18 10:54:45 -05:00
parent f401b0f635
commit f312f6028d
4 changed files with 95 additions and 72 deletions

View File

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

View File

@@ -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

View File

@@ -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<target>[^/.]+)', 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<parent>[^/.]+)', 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):