feature/new-feature-cleanup (#389)

* add json editor to theme editor

* add toolbars tools to recipe sections

* fix recipe yield

* add updated_date to recipe schema

* update time cards

* fix mobile buttons

* fix asset URL

* fix PG errors CRUD

* remove -d from docker-pro

* fix theme tests

* remvoe old typing

* abstract count function

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-05 14:08:13 -08:00 committed by GitHub
parent c1370afb16
commit 1b0de02b71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 263 additions and 132 deletions

View File

@ -1,7 +1,7 @@
const baseURL = "/api/"; const baseURL = "/api/";
import axios from "axios"; import axios from "axios";
import { store } from "../store"; import { store } from "../store";
import utils from "@/utils"; import { utils } from "@/utils";
axios.defaults.headers.common["Authorization"] = `Bearer ${store.getters.getToken}`; axios.defaults.headers.common["Authorization"] = `Bearer ${store.getters.getToken}`;

View File

@ -14,7 +14,7 @@ const recipeURLs = {
recipe: slug => prefix + slug, recipe: slug => prefix + slug,
update: slug => prefix + slug, update: slug => prefix + slug,
delete: slug => prefix + slug, delete: slug => prefix + slug,
createAsset: slug => `${prefix}media/${slug}/assets`, createAsset: slug => `${prefix}${slug}/assets`,
recipeImage: slug => `${prefix}${slug}/image`, recipeImage: slug => `${prefix}${slug}/image`,
updateImage: slug => `${prefix}${slug}/image`, updateImage: slug => `${prefix}${slug}/image`,
}; };

View File

