1492 lines
60 KiB
Python
1492 lines
60 KiB
Python
import traceback
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
from gettext import gettext as _
|
|
from html import escape
|
|
from smtplib import SMTPException
|
|
|
|
from django.contrib.auth.models import AnonymousUser, Group, User
|
|
from django.core.cache import caches
|
|
from django.core.mail import send_mail
|
|
from django.db.models import Q, QuerySet, Sum
|
|
from django.http import BadHeaderError
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django_scopes import scopes_disabled
|
|
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
|
|
from oauth2_provider.models import AccessToken
|
|
from PIL import Image
|
|
from rest_framework import serializers
|
|
from rest_framework.exceptions import NotFound, ValidationError
|
|
from rest_framework.fields import IntegerField
|
|
|
|
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
|
from cookbook.helper.HelperFunctions import str2bool
|
|
from cookbook.helper.permission_helper import above_space_limit
|
|
from cookbook.helper.property_helper import FoodPropertyHelper
|
|
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
|
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
|
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
|
|
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
|
|
Keyword, MealPlan, MealType, NutritionInformation, Property,
|
|
PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport,
|
|
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
|
|
Step, Storage, Supermarket, SupermarketCategory,
|
|
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
|
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig)
|
|
from cookbook.templatetags.custom_tags import markdown
|
|
from recipes.settings import AWS_ENABLED, MEDIA_URL
|
|
|
|
|
|
class ExtendedRecipeMixin(serializers.ModelSerializer):
|
|
# adds image and recipe count to serializer when query param extended=1
|
|
# ORM path to this object from Recipe
|
|
recipe_filter = None
|
|
# list of ORM paths to any image
|
|
images = None
|
|
|
|
image = serializers.SerializerMethodField('get_image')
|
|
numrecipe = serializers.ReadOnlyField(source='recipe_count')
|
|
|
|
def get_fields(self, *args, **kwargs):
|
|
fields = super().get_fields(*args, **kwargs)
|
|
try:
|
|
api_serializer = self.context['view'].serializer_class
|
|
except KeyError:
|
|
api_serializer = None
|
|
# extended values are computationally expensive and not needed in normal circumstances
|
|
try:
|
|
if str2bool(
|
|
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
|
|
return fields
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
try:
|
|
del fields['image']
|
|
del fields['numrecipe']
|
|
except KeyError:
|
|
pass
|
|
return fields
|
|
|
|
def get_image(self, obj):
|
|
if obj.recipe_image:
|
|
if AWS_ENABLED:
|
|
storage = CachedS3Boto3Storage()
|
|
path = storage.url(obj.recipe_image)
|
|
else:
|
|
path = MEDIA_URL + obj.recipe_image
|
|
return path
|
|
|
|
|
|
class OpenDataModelMixin(serializers.ModelSerializer):
|
|
|
|
def create(self, validated_data):
|
|
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[
|
|
'open_data_slug'].strip() == '':
|
|
validated_data['open_data_slug'] = None
|
|
return super().create(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[
|
|
'open_data_slug'].strip() == '':
|
|
validated_data['open_data_slug'] = None
|
|
return super().update(instance, validated_data)
|
|
|
|
|
|
class CustomDecimalField(serializers.Field):
|
|
"""
|
|
Custom decimal field to normalize useless decimal places
|
|
and allow commas as decimal separators
|
|
"""
|
|
|
|
def to_representation(self, value):
|
|
if not isinstance(value, Decimal):
|
|
value = Decimal(value)
|
|
return round(value, 2).normalize()
|
|
|
|
def to_internal_value(self, data):
|
|
if isinstance(data, int) or isinstance(data, float):
|
|
return data
|
|
elif isinstance(data, str):
|
|
if data == '':
|
|
return 0
|
|
try:
|
|
return float(data.replace(',', '.'))
|
|
except ValueError:
|
|
raise ValidationError('A valid number is required')
|
|
|
|
|
|
class CustomOnHandField(serializers.Field):
|
|
def get_attribute(self, instance):
|
|
return instance
|
|
|
|
def to_representation(self, obj):
|
|
if not self.context["request"].user.is_authenticated:
|
|
return []
|
|
shared_users = []
|
|
if c := caches['default'].get(
|
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
|
shared_users = c
|
|
else:
|
|
try:
|
|
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
|
self.context['request'].user.id]
|
|
caches['default'].set(
|
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
|
|
shared_users, timeout=5 * 60)
|
|
# TODO ugly hack that improves API performance significantly, should be done properly
|
|
except AttributeError: # Anonymous users (using share links) don't have shared users
|
|
pass
|
|
return obj.onhand_users.filter(id__in=shared_users).exists()
|
|
|
|
def to_internal_value(self, data):
|
|
return data
|
|
|
|
|
|
class SpaceFilterSerializer(serializers.ListSerializer):
|
|
|
|
def to_representation(self, data):
|
|
if self.context.get('request', None) is None:
|
|
return
|
|
if (isinstance(data, QuerySet) and data.query.is_sliced):
|
|
# if query is sliced it came from api request not nested serializer
|
|
return super().to_representation(data)
|
|
if self.child.Meta.model == User:
|
|
if isinstance(self.context['request'].user, AnonymousUser):
|
|
data = []
|
|
else:
|
|
data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all()
|
|
else:
|
|
data = data.filter(**{'__'.join(data.model.get_space_key()): self.context['request'].space})
|
|
return super().to_representation(data)
|
|
|
|
|
|
class UserSerializer(WritableNestedModelSerializer):
|
|
display_name = serializers.SerializerMethodField('get_user_label')
|
|
|
|
def get_user_label(self, obj):
|
|
return obj.get_user_display_name()
|
|
|
|
class Meta:
|
|
list_serializer_class = SpaceFilterSerializer
|
|
model = User
|
|
fields = ('id', 'username', 'first_name', 'last_name', 'display_name')
|
|
read_only_fields = ('username',)
|
|
|
|
|
|
class GroupSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
|
def create(self, validated_data):
|
|
raise ValidationError('Cannot create using this endpoint')
|
|
|
|
def update(self, instance, validated_data):
|
|
return instance # cannot update group
|
|
|
|
class Meta:
|
|
model = Group
|
|
fields = ('id', 'name')
|
|
|
|
|
|
class FoodInheritFieldSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
|
name = serializers.CharField(allow_null=True, allow_blank=True, required=False)
|
|
field = serializers.CharField(allow_null=True, allow_blank=True, required=False)
|
|
|
|
def create(self, validated_data):
|
|
raise ValidationError('Cannot create using this endpoint')
|
|
|
|
def update(self, instance, validated_data):
|
|
return instance
|
|
|
|
class Meta:
|
|
model = FoodInheritField
|
|
fields = ('id', 'name', 'field',)
|
|
read_only_fields = ['id']
|
|
|
|
|
|
class UserFileSerializer(serializers.ModelSerializer):
|
|
file = serializers.FileField(write_only=True)
|
|
file_download = serializers.SerializerMethodField('get_download_link')
|
|
preview = serializers.SerializerMethodField('get_preview_link')
|
|
|
|
def get_download_link(self, obj):
|
|
return self.context['request'].build_absolute_uri(reverse('api_download_file', args={obj.pk}))
|
|
|
|
def get_preview_link(self, obj):
|
|
try:
|
|
Image.open(obj.file.file.file)
|
|
return self.context['request'].build_absolute_uri(obj.file.url)
|
|
except Exception:
|
|
# traceback.print_exc()
|
|
return ""
|
|
|
|
def check_file_limit(self, validated_data):
|
|
if 'file' in validated_data:
|
|
if self.context['request'].space.max_file_storage_mb == -1:
|
|
raise ValidationError(_('File uploads are not enabled for this Space.'))
|
|
|
|
try:
|
|
current_file_size_mb = \
|
|
UserFile.objects.filter(space=self.context['request'].space).aggregate(Sum('file_size_kb'))[
|
|
'file_size_kb__sum'] / 1000
|
|
except TypeError:
|
|
current_file_size_mb = 0
|
|
|
|
if ((validated_data['file'].size / 1000 / 1000 + current_file_size_mb - 5)
|
|
> self.context['request'].space.max_file_storage_mb != 0):
|
|
raise ValidationError(_('You have reached your file upload limit.'))
|
|
|
|
def create(self, validated_data):
|
|
self.check_file_limit(validated_data)
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
self.check_file_limit(validated_data)
|
|
return super().update(instance, validated_data)
|
|
|
|
class Meta:
|
|
model = UserFile
|
|
fields = ('id', 'name', 'file', 'file_download', 'preview', 'file_size_kb')
|
|
read_only_fields = ('id', 'file_size_kb')
|
|
extra_kwargs = {"file": {"required": False, }}
|
|
|
|
|
|
class UserFileViewSerializer(serializers.ModelSerializer):
|
|
file_download = serializers.SerializerMethodField('get_download_link')
|
|
preview = serializers.SerializerMethodField('get_preview_link')
|
|
|
|
def get_download_link(self, obj):
|
|
return self.context['request'].build_absolute_uri(reverse('api_download_file', args={obj.pk}))
|
|
|
|
def get_preview_link(self, obj):
|
|
try:
|
|
Image.open(obj.file.file.file)
|
|
return self.context['request'].build_absolute_uri(obj.file.url)
|
|
except Exception:
|
|
# traceback.print_exc()
|
|
return ""
|
|
|
|
def create(self, validated_data):
|
|
raise ValidationError('Cannot create File over this view')
|
|
|
|
def update(self, instance, validated_data):
|
|
return instance
|
|
|
|
class Meta:
|
|
model = UserFile
|
|
fields = ('id', 'name', 'file_download', 'preview')
|
|
read_only_fields = ('id', 'file')
|
|
|
|
|
|
class SpaceSerializer(WritableNestedModelSerializer):
|
|
user_count = serializers.SerializerMethodField('get_user_count')
|
|
recipe_count = serializers.SerializerMethodField('get_recipe_count')
|
|
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
|
|
food_inherit = FoodInheritFieldSerializer(many=True)
|
|
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
nav_logo = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
custom_space_theme = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_32 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_128 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_144 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_180 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_192 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_512 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
logo_color_svg = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
|
|
|
def get_user_count(self, obj):
|
|
return UserSpace.objects.filter(space=obj).count()
|
|
|
|
def get_recipe_count(self, obj):
|
|
return Recipe.objects.filter(space=obj).count()
|
|
|
|
def get_file_size_mb(self, obj):
|
|
try:
|
|
return UserFile.objects.filter(space=obj).aggregate(Sum('file_size_kb'))['file_size_kb__sum'] / 1000
|
|
except TypeError:
|
|
return 0
|
|
|
|
def create(self, validated_data):
|
|
raise ValidationError('Cannot create using this endpoint')
|
|
|
|
class Meta:
|
|
model = Space
|
|
fields = (
|
|
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
|
'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb',
|
|
'image', 'nav_logo', 'space_theme', 'custom_space_theme', 'nav_bg_color', 'nav_text_color',
|
|
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg',)
|
|
read_only_fields = (
|
|
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
|
'demo',)
|
|
|
|
|
|
class UserSpaceSerializer(WritableNestedModelSerializer):
|
|
user = UserSerializer(read_only=True)
|
|
groups = GroupSerializer(many=True)
|
|
|
|
def validate(self, data):
|
|
if self.instance.user == self.context['request'].space.created_by: # can't change space owner permission
|
|
raise serializers.ValidationError(_('Cannot modify Space owner permission.'))
|
|
return super().validate(data)
|
|
|
|
def create(self, validated_data):
|
|
raise ValidationError('Cannot create using this endpoint')
|
|
|
|
class Meta:
|
|
model = UserSpace
|
|
fields = (
|
|
'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
|
|
read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space')
|
|
|
|
|
|
class SpacedModelSerializer(serializers.ModelSerializer):
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
|
|
class MealTypeSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|
|
|
def create(self, validated_data):
|
|
validated_data['name'] = validated_data['name'].strip()
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
validated_data['created_by'] = self.context['request'].user
|
|
obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, created_by=self.context['request'].user, defaults=validated_data)
|
|
return obj
|
|
|
|
class Meta:
|
|
list_serializer_class = SpaceFilterSerializer
|
|
model = MealType
|
|
fields = ('id', 'name', 'order', 'color', 'default', 'created_by')
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class UserPreferenceSerializer(WritableNestedModelSerializer):
|
|
food_inherit_default = serializers.SerializerMethodField('get_food_inherit_defaults')
|
|
plan_share = UserSerializer(many=True, allow_null=True, required=False)
|
|
shopping_share = UserSerializer(many=True, allow_null=True, required=False)
|
|
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
|
|
image = UserFileViewSerializer(required=False, allow_null=True, many=False)
|
|
|
|
def get_food_inherit_defaults(self, obj):
|
|
return FoodInheritFieldSerializer(obj.user.get_active_space().food_inherit.all(), many=True).data
|
|
|
|
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()
|
|
|
|
def update(self, instance, validated_data):
|
|
with scopes_disabled():
|
|
return super().update(instance, validated_data)
|
|
|
|
def create(self, validated_data):
|
|
raise ValidationError('Cannot create using this endpoint')
|
|
|
|
class Meta:
|
|
model = UserPreference
|
|
fields = (
|
|
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page',
|
|
'use_fractions', 'use_kj',
|
|
'plan_share', 'nav_sticky',
|
|
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
|
|
'food_inherit_default', 'default_delay',
|
|
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
|
|
'csv_delim', 'csv_prefix',
|
|
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients',
|
|
'food_children_exist'
|
|
)
|
|
|
|
|
|
class StorageSerializer(SpacedModelSerializer):
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = Storage
|
|
fields = (
|
|
'id', 'name', 'method', 'username', 'password',
|
|
'token', 'created_by'
|
|
)
|
|
|
|
read_only_fields = ('created_by',)
|
|
|
|
extra_kwargs = {
|
|
'password': {'write_only': True},
|
|
'token': {'write_only': True},
|
|
}
|
|
|
|
|
|
class ConnectorConfigConfigSerializer(SpacedModelSerializer):
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = ConnectorConfig
|
|
fields = (
|
|
'id', 'name', 'url', 'token', 'todo_entity', 'enabled',
|
|
'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
|
|
'on_shopping_list_entry_deleted_enabled', 'created_by'
|
|
)
|
|
|
|
read_only_fields = ('created_by',)
|
|
|
|
extra_kwargs = {
|
|
'token': {'write_only': True},
|
|
}
|
|
|
|
|
|
class SyncSerializer(SpacedModelSerializer):
|
|
class Meta:
|
|
model = Sync
|
|
fields = (
|
|
'id', 'storage', 'path', 'active', 'last_checked',
|
|
'created_at', 'updated_at'
|
|
)
|
|
|
|
|
|
class SyncLogSerializer(SpacedModelSerializer):
|
|
class Meta:
|
|
model = SyncLog
|
|
fields = ('id', 'sync', 'status', 'msg', 'created_at')
|
|
|
|
|
|
class KeywordLabelSerializer(serializers.ModelSerializer):
|
|
label = serializers.SerializerMethodField('get_label')
|
|
|
|
def get_label(self, obj):
|
|
return str(obj)
|
|
|
|
class Meta:
|
|
list_serializer_class = SpaceFilterSerializer
|
|
model = Keyword
|
|
fields = (
|
|
'id', 'label',
|
|
)
|
|
read_only_fields = ('id', 'label')
|
|
|
|
|
|
class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
|
label = serializers.SerializerMethodField('get_label')
|
|
recipe_filter = 'keywords'
|
|
|
|
def get_label(self, obj):
|
|
return str(obj)
|
|
|
|
def create(self, validated_data):
|
|
# since multi select tags dont have id's
|
|
# duplicate names might be routed to create
|
|
name = validated_data.pop('name').strip()
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
obj, created = Keyword.objects.get_or_create(name=name, space=space, defaults=validated_data)
|
|
return obj
|
|
|
|
class Meta:
|
|
model = Keyword
|
|
fields = (
|
|
'id', 'name', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
|
|
'updated_at', 'full_name')
|
|
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
|
|
|
|
|
|
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin):
|
|
recipe_filter = 'steps__ingredients__unit'
|
|
|
|
def create(self, validated_data):
|
|
# get_or_create drops any field that contains '__' when creating so values must be included in validated data
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
if x := validated_data.get('name', None):
|
|
validated_data['name'] = x.strip()
|
|
if x := validated_data.get('name', None):
|
|
validated_data['plural_name'] = x.strip()
|
|
|
|
if unit := Unit.objects.filter(
|
|
Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']),
|
|
space=space).first():
|
|
return unit
|
|
|
|
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
|
defaults=validated_data)
|
|
return obj
|
|
|
|
def update(self, instance, validated_data):
|
|
validated_data['name'] = validated_data['name'].strip()
|
|
if plural_name := validated_data.get('plural_name', None):
|
|
validated_data['plural_name'] = plural_name.strip()
|
|
return super(UnitSerializer, self).update(instance, validated_data)
|
|
|
|
class Meta:
|
|
model = Unit
|
|
fields = ('id', 'name', 'plural_name', 'description', 'base_unit', 'numrecipe', 'image', 'open_data_slug')
|
|
read_only_fields = ('id', 'numrecipe', 'image')
|
|
|
|
|
|
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer, OpenDataModelMixin):
|
|
|
|
def create(self, validated_data):
|
|
validated_data['name'] = validated_data['name'].strip()
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
|
defaults=validated_data)
|
|
return obj
|
|
|
|
def update(self, instance, validated_data):
|
|
return super(SupermarketCategorySerializer, self).update(instance, validated_data)
|
|
|
|
class Meta:
|
|
model = SupermarketCategory
|
|
fields = ('id', 'name', 'description')
|
|
|
|
|
|
class SupermarketCategoryRelationSerializer(WritableNestedModelSerializer):
|
|
category = SupermarketCategorySerializer()
|
|
|
|
class Meta:
|
|
model = SupermarketCategoryRelation
|
|
fields = ('id', 'category', 'supermarket', 'order')
|
|
|
|
|
|
class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataModelMixin):
|
|
category_to_supermarket = SupermarketCategoryRelationSerializer(many=True, read_only=True)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['name'] = validated_data['name'].strip()
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
|
defaults=validated_data)
|
|
return obj
|
|
|
|
class Meta:
|
|
model = Supermarket
|
|
fields = ('id', 'name', 'description', 'category_to_supermarket', 'open_data_slug')
|
|
|
|
|
|
class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, UniqueFieldsMixin):
|
|
id = serializers.IntegerField(required=False)
|
|
order = IntegerField(default=0, required=False)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['name'] = validated_data['name'].strip()
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
|
defaults=validated_data)
|
|
return obj
|
|
|
|
class Meta:
|
|
model = PropertyType
|
|
fields = ('id', 'name', 'unit', 'description', 'order', 'open_data_slug', 'fdc_id',)
|
|
|
|
|
|
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
|
property_type = PropertyTypeSerializer()
|
|
property_amount = CustomDecimalField(allow_null=True)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = Property
|
|
fields = ('id', 'property_amount', 'property_type')
|
|
|
|
|
|
class RecipeSimpleSerializer(WritableNestedModelSerializer):
|
|
url = serializers.SerializerMethodField('get_url')
|
|
|
|
def get_url(self, obj):
|
|
return reverse('view_recipe', args=[obj.id])
|
|
|
|
def create(self, validated_data):
|
|
# don't allow writing to Recipe via this API
|
|
return Recipe.objects.get(**validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
# don't allow writing to Recipe via this API
|
|
return Recipe.objects.get(**validated_data)
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = ('id', 'name', 'url')
|
|
|
|
|
|
class FoodSimpleSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Food
|
|
fields = ('id', 'name', 'plural_name')
|
|
|
|
|
|
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')
|
|
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)
|
|
|
|
properties = PropertySerializer(many=True, allow_null=True, required=False)
|
|
properties_food_unit = UnitSerializer(allow_null=True, required=False)
|
|
properties_food_amount = CustomDecimalField(required=False)
|
|
|
|
recipe_filter = 'steps__ingredients__food'
|
|
images = ['recipe__image']
|
|
|
|
def get_substitute_onhand(self, obj):
|
|
if not self.context["request"].user.is_authenticated:
|
|
return []
|
|
shared_users = []
|
|
if c := caches['default'].get(
|
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
|
shared_users = c
|
|
else:
|
|
try:
|
|
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
|
self.context['request'].user.id]
|
|
caches['default'].set(
|
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
|
|
shared_users, timeout=5 * 60)
|
|
# TODO ugly hack that improves API performance significantly, should be done properly
|
|
except AttributeError: # Anonymous users (using share links) don't have shared users
|
|
pass
|
|
filter = Q(id__in=obj.substitute.all())
|
|
if obj.substitute_siblings:
|
|
filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth)
|
|
if obj.substitute_children:
|
|
filter |= Q(path__startswith=obj.path, depth__gt=obj.depth)
|
|
return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists()
|
|
|
|
def create(self, validated_data):
|
|
name = validated_data['name'].strip()
|
|
|
|
if plural_name := validated_data.pop('plural_name', None):
|
|
plural_name = plural_name.strip()
|
|
|
|
if food := Food.objects.filter(Q(name=name) | Q(plural_name=name)).first():
|
|
return food
|
|
|
|
space = validated_data.pop('space', self.context['request'].space)
|
|
# supermarket category needs to be handled manually as food.get or create does not create nested serializers unlike a super.create of serializer
|
|
if 'supermarket_category' in validated_data and validated_data['supermarket_category']:
|
|
sm_category = validated_data['supermarket_category']
|
|
sc_name = sm_category.pop('name', None)
|
|
validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create(
|
|
name=sc_name,
|
|
space=space, defaults=sm_category)
|
|
onhand = validated_data.pop('food_onhand', None)
|
|
if recipe := validated_data.get('recipe', None):
|
|
validated_data['recipe'] = Recipe.objects.get(**recipe)
|
|
|
|
# assuming if on hand for user also onhand for shopping_share users
|
|
if onhand is not None:
|
|
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
|
|
if self.instance:
|
|
onhand_users = self.instance.onhand_users.all()
|
|
else:
|
|
onhand_users = []
|
|
if onhand:
|
|
validated_data['onhand_users'] = list(onhand_users) + shared_users
|
|
else:
|
|
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
|
|
|
|
if properties_food_unit := validated_data.pop('properties_food_unit', None):
|
|
properties_food_unit = Unit.objects.filter(name=properties_food_unit['name']).first()
|
|
|
|
properties = validated_data.pop('properties', None)
|
|
|
|
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space,
|
|
properties_food_unit=properties_food_unit,
|
|
defaults=validated_data)
|
|
|
|
if properties and len(properties) > 0:
|
|
for p in properties:
|
|
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'],
|
|
property_amount=p['property_amount'], space=space))
|
|
|
|
return obj
|
|
|
|
def update(self, instance, validated_data):
|
|
if name := validated_data.get('name', None):
|
|
validated_data['name'] = name.strip()
|
|
if plural_name := validated_data.get('plural_name', None):
|
|
validated_data['plural_name'] = plural_name.strip()
|
|
# assuming if on hand for user also onhand for shopping_share users
|
|
onhand = validated_data.get('food_onhand', None)
|
|
reset_inherit = self.initial_data.get('reset_inherit', False)
|
|
if onhand is not None:
|
|
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
|
|
if onhand:
|
|
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
|
|
else:
|
|
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
|
|
|
|
# update before resetting inheritance
|
|
saved_instance = super(FoodSerializer, self).update(instance, validated_data)
|
|
if reset_inherit and (r := self.context.get('request', None)):
|
|
Food.reset_inheritance(food=saved_instance, space=r.space)
|
|
return saved_instance
|
|
|
|
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',
|
|
)
|
|
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
|
|
|
|
|
class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
|
food = FoodSimpleSerializer(allow_null=True)
|
|
unit = UnitSerializer(allow_null=True)
|
|
used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes')
|
|
amount = CustomDecimalField()
|
|
conversions = serializers.SerializerMethodField('get_conversions')
|
|
|
|
def get_used_in_recipes(self, obj):
|
|
used_in = []
|
|
for s in obj.step_set.all():
|
|
for r in s.recipe_set.all():
|
|
used_in.append({'id': r.id, 'name': r.name})
|
|
return used_in
|
|
|
|
def get_conversions(self, obj):
|
|
if obj.unit and obj.food:
|
|
uch = UnitConversionHelper(self.context['request'].space)
|
|
conversions = []
|
|
for c in uch.get_conversions(obj):
|
|
conversions.append(
|
|
{'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
|
|
return conversions
|
|
else:
|
|
return []
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
validated_data.pop('original_text', None)
|
|
return super().update(instance, validated_data)
|
|
|
|
class Meta:
|
|
model = Ingredient
|
|
fields = (
|
|
'id', 'food', 'unit', 'amount', 'conversions', 'note', 'order',
|
|
'is_header', 'no_amount', 'original_text', 'used_in_recipes',
|
|
'always_use_plural_unit', 'always_use_plural_food',
|
|
)
|
|
read_only_fields = ['conversions', ]
|
|
|
|
|
|
class IngredientSerializer(IngredientSimpleSerializer):
|
|
food = FoodSerializer(allow_null=True)
|
|
|
|
|
|
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
|
|
ingredients = IngredientSerializer(many=True)
|
|
instructions_markdown = serializers.SerializerMethodField('get_instructions_markdown')
|
|
file = UserFileViewSerializer(allow_null=True, required=False)
|
|
step_recipe_data = serializers.SerializerMethodField('get_step_recipe_data')
|
|
recipe_filter = 'steps'
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
def get_instructions_markdown(self, obj):
|
|
return obj.get_instruction_render()
|
|
|
|
def get_step_recipes(self, obj):
|
|
return list(obj.recipe_set.values_list('id', flat=True).all())
|
|
|
|
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
|
|
if obj.step_recipe and isinstance(self.parent.root, RecipeSerializer):
|
|
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
|
|
|
|
class Meta:
|
|
model = Step
|
|
fields = (
|
|
'id', 'name', 'instruction', 'ingredients', 'instructions_markdown',
|
|
'time', 'order', 'show_as_header', 'file', 'step_recipe',
|
|
'step_recipe_data', 'numrecipe', 'show_ingredients_table'
|
|
)
|
|
|
|
|
|
class StepRecipeSerializer(WritableNestedModelSerializer):
|
|
steps = StepSerializer(many=True)
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = (
|
|
'id', 'name', 'steps',
|
|
)
|
|
|
|
|
|
class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin):
|
|
name = serializers.SerializerMethodField('get_conversion_name')
|
|
base_unit = UnitSerializer()
|
|
converted_unit = UnitSerializer()
|
|
food = FoodSerializer(allow_null=True, required=False)
|
|
base_amount = CustomDecimalField()
|
|
converted_amount = CustomDecimalField()
|
|
|
|
def get_conversion_name(self, obj):
|
|
text = f'{round(obj.base_amount)} {obj.base_unit} '
|
|
if obj.food:
|
|
text += f' {obj.food}'
|
|
return text + f' = {round(obj.converted_amount)} {obj.converted_unit}'
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = validated_data.pop('space', self.context['request'].space)
|
|
try:
|
|
return UnitConversion.objects.get(
|
|
food__name__iexact=validated_data.get('food', {}).get('name', None),
|
|
base_unit__name__iexact=validated_data.get('base_unit', {}).get('name', None),
|
|
converted_unit__name__iexact=validated_data.get('converted_unit', {}).get('name', None),
|
|
space=validated_data['space']
|
|
)
|
|
except UnitConversion.DoesNotExist:
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = UnitConversion
|
|
fields = (
|
|
'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
|
|
|
|
|
|
class NutritionInformationSerializer(serializers.ModelSerializer):
|
|
carbohydrates = CustomDecimalField()
|
|
fats = CustomDecimalField()
|
|
proteins = CustomDecimalField()
|
|
calories = CustomDecimalField()
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = NutritionInformation
|
|
fields = ('id', 'carbohydrates', 'fats', 'proteins', 'calories', 'source')
|
|
|
|
|
|
class RecipeBaseSerializer(WritableNestedModelSerializer):
|
|
# TODO make days of new recipe a setting
|
|
def is_recipe_new(self, obj):
|
|
if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)):
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
|
|
class CommentSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Comment
|
|
fields = '__all__'
|
|
read_only_fields = ['id', 'created_at', 'created_by', 'updated_at', ]
|
|
|
|
|
|
class RecipeOverviewSerializer(RecipeBaseSerializer):
|
|
keywords = KeywordLabelSerializer(many=True)
|
|
new = serializers.SerializerMethodField('is_recipe_new')
|
|
recent = serializers.ReadOnlyField()
|
|
|
|
rating = CustomDecimalField(required=False, allow_null=True)
|
|
last_cooked = serializers.DateTimeField(required=False, allow_null=True)
|
|
|
|
def create(self, validated_data):
|
|
pass
|
|
|
|
def update(self, instance, validated_data):
|
|
return instance
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = (
|
|
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
|
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
|
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
|
)
|
|
read_only_fields = ['image', 'created_by', 'created_at']
|
|
|
|
|
|
class RecipeSerializer(RecipeBaseSerializer):
|
|
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
|
properties = PropertySerializer(many=True, required=False)
|
|
steps = StepSerializer(many=True)
|
|
keywords = KeywordSerializer(many=True, required=False)
|
|
shared = UserSerializer(many=True, required=False)
|
|
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
|
|
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
|
|
food_properties = serializers.SerializerMethodField('get_food_properties')
|
|
|
|
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)
|
|
|
|
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',
|
|
)
|
|
read_only_fields = ['image', 'created_by', 'created_at', 'food_properties']
|
|
|
|
def validate(self, data):
|
|
above_limit, msg = above_space_limit(self.context['request'].space)
|
|
if above_limit:
|
|
raise serializers.ValidationError(msg)
|
|
return super().validate(data)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
|
|
class RecipeImageSerializer(WritableNestedModelSerializer):
|
|
image = serializers.ImageField(required=False, allow_null=True)
|
|
image_url = serializers.CharField(max_length=4096, required=False, allow_null=True)
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = ['image', 'image_url', ]
|
|
|
|
|
|
class RecipeImportSerializer(SpacedModelSerializer):
|
|
class Meta:
|
|
model = RecipeImport
|
|
fields = '__all__'
|
|
|
|
|
|
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|
shared = UserSerializer(many=True, required=False)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = CustomFilter
|
|
fields = ('id', 'name', 'search', 'shared', 'created_by')
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|
shared = UserSerializer(many=True)
|
|
filter = CustomFilterSerializer(allow_null=True, required=False)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = RecipeBook
|
|
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter', 'order')
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
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)
|
|
|
|
def get_book_content(self, obj):
|
|
return RecipeBookSerializer(context={'request': self.context['request']}).to_representation(obj.book)
|
|
|
|
def get_recipe_content(self, obj):
|
|
return RecipeOverviewSerializer(context={'request': self.context['request']}).to_representation(obj.recipe)
|
|
|
|
def create(self, validated_data):
|
|
book = validated_data['book']
|
|
recipe = validated_data['recipe']
|
|
if not book.get_owner() == self.context['request'].user and not self.context[
|
|
'request'].user in book.get_shared():
|
|
raise NotFound(detail=None, code=None)
|
|
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
|
|
return obj
|
|
|
|
class Meta:
|
|
model = RecipeBookEntry
|
|
fields = ('id', 'book', 'book_content', 'recipe', 'recipe_content',)
|
|
|
|
|
|
class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|
recipe = RecipeOverviewSerializer(required=False, allow_null=True)
|
|
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
|
meal_type = MealTypeSerializer()
|
|
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # 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)
|
|
shopping = serializers.SerializerMethodField('in_shopping')
|
|
|
|
to_date = serializers.DateField(required=False)
|
|
|
|
def get_note_markdown(self, obj):
|
|
return markdown(obj.note)
|
|
|
|
def in_shopping(self, obj):
|
|
return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists()
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
|
|
if 'to_date' not in validated_data or validated_data['to_date'] is None:
|
|
validated_data['to_date'] = validated_data['from_date']
|
|
|
|
mealplan = super().create(validated_data)
|
|
if self.context['request'].data.get('addshopping', False) and self.context['request'].data.get('recipe', None):
|
|
SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
|
|
SLR.create(mealplan=mealplan, servings=validated_data['servings'])
|
|
return mealplan
|
|
|
|
class Meta:
|
|
model = MealPlan
|
|
fields = (
|
|
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
|
|
'from_date', 'to_date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
|
'meal_type_name', 'shopping'
|
|
)
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class AutoMealPlanSerializer(serializers.Serializer):
|
|
start_date = serializers.DateField()
|
|
end_date = serializers.DateField()
|
|
meal_type_id = serializers.IntegerField()
|
|
keyword_ids = serializers.ListField()
|
|
servings = CustomDecimalField()
|
|
shared = UserSerializer(many=True, required=False, allow_null=True)
|
|
addshopping = serializers.BooleanField()
|
|
|
|
|
|
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
|
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
|
|
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
|
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
|
|
mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date')
|
|
mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name')
|
|
servings = CustomDecimalField()
|
|
|
|
def get_name(self, obj):
|
|
if not isinstance(value := obj.servings, Decimal):
|
|
value = Decimal(value)
|
|
value = value.quantize(
|
|
Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
|
|
return (
|
|
obj.name
|
|
or getattr(obj.mealplan, 'title', None)
|
|
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
|
|
or obj.recipe.name
|
|
) + f' ({value:.2g})'
|
|
|
|
def update(self, instance, validated_data):
|
|
# TODO remove once old shopping list
|
|
if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet':
|
|
SLR = RecipeShoppingEditor(user=self.context['request'].user, space=self.context['request'].space)
|
|
SLR.edit_servings(servings=validated_data['servings'], id=instance.id)
|
|
return super().update(instance, validated_data)
|
|
|
|
class Meta:
|
|
model = ShoppingListRecipe
|
|
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date',
|
|
'mealplan_type')
|
|
read_only_fields = ('id',)
|
|
|
|
|
|
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
|
food = FoodSerializer(allow_null=True)
|
|
unit = UnitSerializer(allow_null=True, required=False)
|
|
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
|
|
amount = CustomDecimalField()
|
|
created_by = UserSerializer(read_only=True)
|
|
completed_at = serializers.DateTimeField(allow_null=True, required=False)
|
|
|
|
def get_fields(self, *args, **kwargs):
|
|
fields = super().get_fields(*args, **kwargs)
|
|
|
|
# autosync values are only needed for frequent 'checked' value updating
|
|
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
|
|
for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])):
|
|
del fields[f]
|
|
return fields
|
|
|
|
def run_validation(self, data):
|
|
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
|
|
if (
|
|
data.get('checked', False)
|
|
and self.root.instance
|
|
and not self.root.instance.checked
|
|
):
|
|
# if checked flips from false to true set completed datetime
|
|
data['completed_at'] = timezone.now()
|
|
|
|
elif not data.get('checked', False):
|
|
# if not checked set completed to None
|
|
data['completed_at'] = None
|
|
else:
|
|
# otherwise don't write anything
|
|
if 'completed_at' in data:
|
|
del data['completed_at']
|
|
|
|
return super().run_validation(data)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
user = self.context['request'].user
|
|
# update the onhand for food if shopping_add_onhand is True
|
|
if user.userpreference.shopping_add_onhand:
|
|
if checked := validated_data.get('checked', None):
|
|
instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
|
|
elif checked == False:
|
|
instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
|
|
return super().update(instance, validated_data)
|
|
|
|
class Meta:
|
|
model = ShoppingListEntry
|
|
fields = (
|
|
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked',
|
|
'recipe_mealplan',
|
|
'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until'
|
|
)
|
|
read_only_fields = ('id', 'created_by', 'created_at', 'updated_at',)
|
|
|
|
|
|
class ShoppingListEntryBulkSerializer(serializers.Serializer):
|
|
ids = serializers.ListField()
|
|
checked = serializers.BooleanField()
|
|
|
|
|
|
# TODO deprecate
|
|
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = ShoppingListEntry
|
|
fields = ('id', 'checked')
|
|
|
|
|
|
# TODO deprecate
|
|
class ShoppingListSerializer(WritableNestedModelSerializer):
|
|
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
|
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
|
shared = UserSerializer(many=True)
|
|
supermarket = SupermarketSerializer(allow_null=True)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
validated_data['created_by'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = ShoppingList
|
|
fields = (
|
|
'id', 'uuid', 'note', 'recipes', 'entries',
|
|
'shared', 'finished', 'supermarket', 'created_by', 'created_at'
|
|
)
|
|
read_only_fields = ('id', 'created_by',)
|
|
|
|
|
|
# TODO deprecate
|
|
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
|
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
|
|
|
class Meta:
|
|
model = ShoppingList
|
|
fields = ('id', 'entries',)
|
|
read_only_fields = ('id',)
|
|
|
|
|
|
class ShareLinkSerializer(SpacedModelSerializer):
|
|
class Meta:
|
|
model = ShareLink
|
|
fields = '__all__'
|
|
|
|
|
|
class CookLogSerializer(serializers.ModelSerializer):
|
|
created_by = UserSerializer(read_only=True)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = CookLog
|
|
fields = ('id', 'recipe', 'servings', 'rating', 'comment', 'created_by', 'created_at', 'updated_at')
|
|
read_only_fields = ('id', 'created_by')
|
|
|
|
|
|
class ViewLogSerializer(serializers.ModelSerializer):
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = ViewLog
|
|
fields = ('id', 'recipe', 'created_by', 'created_at')
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class ImportLogSerializer(serializers.ModelSerializer):
|
|
keyword = KeywordSerializer(read_only=True)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = ImportLog
|
|
fields = (
|
|
'id', 'type', 'msg', 'running', 'keyword', 'total_recipes', 'imported_recipes', 'created_by', 'created_at')
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class ExportLogSerializer(serializers.ModelSerializer):
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = ExportLog
|
|
fields = (
|
|
'id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration',
|
|
'possibly_not_expired',
|
|
'created_by', 'created_at')
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class AutomationSerializer(serializers.ModelSerializer):
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = Automation
|
|
fields = (
|
|
'id', 'type', 'name', 'description', 'param_1', 'param_2', 'param_3', 'order', 'disabled', 'created_by',)
|
|
read_only_fields = ('created_by',)
|
|
|
|
|
|
class InviteLinkSerializer(WritableNestedModelSerializer):
|
|
group = GroupSerializer()
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
obj = super().create(validated_data)
|
|
|
|
if obj.email:
|
|
try:
|
|
if InviteLink.objects.filter(space=self.context['request'].space,
|
|
created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
|
|
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(
|
|
self.context['request'].user.get_user_display_name())
|
|
message += _(' to join their Tandoor Recipes space ') + escape(
|
|
self.context['request'].space.name) + '.\n\n'
|
|
message += _('Click the following link to activate your account: ') + self.context[
|
|
'request'].build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
|
|
message += _('If the link does not work use the following code to manually join the space: ') + str(
|
|
obj.uuid) + '\n\n'
|
|
message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n'
|
|
message += _(
|
|
'Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
|
|
|
|
send_mail(
|
|
_('Tandoor Recipes Invite'),
|
|
message,
|
|
None,
|
|
[obj.email],
|
|
fail_silently=True,
|
|
)
|
|
except (SMTPException, BadHeaderError, TimeoutError):
|
|
pass
|
|
|
|
return obj
|
|
|
|
class Meta:
|
|
model = InviteLink
|
|
fields = (
|
|
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
|
|
'created_at',)
|
|
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)
|
|
|
|
|
|
# CORS, REST and Scopes aren't currently working
|
|
# Scopes are evaluating before REST has authenticated the user assigning a None space
|
|
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
|
|
class BookmarkletImportListSerializer(serializers.ModelSerializer):
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = BookmarkletImport
|
|
fields = ('id', 'url', 'created_by', 'created_at')
|
|
read_only_fields = ('created_by', 'space')
|
|
|
|
|
|
class BookmarkletImportSerializer(BookmarkletImportListSerializer):
|
|
class Meta:
|
|
model = BookmarkletImport
|
|
fields = ('id', 'url', 'html', 'created_by', 'created_at')
|
|
read_only_fields = ('created_by', 'space')
|
|
|
|
|
|
# OAuth / Auth Token related Serializers
|
|
|
|
class AccessTokenSerializer(serializers.ModelSerializer):
|
|
token = serializers.SerializerMethodField('get_token')
|
|
|
|
def create(self, validated_data):
|
|
validated_data['token'] = f'tda_{str(uuid.uuid4()).replace("-", "_")}'
|
|
validated_data['user'] = self.context['request'].user
|
|
return super().create(validated_data)
|
|
|
|
def get_token(self, obj):
|
|
if (timezone.now() - obj.created).seconds < 15:
|
|
return obj.token
|
|
return f'tda_************_******_***********{obj.token[len(obj.token) - 4:]}'
|
|
|
|
class Meta:
|
|
model = AccessToken
|
|
fields = ('id', 'token', 'expires', 'scope', 'created', 'updated')
|
|
read_only_fields = ('id', 'token',)
|
|
|
|
|
|
# Export/Import Serializers
|
|
|
|
class KeywordExportSerializer(KeywordSerializer):
|
|
class Meta:
|
|
model = Keyword
|
|
fields = ('name', 'description', 'created_at', 'updated_at')
|
|
|
|
|
|
class NutritionInformationExportSerializer(NutritionInformationSerializer):
|
|
class Meta:
|
|
model = NutritionInformation
|
|
fields = ('carbohydrates', 'fats', 'proteins', 'calories', 'source')
|
|
|
|
|
|
class SupermarketCategoryExportSerializer(SupermarketCategorySerializer):
|
|
class Meta:
|
|
model = SupermarketCategory
|
|
fields = ('name',)
|
|
|
|
|
|
class UnitExportSerializer(UnitSerializer):
|
|
class Meta:
|
|
model = Unit
|
|
fields = ('name', 'plural_name', 'description')
|
|
|
|
|
|
class FoodExportSerializer(FoodSerializer):
|
|
supermarket_category = SupermarketCategoryExportSerializer(allow_null=True, required=False)
|
|
|
|
class Meta:
|
|
model = Food
|
|
fields = ('name', 'plural_name', 'ignore_shopping', 'supermarket_category',)
|
|
|
|
|
|
class IngredientExportSerializer(WritableNestedModelSerializer):
|
|
food = FoodExportSerializer(allow_null=True)
|
|
unit = UnitExportSerializer(allow_null=True)
|
|
amount = CustomDecimalField()
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = Ingredient
|
|
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit',
|
|
'always_use_plural_food')
|
|
|
|
|
|
class StepExportSerializer(WritableNestedModelSerializer):
|
|
ingredients = IngredientExportSerializer(many=True)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
class Meta:
|
|
model = Step
|
|
fields = ('name', 'instruction', 'ingredients', 'time', 'order', 'show_as_header', 'show_ingredients_table')
|
|
|
|
|
|
class RecipeExportSerializer(WritableNestedModelSerializer):
|
|
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
|
steps = StepExportSerializer(many=True)
|
|
keywords = KeywordExportSerializer(many=True)
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = (
|
|
'name', 'description', 'keywords', 'steps', 'working_time',
|
|
'waiting_time', 'internal', 'nutrition', 'servings', 'servings_text', 'source_url',
|
|
)
|
|
|
|
def create(self, validated_data):
|
|
validated_data['created_by'] = self.context['request'].user
|
|
validated_data['space'] = self.context['request'].space
|
|
return super().create(validated_data)
|
|
|
|
|
|
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
|
|
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False,
|
|
help_text=_("Existing shopping list to update"))
|
|
ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
|
|
"List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
|
|
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_(
|
|
"Providing a list_recipe ID and servings of 0 will delete that shopping list."))
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = ['id', 'list_recipe', 'ingredients', 'servings', ]
|
|
|
|
|
|
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
|
|
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False,
|
|
help_text=_("Amount of food to add to the shopping list"))
|
|
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False,
|
|
help_text=_("ID of unit to use for the shopping list"))
|
|
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True,
|
|
help_text=_("When set to true will delete all food from active shopping lists."))
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = ['id', 'amount', 'unit', 'delete', ]
|
|
|
|
|
|
# non model serializers
|
|
|
|
class RecipeFromSourceSerializer(serializers.Serializer):
|
|
url = serializers.CharField(max_length=4096, required=False, allow_null=True, allow_blank=True)
|
|
data = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
|
bookmarklet = serializers.IntegerField(required=False, allow_null=True, )
|