feature: proper multi-tenant-support (#969)(WIP)

* update naming

* refactor tests to use shared structure

* shorten names

* add tools test case

* refactor to support multi-tenant

* set group_id on creation

* initial refactor for multitenant tags/cats

* spelling

* additional test case for same valued resources

* fix recipe update tests

* apply indexes to foreign keys

* fix performance regressions

* handle unknown exception

* utility decorator for function debugging

* migrate recipe_id to UUID

* GUID for recipes

* remove unused import

* move image functions into package

* move utilities to packages dir

* update import

* linter

* image image and asset routes

* update assets and images to use UUIDs

* fix migration base

* image asset test coverage

* use ids for categories and tag crud functions

* refactor recipe organizer test suite to reduce duplication

* add uuid serlization utility

* organizer base router

* slug routes testing and fixes

* fix postgres error

* adopt UUIDs

* move tags, categories, and tools under "organizers" umbrella

* update composite label

* generate ts types

* fix import error

* update frontend types

* fix type errors

* fix postgres errors

* fix #978

* add null check for title validation

* add note in docs on multi-tenancy
This commit is contained in:
Hayden 2022-02-13 12:23:42 -09:00 committed by GitHub
parent 9a82a172cb
commit c617251f4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
157 changed files with 1866 additions and 1578 deletions

View File

@ -14,6 +14,7 @@
- User/Group settings are now completely separated from the Administration page.
- All settings and configurations pages now have some sort of self-documenting help text. Additional text or descriptions are welcome from PRs
- New experimental banner for the site to give users a sense of what features are still "in development" and provide a link to a github issue that provides additional context.
- Groups now offer full multi-tenant support so you can all groups have their own set of data.
#### ⚙️ Site Settings Page

View File

@ -1,47 +0,0 @@
import { BaseCRUDAPI } from "../_base";
import { Recipe } from "~/types/api-types/recipe";
const prefix = "/api";
export interface Category {
name: string;
id: number;
slug: string;
recipes?: Recipe[];
}
export interface CreateCategory {
name: string;
}
const routes = {
categories: `${prefix}/categories`,
categoriesEmpty: `${prefix}/categories/empty`,
categoriesCategory: (category: string) => `${prefix}/categories/${category}`,
};
export class CategoriesAPI extends BaseCRUDAPI<Category, CreateCategory> {
baseRoute: string = routes.categories;
itemRoute = routes.categoriesCategory;
/** Returns a list of categories that do not contain any recipes
*/
async getEmptyCategories() {
return await this.requests.get(routes.categoriesEmpty);
}
/** Returns a list of recipes associated with the provided category.
*/
async getAllRecipesByCategory(category: string) {
return await this.requests.get(routes.categoriesCategory(category));
}
/** Removes a recipe category from the database. Deleting a
* category does not impact a recipe. The category will be removed
* from any recipes that contain it
*/
async deleteRecipeCategory(category: string) {
return await this.requests.delete(routes.categoriesCategory(category));
}
}

View File

@ -1,6 +1,6 @@
import { BaseCRUDAPI } from "../_base";
import { Category } from "./categories";
import { CategoryBase } from "~/types/api-types/recipe";
import { RecipeCategory } from "~/types/api-types/user";
const prefix = "/api";
@ -14,7 +14,7 @@ export interface CookBook extends CreateCookBook {
description: string;
position: number;
group_id: number;
categories: Category[] | CategoryBase[];
categories: RecipeCategory[] | CategoryBase[];
}
const routes = {

View File

@ -16,7 +16,7 @@ export interface CreateMealPlan {
entryType: PlanEntryType;
title: string;
text: string;
recipeId?: number;
recipeId?: string;
}
export interface UpdateMealPlan extends CreateMealPlan {

View File

@ -12,7 +12,7 @@ const prefix = "/api";
const routes = {
shoppingLists: `${prefix}/groups/shopping/lists`,
shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`,
shoppingListIdAddRecipe: (id: string, recipeId: number) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`,
shoppingListIdAddRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`,
shoppingListItems: `${prefix}/groups/shopping/items`,
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
@ -22,11 +22,11 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListOut, ShoppingListC
baseRoute = routes.shoppingLists;
itemRoute = routes.shoppingListsId;
async addRecipe(itemId: string, recipeId: number) {
async addRecipe(itemId: string, recipeId: string) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {});
}
async removeRecipe(itemId: string, recipeId: number) {
async removeRecipe(itemId: string, recipeId: string) {
return await this.requests.delete(routes.shoppingListIdAddRecipe(itemId, recipeId));
}
}

View File

@ -0,0 +1,20 @@
import { BaseCRUDAPI } from "../_base";
import { CategoryIn, RecipeCategoryResponse } from "~/types/api-types/recipe";
import { config } from "~/api/config";
const prefix = config.PREFIX + "/organizers";
const routes = {
categories: `${prefix}/categories`,
categoriesId: (category: string) => `${prefix}/categories/${category}`,
categoriesSlug: (category: string) => `${prefix}/categories/slug/${category}`,
};
export class CategoriesAPI extends BaseCRUDAPI<RecipeCategoryResponse, CategoryIn> {
baseRoute: string = routes.categories;
itemRoute = routes.categoriesId;
async bySlug(slug: string) {
return await this.requests.get<RecipeCategoryResponse>(routes.categoriesSlug(slug));
}
}

View File

@ -0,0 +1,20 @@
import { BaseCRUDAPI } from "../_base";
import { RecipeTagResponse, TagIn } from "~/types/api-types/recipe";
import { config } from "~/api/config";
const prefix = config.PREFIX + "/organizers";
const routes = {
tags: `${prefix}/tags`,
tagsId: (tag: string) => `${prefix}/tags/${tag}`,
tagsSlug: (tag: string) => `${prefix}/tags/slug/${tag}`,
};
export class TagsAPI extends BaseCRUDAPI<RecipeTagResponse, TagIn> {
baseRoute: string = routes.tags;
itemRoute = routes.tagsId;
async bySlug(slug: string) {
return await this.requests.get<RecipeTagResponse>(routes.tagsSlug(slug));
}
}

View File

@ -1,7 +1,9 @@
import { BaseCRUDAPI } from "../_base";
import { RecipeTool, RecipeToolCreate, RecipeToolResponse } from "~/types/api-types/recipe";
const prefix = "/api";
import { config } from "~/api/config";
const prefix = config.PREFIX + "/organizers";
const routes = {
tools: `${prefix}/tools`,
@ -13,7 +15,7 @@ export class ToolsApi extends BaseCRUDAPI<RecipeTool, RecipeToolCreate> {
baseRoute: string = routes.tools;
itemRoute = routes.toolsId;
async byslug(slug: string) {
async bySlug(slug: string) {
return await this.requests.get<RecipeToolResponse>(routes.toolsSlug(slug));
}
}

View File

@ -1,22 +1,14 @@
import { BaseCRUDAPI } from "../_base";
import { CreateIngredientFood, IngredientFood } from "~/types/api-types/recipe";
const prefix = "/api";
export interface CreateFood {
name: string;
description: string;
}
export interface Food extends CreateFood {
id: number;
}
const routes = {
food: `${prefix}/foods`,
foodsFood: (tag: string) => `${prefix}/foods/${tag}`,
};
export class FoodAPI extends BaseCRUDAPI<Food, CreateFood> {
export class FoodAPI extends BaseCRUDAPI<IngredientFood, CreateIngredientFood> {
baseRoute: string = routes.food;
itemRoute = routes.foodsFood;
}

View File

@ -8,12 +8,12 @@ const routes = {
};
export interface RecipeShareTokenCreate {
recipeId: number;
recipeId: string;
expiresAt?: Date;
}
export interface RecipeShareToken {
recipeId: number;
recipeId: string;
id: string;
groupId: number;
expiresAt: string;

View File

@ -1,6 +1,5 @@
import { Category } from "../categories";
import { Tag } from "../tags";
import { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit } from "~/types/api-types/recipe";
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
export type Parser = "nlp" | "brute";
@ -30,8 +29,8 @@ export interface ParsedIngredient {
export interface BulkCreateRecipe {
url: string;
categories: Category[];
tags: Tag[];
categories: RecipeCategory[];
tags: RecipeTag[];
}
export interface BulkCreatePayload {
@ -50,7 +49,7 @@ export interface CreateAsset {
}
export interface RecipeCommentCreate {
recipeId: number;
recipeId: string;
text: string;
}

View File

@ -1,47 +0,0 @@
import { BaseCRUDAPI } from "../_base";
import { Recipe } from "~/types/api-types/admin";
const prefix = "/api";
export interface Tag {
name: string;
id: number;
slug: string;
recipes?: Recipe[];
}
export interface CreateTag {
name: string;
}
const routes = {
tags: `${prefix}/tags`,
tagsEmpty: `${prefix}/tags/empty`,
tagsTag: (tag: string) => `${prefix}/tags/${tag}`,
};
export class TagsAPI extends BaseCRUDAPI<Tag, CreateTag> {
baseRoute: string = routes.tags;
itemRoute = routes.tagsTag;
/** Returns a list of categories that do not contain any recipes
*/
async getEmptyCategories() {
return await this.requests.get(routes.tagsEmpty);
}
/** Returns a list of recipes associated with the provided category.
*/
async getAllRecipesByCategory(category: string) {
return await this.requests.get(routes.tagsTag(category));
}
/** Removes a recipe category from the database. Deleting a
* category does not impact a recipe. The category will be removed
* from any recipes that contain it
*/
async deleteRecipeCategory(category: string) {
return await this.requests.delete(routes.tagsTag(category));
}
}

5
frontend/api/config.ts Normal file
View File

@ -0,0 +1,5 @@
const PREFIX = "/api";
export const config = {
PREFIX,
};

View File

@ -4,8 +4,8 @@ import { GroupAPI } from "./class-interfaces/groups";
import { EventsAPI } from "./class-interfaces/events";
import { BackupAPI } from "./class-interfaces/backups";
import { UploadFile } from "./class-interfaces/upload";
import { CategoriesAPI } from "./class-interfaces/categories";
import { TagsAPI } from "./class-interfaces/tags";
import { CategoriesAPI } from "./class-interfaces/organizer-categories";
import { TagsAPI } from "./class-interfaces/organizer-tags";
import { UtilsAPI } from "./class-interfaces/utils";
import { FoodAPI } from "./class-interfaces/recipe-foods";
import { UnitAPI } from "./class-interfaces/recipe-units";
@ -17,7 +17,7 @@ import { EmailAPI } from "./class-interfaces/email";
import { BulkActionsAPI } from "./class-interfaces/recipe-bulk-actions";
import { GroupServerTaskAPI } from "./class-interfaces/group-tasks";
import { AdminAPI } from "./admin-api";
import { ToolsApi } from "./class-interfaces/tools";
import { ToolsApi } from "./class-interfaces/organizer-tools";
import { GroupMigrationApi } from "./class-interfaces/group-migrations";
import { GroupReportsApi } from "./class-interfaces/group-reports";
import { ShoppingApi } from "./class-interfaces/group-shopping-lists";

View File

@ -24,15 +24,7 @@
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always />
<v-tooltip v-if="!locked" bottom color="info">
<template #activator="{ on, attrs }">
<v-btn
fab
small
class="mx-1"
color="info"
v-bind="attrs"
v-on="on"
@click="$emit('input', true)"
>
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('input', true)">
<v-icon> {{ $globals.icons.edit }} </v-icon>
</v-btn>
</template>
@ -40,14 +32,7 @@
</v-tooltip>
<v-tooltip v-else bottom color="info">
<template #activator="{ on, attrs }">
<v-btn
fab
small
class="mx-1"
color="info"
v-bind="attrs"
v-on="on"
>
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on">
<v-icon> {{ $globals.icons.lock }} </v-icon>
</v-btn>
</template>
@ -93,7 +78,7 @@
</template>
<script lang="ts">
import {defineComponent, ref, useContext} from "@nuxtjs/composition-api";
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
@ -123,7 +108,7 @@ export default defineComponent({
},
recipeId: {
required: true,
type: Number,
type: String,
},
locked: {
type: Boolean,
@ -191,7 +176,7 @@ export default defineComponent({
editorButtons,
emitHandler,
emitDelete,
}
};
},
});
</script>

View File

@ -86,6 +86,10 @@ export default defineComponent({
type: String,
required: true,
},
recipeId: {
type: String,
required: true,
},
value: {
type: Array,
required: true,
@ -143,7 +147,7 @@ export default defineComponent({
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.slug, assetName);
return recipeAssetPath(props.recipeId, assetName);
}
function assetEmbed(name: string) {

View File

@ -8,7 +8,14 @@
:min-height="imageHeight + 75"
@click="$emit('click')"
>
<RecipeCardImage :icon-size="imageHeight" :height="imageHeight" :slug="slug" small :image-version="image">
<RecipeCardImage
:icon-size="imageHeight"
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
small
:image-version="image"
>
<v-expand-transition v-if="description">
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%">
<v-card-text class="v-card--text-show white--text">
@ -95,7 +102,7 @@ export default defineComponent({
},
recipeId: {
required: true,
type: Number,
type: String,
},
imageHeight: {
type: Number,

View File

@ -2,7 +2,7 @@
<v-img
v-if="!fallBackImage"
:height="height"
:src="getImage(slug)"
:src="getImage(recipeId)"
@click="$emit('click')"
@load="fallBackImage = false"
@error="fallBackImage = true"
@ -18,7 +18,7 @@
</template>
<script lang="ts">
import {computed, defineComponent, ref, watch} from "@nuxtjs/composition-api";
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api";
export default defineComponent({
@ -43,6 +43,10 @@ export default defineComponent({
type: String,
default: null,
},
recipeId: {
type: String,
required: true,
},
imageVersion: {
type: String,
default: null,
@ -63,20 +67,23 @@ export default defineComponent({
if (props.small) return "small";
if (props.large) return "large";
return "large";
})
watch(() => props.slug, () => {
fallBackImage.value = false;
});
function getImage(slug: string) {
watch(
() => props.recipeId,
() => {
fallBackImage.value = false;
}
);
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(slug, props.imageVersion);
return recipeTinyImage(recipeId, props.imageVersion);
case "small":
return recipeSmallImage(slug, props.imageVersion);
return recipeSmallImage(recipeId, props.imageVersion);
case "large":
return recipeImage(slug, props.imageVersion);
return recipeImage(recipeId, props.imageVersion);
}
}

View File

@ -10,7 +10,14 @@
<v-list-item three-line>
<slot name="avatar">
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<RecipeCardImage :icon-size="100" :height="125" :slug="slug" small :image-version="image"></RecipeCardImage>
<RecipeCardImage
:icon-size="100"
:height="125"
:slug="slug"
:recipe-id="recipeId"
small
:image-version="image"
></RecipeCardImage>
</v-list-item-avatar>
</slot>
<v-list-item-content>
@ -93,7 +100,7 @@ export default defineComponent({
default: true,
},
recipeId: {
type: Number,
type: String,
required: true,
},
},
@ -105,7 +112,7 @@ export default defineComponent({
return {
loggedIn,
}
};
},
});
</script>

View File

@ -46,8 +46,7 @@
import { computed, defineComponent, onMounted, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog.vue";
import { useTags, useCategories } from "~/composables/recipes";
import { Category } from "~/api/class-interfaces/categories";
import { Tag } from "~/api/class-interfaces/tags";
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
const MOUNTED_EVENT = "mounted";
@ -57,7 +56,7 @@ export default defineComponent({
},
props: {
value: {
type: Array as () => (Category | Tag | string)[],
type: Array as () => (RecipeTag | RecipeCategory | string)[],
required: true,
},
solo: {
@ -103,9 +102,12 @@ export default defineComponent({
const state = reactive({
selected: props.value,
});
watch(() => props.value, (val) => {
state.selected = val;
});
watch(
() => props.value,
(val) => {
state.selected = val;
}
);
const { i18n } = useContext();
const inputLabel = computed(() => {
@ -114,14 +116,14 @@ export default defineComponent({
});
const activeItems = computed(() => {
let itemObjects: Tag[] | Category[] | null;
let itemObjects: RecipeTag[] | RecipeCategory[] | null;
if (props.tagSelector) itemObjects = allTags.value;
else {
itemObjects = allCategories.value;
}
if (props.returnObject) return itemObjects;
else {
return itemObjects?.map((x: Tag | Category) => x.name);
return itemObjects?.map((x: RecipeTag | RecipeCategory) => x.name);
}
});
@ -145,7 +147,7 @@ export default defineComponent({
state.selected.splice(index, 1);
}
function pushToItem(createdItem: Tag | Category) {
function pushToItem(createdItem: RecipeTag | RecipeCategory) {
// TODO: Remove excessive get calls
getAllCategories();
getAllTags();
@ -164,4 +166,3 @@ export default defineComponent({
},
});
</script>

View File

@ -69,7 +69,7 @@ export default defineComponent({
required: true,
},
recipeId: {
type: Number,
type: String,
required: true,
},
},
@ -114,4 +114,4 @@ export default defineComponent({
return { api, comments, ...toRefs(state), submitComment, deleteComment };
},
});
</script>
</script>

View File

@ -168,7 +168,7 @@ export default defineComponent({
},
recipeId: {
required: true,
type: Number,
type: String,
},
},
setup(props, context) {

View File

@ -70,7 +70,7 @@ export default defineComponent({
default: false,
},
recipeId: {
type: Number,
type: String,
required: true,
},
name: {

View File

@ -158,14 +158,15 @@ export default defineComponent({
}
function handleUnitEnter() {
if (value.unit === undefined || !value.unit.name.includes(unitSearch.value)) {
if (value.unit === undefined || value.unit === null || !value.unit.name.includes(unitSearch.value)) {
console.log("Creating");
createAssignUnit();
}
}
function handleFoodEnter() {
if (value.food === undefined || !value.food.name.includes(foodSearch.value)) {
console.log(value.food);
if (value.food === undefined || value.food === null || !value.food.name.includes(foodSearch.value)) {
console.log("Creating");
createAssignFood();
}
@ -190,7 +191,7 @@ export default defineComponent({
});
</script>
<style >
<style>
.v-input__append-outer {
margin: 0 !important;
padding: 0 !important;

View File

@ -50,7 +50,7 @@ export default defineComponent({
},
setup(props) {
function validateTitle(title?: string) {
return !(title === undefined || title === "");
return !(title === undefined || title === "" || title === null);
}
const state = reactive({

View File

@ -10,20 +10,20 @@ export const useStaticRoutes = () => {
const fullBase = serverBase + prefix;
// Methods to Generate reference urls for assets/images *
function recipeImage(recipeSlug: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeSlug}/images/original.webp?&rnd=${key}&version=${version}`;
function recipeImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?&rnd=${key}&version=${version}`;
}
function recipeSmallImage(recipeSlug: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeSlug}/images/min-original.webp?&rnd=${key}&version=${version}`;
function recipeSmallImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?&rnd=${key}&version=${version}`;
}
function recipeTinyImage(recipeSlug: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeSlug}/images/tiny-original.webp?&rnd=${key}&version=${version}`;
function recipeTinyImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?&rnd=${key}&version=${version}`;
}
function recipeAssetPath(recipeSlug: string, assetName: string) {
return `${fullBase}/media/recipes/${recipeSlug}/assets/${assetName}`;
function recipeAssetPath(recipeId: string, assetName: string) {
return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`;
}
return {

View File

@ -12,11 +12,11 @@ export const useFoods = function () {
const deleteTargetId = ref(0);
const validForm = ref(true);
const workingFoodData = reactive({
id: 0,
const workingFoodData = reactive<IngredientFood>({
id: "",
name: "",
description: "",
labelId: "",
labelId: undefined,
});
const actions = {
@ -80,16 +80,16 @@ export const useFoods = function () {
}
},
resetWorking() {
workingFoodData.id = 0;
workingFoodData.id = "";
workingFoodData.name = "";
workingFoodData.description = "";
workingFoodData.labelId = "";
workingFoodData.labelId = undefined;
},
setWorking(item: IngredientFood) {
workingFoodData.id = item.id;
workingFoodData.name = item.name;
workingFoodData.description = item.description || "";
workingFoodData.labelId = item.labelId || "";
workingFoodData.labelId = item.labelId;
},
flushStore() {
foodStore = null;

View File

@ -1,9 +1,7 @@
import { Ref } from "@nuxtjs/composition-api";
// import { useStaticRoutes } from "../api";
import { Recipe } from "~/types/api-types/recipe";
export const useRecipeMeta = (recipe: Ref<Recipe | null>) => {
// const { recipeImage } = useStaticRoutes();
return () => {
const imageURL = "";
return {

View File

@ -2,10 +2,11 @@ import { reactive, ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
import { VForm } from "~/types/vuetify";
import { RecipeTool } from "~/types/api-types/user";
export const useTools = function (eager = true) {
const workingToolData = reactive({
id: 0,
const workingToolData = reactive<RecipeTool>({
id: "",
name: "",
slug: "",
onHand: false,
@ -72,7 +73,7 @@ export const useTools = function (eager = true) {
reset() {
workingToolData.name = "";
workingToolData.id = 0;
workingToolData.id = "";
loading.value = false;
validForm.value = true;
},

View File

@ -1,13 +1,17 @@
import { Ref, ref, useAsync } from "@nuxtjs/composition-api";
import { useUserApi } from "../api";
import { useAsyncKey } from "../use-utils";
import { CategoriesAPI, Category } from "~/api/class-interfaces/categories";
import { Tag, TagsAPI } from "~/api/class-interfaces/tags";
import { CategoriesAPI } from "~/api/class-interfaces/organizer-categories";
import { TagsAPI } from "~/api/class-interfaces/organizer-tags";
import { RecipeTag, RecipeCategory } from "~/types/api-types/recipe";
export const allCategories = ref<Category[] | null>([]);
export const allTags = ref<Tag[] | null>([]);
export const allCategories = ref<RecipeCategory[] | null>([]);
export const allTags = ref<RecipeTag[] | null>([]);
function baseTagsCategories(reference: Ref<Category[] | null> | Ref<Tag[] | null>, api: TagsAPI | CategoriesAPI) {
function baseTagsCategories(
reference: Ref<RecipeCategory[] | null> | Ref<RecipeTag[] | null>,
api: TagsAPI | CategoriesAPI
) {
function useAsyncGetAll() {
useAsync(async () => {
await refreshItems();

View File

@ -107,6 +107,7 @@
<v-list-item-avatar :rounded="false">
<RecipeCardImage
v-if="mealplan.recipe"
:recipe-id="mealplan.recipe.id"
tiny
icon-size="25"
:slug="mealplan.recipe ? mealplan.recipe.slug : ''"

View File

@ -7,8 +7,8 @@
></RecipeCardSection>
</v-container>
</template>
<script lang="ts">
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useRecipes, recentRecipes } from "~/composables/recipes";
@ -23,4 +23,3 @@ export default defineComponent({
},
});
</script>

View File

@ -35,7 +35,7 @@
:max-width="enableLandscape ? null : '50%'"
min-height="50"
:height="hideImage ? undefined : imageHeight"
:src="recipeImage(recipe.slug, imageKey)"
:src="recipeImage(recipe.id, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
@ -284,6 +284,7 @@
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
@ -362,6 +363,7 @@
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
:recipe-id="recipe.id"
/>
</client-only>
</div>
@ -562,7 +564,6 @@ export default defineComponent({
const { recipeImage } = useStaticRoutes();
// ===========================================================================
// Layout Helpers
@ -691,9 +692,8 @@ export default defineComponent({
// Recipe Tools
async function updateTool(tool: RecipeTool) {
if (tool.id === undefined)
return;
if (tool.id === undefined) return;
const { response } = await api.tools.updateOne(tool.id, tool);
if (response?.status === 200) {

View File

@ -72,7 +72,7 @@ export default defineComponent({
});
const category = useAsync(async () => {
const { data } = await api.categories.getOne(slug);
const { data } = await api.categories.bySlug(slug);
if (data) {
state.initialValue = data.name;
}
@ -93,7 +93,7 @@ export default defineComponent({
if (!category.value) {
return;
}
const { data } = await api.categories.updateOne(category.value.slug, category.value);
const { data } = await api.categories.updateOne(category.value.id, category.value);
if (data) {
router.push("/recipes/categories/" + data.slug);

View File

@ -72,7 +72,7 @@ export default defineComponent({
});
const tags = useAsync(async () => {
const { data } = await api.tags.getOne(slug);
const { data } = await api.tags.bySlug(slug);
if (data) {
state.initialValue = data.name;
}
@ -93,7 +93,7 @@ export default defineComponent({
if (!tags.value) {
return;
}
const { data } = await api.tags.updateOne(tags.value.slug, tags.value);
const { data } = await api.tags.updateOne(tags.value.id, tags.value);
if (data) {
router.push("/recipes/tags/" + data.slug);

View File

@ -66,7 +66,7 @@ export default defineComponent({
});
const tools = useAsync(async () => {
const { data } = await api.tools.byslug(slug);
const { data } = await api.tools.bySlug(slug);
if (data) {
state.initialValue = data.name;
}

View File

@ -115,8 +115,8 @@ import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategory
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useRecipes, allRecipes, useFoods } from "~/composables/recipes";
import { RecipeSummary } from "~/types/api-types/recipe";
import { Tag } from "~/api/class-interfaces/tags";
import { useRouteQuery } from "~/composables/use-router";
import { RecipeTag } from "~/types/api-types/user";
interface GenericFilter {
exclude: boolean;
@ -189,7 +189,7 @@ export default defineComponent({
state.includeTags,
// @ts-ignore See above
recipe.tags.map((x: Tag) => x.name),
recipe.tags.map((x: RecipeTag) => x.name),
state.tagFilter.matchAny,
state.tagFilter.exclude
);

View File

@ -324,7 +324,7 @@ export default defineComponent({
if (data) {
if (data && data !== undefined) {
console.log("Computed Meta. RefKey=");
const imageURL = data.slug ? recipeImage(data.slug) : undefined;
const imageURL = data.id ? recipeImage(data.id) : undefined;
title.value = data.name;
meta.value = [

View File

@ -411,7 +411,7 @@ export default defineComponent({
return shoppingList.value?.recipeReferences?.map((ref) => ref.recipe) ?? [];
});
async function addRecipeReferenceToList(recipeId: number) {
async function addRecipeReferenceToList(recipeId: string) {
if (!shoppingList.value) {
return;
}
@ -423,7 +423,7 @@ export default defineComponent({
}
}
async function removeRecipeReferenceToList(recipeId: number) {
async function removeRecipeReferenceToList(recipeId: string) {
if (!shoppingList.value) {
return;
}
@ -577,4 +577,3 @@ export default defineComponent({
max-width: 50px;
}
</style>

View File

@ -72,12 +72,12 @@ export interface CustomPageBase {
}
export interface RecipeCategoryResponse {
name: string;
id: number;
id: string;
slug: string;
recipes?: Recipe[];
recipes?: RecipeSummary[];
}
export interface Recipe {
id?: number;
export interface RecipeSummary {
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -97,28 +97,19 @@ export interface Recipe {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
recipeInstructions?: RecipeStep[];
nutrition?: Nutrition;
settings?: RecipeSettings;
assets?: RecipeAsset[];
notes?: RecipeNote[];
extras?: {
[k: string]: unknown;
};
comments?: RecipeCommentOut[];
}
export interface RecipeCategory {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
id: string;
name: string;
slug: string;
onHand?: boolean;
@ -137,7 +128,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
id: string;
}
export interface CreateIngredientUnit {
name: string;
@ -149,7 +140,7 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
id: number;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
@ -163,59 +154,6 @@ export interface CreateIngredientFood {
description?: string;
labelId?: string;
}
export interface RecipeStep {
id?: string;
title?: string;
text: string;
ingredientReferences?: IngredientReferences[];
}
/**
* A list of ingredient references.
*/
export interface IngredientReferences {
referenceId?: string;
}
export interface Nutrition {
calories?: string;
fatContent?: string;
proteinContent?: string;
carbohydrateContent?: string;
fiberContent?: string;
sodiumContent?: string;
sugarContent?: string;
}
export interface RecipeSettings {
public?: boolean;
showNutrition?: boolean;
showAssets?: boolean;
landscapeView?: boolean;
disableComments?: boolean;
disableAmount?: boolean;
locked?: boolean;
}
export interface RecipeAsset {
name: string;
icon: string;
fileName?: string;
}
export interface RecipeNote {
title: string;
text: string;
}
export interface RecipeCommentOut {
recipeId: number;
text: string;
id: string;
createdAt: string;
updateAt: string;
userId: string;
user: UserBase;
}
export interface UserBase {
id: number;
username?: string;
admin: boolean;
}
export interface CustomPageImport {
name: string;
status: boolean;

View File

@ -7,7 +7,7 @@
export interface CategoryBase {
name: string;
id: number;
id: string;
slug: string;
}
export interface CreateCookBook {
@ -28,12 +28,12 @@ export interface ReadCookBook {
}
export interface RecipeCategoryResponse {
name: string;
id: number;
id: string;
slug: string;
recipes?: Recipe[];
recipes?: RecipeSummary[];
}
export interface Recipe {
id?: number;
export interface RecipeSummary {
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -53,28 +53,19 @@ export interface Recipe {
recipeIngredient?: RecipeIngredient[];
dateAdded?: string;
dateUpdated?: string;
recipeInstructions?: RecipeStep[];
nutrition?: Nutrition;
settings?: RecipeSettings;
assets?: RecipeAsset[];
notes?: RecipeNote[];
extras?: {
[k: string]: unknown;
};
comments?: RecipeCommentOut[];
}
export interface RecipeCategory {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
id: string;
name: string;
slug: string;
onHand?: boolean;
@ -93,7 +84,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
id: string;
}
export interface CreateIngredientUnit {
name: string;
@ -105,7 +96,7 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
id: number;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
@ -119,59 +110,6 @@ export interface CreateIngredientFood {
description?: string;
labelId?: string;
}
export interface RecipeStep {
id?: string;
title?: string;
text: string;
ingredientReferences?: IngredientReferences[];
}
/**
* A list of ingredient references.
*/
export interface IngredientReferences {
referenceId?: string;
}
export interface Nutrition {
calories?: string;
fatContent?: string;
proteinContent?: string;
carbohydrateContent?: string;
fiberContent?: string;
sodiumContent?: string;
sugarContent?: string;
}
export interface RecipeSettings {
public?: boolean;
showNutrition?: boolean;
showAssets?: boolean;
landscapeView?: boolean;
disableComments?: boolean;
disableAmount?: boolean;
locked?: boolean;
}
export interface RecipeAsset {
name: string;
icon: string;
fileName?: string;
}
export interface RecipeNote {
title: string;
text: string;
}
export interface RecipeCommentOut {
recipeId: number;
text: string;
id: string;
createdAt: string;
updateAt: string;
userId: string;
user: UserBase;
}
export interface UserBase {
id: number;
username?: string;
admin: boolean;
}
export interface RecipeCookBook {
name: string;
description?: string;

View File

@ -180,7 +180,7 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
id: number;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
@ -194,7 +194,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
id: string;
}
export interface ReadGroupPreferences {
privateGroup?: boolean;
@ -222,7 +222,7 @@ export interface ReadWebhook {
id: number;
}
export interface RecipeSummary {
id?: number;
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -244,17 +244,17 @@ export interface RecipeSummary {
dateUpdated?: string;
}
export interface RecipeCategory {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
id: string;
name: string;
slug: string;
onHand?: boolean;
@ -307,15 +307,15 @@ export interface ShoppingListItemCreate {
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: number;
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
}
export interface ShoppingListItemRecipeRef {
recipeId: number;
recipeId: string;
recipeQuantity: number;
}
export interface ShoppingListItemOut {
@ -325,9 +325,9 @@ export interface ShoppingListItemOut {
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: number;
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRefOut[];
@ -335,7 +335,7 @@ export interface ShoppingListItemOut {
label?: MultiPurposeLabelSummary;
}
export interface ShoppingListItemRecipeRefOut {
recipeId: number;
recipeId: string;
recipeQuantity: number;
id: string;
shoppingListItemId: string;
@ -347,9 +347,9 @@ export interface ShoppingListItemUpdate {
isFood?: boolean;
note?: string;
quantity?: number;
unitId?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: number;
foodId?: string;
food?: IngredientFood;
labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[];
@ -365,7 +365,7 @@ export interface ShoppingListOut {
export interface ShoppingListRecipeRefOut {
id: string;
shoppingListId: string;
recipeId: number;
recipeId: string;
recipeQuantity: number;
recipe: RecipeSummary;
}

View File

@ -10,7 +10,7 @@ export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "fr
export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "unset";
export interface Category {
id: number;
id: string;
name: string;
slug: string;
}
@ -23,7 +23,7 @@ export interface CreatePlanEntry {
entryType?: PlanEntryType & string;
title?: string;
text?: string;
recipeId?: number;
recipeId?: string;
}
export interface ListItem {
title?: string;
@ -66,7 +66,7 @@ export interface PlanRulesCreate {
tags?: Tag[];
}
export interface Tag {
id: number;
id: string;
name: string;
slug: string;
}
@ -90,13 +90,13 @@ export interface ReadPlanEntry {
entryType?: PlanEntryType & string;
title?: string;
text?: string;
recipeId?: number;
recipeId?: string;
id: number;
groupId: string;
recipe?: RecipeSummary;
}
export interface RecipeSummary {
id?: number;
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -118,17 +118,17 @@ export interface RecipeSummary {
dateUpdated?: string;
}
export interface RecipeCategory {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
id: string;
name: string;
slug: string;
onHand?: boolean;
@ -147,7 +147,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
id: string;
}
export interface CreateIngredientUnit {
name: string;
@ -159,7 +159,7 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
id: number;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
@ -178,7 +178,7 @@ export interface SavePlanEntry {
entryType?: PlanEntryType & string;
title?: string;
text?: string;
recipeId?: number;
recipeId?: string;
groupId: string;
}
export interface ShoppingListIn {
@ -197,7 +197,7 @@ export interface UpdatePlanEntry {
entryType?: PlanEntryType & string;
title?: string;
text?: string;
recipeId?: number;
recipeId?: string;
id: number;
groupId: string;
}

View File

@ -14,7 +14,7 @@ export interface AssignCategories {
}
export interface CategoryBase {
name: string;
id: number;
id: string;
slug: string;
}
export interface AssignTags {
@ -23,7 +23,7 @@ export interface AssignTags {
}
export interface TagBase {
name: string;
id: number;
id: string;
slug: string;
}
export interface BulkActionError {
@ -38,6 +38,15 @@ export interface BulkActionsResponse {
export interface CategoryIn {
name: string;
}
export interface CategoryOut {
name: string;
id: string;
slug: string;
}
export interface CategorySave {
name: string;
groupId: string;
}
export interface CreateIngredientFood {
name: string;
description?: string;
@ -58,12 +67,12 @@ export interface CreateRecipeBulk {
tags?: RecipeTag[];
}
export interface RecipeCategory {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
id: string;
name: string;
slug: string;
}
@ -95,7 +104,7 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
id: number;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {
@ -119,7 +128,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
id: string;
}
export interface IngredientsRequest {
parser?: RegisteredParser & string;
@ -149,7 +158,7 @@ export interface RecipeIngredient {
referenceId?: string;
}
export interface Recipe {
id?: number;
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -180,7 +189,7 @@ export interface Recipe {
comments?: RecipeCommentOut[];
}
export interface RecipeTool {
id?: number;
id: string;
name: string;
slug: string;
onHand?: boolean;
@ -210,7 +219,7 @@ export interface RecipeNote {
text: string;
}
export interface RecipeCommentOut {
recipeId: number;
recipeId: string;
text: string;
id: string;
createdAt: string;
@ -225,52 +234,12 @@ export interface UserBase {
}
export interface RecipeCategoryResponse {
name: string;
id: number;
slug: string;
recipes?: Recipe[];
}
export interface RecipeCommentCreate {
recipeId: number;
text: string;
}
export interface RecipeCommentSave {
recipeId: number;
text: string;
userId: string;
}
export interface RecipeCommentUpdate {
id: string;
text: string;
}
export interface RecipeShareToken {
recipeId: number;
expiresAt?: string;
groupId: string;
id: string;
createdAt: string;
recipe: Recipe;
}
export interface RecipeShareTokenCreate {
recipeId: number;
expiresAt?: string;
}
export interface RecipeShareTokenSave {
recipeId: number;
expiresAt?: string;
groupId: string;
}
export interface RecipeShareTokenSummary {
recipeId: number;
expiresAt?: string;
groupId: string;
id: string;
createdAt: string;
}
export interface RecipeSlug {
id: string;
slug: string;
recipes?: RecipeSummary[];
}
export interface RecipeSummary {
id?: number;
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -291,16 +260,56 @@ export interface RecipeSummary {
dateAdded?: string;
dateUpdated?: string;
}
export interface RecipeCommentCreate {
recipeId: string;
text: string;
}
export interface RecipeCommentSave {
recipeId: string;
text: string;
userId: string;
}
export interface RecipeCommentUpdate {
id: string;
text: string;
}
export interface RecipeShareToken {
recipeId: string;
expiresAt?: string;
groupId: string;
id: string;
createdAt: string;
recipe: Recipe;
}
export interface RecipeShareTokenCreate {
recipeId: string;
expiresAt?: string;
}
export interface RecipeShareTokenSave {
recipeId: string;
expiresAt?: string;
groupId: string;
}
export interface RecipeShareTokenSummary {
recipeId: string;
expiresAt?: string;
groupId: string;
id: string;
createdAt: string;
}
export interface RecipeSlug {
slug: string;
}
export interface RecipeTagResponse {
name: string;
id: number;
id: string;
slug: string;
recipes?: Recipe[];
recipes?: RecipeSummary[];
}
export interface RecipeTool1 {
name: string;
onHand?: boolean;
id: number;
id: string;
slug: string;
}
export interface RecipeToolCreate {
@ -310,14 +319,42 @@ export interface RecipeToolCreate {
export interface RecipeToolResponse {
name: string;
onHand?: boolean;
id: number;
id: string;
slug: string;
recipes?: Recipe[];
}
export interface RecipeToolSave {
name: string;
onHand?: boolean;
groupId: string;
}
export interface SaveIngredientFood {
name: string;
description?: string;
labelId?: string;
groupId: string;
}
export interface SaveIngredientUnit {
name: string;
description?: string;
fraction?: boolean;
abbreviation?: string;
groupId: string;
}
export interface SlugResponse {}
export interface TagIn {
name: string;
}
export interface TagOut {
name: string;
groupId: string;
id: string;
slug: string;
}
export interface TagSave {
name: string;
groupId: string;
}
export interface UnitFoodBase {
name: string;
description?: string;

View File

@ -7,7 +7,7 @@
export interface CategoryBase {
name: string;
id: number;
id: string;
slug: string;
}
export interface ChangePassword {
@ -109,7 +109,7 @@ export interface PrivatePasswordResetToken {
user: PrivateUser;
}
export interface RecipeSummary {
id?: number;
id?: string;
userId?: string;
groupId?: string;
name?: string;
@ -131,17 +131,17 @@ export interface RecipeSummary {
dateUpdated?: string;
}
export interface RecipeCategory {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: number;
id: string;
name: string;
slug: string;
}
export interface RecipeTool {
id?: number;
id: string;
name: string;
slug: string;
onHand?: boolean;
@ -160,7 +160,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
id: number;
id: string;
}
export interface CreateIngredientUnit {
name: string;
@ -172,7 +172,7 @@ export interface IngredientFood {
name: string;
description?: string;
labelId?: string;
id: number;
id: string;
label?: MultiPurposeLabelSummary;
}
export interface MultiPurposeLabelSummary {

View File

@ -82,7 +82,6 @@ setup-model: ## 🤖 Get the latest NLP CRF++ Model
backend: ## 🎬 Start Mealie Backend Development Server
poetry run python mealie/db/init_db.py && \
poetry run python mealie/services/image/minify.py && \
poetry run python mealie/app.py
.PHONY: frontend

View File

@ -9,8 +9,6 @@ DATA_DIR = determine_data_dir()
from .config import get_app_settings
settings = get_app_settings()
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
DATE_FORMAT = "%d-%b-%y %H:%M:%S"
LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s"
@ -27,6 +25,8 @@ class LoggerConfig:
@lru_cache
def get_logger_config():
settings = get_app_settings()
if not settings.PRODUCTION:
from rich.logging import RichHandler
@ -69,7 +69,6 @@ def logger_init() -> logging.Logger:
root_logger = logger_init()
root_logger.info("Testing Root Logger")
def get_logger(module=None) -> logging.Logger:

View File

@ -12,7 +12,12 @@ def sql_global_init(db_url: str):
if "sqlite" in db_url:
connect_args["check_same_thread"] = False
engine = sa.create_engine(db_url, echo=False, connect_args=connect_args, pool_pre_ping=True)
engine = sa.create_engine(
db_url,
echo=False,
connect_args=connect_args,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@ -58,6 +58,9 @@ class Group(SqlAlchemyBase, BaseMixins):
# Owned Models
ingredient_units = orm.relationship("IngredientUnitModel", **common_args)
ingredient_foods = orm.relationship("IngredientFoodModel", **common_args)
tools = orm.relationship("Tool", **common_args)
tags = orm.relationship("Tag", **common_args)
categories = orm.relationship("Category", **common_args)
class Config:
exclude = {

View File

@ -1,5 +1,4 @@
from sqlalchemy import Column, Date, ForeignKey, String, orm
from sqlalchemy.sql.sqltypes import Integer
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
@ -36,7 +35,7 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins):
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
group = orm.relationship("Group", back_populates="mealplans")
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="meal_entries", uselist=False)
@auto_init()

View File

@ -9,7 +9,7 @@ from .._model_utils import auto_init
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_preferences"
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="preferences")
private_group: bool = sa.Column(sa.Boolean, default=True)

View File

@ -38,7 +38,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins):
entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan")
# Relationships
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="group_reports", single_parent=True)
class Config:

View File

@ -13,7 +13,7 @@ class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
id = Column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item_id = Column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True)
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity = Column(Float, nullable=False)
@ -40,10 +40,10 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
is_food = Column(Boolean, default=False)
# Scaling Items
unit_id = Column(Integer, ForeignKey("ingredient_units.id"))
unit_id = Column(GUID, ForeignKey("ingredient_units.id"))
unit = orm.relationship(IngredientUnitModel, uselist=False)
food_id = Column(Integer, ForeignKey("ingredient_foods.id"))
food_id = Column(GUID, ForeignKey("ingredient_foods.id"))
food = orm.relationship(IngredientFoodModel, uselist=False)
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id"))
@ -66,7 +66,7 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", uselist=False, back_populates="shopping_list_refs")
recipe_quantity = Column(Float, nullable=False)
@ -83,7 +83,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id = Column(GUID, primary_key=True, default=GUID.generate)
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String)

View File

@ -12,7 +12,7 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
name = Column(String(255), nullable=False)
color = Column(String(10), nullable=False, default="")
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="labels")
shopping_list_items = orm.relationship("ShoppingListItem", back_populates="label")

View File

@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class ApiExtras(SqlAlchemyBase):
__tablename__ = "api_extras"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipee_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
key_name = sa.Column(sa.String)
value = sa.Column(sa.String)

View File

@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
name = sa.Column(sa.String)
icon = sa.Column(sa.String)
file_name = sa.Column(sa.String)

View File

@ -15,47 +15,51 @@ group2categories = sa.Table(
"group2categories",
SqlAlchemyBase.metadata,
sa.Column("group_id", GUID, sa.ForeignKey("groups.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
plan_rules_to_categories = sa.Table(
"plan_rules_to_categories",
SqlAlchemyBase.metadata,
sa.Column("group_plan_rule_id", GUID, sa.ForeignKey("group_meal_plan_rules.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
recipes2categories = sa.Table(
"recipes2categories",
recipes_to_categories = sa.Table(
"recipes_to_categories",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("recipe_id", GUID, sa.ForeignKey("recipes.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
cookbooks_to_categories = sa.Table(
"cookbooks_to_categories",
SqlAlchemyBase.metadata,
sa.Column("cookbook_id", sa.Integer, sa.ForeignKey("cookbooks.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
class Category(SqlAlchemyBase, BaseMixins):
__tablename__ = "categories"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes2categories, back_populates="recipe_category")
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
class Config:
get_attr = "slug"
# ID Relationships
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
id = sa.Column(GUID, primary_key=True, default=GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category")
@validates("name")
def validate_name(self, key, name):
assert name != ""
return name
def __init__(self, name, **_) -> None:
def __init__(self, name, group_id, **_) -> None:
self.group_id = group_id
self.name = name.strip()
self.slug = slugify(name)

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from sqlalchemy import Column, ForeignKey, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init
@ -11,7 +11,7 @@ class RecipeComment(SqlAlchemyBase, BaseMixins):
text = Column(String)
# Recipe Link
recipe_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
recipe_id = Column(GUID, ForeignKey("recipes.id"), nullable=False)
recipe = orm.relationship("RecipeModel", back_populates="comments")
# User Link

View File

@ -9,12 +9,12 @@ from .._model_utils.guid import GUID
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units"
id = Column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
abbreviation = Column(String)
@ -28,12 +28,12 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods"
id = Column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
ingredients = orm.relationship("RecipeIngredient", back_populates="food")
@ -50,16 +50,16 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients"
id = Column(Integer, primary_key=True)
position = Column(Integer)
parent_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"))
title = Column(String) # Section Header - Shows if Present
note = Column(String) # Force Show Text - Overrides Concat
# Scaling Items
unit_id = Column(Integer, ForeignKey("ingredient_units.id"))
unit_id = Column(GUID, ForeignKey("ingredient_units.id"))
unit = orm.relationship(IngredientUnitModel, uselist=False)
food_id = Column(Integer, ForeignKey("ingredient_foods.id"))
food_id = Column(GUID, ForeignKey("ingredient_foods.id"))
food = orm.relationship(IngredientFoodModel, uselist=False)
quantity = Column(Integer)

View File

@ -18,7 +18,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions"
id = Column(GUID, primary_key=True, default=GUID.generate)
parent_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"))
position = Column(Integer)
type = Column(String, default="")
title = Column(String)

View File

@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class Note(SqlAlchemyBase):
__tablename__ = "notes"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
title = sa.Column(sa.String)
text = sa.Column(sa.String)

View File

@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
calories = sa.Column(sa.String)
fat_content = sa.Column(sa.String)
fiber_content = sa.Column(sa.String)

View File

@ -13,14 +13,14 @@ from .._model_utils import auto_init
from ..users import users_to_favorites
from .api_extras import ApiExtras
from .assets import RecipeAsset
from .category import recipes2categories
from .category import recipes_to_categories
from .ingredient import RecipeIngredient
from .instruction import RecipeInstruction
from .note import Note
from .nutrition import Nutrition
from .settings import RecipeSettings
from .shared import RecipeShareTokenModel
from .tag import Tag, recipes2tags
from .tag import Tag, recipes_to_tags
from .tool import recipes_to_tools
@ -43,13 +43,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
id = sa.Column(GUID, primary_key=True, default=GUID.generate)
slug = sa.Column(sa.String, index=True)
# ID Relationships
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
user_id = sa.Column(GUID, sa.ForeignKey("users.id"))
user_id = sa.Column(GUID, sa.ForeignKey("users.id"), index=True)
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan")
@ -72,7 +73,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipe_category = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
recipe_category = orm.relationship("Category", secondary=recipes_to_categories, back_populates="recipes")
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: list[RecipeIngredient] = orm.relationship(
@ -96,7 +97,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Mealie Specific
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=recipes_to_tags, back_populates="recipes")
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
rating = sa.Column(sa.Integer)
org_url = sa.Column(sa.String)

View File

@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
public = sa.Column(sa.Boolean)
show_nutrition = sa.Column(sa.Boolean)
show_assets = sa.Column(sa.Boolean)

View File

@ -15,9 +15,9 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens"
id = sa.Column(GUID, primary_key=True, default=uuid4)
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
recipe_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"), nullable=False)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), nullable=False)
recipe = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at = sa.Column(sa.DateTime, nullable=False)

View File

@ -9,27 +9,33 @@ from mealie.db.models._model_utils import guid
logger = root_logger.get_logger()
recipes2tags = sa.Table(
"recipes2tags",
recipes_to_tags = sa.Table(
"recipes_to_tags",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
sa.Column("recipe_id", guid.GUID, sa.ForeignKey("recipes.id")),
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")),
)
plan_rules_to_tags = sa.Table(
"plan_rules_to_tags",
SqlAlchemyBase.metadata,
sa.Column("plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id")),
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")),
)
class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags"
id = sa.Column(sa.Integer, primary_key=True)
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
# ID Relationships
group_id = sa.Column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
id = sa.Column(guid.GUID, primary_key=True, default=guid.GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes2tags, back_populates="tags")
slug = sa.Column(sa.String, index=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tags, back_populates="tags")
class Config:
get_attr = "slug"
@ -39,7 +45,8 @@ class Tag(SqlAlchemyBase, BaseMixins):
assert name != ""
return name
def __init__(self, name, **_) -> None:
def __init__(self, name, group_id, **_) -> None:
self.group_id = group_id
self.name = name.strip()
self.slug = slugify(self.name)

View File

@ -1,19 +1,27 @@
from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init
from mealie.db.models._model_utils.guid import GUID
recipes_to_tools = Table(
"recipes_to_tools",
SqlAlchemyBase.metadata,
Column("recipe_id", Integer, ForeignKey("recipes.id")),
Column("tool_id", Integer, ForeignKey("tools.id")),
Column("recipe_id", GUID, ForeignKey("recipes.id")),
Column("tool_id", GUID, ForeignKey("tools.id")),
)
class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools"
id = Column(GUID, primary_key=True, default=GUID.generate)
__table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),)
# ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
name = Column(String, index=True, unique=True, nullable=False)
slug = Column(String, index=True, unique=True, nullable=False)
on_hand = Column(Boolean, default=False)

View File

@ -13,7 +13,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins):
status = Column(String, nullable=False)
log = Column(String, nullable=True)
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="server_tasks")
@auto_init()

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, Integer, Table
from sqlalchemy import Column, ForeignKey, Table
from .._model_base import SqlAlchemyBase
from .._model_utils import GUID
@ -7,5 +7,5 @@ users_to_favorites = Table(
"users_to_favorites",
SqlAlchemyBase.metadata,
Column("user_id", GUID, ForeignKey("users.id")),
Column("recipe_id", Integer, ForeignKey("recipes.id")),
Column("recipe_id", GUID, ForeignKey("recipes.id")),
)

View File

@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from sqlalchemy import Boolean, Column, ForeignKey, String, orm
from mealie.core.config import get_app_settings
from mealie.db.models._model_utils.guid import GUID
@ -33,7 +33,7 @@ class User(SqlAlchemyBase, BaseMixins):
admin = Column(Boolean, default=False)
advanced = Column(Boolean, default=False)
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="users")
cache_key = Column(String, default="1234")
@ -53,7 +53,7 @@ class User(SqlAlchemyBase, BaseMixins):
comments = orm.relationship("RecipeComment", **sp_args)
password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args)
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
owned_recipes_id = Column(GUID, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")

1
mealie/pkgs/cache/__init__.py vendored Normal file
View File

@ -0,0 +1 @@
from .cache_key import *

View File

@ -2,8 +2,7 @@ import random
import string
def new_cache_key(length=4) -> str:
def new_key(length=4) -> str:
"""returns a 4 character string to be used as a cache key for frontend data"""
options = string.ascii_letters + string.digits
return "".join(random.choices(options, k=length))

View File

@ -0,0 +1,7 @@
"""
This package containers helpful development tools to be used for development and testing. It shouldn't be used for or imported
in production
"""
from .lifespan_tracker import *
from .timer import *

12
mealie/pkgs/dev/timer.py Normal file
View File

@ -0,0 +1,12 @@
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start} seconds") # noqa: T001
return result
return wrapper

View File

@ -0,0 +1,7 @@
"""
The img package is a collection of utilities for working with images. While it offers some Mealie specific functionality, libraries
within the img package should not be tightly coupled to Mealie.
"""
from .minify import *

130
mealie/pkgs/img/minify.py Normal file
View File

@ -0,0 +1,130 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from logging import Logger
from pathlib import Path
from PIL import Image
WEBP = ".webp"
FORMAT = "WEBP"
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
def get_format(image: Path) -> str:
img = Image.open(image)
return img.format
def sizeof_fmt(file_path: Path, decimal_places=2):
if not file_path.exists():
return "(File Not Found)"
size = file_path.stat().st_size
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
if size < 1024.0 or unit == "PiB":
break
size /= 1024.0
return f"{size:.{decimal_places}f} {unit}"
@dataclass
class MinifierOptions:
original: bool = True
minature: bool = True
tiny: bool = True
class ABCMinifier(ABC):
def __init__(self, purge=False, opts: MinifierOptions = None, logger: Logger = None):
self._purge = purge
self._opts = opts or MinifierOptions()
self._logger = logger or Logger("Minifier")
def get_image_sizes(self, org_img: Path, min_img: Path, tiny_img: Path):
self._logger.info(
f"{org_img.name} Minified: {sizeof_fmt(org_img)} -> {sizeof_fmt(min_img)} -> {sizeof_fmt(tiny_img)}"
)
@abstractmethod
def minify(self, image: Path, force=True):
...
def purge(self, image: Path):
if not self._purge:
return
for file in image.parent.glob("*.*"):
if file.suffix != WEBP:
file.unlink()
class PillowMinifier(ABCMinifier):
@staticmethod
def to_webp(image_file: Path, dest: Path = None, quality: int = 100) -> Path:
"""
Converts an image to the webp format in-place. The original image is not
removed By default, the quality is set to 100.
"""
if image_file.suffix == WEBP:
return image_file
img = Image.open(image_file)
dest = dest or image_file.with_suffix(WEBP)
img.save(dest, FORMAT, quality=quality)
return dest
@staticmethod
def crop_center(pil_img: Image, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
return pil_img.crop(
(
(img_width - crop_width) // 2,
(img_height - crop_height) // 2,
(img_width + crop_width) // 2,
(img_height + crop_height) // 2,
)
)
def minify(self, image_file: Path, force=True):
if not image_file.exists():
raise FileNotFoundError(f"{image_file.name} does not exist")
org_dest = image_file.parent.joinpath("original.webp")
min_dest = image_file.parent.joinpath("min-original.webp")
tiny_dest = image_file.parent.joinpath("tiny-original.webp")
if not force and min_dest.exists() and tiny_dest.exists() and org_dest.exists():
self._logger.info(f"{image_file.name} already minified")
return
success = False
if self._opts.original:
if not force and org_dest.exists():
self._logger.info(f"{image_file.name} already minified")
else:
PillowMinifier.to_webp(image_file, org_dest, quality=70)
success = True
if self._opts.minature:
if not force and min_dest.exists():
self._logger.info(f"{image_file.name} already minified")
else:
PillowMinifier.to_webp(image_file, min_dest, quality=70)
self._logger.info(f"{image_file.name} minified")
success = True
if self._opts.tiny:
if not force and tiny_dest.exists():
self._logger.info(f"{image_file.name} already minified")
else:
img = Image.open(image_file)
tiny_image = PillowMinifier.crop_center(img)
tiny_image.save(tiny_dest, FORMAT, quality=70)
self._logger.info("Tiny image saved")
success = True
if self._purge and success:
self.purge(image_file)

View File

@ -0,0 +1 @@
from .fs_stats import *

View File

@ -46,7 +46,8 @@ from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.labels import MultiPurposeLabelOut
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.meal_plan.plan_rules import PlanRulesOut
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
from mealie.schema.recipe import Recipe, RecipeCommentOut, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryOut, TagOut
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_share_token import RecipeShareToken
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
@ -67,12 +68,12 @@ PK_TOKEN = "token"
PK_GROUP_ID = "group_id"
class RepositoryCategories(RepositoryGeneric):
class RepositoryCategories(RepositoryGeneric[CategoryOut, Category]):
def get_empty(self):
return self.session.query(Category).filter(~Category.recipes.any()).all()
class RepositoryTags(RepositoryGeneric):
class RepositoryTags(RepositoryGeneric[TagOut, Tag]):
def get_empty(self):
return self.session.query(Tag).filter(~Tag.recipes.any()).all()
@ -114,11 +115,11 @@ class AllRepositories:
@cached_property
def categories(self) -> RepositoryCategories:
# TODO: Fix Typing for Category Repository
return RepositoryCategories(self.session, PK_SLUG, Category, RecipeCategoryResponse)
return RepositoryCategories(self.session, PK_ID, Category, CategoryOut)
@cached_property
def tags(self) -> RepositoryTags:
return RepositoryTags(self.session, PK_SLUG, Tag, RecipeTagResponse)
return RepositoryTags(self.session, PK_ID, Tag, TagOut)
@cached_property
def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:

View File

@ -11,7 +11,7 @@ from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag
from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag
from .repository_generic import RepositoryGeneric
@ -89,7 +89,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.all()
)
def get_by_categories(self, categories: list[RecipeCategory]) -> list[Recipe]:
def get_by_categories(self, categories: list[RecipeCategory]) -> list[RecipeSummary]:
"""
get_by_categories returns all the Recipes that contain every category provided in the list
"""
@ -97,7 +97,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
ids = [x.id for x in categories]
return [
self.schema.from_orm(x)
RecipeSummary.from_orm(x)
for x in self.session.query(RecipeModel)
.join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
@ -120,13 +120,11 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
if categories:
cat_ids = [x.id for x in categories]
for cat_id in cat_ids:
filters.append(RecipeModel.recipe_category.any(Category.id.is_(cat_id)))
filters.extend(RecipeModel.recipe_category.any(Category.id.is_(cat_id)) for cat_id in cat_ids)
if tags:
tag_ids = [x.id for x in tags]
for tag_id in tag_ids:
filters.append(RecipeModel.tags.any(Tag.id.is_(tag_id)))
filters.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids)
return [
self.schema.from_orm(x)

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter
from . import admin, app, auth, categories, comments, groups, parser, recipe, shared, tags, tools, unit_and_foods, users
from . import admin, app, auth, comments, groups, organizers, parser, recipe, shared, unit_and_foods, users
router = APIRouter(prefix="/api")
@ -9,11 +9,9 @@ router.include_router(auth.router)
router.include_router(users.router)
router.include_router(groups.router)
router.include_router(recipe.router)
router.include_router(organizers.router)
router.include_router(shared.router)
router.include_router(comments.router)
router.include_router(parser.router)
router.include_router(unit_and_foods.router)
router.include_router(tools.router)
router.include_router(categories.router)
router.include_router(tags.router)
router.include_router(admin.router)

View File

@ -10,13 +10,13 @@ from mealie.core.dependencies import get_current_user
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session
from mealie.pkgs.stats.fs_stats import pretty_size
from mealie.routes._base.routers import AdminAPIRouter
from mealie.schema.admin import AllBackups, BackupFile, CreateBackup, ImportJob
from mealie.schema.user.user import PrivateUser
from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
from mealie.utils.fs_stats import pretty_size
router = AdminAPIRouter(prefix="/api/backups", tags=["Backups"])
logger = get_logger()

View File

@ -1,6 +0,0 @@
from fastapi import APIRouter
from . import categories
router = APIRouter()
router.include_router(categories.router)

View File

@ -1,69 +0,0 @@
from functools import cached_property
from fastapi import APIRouter
from pydantic import BaseModel
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
from mealie.schema.recipe.recipe_category import CategoryBase
router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
class CategorySummary(BaseModel):
id: int
slug: str
name: str
class Config:
orm_mode = True
@controller(router)
class RecipeCategoryController(BaseUserController):
# =========================================================================
# CRUD Operations
@cached_property
def mixins(self):
return CrudMixins(self.repos.categories, self.deps.logger)
@router.get("", response_model=list[CategorySummary])
def get_all(self):
"""Returns a list of available categories in the database"""
return self.repos.categories.get_all_limit_columns(fields=["slug", "name"])
@router.post("", status_code=201)
def create_one(self, category: CategoryIn):
"""Creates a Category in the database"""
return self.mixins.create_one(category)
@router.get("/{slug}", response_model=RecipeCategoryResponse)
def get_all_recipes_by_category(self, slug: str):
"""Returns a list of recipes associated with the provided category."""
category_obj = self.repos.categories.get(slug)
category_obj = RecipeCategoryResponse.from_orm(category_obj)
return category_obj
@router.put("/{slug}", response_model=RecipeCategoryResponse)
def update_one(self, slug: str, update_data: CategoryIn):
"""Updates an existing Tag in the database"""
return self.mixins.update_one(update_data, slug)
@router.delete("/{slug}")
def delete_one(self, slug: str):
"""
Removes a recipe category from the database. Deleting a
category does not impact a recipe. The category will be removed
from any recipes that contain it
"""
self.mixins.delete_one(slug)
# =========================================================================
# Read All Operations
@router.get("/empty", response_model=list[CategoryBase])
def get_all_empty(self):
"""Returns a list of categories that do not contain any recipes"""
return self.repos.categories.get_empty()

View File

@ -143,9 +143,9 @@ class ShoppingListController(BaseUserController):
# Other Operations
@router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: int):
def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: UUID4):
return self.service.add_recipe_ingredients_to_list(item_id, recipe_id)
@router.delete("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: int):
def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: UUID4):
return self.service.remove_recipe_ingredients_from_list(item_id, recipe_id)

View File

@ -1,6 +1,7 @@
from enum import Enum
from fastapi import APIRouter, HTTPException, status
from pydantic import UUID4
from starlette.responses import FileResponse
from mealie.schema.recipe import Recipe
@ -19,11 +20,13 @@ class ImageType(str, Enum):
tiny = "tiny-original.webp"
@router.get("/{slug}/images/{file_name}")
async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
@router.get("/{recipe_id}/images/{file_name}")
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original):
"""
Takes in a recipe recipe_id, returns the static image. This route is proxied in the docker image
and should not hit the API in production
"""
recipe_image = Recipe.directory_from_id(recipe_id).joinpath("images", file_name.value)
if recipe_image.exists():
return FileResponse(recipe_image)
@ -31,10 +34,10 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{slug}/assets/{file_name}")
async def get_recipe_asset(slug: str, file_name: str):
@router.get("/{recipe_id}/assets/{file_name}")
async def get_recipe_asset(recipe_id: UUID4, file_name: str):
"""Returns a recipe asset"""
file = Recipe(slug=slug).asset_dir.joinpath(file_name)
file = Recipe.directory_from_id(recipe_id).joinpath("assets", file_name)
try:
return FileResponse(file)

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
from . import controller_categories, controller_tags, controller_tools
router = APIRouter(prefix="/organizers")
router.include_router(controller_categories.router)
router.include_router(controller_tags.router)
router.include_router(controller_tools.router)

View File

@ -0,0 +1,87 @@
from functools import cached_property
from fastapi import APIRouter
from pydantic import UUID4, BaseModel
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
from mealie.schema.recipe.recipe import RecipeCategory
from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave
router = APIRouter(prefix="/categories", tags=["Organizer: Categories"])
class CategorySummary(BaseModel):
id: UUID4
slug: str
name: str
class Config:
orm_mode = True
@controller(router)
class RecipeCategoryController(BaseUserController):
# =========================================================================
# CRUD Operations
@cached_property
def repo(self):
return self.repos.categories.by_group(self.group_id)
@cached_property
def mixins(self):
return CrudMixins(self.repo, self.deps.logger)
@router.get("", response_model=list[CategorySummary])
def get_all(self):
"""Returns a list of available categories in the database"""
return self.repo.get_all(override_schema=CategorySummary)
@router.post("", status_code=201)
def create_one(self, category: CategoryIn):
"""Creates a Category in the database"""
save_data = mapper.cast(category, CategorySave, group_id=self.group_id)
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=CategorySummary)
def get_one(self, item_id: UUID4):
"""Returns a list of recipes associated with the provided category."""
category_obj = self.mixins.get_one(item_id)
category_obj = CategorySummary.from_orm(category_obj)
return category_obj
@router.put("/{item_id}", response_model=CategorySummary)
def update_one(self, item_id: UUID4, update_data: CategoryIn):
"""Updates an existing Tag in the database"""
save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id)
return self.mixins.update_one(save_data, item_id)
@router.delete("/{item_id}")
def delete_one(self, item_id: UUID4):
"""
Removes a recipe category from the database. Deleting a
category does not impact a recipe. The category will be removed
from any recipes that contain it
"""
self.mixins.delete_one(item_id)
# =========================================================================
# Read All Operations
@router.get("/empty", response_model=list[CategoryBase])
def get_all_empty(self):
"""Returns a list of categories that do not contain any recipes"""
return self.repos.categories.get_empty()
@router.get("/slug/{category_slug}")
def get_one_by_slug(self, category_slug: str):
"""Returns a category object with the associated recieps relating to the category"""
category: RecipeCategory = self.mixins.get_one(category_slug, "slug")
return RecipeCategoryResponse.construct(
id=category.id,
slug=category.slug,
name=category.name,
recipes=self.repos.recipes.by_group(self.group_id).get_by_categories([category]),
)

View File

@ -0,0 +1,66 @@
from functools import cached_property
from fastapi import APIRouter, HTTPException, status
from pydantic import UUID4
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.recipe import RecipeTagResponse, TagIn
from mealie.schema.recipe.recipe import RecipeTag
from mealie.schema.recipe.recipe_category import TagSave
router = APIRouter(prefix="/tags", tags=["Organizer: Tags"])
@controller(router)
class TagController(BaseUserController):
@cached_property
def repo(self):
return self.repos.tags.by_group(self.group_id)
@cached_property
def mixins(self):
return CrudMixins(self.repo, self.deps.logger)
@router.get("")
async def get_all(self):
"""Returns a list of available tags in the database"""
return self.repo.get_all(override_schema=RecipeTag)
@router.get("/empty")
def get_empty_tags(self):
"""Returns a list of tags that do not contain any recipes"""
return self.repo.get_empty()
@router.get("/{item_id}", response_model=RecipeTagResponse)
def get_one(self, item_id: UUID4):
"""Returns a list of recipes associated with the provided tag."""
return self.mixins.get_one(item_id)
@router.post("", status_code=201)
def create_one(self, tag: TagIn):
"""Creates a Tag in the database"""
save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
return self.repo.create(save_data)
@router.put("/{item_id}", response_model=RecipeTagResponse)
def update_one(self, item_id: UUID4, new_tag: TagIn):
"""Updates an existing Tag in the database"""
save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id)
return self.repo.update(item_id, save_data)
@router.delete("/{item_id}")
def delete_recipe_tag(self, item_id: UUID4):
"""Removes a recipe tag from the database. Deleting a
tag does not impact a recipe. The tag will be removed
from any recipes that contain it"""
try:
self.repo.delete(item_id)
except Exception as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST) from e
@router.get("/slug/{tag_slug}", response_model=RecipeTagResponse)
async def get_one_by_slug(self, tag_slug: str):
return self.repo.get_one(tag_slug, "slug", override_schema=RecipeTagResponse)

View File

@ -1,22 +1,24 @@
from functools import cached_property
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper
from mealie.schema.query import GetAll
from mealie.schema.recipe.recipe import RecipeTool
from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse
from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave
router = APIRouter(prefix="/tools", tags=["Recipes: Tools"])
router = APIRouter(prefix="/tools", tags=["Organizer: Tools"])
@controller(router)
class RecipeToolController(BaseUserController):
@cached_property
def repo(self):
return self.repos.tools
return self.repos.tools.by_group(self.group_id)
@property
def mixins(self) -> CrudMixins:
@ -28,18 +30,19 @@ class RecipeToolController(BaseUserController):
@router.post("", response_model=RecipeTool, status_code=201)
def create_one(self, data: RecipeToolCreate):
return self.mixins.create_one(data)
save_data = mapper.cast(data, RecipeToolSave, group_id=self.group_id)
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=RecipeTool)
def get_one(self, item_id: int):
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=RecipeTool)
def update_one(self, item_id: int, data: RecipeToolCreate):
def update_one(self, item_id: UUID4, data: RecipeToolCreate):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=RecipeTool)
def delete_one(self, item_id: int):
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id) # type: ignore
@router.get("/slug/{tool_slug}", response_model=RecipeToolResponse)

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter
from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, shared_routes
from . import all_recipe_routes, bulk_actions, comments, recipe_crud_routes, shared_routes
prefix = "/recipes"
@ -9,7 +9,6 @@ router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_crud_routes.router_exports)
router.include_router(recipe_crud_routes.router)
router.include_router(image_and_assets.router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix)
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"])

View File

@ -1,68 +0,0 @@
from shutil import copyfileobj
from fastapi import Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from pydantic import BaseModel
from slugify import slugify
from sqlalchemy.orm.session import Session
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeAsset
from mealie.services.image.image import scrape_image, write_image
router = UserAPIRouter()
class UpdateImageResponse(BaseModel):
image: str
@router.post("/{slug}/image")
def scrape_image_url(slug: str, url: CreateRecipeByUrl):
"""Removes an existing image and replaces it with the incoming file."""
scrape_image(url.url, slug)
@router.put("/{slug}/image", response_model=UpdateImageResponse)
def update_recipe_image(
slug: str,
image: bytes = File(...),
extension: str = Form(...),
session: Session = Depends(generate_session),
):
"""Removes an existing image and replaces it with the incoming file."""
db = get_repositories(session)
write_image(slug, image, extension)
new_version = db.recipes.update_image(slug, extension)
return UpdateImageResponse(image=new_version)
@router.post("/{slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset(
slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
session: Session = Depends(generate_session),
):
"""Upload a file to store as a recipe asset"""
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = Recipe(slug=slug).asset_dir.joinpath(file_name)
with dest.open("wb") as buffer:
copyfileobj(file.file, buffer)
if not dest.is_file():
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
db = get_repositories(session)
recipe: Recipe = db.recipes.get(slug)
recipe.assets.append(asset_in)
db.recipes.update(slug, recipe.dict())
return asset_in

View File

@ -1,12 +1,14 @@
from functools import cached_property
from shutil import copyfileobj
from zipfile import ZipFile
import sqlalchemy
from fastapi import BackgroundTasks, Depends, File, HTTPException
from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from slugify import slugify
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
@ -22,8 +24,10 @@ from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.query import GetAll
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
from mealie.schema.recipe.recipe_asset import RecipeAsset
from mealie.schema.response.responses import ErrorResponse
from mealie.schema.server.tasks import ServerTaskNames
from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
from mealie.services.scraper.scraper import create_from_url
@ -49,6 +53,10 @@ class RecipeGetAll(GetAll):
load_food: bool = False
class UpdateImageResponse(BaseModel):
image: str
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
@ -158,10 +166,9 @@ class RecipeController(BaseRecipeController):
@router.post("/test-scrape-url")
def test_parse_recipe_url(self, url: CreateRecipeByUrl):
# Debugger should produce the same result as the scraper sees before cleaning
scraped_data = RecipeScraperPackage(url.url).scrape_url()
if scraped_data:
if scraped_data := RecipeScraperPackage(url.url).scrape_url():
return scraped_data.schema.data
return "recipe_scrapers was unable to scrape this URL"
@router.post("/create-from-zip", status_code=201)
@ -217,6 +224,12 @@ class RecipeController(BaseRecipeController):
self.deps.logger.error("SQL Integrity Error on recipe controller action")
raise HTTPException(status_code=400, detail=ErrorResponse.respond(message="Recipe already exists"))
case _:
self.deps.logger.error("Unknown Error on recipe controller action")
raise HTTPException(
status_code=500, detail=ErrorResponse.respond(message="Unknown Error", exception=ex)
)
@router.put("/{slug}")
def update_one(self, slug: str, data: Recipe):
"""Updates a recipe by existing slug and data."""
@ -243,3 +256,51 @@ class RecipeController(BaseRecipeController):
return self.service.delete_one(slug)
except Exception as e:
self.handle_exceptions(e)
# ==================================================================================================================
# Image and Assets
@router.post("/{slug}/image", tags=["Recipe: Images and Assets"])
def scrape_image_url(self, slug: str, url: CreateRecipeByUrl) -> str:
recipe = self.mixins.get_one(slug)
data_service = RecipeDataService(recipe.id)
data_service.scrape_image(url.url)
@router.put("/{slug}/image", response_model=UpdateImageResponse, tags=["Recipe: Images and Assets"])
def update_recipe_image(self, slug: str, image: bytes = File(...), extension: str = Form(...)):
recipe = self.mixins.get_one(slug)
data_service = RecipeDataService(recipe.id)
data_service.write_image(image, extension)
new_version = self.repo.update_image(slug, extension)
return UpdateImageResponse(image=new_version)
@router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])
def upload_recipe_asset(
self,
slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
):
"""Upload a file to store as a recipe asset"""
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
recipe = self.mixins.get_one(slug)
dest = recipe.asset_dir / file_name
with dest.open("wb") as buffer:
copyfileobj(file.file, buffer)
if not dest.is_file():
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
recipe: Recipe = self.mixins.get_one(slug)
recipe.assets.append(asset_in)
self.mixins.update_one(recipe, slug)
return asset_in

View File

@ -22,7 +22,7 @@ class RecipeSharedController(BaseUserController):
return CrudMixins[RecipeShareTokenSave, RecipeShareToken, RecipeShareTokenCreate](self.repo, self.deps.logger)
@router.get("", response_model=list[RecipeShareTokenSummary])
def get_all(self, recipe_id: int = None):
def get_all(self, recipe_id: UUID4 = None):
if recipe_id:
return self.repo.multi_query({"recipe_id": recipe_id}, override_schema=RecipeShareTokenSummary)
else:

View File

@ -1,51 +0,0 @@
from functools import cached_property
from fastapi import APIRouter, HTTPException, status
from mealie.routes._base import BaseUserController, controller
from mealie.schema.recipe import RecipeTagResponse, TagIn
router = APIRouter(prefix="/tags", tags=["Tags: CRUD"])
@controller(router)
class TagController(BaseUserController):
@cached_property
def repo(self):
return self.repos.tags
@router.get("")
async def get_all_recipe_tags(self):
"""Returns a list of available tags in the database"""
return self.repo.get_all_limit_columns(["slug", "name"])
@router.get("/empty")
def get_empty_tags(self):
"""Returns a list of tags that do not contain any recipes"""
return self.repo.get_empty()
@router.get("/{tag_slug}", response_model=RecipeTagResponse)
def get_all_recipes_by_tag(self, tag_slug: str):
"""Returns a list of recipes associated with the provided tag."""
return self.repo.get_one(tag_slug, override_schema=RecipeTagResponse)
@router.post("", status_code=201)
def create_recipe_tag(self, tag: TagIn):
"""Creates a Tag in the database"""
return self.repo.create(tag)
@router.put("/{tag_slug}", response_model=RecipeTagResponse)
def update_recipe_tag(self, tag_slug: str, new_tag: TagIn):
"""Updates an existing Tag in the database"""
return self.repo.update(tag_slug, new_tag)
@router.delete("/{tag_slug}")
def delete_recipe_tag(self, tag_slug: str):
"""Removes a recipe tag from the database. Deleting a
tag does not impact a recipe. The tag will be removed
from any recipes that contain it"""
try:
self.repo.delete(tag_slug)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,6 +1,7 @@
from functools import cached_property
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
@ -36,13 +37,13 @@ class IngredientFoodsController(BaseUserController):
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=IngredientFood)
def get_one(self, item_id: int):
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=IngredientFood)
def update_one(self, item_id: int, data: CreateIngredientFood):
def update_one(self, item_id: UUID4, data: CreateIngredientFood):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientFood)
def delete_one(self, item_id: int):
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id)

View File

@ -1,6 +1,7 @@
from functools import cached_property
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
@ -36,13 +37,13 @@ class IngredientUnitsController(BaseUserController):
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=IngredientUnit)
def get_one(self, item_id: int):
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=IngredientUnit)
def update_one(self, item_id: int, data: CreateIngredientUnit):
def update_one(self, item_id: UUID4, data: CreateIngredientUnit):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientUnit)
def delete_one(self, item_id: int):
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id) # type: ignore

View File

@ -4,13 +4,12 @@ from pathlib import Path
from fastapi import Depends, File, HTTPException, UploadFile, status
from pydantic import UUID4
from mealie import utils
from mealie.core.dependencies.dependencies import temporary_dir
from mealie.pkgs import cache, img
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.user import PrivateUser
from mealie.services.image import minify
router = UserAPIRouter(prefix="", tags=["Users: Images"])
@ -31,12 +30,12 @@ class UserImageController(BaseUserController):
with temp_img.open("wb") as buffer:
shutil.copyfileobj(profile.file, buffer)
image = minify.to_webp(temp_img)
image = img.PillowMinifier.to_webp(temp_img)
dest = PrivateUser.get_directory(id) / "profile.webp"
shutil.copyfile(image, dest)
self.repos.users.patch(id, {"cache_key": utils.new_cache_key()})
self.repos.users.patch(id, {"cache_key": cache.new_key()})
if not dest.is_file:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

Some files were not shown because too many files have changed in this diff Show More