@ -21,7 +21,7 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils"; import { utils } from "@/utils";
import MealPlanCard from "./MealPlanCard"; import MealPlanCard from "./MealPlanCard";
export default { export default {
components: { components: {

View File

@ -82,7 +82,7 @@
const CREATE_EVENT = "created"; const CREATE_EVENT = "created";
import DatePicker from "@/components/FormHelpers/DatePicker"; import DatePicker from "@/components/FormHelpers/DatePicker";
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils"; import { utils } from "@/utils";
import MealPlanCard from "./MealPlanCard"; import MealPlanCard from "./MealPlanCard";
export default { export default {
components: { components: {

View File

@ -8,7 +8,7 @@
<v-list-item-title class=" mb-1">{{ name }} </v-list-item-title> <v-list-item-title class=" mb-1">{{ name }} </v-list-item-title>
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle> <v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
<div class="d-flex justify-center align-center"> <div class="d-flex justify-center align-center">
<RecipeChips :items="tags" :title="false" :limit="1" :small="true" :isCategory="false" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="1" :small="true" :isCategory="false" />
<v-rating <v-rating
color="secondary" color="secondary"
class="ml-auto" class="ml-auto"

View File

@ -56,7 +56,7 @@
import BulkAdd from "@/components/Recipe/Parts/Helpers/BulkAdd"; import BulkAdd from "@/components/Recipe/Parts/Helpers/BulkAdd";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import utils from "@/utils"; import { utils } from "@/utils";
export default { export default {
components: { components: {
BulkAdd, BulkAdd,

View File

@ -66,7 +66,7 @@
<script> <script>
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils"; import { utils } from "@/utils";
export default { export default {
components: { components: {
VueMarkdown, VueMarkdown,

View File

@ -35,7 +35,7 @@
<script> <script>
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils"; import { utils } from "@/utils";
export default { export default {
components: { components: {
VueMarkdown, VueMarkdown,

View File

@ -25,7 +25,7 @@
<v-card-actions> <v-card-actions>
<Rating :value="rating" :name="name" :slug="slug" :small="true" /> <Rating :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeChips :items="tags" :title="false" :limit="2" :small="true" :isCategory="false" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :isCategory="false" />
<ContextMenu :slug="slug" /> <ContextMenu :slug="slug" />
</v-card-actions> </v-card-actions>
</v-card> </v-card>

View File

@ -124,7 +124,7 @@
</template> </template>
<script> <script>
import utils from "@/utils"; import { utils } from "@/utils";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {

View File

@ -1,29 +1,13 @@
<template> <template>
<v-card
color="accent"
class="custom-transparent d-flex justify-start align-center text-center time-card-flex"
tile
v-if="showCards"
>
<v-card flat color="rgb(255, 0, 0, 0.0)">
<v-icon large color="white" class="mx-2"> mdi-clock-outline </v-icon>
</v-card>
<v-card
v-for="(time, index) in allTimes"
:key="index"
class="d-flex justify-start align-center text-center time-card-flex"
flat
color="rgb(255, 0, 0, 0.0)"
>
<v-card-text class="caption white--text py-2">
<div> <div>
<strong> {{ time.name }} </strong> <v-chip label color="accent custom-transparent" class="ma-1" v-for="(time, index) in allTimes" :key="index">
<v-icon left>
mdi-clock-outline
</v-icon>
{{ time.name }} |
{{ time.value }}
</v-chip>
</div> </div>
<div>{{ time.value }}</div>
</v-card-text>
</v-card>
</v-card>
</template> </template>
<script> <script>

View File

@ -11,7 +11,7 @@
:to="`/recipes/${urlParam}/${getSlug(category)}`" :to="`/recipes/${urlParam}/${getSlug(category)}`"
:key="category" :key="category"
> >
{{ category }} {{ truncateText(category) }}
</v-chip> </v-chip>
</div> </div>
</template> </template>
@ -19,6 +19,9 @@
<script> <script>
export default { export default {
props: { props: {
truncate: {
default: false,
},
items: { items: {
default: [], default: [],
}, },
@ -34,6 +37,7 @@ export default {
small: { small: {
default: false, default: false,
}, },
maxWidth: {},
}, },
computed: { computed: {
allCategories() { allCategories() {
@ -58,6 +62,14 @@ export default {
if (matches.length > 0) return matches[0].slug; if (matches.length > 0) return matches[0].slug;
} }
}, },
truncateText(text, length = 20, clamp) {
if (!this.truncate) return text;
clamp = clamp || "...";
var node = document.createElement("div");
node.innerHTML = text;
var content = node.textContent;
return content.length > length ? content.slice(0, length) + clamp : content;
},
}, },
}; };
</script> </script>

View File

@ -8,7 +8,7 @@
<v-row dense disabled> <v-row dense disabled>
<v-col> <v-col>
<v-btn <v-btn
v-if="recipe.yields" v-if="recipe.recipeYield"
dense dense
small small
:hover="false" :hover="false"
@ -18,7 +18,7 @@
color="secondary darken-1" color="secondary darken-1"
class="rounded-sm static" class="rounded-sm static"
> >
{{ recipe.yields }} {{ recipe.recipeYield }}
</v-btn> </v-btn>
</v-col> </v-col>
<Rating :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" /> <Rating :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
@ -88,7 +88,7 @@
<script> <script>
import Nutrition from "@/components/Recipe/Parts/Nutrition"; import Nutrition from "@/components/Recipe/Parts/Nutrition";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils"; import { utils } from "@/utils";
import RecipeChips from "./RecipeChips"; import RecipeChips from "./RecipeChips";
import Rating from "@/components/Recipe/Parts/Rating"; import Rating from "@/components/Recipe/Parts/Rating";
import Notes from "@/components/Recipe/Parts/Notes"; import Notes from "@/components/Recipe/Parts/Notes";

View File

@ -6,21 +6,28 @@
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-menu offset-y v-if="$listeners.sortRecent || $listeners.sort"> <v-btn text @click="navigateRandom">
Random
</v-btn>
<v-menu offset-y v-if="$listeners.sort">
<template v-slot:activator="{ on, attrs }"> <template v-slot:activator="{ on, attrs }">
<v-btn-toggle group>
<v-btn text v-bind="attrs" v-on="on"> <v-btn text v-bind="attrs" v-on="on">
{{ $t("general.sort") }} {{ $t("general.sort") }}
</v-btn> </v-btn>
</v-btn-toggle>
</template> </template>
<v-list> <v-list>
<v-list-item @click="$emit('sortRecent')"> <v-list-item @click="sortRecipes(EVENTS.az)">
<v-list-item-title>{{ $t("general.recent") }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('sort')">
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title> <v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item @click="sortRecipes(EVENTS.rating)">
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.updated)">
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.created)">
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
</v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</v-app-bar> </v-app-bar>
@ -76,6 +83,9 @@
<script> <script>
import RecipeCard from "../Recipe/RecipeCard"; import RecipeCard from "../Recipe/RecipeCard";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard"; import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
import { utils } from "@/utils";
const SORT_EVENT = "sort";
export default { export default {
components: { components: {
RecipeCard, RecipeCard,
@ -106,6 +116,12 @@ export default {
return { return {
cardLimit: 30, cardLimit: 30,
loading: false, loading: false,
EVENTS: {
az: "az",
rating: "rating",
created: "created",
updated: "updated",
},
}; };
}, },
watch: { watch: {
@ -144,6 +160,31 @@ export default {
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
this.loading = false; this.loading = false;
}, },
navigateRandom() {
const recipe = utils.recipe.randomRecipe(this.recipes);
this.$router.push(`/recipe/${recipe.slug}`);
},
sortRecipes(sortType) {
let sortTarget = [...this.recipes];
switch (sortType) {
case this.EVENTS.az:
utils.recipe.sortAToZ(sortTarget);
break;
case this.EVENTS.rating:
utils.recipe.sortByRating(sortTarget);
break;
case this.EVENTS.created:
utils.recipe.sortByCreated(sortTarget);
break;
case this.EVENTS.updated:
utils.recipe.sortByUpdated(sortTarget);
break;
default:
console.log("Unknown Event", sortType);
return;
}
this.$emit(SORT_EVENT, sortTarget);
},
}, },
}; };
</script> </script>

View File

@ -36,6 +36,7 @@
"close": "Close", "close": "Close",
"confirm": "Confirm", "confirm": "Confirm",
"create": "Create", "create": "Create",
"created": "Created",
"current-parenthesis": "(Current)", "current-parenthesis": "(Current)",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"delete": "Delete", "delete": "Delete",
@ -63,6 +64,7 @@
"no": "No", "no": "No",
"ok": "OK", "ok": "OK",
"options": "Options:", "options": "Options:",
"rating": "Rating",
"random": "Random", "random": "Random",
"recent": "Recent", "recent": "Recent",
"recipes": "Recipes", "recipes": "Recipes",
@ -83,6 +85,7 @@
"token": "Token", "token": "Token",
"tuesday": "Tuesday", "tuesday": "Tuesday",
"update": "Update", "update": "Update",
"updated": "Updated",
"upload": "Upload", "upload": "Upload",
"url": "URL", "url": "URL",
"users": "Users", "users": "Users",

View File

@ -92,26 +92,36 @@
:label="$t('settings.theme.theme-name')" :label="$t('settings.theme.theme-name')"
v-model="defaultData.name" v-model="defaultData.name"
:rules="[rules.required]" :rules="[rules.required]"
:append-outer-icon="jsonEditor ? 'mdi-form-select' : 'mdi-code-braces'"
@click:append-outer="jsonEditor = !jsonEditor"
></v-text-field> ></v-text-field>
<v-row dense dflex wrap justify-content-center v-if="defaultData.colors"> <v-row dense dflex wrap justify-content-center v-if="defaultData.colors && !jsonEditor">
<v-col cols="12" sm="6" v-for="(_, key) in defaultData.colors" :key="key"> <v-col cols="12" sm="6" v-for="(_, key) in defaultData.colors" :key="key">
<ColorPickerDialog :button-text="labels[key]" v-model="defaultData.colors[key]" /> <ColorPickerDialog :button-text="labels[key]" v-model="defaultData.colors[key]" />
</v-col> </v-col>
</v-row> </v-row>
<VJsoneditor @error="logError()" v-else v-model="defaultData" height="250px" :options="jsonEditorOptions" />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
</div> </div>
</template> </template>
<script> <script>
import VJsoneditor from "v-jsoneditor";
import { api } from "@/api"; import { api } from "@/api";
import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog"; import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog"; import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard"; import StatCard from "@/components/UI/StatCard";
export default { export default {
components: { StatCard, BaseDialog, ColorPickerDialog }, components: { StatCard, BaseDialog, ColorPickerDialog, VJsoneditor },
data() { data() {
return { return {
jsonEditor: true,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
availableThemes: [], availableThemes: [],
color: "accent", color: "accent",
newTheme: false, newTheme: false,

View File

@ -54,7 +54,7 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils"; import { utils } from "@/utils";
import NewMeal from "@/components/MealPlan/MealPlanNew"; import NewMeal from "@/components/MealPlan/MealPlanNew";
import EditPlan from "@/components/MealPlan/MealPlanEditor"; import EditPlan from "@/components/MealPlan/MealPlanEditor";
import ShoppingListDialog from "@/components/MealPlan/ShoppingListDialog"; import ShoppingListDialog from "@/components/MealPlan/ShoppingListDialog";

View File

@ -36,7 +36,7 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils"; import { utils } from "@/utils";
export default { export default {
data() { data() {
return { return {

View File

@ -6,7 +6,7 @@
<v-card v-else id="myRecipe"> <v-card v-else id="myRecipe">
<v-img height="400" :src="getImage(recipeDetails.slug)" class="d-print-none" :key="imageKey"> <v-img height="400" :src="getImage(recipeDetails.slug)" class="d-print-none" :key="imageKey">
<RecipeTimeCard <RecipeTimeCard
class="force-bottom" :class="isMobile ? undefined : 'force-bottom'"
:prepTime="recipeDetails.prepTime" :prepTime="recipeDetails.prepTime"
:totalTime="recipeDetails.totalTime" :totalTime="recipeDetails.totalTime"
:performTime="recipeDetails.performTime" :performTime="recipeDetails.performTime"
@ -106,6 +106,9 @@ export default {
}, },
computed: { computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
currentRecipe() { currentRecipe() {
return this.$route.params.recipe; return this.$route.params.recipe;
}, },

View File

@ -5,9 +5,8 @@
title-icon="" title-icon=""
:sortable="true" :sortable="true"
:title="$t('page.all-recipes')" :title="$t('page.all-recipes')"
:recipes="allRecipes" :recipes="shownRecipes"
@sort="sortAZ" @sort="assignSorted"
@sort-recent="sortRecent"
/> />
</v-container> </v-container>
</template> </template>
@ -22,6 +21,7 @@ export default {
data() { data() {
return { return {
loading: false, loading: false,
sortedResults: [],
}; };
}, },
async mounted() { async mounted() {
@ -35,13 +35,17 @@ export default {
allRecipes() { allRecipes() {
return this.$store.getters.getAllRecipes; return this.$store.getters.getAllRecipes;
}, },
shownRecipes() {
if (this.sortedResults.length > 0) {
return this.sortedResults;
} else {
return this.allRecipes;
}
},
}, },
methods: { methods: {
sortAZ() { assignSorted(val) {
this.allRecipes.sort((a, b) => (a.name > b.name ? 1 : -1)); this.sortedResults = val;
},
sortRecent() {
this.allRecipes.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
}, },
}, },
}; };

View File

@ -47,31 +47,20 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="fuzzyRecipes"> <CardSection title-icon="mdi-mag" :recipes="showRecipes" :hardLimit="maxResults" @sort="assignFuzzy" />
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="item in fuzzyRecipes.slice(0, maxResults)" :key="item.name">
<RecipeCard
:name="item.item.name"
:description="item.item.description"
:slug="item.item.slug"
:rating="item.item.rating"
:image="item.item.image"
:tags="item.item.tags"
/>
</v-col>
</v-row>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>
<script> <script>
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import RecipeCard from "@/components/Recipe/RecipeCard";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector"; import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import CardSection from "@/components/UI/CardSection";
import FilterSelector from "./FilterSelector.vue"; import FilterSelector from "./FilterSelector.vue";
export default { export default {
components: { components: {
RecipeCard, CardSection,
CategoryTagSelector, CategoryTagSelector,
FilterSelector, FilterSelector,
}, },
@ -88,6 +77,7 @@ export default {
exclude: false, exclude: false,
matchAny: false, matchAny: false,
}, },
sortedResults: [],
includeCategories: [], includeCategories: [],
includeTags: [], includeTags: [],
options: { options: {
@ -126,16 +116,31 @@ export default {
}, },
fuzzyRecipes() { fuzzyRecipes() {
if (this.searchString.trim() === "") { if (this.searchString.trim() === "") {
return this.filteredRecipes.map(x => ({ item: x })); return this.filteredRecipes;
} }
const result = this.fuse.search(this.searchString.trim()); const result = this.fuse.search(this.searchString.trim());
return result; return result.map(x => x.item);
}, },
isSearching() { isSearching() {
return this.searchString && this.searchString.length > 0; return this.searchString && this.searchString.length > 0;
}, },
showRecipes() {
if (this.sortedResults.length > 0) {
return this.sortedResults;
} else {
return this.fuzzyRecipes;
}
},
},
watch: {
showRecipes(val) {
console.log(val);
},
}, },
methods: { methods: {
assignFuzzy(val) {
this.sortedResults = val;
},
check(filterBy, recipeList, matchAny, exclude) { check(filterBy, recipeList, matchAny, exclude) {
let isMatch = true; let isMatch = true;
if (filterBy.length === 0) return isMatch; if (filterBy.length === 0) return isMatch;

View File

@ -3,7 +3,7 @@ import ThisWeek from "@/pages/MealPlan/ThisWeek";
import { api } from "@/api"; import { api } from "@/api";
import i18n from "@/i18n.js"; import i18n from "@/i18n.js";
import utils from "@/utils"; import { utils } from "@/utils";
export const mealRoutes = [ export const mealRoutes = [
{ {

View File

@ -1,4 +1,5 @@
import { vueApp } from "../main"; import { vueApp } from "../main";
import { recipe } from "@/utils/recipe";
// TODO: Migrate to Mixins // TODO: Migrate to Mixins
const notifyHelpers = { const notifyHelpers = {
@ -9,7 +10,8 @@ const notifyHelpers = {
info: "notify-info-color", info: "notify-info-color",
}; };
export default { export const utils = {
recipe: recipe,
getImageURL(image) { getImageURL(image) {
return `/api/recipes/${image}/image?image_type=small`; return `/api/recipes/${image}/image?image_type=small`;
}, },

View File

@ -0,0 +1,31 @@
export const recipe = {
/**
* Sorts a list of recipes in place
* @param {Array<Object>} list of recipes
* @param {Boolean} inverse - Z or A First
*/
sortAToZ(list) {
list.sort((a, b) => {
var textA = a.name.toUpperCase();
var textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
},
sortByCreated(list) {
list.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
},
sortByUpdated(list) {
list.sort((a, b) => (a.dateUpdated > b.dateUpdated ? -1 : 1));
},
sortByRating(list) {
list.sort((a, b) => (a.rating > b.rating ? -1 : 1));
},
/**
*
* @param {Array<Object>} list
* @returns String / Recipe Slug
*/
randomRecipe(list) {
return list[Math.floor(Math.random() * list.length)];
},
};

View File

@ -77,7 +77,7 @@ docker-dev: ## Build and Start Docker Development Stack
docker-compose -f docker-compose.dev.yml -p dev-mealie up --build docker-compose -f docker-compose.dev.yml -p dev-mealie up --build
docker-prod: ## Build and Start Docker Production Stack docker-prod: ## Build and Start Docker Production Stack
docker-compose -f docker-compose.yml -p mealie up --build -d docker-compose -f docker-compose.yml -p mealie up --build
code-gen: ## Run Code-Gen Scripts code-gen: ## Run Code-Gen Scripts
poetry run python dev/scripts/app_routes_gen.py poetry run python dev/scripts/app_routes_gen.py

View File

@ -37,24 +37,14 @@ class _Recipes(BaseDocument):
return f"{slug}.{extension}" return f"{slug}.{extension}"
def count_uncategorized(self, session: Session, count=True, override_schema=None) -> int: def count_uncategorized(self, session: Session, count=True, override_schema=None) -> int:
eff_schema = override_schema or self.schema return self._countr_attribute(
if count: session, attribute_name=RecipeModel.recipe_category, attr_match=None, count=True, override_schema=None
return session.query(self.sql_model).filter(RecipeModel.recipe_category == None).count() # noqa: 711 )
else:
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter(RecipeModel.tags == None).all() # noqa: 711
]
def count_untagged(self, session: Session, count=True, override_schema=None) -> int: def count_untagged(self, session: Session, count=True, override_schema=None) -> int:
eff_schema = override_schema or self.schema return self._countr_attribute(
if count: session, attribute_name=RecipeModel.tags, attr_match=None, count=True, override_schema=None
return session.query(self.sql_model).filter(RecipeModel.tags == None).count() # noqa: 711 )
else:
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter(RecipeModel.tags == None).all() # noqa: 711
]
class _Categories(BaseDocument): class _Categories(BaseDocument):

View File

@ -1,4 +1,4 @@
from typing import List from typing import Union
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.db.models.model_base import SqlAlchemyBase from mealie.db.models.model_base import SqlAlchemyBase
@ -20,7 +20,7 @@ class BaseDocument:
# TODO: Improve Get All Query Functionality # TODO: Improve Get All Query Functionality
def get_all( def get_all(
self, session: Session, limit: int = None, order_by: str = None, start=0, end=9999, override_schema=None self, session: Session, limit: int = None, order_by: str = None, start=0, end=9999, override_schema=None
) -> List[dict]: ) -> list[dict]:
eff_schema = override_schema or self.schema eff_schema = override_schema or self.schema
if order_by: if order_by:
@ -33,13 +33,13 @@ class BaseDocument:
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()] return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()]
def get_all_limit_columns(self, session: Session, fields: List[str], limit: int = None) -> List[SqlAlchemyBase]: def get_all_limit_columns(self, session: Session, fields: list[str], limit: int = None) -> list[SqlAlchemyBase]:
"""Queries the database for the selected model. Restricts return responses to the """Queries the database for the selected model. Restricts return responses to the
keys specified under "fields" keys specified under "fields"
Args: \n Args: \n
session (Session): Database Session Object session (Session): Database Session Object
fields (List[str]): List of column names to query fields (list[str]): list of column names to query
limit (int): A limit of values to return limit (int): A limit of values to return
Returns: Returns:
@ -47,7 +47,7 @@ class BaseDocument:
""" """
return session.query(self.sql_model).options(load_only(*fields)).limit(limit).all() return session.query(self.sql_model).options(load_only(*fields)).limit(limit).all()
def get_all_primary_keys(self, session: Session) -> List[str]: def get_all_primary_keys(self, session: Session) -> list[str]:
"""Queries the database of the selected model and returns a list """Queries the database of the selected model and returns a list
of all primary_key values of all primary_key values
@ -79,7 +79,7 @@ class BaseDocument:
def get( def get(
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False
) -> BaseModel or List[BaseModel]: ) -> Union[BaseModel, list[BaseModel]]:
"""Retrieves an entry from the database by matching a key/value pair. If no """Retrieves an entry from the database by matching a key/value pair. If no
key is provided the class objects primary key will be used to match against. key is provided the class objects primary key will be used to match against.
@ -120,6 +120,8 @@ class BaseDocument:
Returns: Returns:
dict: A dictionary representation of the database entry dict: A dictionary representation of the database entry
""" """
document = document if isinstance(document, dict) else document.dict()
new_document = self.sql_model(session=session, **document) new_document = self.sql_model(session=session, **document)
session.add(new_document) session.add(new_document)
session.commit() session.commit()
@ -136,6 +138,7 @@ class BaseDocument:
Returns: Returns:
dict: Returns a dictionary representation of the database entry dict: Returns a dictionary representation of the database entry
""" """
new_data = new_data if isinstance(new_data, dict) else new_data.dict()
entry = self._query_one(session=session, match_value=match_value) entry = self._query_one(session=session, match_value=match_value)
entry.update(session=session, **new_data) entry.update(session=session, **new_data)
@ -144,6 +147,8 @@ class BaseDocument:
return self.schema.from_orm(entry) return self.schema.from_orm(entry)
def patch(self, session: Session, match_value: str, new_data: dict) -> BaseModel: def patch(self, session: Session, match_value: str, new_data: dict) -> BaseModel:
new_data = new_data if isinstance(new_data, dict) else new_data.dict()
entry = self._query_one(session=session, match_value=match_value) entry = self._query_one(session=session, match_value=match_value)
if not entry: if not entry:
@ -168,8 +173,21 @@ class BaseDocument:
session.commit() session.commit()
def count_all(self, session: Session, match_key=None, match_value=None) -> int: def count_all(self, session: Session, match_key=None, match_value=None) -> int:
if None in [match_key, match_value]: if None in [match_key, match_value]:
return session.query(self.sql_model).count() return session.query(self.sql_model).count()
else: else:
return session.query(self.sql_model).filter_by(**{match_key: match_value}).count() return session.query(self.sql_model).filter_by(**{match_key: match_value}).count()
def _countr_attribute(
self, session: Session, attribute_name: str, attr_match: str = None, count=True, override_schema=None
) -> Union[int, BaseModel]:
eff_schema = override_schema or self.schema
# attr_filter = getattr(self.sql_model, attribute_name)
if count:
return session.query(self.sql_model).filter(attribute_name == attr_match).count() # noqa: 711
else:
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter(attribute_name == attr_match).all() # noqa: 711
]

View File

@ -24,7 +24,23 @@ def init_db(db: Session = None) -> None:
def default_theme_init(session: Session): def default_theme_init(session: Session):
db.themes.create(session, SiteTheme().dict()) default_themes = [
SiteTheme().dict(),
{
"name": "Dark",
"colors": {
"primary": "#424242",
"accent": "#455A64",
"secondary": "#00796B",
"success": "#43A047",
"info": "#1976D2",
"warning": "#FF6F00",
"error": "#EF5350",
},
},
]
for theme in default_themes:
db.themes.create(session, theme)
def default_settings_init(session: Session): def default_settings_init(session: Session):

View File

@ -1,9 +1,9 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from mealie.core.config import settings
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.category import Category, group2categories from mealie.db.models.recipe.category import Category, group2categories
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import settings
class WebhookURLModel(SqlAlchemyBase): class WebhookURLModel(SqlAlchemyBase):
@ -50,10 +50,6 @@ class Group(SqlAlchemyBase, BaseMixins):
self.webhook_time = webhook_time self.webhook_time = webhook_time
self.webhook_urls = [WebhookURLModel(url=x) for x in webhook_urls] self.webhook_urls = [WebhookURLModel(url=x) for x in webhook_urls]
def update(self, session: Session, *args, **kwargs):
self.__init__(session=session, *args, **kwargs)
@staticmethod @staticmethod
def get_ref(session: Session, name: str): def get_ref(session: Session, name: str):
item = session.query(Group).filter(Group.name == name).one_or_none() item = session.query(Group).filter(Group.name == name).one_or_none()

View File

@ -27,12 +27,16 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
name = sa.Column(sa.String, nullable=False) name = sa.Column(sa.String, nullable=False)
description = sa.Column(sa.String) description = sa.Column(sa.String)
image = sa.Column(sa.String) image = sa.Column(sa.String)
# Time Related Properties
total_time = sa.Column(sa.String) total_time = sa.Column(sa.String)
prep_time = sa.Column(sa.String) prep_time = sa.Column(sa.String)
perform_time = sa.Column(sa.String) perform_time = sa.Column(sa.String)
cookTime = sa.Column(sa.String) cook_time = sa.Column(sa.String)
recipe_yield = sa.Column(sa.String) recipe_yield = sa.Column(sa.String)
recipeCuisine = sa.Column(sa.String) recipeCuisine = sa.Column(sa.String)
tools: list[Tool] = orm.relationship("Tool", cascade="all, delete-orphan") tools: list[Tool] = orm.relationship("Tool", cascade="all, delete-orphan")
assets: list[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan") assets: list[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
@ -55,12 +59,15 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
slug = sa.Column(sa.String, index=True, unique=True) slug = sa.Column(sa.String, index=True, unique=True)
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan") settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes") tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
date_added = sa.Column(sa.Date, default=date.today)
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan") notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
rating = sa.Column(sa.Integer) rating = sa.Column(sa.Integer)
org_url = sa.Column(sa.String) org_url = sa.Column(sa.String)
extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan") extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
# Time Stamp Properties
date_added = sa.Column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime)
@validates("name") @validates("name")
def validate_name(self, key, name): def validate_name(self, key, name):
assert name != "" assert name != ""
@ -78,6 +85,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
recipeCuisine: str = None, recipeCuisine: str = None,
total_time: str = None, total_time: str = None,
prep_time: str = None, prep_time: str = None,
cook_time: str = None,
nutrition: dict = None, nutrition: dict = None,
tools: list[str] = None, tools: list[str] = None,
perform_time: str = None, perform_time: str = None,
@ -85,6 +93,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
recipe_category: list[str] = None, recipe_category: list[str] = None,
tags: list[str] = None, tags: list[str] = None,
date_added: datetime.date = None, date_added: datetime.date = None,
date_updated: datetime.datetime = None,
notes: list[dict] = None, notes: list[dict] = None,
rating: int = None, rating: int = None,
org_url: str = None, org_url: str = None,
@ -113,6 +122,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.total_time = total_time self.total_time = total_time
self.prep_time = prep_time self.prep_time = prep_time
self.perform_time = perform_time self.perform_time = perform_time
self.cook_time = cook_time
self.recipe_category = [Category.create_if_not_exist(session=session, name=cat) for cat in recipe_category] self.recipe_category = [Category.create_if_not_exist(session=session, name=cat) for cat in recipe_category]
@ -120,12 +130,15 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.settings = RecipeSettings(**settings) if settings else RecipeSettings() self.settings = RecipeSettings(**settings) if settings else RecipeSettings()
self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags] self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags]
self.slug = slug self.slug = slug
self.date_added = date_added
self.notes = [Note(**note) for note in notes] self.notes = [Note(**note) for note in notes]
self.rating = rating self.rating = rating
self.org_url = org_url self.org_url = org_url
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()] self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
# Time Stampes
self.date_added = date_added
self.date_updated = datetime.datetime.now()
def update(self, *args, **kwargs): def update(self, *args, **kwargs):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions""" """Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
self.__init__(*args, **kwargs) self.__init__(*args, **kwargs)

View File

@ -1,14 +1,13 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy.sql.sqltypes import Integer from sqlalchemy import Column, ForeignKey, Integer, String
class SiteThemeModel(SqlAlchemyBase, BaseMixins): class SiteThemeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "site_theme" __tablename__ = "site_theme"
id = sa.Column(Integer, primary_key=True, unique=True) id = Column(Integer, primary_key=True, unique=True)
name = sa.Column(sa.String, nullable=False, unique=True) name = Column(String, nullable=False, unique=True)
colors = orm.relationship("ThemeColorsModel", uselist=False, cascade="all, delete") colors = orm.relationship("ThemeColorsModel", uselist=False, single_parent=True, cascade="all, delete-orphan")
def __init__(self, name: str, colors: dict, *arg, **kwargs) -> None: def __init__(self, name: str, colors: dict, *arg, **kwargs) -> None:
self.name = name self.name = name
@ -17,12 +16,12 @@ class SiteThemeModel(SqlAlchemyBase, BaseMixins):
class ThemeColorsModel(SqlAlchemyBase, BaseMixins): class ThemeColorsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "theme_colors" __tablename__ = "theme_colors"
id = sa.Column(sa.Integer, primary_key=True) id = Column(Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("site_theme.name")) parent_id = Column(Integer, ForeignKey("site_theme.id"))
primary = sa.Column(sa.String) primary = Column(String)
accent = sa.Column(sa.String) accent = Column(String)
secondary = sa.Column(sa.String) secondary = Column(String)
success = sa.Column(sa.String) success = Column(String)
info = sa.Column(sa.String) info = Column(String)
warning = sa.Column(sa.String) warning = Column(String)
error = sa.Column(sa.String) error = Column(String)

View File

@ -69,6 +69,9 @@ class RecipeSummary(CamelModel):
tags: Optional[list[str]] = [] tags: Optional[list[str]] = []
rating: Optional[int] rating: Optional[int]
date_added: Optional[datetime.date]
date_updated: Optional[datetime.datetime]
class Config: class Config:
orm_mode = True orm_mode = True
@ -95,7 +98,6 @@ class Recipe(RecipeSummary):
# Mealie Specific # Mealie Specific
settings: Optional[RecipeSettings] settings: Optional[RecipeSettings]
assets: Optional[list[RecipeAsset]] = [] assets: Optional[list[RecipeAsset]] = []
date_added: Optional[datetime.date]
notes: Optional[list[RecipeNote]] = [] notes: Optional[list[RecipeNote]] = []
org_url: Optional[str] = Field(None, alias="orgURL") org_url: Optional[str] = Field(None, alias="orgURL")
extras: Optional[dict] = {} extras: Optional[dict] = {}

View File

@ -8,8 +8,8 @@ class Colors(BaseModel):
accent: str = "#00457A" accent: str = "#00457A"
secondary: str = "#973542" secondary: str = "#973542"
success: str = "#43A047" success: str = "#43A047"
info: str = "#4990BA" info: str = "#1976D2"
warning: str = "#FF4081" warning: str = "#FF6F00"
error: str = "#EF5350" error: str = "#EF5350"
class Config: class Config:

View File

@ -20,7 +20,7 @@ def default_theme():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def new_theme(): def new_theme():
return { return {
"id": 2, "id": 3,
"name": "myTestTheme", "name": "myTestTheme",
"colors": { "colors": {
"primary": "#E58325", "primary": "#E58325",
@ -73,7 +73,9 @@ def test_create_theme(api_client: TestClient, api_routes: AppRoutes, new_theme,
def test_read_all_themes(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme): def test_read_all_themes(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme):
response = api_client.get(api_routes.themes) response = api_client.get(api_routes.themes)
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.content) == [default_theme, new_theme] response_dict = json.loads(response.content)
assert default_theme in response_dict
assert new_theme in response_dict
def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme): def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme):