Squashed commit of the following:

This commit is contained in:
smilerz 2021-09-05 11:18:57 -05:00
parent d1556f69c2
commit d33a49538e
13 changed files with 88 additions and 28 deletions

View File

@ -12,7 +12,7 @@ from django.contrib.postgres.search import SearchVectorField
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Index
from django.db.models import Index, ProtectedError
from django.utils import timezone
from django.utils.translation import gettext as _
from treebeard.mp_tree import MP_Node, MP_NodeManager
@ -385,6 +385,12 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
def __str__(self):
return self.name
def delete(self):
if len(self.ingredient_set.all().exclude(step=None)) > 0:
raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
else:
return super().delete()
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
@ -393,7 +399,8 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
food = models.ForeignKey(Food, on_delete=models.PROTECT, null=True, blank=True)
# a pre-delete signal on Food checks if the Ingredient is part of a Step, if it is raises a ProtectedError instead of cascading the delete
food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True)
unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True)
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
note = models.CharField(max_length=256, null=True, blank=True)

View File

@ -338,11 +338,11 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
validated_data['space'] = self.context['request'].space
supermarket = validated_data.pop('supermarket_category', None)
# supermarket = validated_data.pop('supermarket_category', None)
obj, created = Food.objects.get_or_create(**validated_data)
if supermarket:
obj.supermarket_category, created = SupermarketCategory.objects.get_or_create(name=supermarket['name'], space=self.context['request'].space)
obj.save()
# if supermarket:
# obj.supermarket_category, created = SupermarketCategory.objects.get_or_create(name=supermarket.name, space=self.context['request'].space)
# obj.save()
return obj
def update(self, instance, validated_data):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -243,6 +243,42 @@ def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
assert Food.find_problems() == ([], [], [], [], [])
def test_integrity(u1_s1, recipe_1_s1):
with scopes_disabled():
assert Food.objects.count() == 10
assert Ingredient.objects.count() == 10
f_1 = Food.objects.first()
# deleting food will fail because food is part of recipe
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={f_1.id}
)
)
assert r.status_code == 403
with scopes_disabled():
i_1 = f_1.ingredient_set.first()
# remove Ingredient that references Food from recipe step
i_1.step_set.first().ingredients.remove(i_1)
assert Food.objects.count() == 10
assert Ingredient.objects.count() == 10
# deleting food will succeed because its not part of recipe and delete will cascade to Ingredient
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={f_1.id}
)
)
assert r.status_code == 204
with scopes_disabled():
assert Food.objects.count() == 9
assert Ingredient.objects.count() == 9
def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
url = reverse(MOVE_URL, args=[obj_1_1.id, obj_2.id])
with scopes_disabled():

View File

@ -12,7 +12,7 @@ from django.contrib.auth.models import User
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import FieldError, ValidationError
from django.core.files import File
from django.db.models import Case, Q, Value, When
from django.db.models import Case, ProtectedError, Q, Value, When
from django.db.models.fields.related import ForeignObjectRel
from django.http import FileResponse, HttpResponse, JsonResponse
from django_scopes import scopes_disabled
@ -380,6 +380,13 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
def destroy(self, *args, **kwargs):
try:
return (super().destroy(self, *args, **kwargs))
except ProtectedError as e:
content = {'error': True, 'msg': e.args[0]}
return Response(content, status=status.HTTP_403_FORBIDDEN)
class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
queryset = RecipeBook.objects
@ -486,7 +493,7 @@ class RecipePagination(PageNumberPagination):
max_page_size = 100
def paginate_queryset(self, queryset, request, view=None):
self.facets = get_facet(queryset, request.query_params, request.space)
self.facets = get_facet(queryset, request)
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):

View File

@ -16,6 +16,7 @@
## Coming Next
- Heirarchical Ingredients
- Faceted Search
- Search filter by rating
- What Can I Make Now?
- Better ingredient/unit matching on import
- Custom word replacement on import (e.g. 'grams' automatically imported as 'g')

View File

@ -355,7 +355,7 @@ export default {
this.loadMealPlan()
// this.loadRecentlyViewed()
// this.refreshData(false) // this gets triggered when the cookies get loaded
this.refreshData(false)
})
this.$i18n.locale = window.CUSTOM_LOCALE

View File

@ -4,12 +4,14 @@
:options="objects"
:close-on-select="true"
:clear-on-select="true"
:hide-selected="true"
:hide-selected="multiple"
:preserve-search="true"
:placeholder="lookupPlaceholder"
:label="label"
track-by="id"
:multiple="multiple"
:taggable="create_new"
:tag-placeholder="createText"
:loading="loading"
@search-change="search"
@input="selectionChanged">
@ -41,7 +43,9 @@ export default {
limit: {type: Number, default: 10,},
sticky_options: {type:Array, default(){return []}},
initial_selection: {type:Array, default(){return []}},
multiple: {type: Boolean, default: true}
multiple: {type: Boolean, default: true},
create_new: {type: Boolean, default: false}, // TODO: this will create option to add new drop-downs
create_text: {type: String, default: 'You Forgot to Add a Tag Placeholder'},
},
watch: {
initial_selection: function (newVal, oldVal) { // watch it
@ -64,6 +68,9 @@ export default {
lookupPlaceholder() {
return this.placeholder || this.model.name || this.$t('Search')
},
createText() {
return this.create_text
},
},
methods: {
// this.genericAPI inherited from ApiMixin

View File

@ -4,6 +4,7 @@
<template v-slot:modal-title><h4>{{form.title}}</h4></template>
<div v-for="(f, i) in form.fields" v-bind:key=i>
<p v-if="f.type=='instruction'">{{f.label}}</p>
<!-- this lookup is single selection -->
<lookup-input v-if="f.type=='lookup'"
:label="f.label"
:value="f.value"
@ -12,6 +13,7 @@
:sticky_options="f.sticky_options || undefined"
@change="storeValue"/> <!-- TODO add ability to create new items associated with lookup -->
<!-- TODO: add emoji field -->
<!-- TODO: add multi-selection input list -->
<checkbox-input v-if="f.type=='checkbox'"
:label="f.label"
:value="f.value"

View File

@ -3,15 +3,15 @@
<b-form-group
v-bind:label="label"
class="mb-3">
<generic-multiselect
@change="new_value=$event.val['id']"
:initial_selection="[]"
:model="model"
:multiple="false"
:sticky_options="sticky_options"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName">
</generic-multiselect>
<generic-multiselect
@change="new_value=$event.val"
:initial_selection="[value]"
:model="model"
:multiple="false"
:sticky_options="sticky_options"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName">
</generic-multiselect>
</b-form-group>
</div>
</template>
@ -28,15 +28,15 @@ export default {
value: {type: Object, default () {return {}}},
model: {type: Object, default () {return {}}},
sticky_options: {type:Array, default(){return []}},
// TODO: include create_new and create_text props and associated functionality to create objects for drop down
// see 'tagging' here: https://vue-multiselect.js.org/#sub-tagging
// perfect world would have it trigger a new modal associated with the associated item model
},
data() {
return {
new_value: undefined,
}
},
mounted() {
this.new_value = this.value.id
},
computed: {
modelName() {
return this?.model?.name ?? this.$t('Search')
@ -44,7 +44,7 @@ export default {
},
watch: {
'new_value': function () {
this.$root.$emit('change', this.field, this.new_value)
this.$root.$emit('change', this.field, this.new_value?.id ?? null)
},
},
methods: {

View File

@ -99,7 +99,7 @@ export class Models {
'type': 'lookup',
'field': 'supermarket_category',
'list': 'SHOPPING_CATEGORY',
'label': i18n.t('Shopping_Category')
'label': i18n.t('Shopping_Category'),
},
}
},