Feature/user seedable foods (#1176)

* remove odd ingredients

* UI Elements for food

* update translated percentage

* spek -> speck

* generate types

* seeder api endpoints + tests

* implement foods seeder UI

* localize some food strings
This commit is contained in:
Hayden 2022-05-01 12:45:50 -08:00 committed by GitHub
parent 67178f9b74
commit d6e2b4ab85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 478 additions and 172 deletions

View File

@ -19,7 +19,7 @@ repos:
- id: isort
name: isort (python)
- repo: https://github.com/psf/black
rev: 21.12b0
rev: 22.3.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8

View File

@ -0,0 +1,26 @@
import { BaseAPI } from "../_base";
import { SuccessResponse } from "~/types/api-types/response";
import { SeederConfig } from "~/types/api-types/group";
const prefix = "/api";
const routes = {
base: `${prefix}/groups/seeders`,
foods: `${prefix}/groups/seeders/foods`,
units: `${prefix}/groups/seeders/units`,
labels: `${prefix}/groups/seeders/labels`,
};
export class GroupDataSeederApi extends BaseAPI {
foods(payload: SeederConfig) {
return this.requests.post<SuccessResponse>(routes.foods, payload);
}
units(payload: SeederConfig) {
return this.requests.post<SuccessResponse>(routes.units, payload);
}
labels(payload: SeederConfig) {
return this.requests.post<SuccessResponse>(routes.labels, payload);
}
}

View File

@ -23,6 +23,7 @@ import { ShoppingApi } from "./class-interfaces/group-shopping-lists";
import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels";
import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier";
import { MealPlanRulesApi } from "./class-interfaces/group-mealplan-rules";
import { GroupDataSeederApi } from "./class-interfaces/group-seeder";
import { ApiRequestInstance } from "~/types/api";
class Api {
@ -50,6 +51,7 @@ class Api {
public multiPurposeLabels: MultiPurposeLabelsApi;
public groupEventNotifier: GroupEventNotifierApi;
public upload: UploadFile;
public seeders: GroupDataSeederApi;
constructor(requests: ApiRequestInstance) {
// Recipes
@ -75,6 +77,7 @@ class Api {
this.groupReports = new GroupReportsApi(requests);
this.shopping = new ShoppingApi(requests);
this.multiPurposeLabels = new MultiPurposeLabelsApi(requests);
this.seeders = new GroupDataSeederApi(requests);
// Admin
this.backups = new BackupAPI(requests);

View File

@ -9,7 +9,7 @@
:fullscreen="$vuetify.breakpoint.xsOnly"
>
<v-card height="100%">
<v-app-bar dark :color="color" class="mt-n1">
<v-app-bar dark dense :color="color" class="">
<v-icon large left>
{{ icon }}
</v-icon>

View File

@ -6,7 +6,6 @@
v-model="locale"
:items="locales"
item-text="name"
menu-props="auto"
class="my-3"
hide-details
outlined

View File

@ -3,12 +3,12 @@ export const LOCALES = [
{
name: "繁體中文 (Chinese traditional)",
value: "zh-TW",
progress: 100,
progress: 90,
},
{
name: "简体中文 (Chinese simplified)",
value: "zh-CN",
progress: 100,
progress: 74,
},
{
name: "Tiếng Việt (Vietnamese)",
@ -18,17 +18,17 @@ export const LOCALES = [
{
name: "Українська (Ukrainian)",
value: "uk-UA",
progress: 100,
progress: 99,
},
{
name: "Türkçe (Turkish)",
value: "tr-TR",
progress: 7,
progress: 5,
},
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 100,
progress: 92,
},
{
name: "српски (Serbian)",
@ -38,12 +38,12 @@ export const LOCALES = [
{
name: "Slovak",
value: "sk-SK",
progress: 100,
progress: 74,
},
{
name: "Pусский (Russian)",
value: "ru-RU",
progress: 100,
progress: 74,
},
{
name: "Română (Romanian)",
@ -53,12 +53,12 @@ export const LOCALES = [
{
name: "Português (Portugese)",
value: "pt-PT",
progress: 15,
progress: 11,
},
{
name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR",
progress: 64,
progress: 47,
},
{
name: "Polski (Polish)",
@ -68,12 +68,12 @@ export const LOCALES = [
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 100,
progress: 74,
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 100,
progress: 98,
},
{
name: "한국어 (Korean)",
@ -88,7 +88,7 @@ export const LOCALES = [
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 99,
progress: 96,
},
{
name: "Magyar (Hungarian)",
@ -103,12 +103,12 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 100,
progress: 99,
},
{
name: "French, Canada",
value: "fr-CA",
progress: 100,
progress: 88,
},
{
name: "Suomi (Finnish)",
@ -118,7 +118,7 @@ export const LOCALES = [
{
name: "Español (Spanish)",
value: "es-ES",
progress: 100,
progress: 74,
},
{
name: "American English",
@ -128,22 +128,22 @@ export const LOCALES = [
{
name: "British English",
value: "en-GB",
progress: 100,
progress: 74,
},
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 100,
progress: 86,
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 100,
progress: 99,
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 100,
progress: 83,
},
{
name: "Čeština (Czech)",
@ -153,7 +153,7 @@ export const LOCALES = [
{
name: "Català (Catalan)",
value: "ca-ES",
progress: 100,
progress: 74,
},
{
name: "العربية (Arabic)",
@ -165,4 +165,4 @@ export const LOCALES = [
value: "af-ZA",
progress: 0,
},
];
]

View File

@ -130,7 +130,9 @@
"url": "URL",
"view": "View",
"wednesday": "Wednesday",
"yes": "Yes"
"yes": "Yes",
"foods": "Foods",
"units": "Units"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
@ -502,5 +504,14 @@
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
"read-the-docs": "Read the docs"
},
"data-pages": {
"seed-data": "Seed Data",
"foods": {
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
"merge-food-example": "Merging {food1} into {food2}",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
}
}
}

View File

@ -3,18 +3,57 @@
<!-- Merge Dialog -->
<BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" title="Combine Food" @confirm="mergeFoods">
<v-card-text>
Combining the selected foods will merge the Source Food and Target Food into a single food. The
<strong> Source Food will be deleted </strong> and all of the references to the Source Food will be updated to
point to the Target Food.
<div>
{{ $t("data-pages.foods.merge-dialog-text") }}
</div>
<v-autocomplete v-model="fromFood" return-object :items="foods" item-text="name" label="Source Food" />
<v-autocomplete v-model="toFood" return-object :items="foods" item-text="name" label="Target Food" />
<template v-if="canMerge && fromFood && toFood">
<div class="text-center">Merging {{ fromFood.name }} into {{ toFood.name }}</div>
<div class="text-center">
{{ $t("data-pages.foods.merge-food-example", { food1: fromFood.name, food2: toFood.name }) }}
</div>
</template>
</v-card-text>
</BaseDialog>
<!-- Seed Dialog-->
<BaseDialog
v-model="seedDialog"
:icon="$globals.icons.foods"
:title="$tc('data-pages.seed-data')"
@confirm="seedDatabase"
>
<v-card-text>
<div class="pb-2">
{{ $t("data-pages.foods.seed-dialog-text") }}
</div>
<v-autocomplete
v-model="locale"
:items="locales"
item-text="name"
label="Select Language"
class="my-3"
hide-details
outlined
offset
>
<template #item="{ item }">
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-list-item-subtitle>
{{ item.progress }}% {{ $tc("language-dialog.translated") }}
</v-list-item-subtitle>
</v-list-item-content>
</template>
</v-autocomplete>
<v-alert v-if="foods.length > 0" type="error" class="mb-0 text-body-2">
{{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
@ -73,6 +112,12 @@
{{ item.label.name }}
</MultiPurposeLabel>
</template>
<template #button-bottom>
<BaseButton @click="seedDialog = true">
<template #icon> {{ $globals.icons.database }} </template>
Seed
</BaseButton>
</template>
</CrudTable>
</div>
</template>
@ -80,11 +125,13 @@
<script lang="ts">
import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
import { computed } from "vue-demi";
import type { LocaleObject } from "@nuxtjs/i18n";
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { IngredientFood } from "~/types/api-types/recipe";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
import { useLocales } from "~/composables/use-locales";
export default defineComponent({
components: { MultiPurposeLabel },
@ -193,6 +240,30 @@ export default defineComponent({
allLabels.value = data ?? [];
}
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: LOCALES, locale: currentLocale, i18n } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
const locales = LOCALES.filter((locale) =>
(i18n.locales as LocaleObject[]).map((i18nLocale) => i18nLocale.code).includes(locale.value)
);
async function seedDatabase() {
const { data } = await userApi.seeders.foods({ locale: locale.value });
if (data) {
refreshFoods();
}
}
refreshLabels();
return {
tableConfig,
@ -215,6 +286,11 @@ export default defineComponent({
mergeDialog,
fromFood,
toFood,
// Seed Data
locale,
locales,
seedDialog,
seedDatabase,
};
},
});

View File

@ -103,13 +103,11 @@ export interface RecipeSummary {
}
export interface RecipeCategory {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
groupId: string;
name: string;
slug: string;
}

View File

@ -89,13 +89,11 @@ export interface RecipeSummary {
}
export interface RecipeCategory {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
groupId: string;
name: string;
slug: string;
}

View File

@ -258,13 +258,11 @@ export interface RecipeSummary {
}
export interface RecipeCategory {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
groupId: string;
name: string;
slug: string;
}
@ -307,6 +305,9 @@ export interface SaveWebhook {
time?: string;
groupId: string;
}
export interface SeederConfig {
locale: string;
}
export interface SetPermissions {
userId: string;
canManage?: boolean;

View File

@ -119,13 +119,11 @@ export interface RecipeSummary {
}
export interface RecipeCategory {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
groupId: string;
name: string;
slug: string;
}

View File

@ -42,6 +42,7 @@ export interface CategoryOut {
name: string;
id: string;
slug: string;
groupId: string;
}
export interface CategorySave {
name: string;
@ -68,19 +69,14 @@ export interface CreateRecipeBulk {
}
export interface RecipeCategory {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface CreateRecipeByUrl {
url: string;
}
export interface CreateRecipeByUrlBulk {
imports: CreateRecipeBulk[];
}
@ -115,10 +111,6 @@ export interface MultiPurposeLabelSummary {
groupId: string;
id: string;
}
export interface IngredientMerge {
fromFood: string;
toFood: string;
}
/**
* A list of ingredient references.
*/
@ -140,6 +132,14 @@ export interface IngredientsRequest {
parser?: RegisteredParser & string;
ingredients: string[];
}
export interface MergeFood {
fromFood: string;
toFood: string;
}
export interface MergeUnit {
fromUnit: string;
toUnit: string;
}
export interface Nutrition {
calories?: string;
fatContent?: string;
@ -348,6 +348,13 @@ export interface SaveIngredientUnit {
abbreviation?: string;
groupId: string;
}
export interface ScrapeRecipe {
url: string;
includeTags?: boolean;
}
export interface ScrapeRecipeTest {
url: string;
}
export interface SlugResponse {}
export interface TagIn {
name: string;

View File

@ -132,13 +132,11 @@ export interface RecipeSummary {
}
export interface RecipeCategory {
id?: string;
groupId: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
groupId: string;
name: string;
slug: string;
}

View File

@ -1,76 +1,74 @@
// This Code is auto generated by gen_global_components.py
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import ButtonLink from "@/components/global/ButtonLink.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import ButtonLink from "@/components/global/ButtonLink.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
declare module "vue" {
export interface GlobalComponents {
// Global Components
BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
}
}

View File

@ -13,7 +13,7 @@ def fix_slug_food_names(db: AllRepositories):
logger = root_logger.get_logger("init_db")
if not food:
logger.info("No food found with slug: '{}' skipping fix".format(check_for_food))
logger.info(f"No food found with slug: '{check_for_food}' skipping fix")
return
all_foods = db.ingredient_foods.get_all()
@ -23,5 +23,5 @@ def fix_slug_food_names(db: AllRepositories):
for food in all_foods:
if food.name in seed_foods:
food.name = seed_foods[food.name]
logger.info("Updating food: {}".format(food.name))
logger.info(f"Updating food: {food.name}")
db.ingredient_foods.update(food.id, food)

View File

@ -14,7 +14,7 @@ from mealie.db.fixes.fix_slug_foods import fix_slug_food_names
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.seed.init_users import default_user_init
from mealie.repos.seed.seeders import IngredientFoodsSeeder, IngredientUnitsSeeder, MultiPurposeLabelSeeder
from mealie.repos.seed.seeders import IngredientUnitsSeeder, MultiPurposeLabelSeeder
from mealie.schema.user.user import GroupBase
from mealie.services.group_services.group_service import GroupService
@ -32,7 +32,6 @@ def init_db(db: AllRepositories) -> None:
seeders = [
MultiPurposeLabelSeeder(db, group_id=group_id),
IngredientFoodsSeeder(db, group_id=group_id),
IngredientUnitsSeeder(db, group_id=group_id),
]
@ -63,11 +62,11 @@ def db_is_at_head(alembic_cfg: config.Config) -> bool:
return set(context.get_current_heads()) == set(directory.get_heads())
def safe_try(name: str, func: Callable):
def safe_try(func: Callable):
try:
func()
except Exception as e:
logger.error(f"Error calling '{name}': {e}")
logger.error(f"Error calling '{func.__name__}': {e}")
def connect(session: orm.Session) -> bool:
@ -108,14 +107,13 @@ def main():
db = get_repositories(session)
init_user = db.users.get_all()
if init_user:
if db.users.get_all():
logger.info("Database exists")
else:
logger.info("Database contains no users, initializing...")
init_db(db)
safe_try("fix slug food names", lambda: fix_slug_food_names(db))
safe_try(lambda: fix_slug_food_names(db))
if __name__ == "__main__":

View File

@ -25,5 +25,5 @@ class AbstractSeeder(ABC):
self.resources = Path(__file__).parent / "resources"
@abstractmethod
def seed(self):
def seed(self, locale: str | None = None) -> None:
...

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "Aubergine",
"endive": "Endivie",
"fats": "Fette",
"spek": "spek",
"speck": "speck",
"fava-beans": "Ackerbohnen",
"fiddlehead": "Farnspitzen",
"fish": "Fisch",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "ψάρι",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",
@ -119,7 +119,6 @@
"oregano": "oregano",
"parsley": "parsley",
"honey": "honey",
"horse": "horse",
"icing-sugar": "icing sugar",
"isomalt": "isomalt",
"jackfruit": "jackfruit",
@ -176,7 +175,6 @@
"pumpkin": "pumpkin",
"pumpkin-seeds": "pumpkin seeds",
"radish": "radish",
"rape": "rape",
"raw-sugar": "raw sugar",
"refined-sugar": "refined sugar",
"rice-flour": "rice flour",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "aubergine",
"endive": "endive",
"fats": "matières grasses",
"spek": "spek",
"speck": "speck",
"fava-beans": "fèves",
"fiddlehead": "crosse de fougère",
"fish": "poisson",

View File

@ -80,7 +80,7 @@
"eggplant": "aubergine",
"endive": "endive",
"fats": "matières grasses",
"spek": "spek",
"speck": "speck",
"fava-beans": "fèves",
"fiddlehead": "crosse de fougère",
"fish": "poisson",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "padlizsán",
"endive": "endívia",
"fats": "zsírok",
"spek": "speck sonka",
"speck": "speck sonka",
"fava-beans": "lóbab",
"fiddlehead": "hegedűfej",
"fish": "hal",

View File

@ -80,7 +80,7 @@
"eggplant": "melanzana",
"endive": "endive",
"fats": "grassi",
"spek": "spek",
"speck": "speck",
"fava-beans": "fave",
"fiddlehead": "fiddlehead",
"fish": "pesce",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "aubergine",
"endive": "andijvie",
"fats": "vetten",
"spek": "spek",
"speck": "speck",
"fava-beans": "tuinbonen",
"fiddlehead": "varenkrul",
"fish": "vis",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "bakłażan",
"endive": "endywia",
"fats": "tłuszcze",
"spek": "boczek",
"speck": "boczek",
"fava-beans": "bób",
"fiddlehead": "pędy paproci",
"fish": "ryba",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "äggplanta",
"endive": "endive",
"fats": "fetter",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fisk",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "баклажан",
"endive": "ендивій (салатний цикорій)",
"fats": "жири",
"spek": "шпек",
"speck": "шпек",
"fava-beans": "біб кінський",
"fiddlehead": "рахіси папороті",
"fish": "риба",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "eggplant",
"endive": "endive",
"fats": "fats",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "fish",

View File

@ -80,7 +80,7 @@
"eggplant": "茄子",
"endive": "endive",
"fats": "脂肪",
"spek": "spek",
"speck": "speck",
"fava-beans": "fava beans",
"fiddlehead": "fiddlehead",
"fish": "魚",

View File

@ -1,4 +1,5 @@
import json
import pathlib
from collections.abc import Generator
from mealie.schema.labels import MultiPurposeLabelSave
@ -9,8 +10,12 @@ from .resources import foods, labels, units
class MultiPurposeLabelSeeder(AbstractSeeder):
def load_data(self) -> Generator[MultiPurposeLabelSave, None, None]:
file = labels.en_US
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "labels" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else labels.en_US
def load_data(self, locale: str | None = None) -> Generator[MultiPurposeLabelSave, None, None]:
file = self.get_file(locale)
for label in json.loads(file.read_text()):
yield MultiPurposeLabelSave(
@ -18,9 +23,9 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
group_id=self.group_id,
)
def seed(self) -> None:
def seed(self, locale: str | None = None) -> None:
self.logger.info("Seeding MultiPurposeLabel")
for label in self.load_data():
for label in self.load_data(locale):
try:
self.repos.group_multi_purpose_labels.create(label)
except Exception as e:
@ -28,8 +33,13 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
class IngredientUnitsSeeder(AbstractSeeder):
def load_data(self) -> Generator[SaveIngredientUnit, None, None]:
file = units.en_US
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "units" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else units.en_US
def load_data(self, locale: str | None = None) -> Generator[SaveIngredientUnit, None, None]:
file = self.get_file(locale)
for unit in json.loads(file.read_text()).values():
yield SaveIngredientUnit(
group_id=self.group_id,
@ -38,9 +48,9 @@ class IngredientUnitsSeeder(AbstractSeeder):
abbreviation=unit["abbreviation"],
)
def seed(self) -> None:
def seed(self, locale: str | None = None) -> None:
self.logger.info("Seeding Ingredient Units")
for unit in self.load_data():
for unit in self.load_data(locale):
try:
self.repos.ingredient_units.create(unit)
except Exception as e:
@ -48,8 +58,13 @@ class IngredientUnitsSeeder(AbstractSeeder):
class IngredientFoodsSeeder(AbstractSeeder):
def load_data(self) -> Generator[SaveIngredientFood, None, None]:
file = foods.en_US
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "foods" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else foods.en_US
def load_data(self, locale: str | None = None) -> Generator[SaveIngredientFood, None, None]:
file = self.get_file(locale)
seed_foods: dict[str, str] = json.loads(file.read_text())
for food in seed_foods.values():
yield SaveIngredientFood(
@ -58,9 +73,9 @@ class IngredientFoodsSeeder(AbstractSeeder):
description="",
)
def seed(self) -> None:
def seed(self, locale: str | None = None) -> None:
self.logger.info("Seeding Ingredient Foods")
for food in self.load_data():
for food in self.load_data(locale):
try:
self.repos.ingredient_foods.create(food)
except Exception as e:

View File

@ -11,6 +11,7 @@ from . import (
controller_mealplan_config,
controller_mealplan_rules,
controller_migrations,
controller_seeder,
controller_shopping_lists,
controller_webhooks,
)
@ -30,3 +31,4 @@ router.include_router(controller_shopping_lists.router)
router.include_router(controller_shopping_lists.item_router)
router.include_router(controller_labels.router)
router.include_router(controller_group_notifications.router)
router.include_router(controller_seeder.router)

View File

@ -0,0 +1,38 @@
from functools import cached_property
from fastapi import APIRouter, HTTPException
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.schema.group.group_seeder import SeederConfig
from mealie.schema.response.responses import ErrorResponse, SuccessResponse
from mealie.services.seeder.seeder_service import SeederService
router = APIRouter(prefix="/groups/seeders", tags=["Groups: Seeders"])
@controller(router)
class DataSeederController(BaseUserController):
@cached_property
def service(self) -> SeederService:
return SeederService(self.repos, self.user, self.group)
def _wrap(self, func):
try:
func()
except Exception as e:
raise HTTPException(status_code=500, detail=ErrorResponse.respond("Seeding Failed")) from e
return SuccessResponse.respond("Seeding Successful")
@router.post("/foods", response_model=SuccessResponse)
def seed_foods(self, data: SeederConfig) -> dict:
return self._wrap(lambda: self.service.seed_foods(data.locale))
@router.post("/labels", response_model=SuccessResponse)
def seed_labels(self, data: SeederConfig) -> dict:
return self._wrap(lambda: self.service.seed_labels(data.locale))
@router.post("/units", response_model=SuccessResponse)
def seed_units(self, data: SeederConfig) -> dict:
return self._wrap(lambda: self.service.seed_units(data.locale))

View File

@ -5,6 +5,7 @@ from .group_exports import *
from .group_migration import *
from .group_permissions import *
from .group_preferences import *
from .group_seeder import *
from .group_shopping_list import *
from .group_statistics import *
from .invite_token import *

View File

@ -0,0 +1,53 @@
from pydantic import validator
from mealie.schema._mealie.mealie_model import MealieModel
def validate_locale(locale: str) -> bool:
valid = {
"el-GR",
"it-IT",
"ko-KR",
"es-ES",
"ja-JP",
"zh-CN",
"tr-TR",
"ar-SA",
"hu-HU",
"pt-PT",
"no-NO",
"sv-SE",
"ro-RO",
"sk-SK",
"uk-UA",
"fr-CA",
"pl-PL",
"da-DK",
"pt-BR",
"de-DE",
"ca-ES",
"sr-SP",
"cs-CZ",
"fr-FR",
"zh-TW",
"af-ZA",
"ru-RU",
"he-IL",
"nl-NL",
"en-US",
"en-GB",
"fi-FI",
"vi-VN",
}
return locale in valid
class SeederConfig(MealieModel):
locale: str
@validator("locale")
def valid_locale(cls, v, values, **kwargs):
if not validate_locale(v):
raise ValueError("passwords do not match")
return v

View File

View File

@ -0,0 +1,24 @@
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.seed.seeders import IngredientFoodsSeeder, IngredientUnitsSeeder, MultiPurposeLabelSeeder
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService
class SeederService(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
self.repos = repos
self.user = user
self.group = group
super().__init__()
def seed_foods(self, locale: str) -> None:
seeder = IngredientFoodsSeeder(self.repos, self.logger, self.group.id)
seeder.seed(locale)
def seed_labels(self, locale: str) -> None:
seeder = MultiPurposeLabelSeeder(self.repos, self.logger, self.group.id)
seeder.seed(locale)
def seed_units(self, locale: str) -> None:
seeder = IngredientUnitsSeeder(self.repos, self.logger, self.group.id)
seeder.seed(locale)

View File

@ -0,0 +1,56 @@
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from tests.utils.fixture_schemas import TestUser
from tests.utils.routes import RoutesSeeders
def test_seed_invalid_locale(api_client: TestClient, unique_user: TestUser):
for route in [RoutesSeeders.foods, RoutesSeeders.labels, RoutesSeeders.units]:
resp = api_client.post(route, json={"locale": "invalid"}, headers=unique_user.token)
assert resp.status_code == 422
def test_seed_foods(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
CREATED_FOODS = 220
# Check that the foods was created
foods = database.ingredient_foods.by_group(unique_user.group_id).get_all()
assert len(foods) == 0
resp = api_client.post(RoutesSeeders.foods, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
foods = database.ingredient_foods.by_group(unique_user.group_id).get_all()
assert len(foods) == CREATED_FOODS
def test_seed_units(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
CREATED_UNITS = 20
# Check that the foods was created
units = database.ingredient_units.by_group(unique_user.group_id).get_all()
assert len(units) == 0
resp = api_client.post(RoutesSeeders.units, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
units = database.ingredient_units.by_group(unique_user.group_id).get_all()
assert len(units) == CREATED_UNITS
def test_seed_labels(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
CREATED_LABELS = 21
# Check that the foods was created
labels = database.group_multi_purpose_labels.by_group(unique_user.group_id).get_all()
assert len(labels) == 0
resp = api_client.post(RoutesSeeders.labels, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
labels = database.group_multi_purpose_labels.by_group(unique_user.group_id).get_all()
assert len(labels) == CREATED_LABELS

View File

@ -50,3 +50,11 @@ class RoutesAdminUsers(RoutesBase):
class RoutesUsers(RoutesBase):
base = "/api/users"
self = f"{base}/self"
class RoutesSeeders(RoutesBase):
base = "/api/groups/seeders"
foods = f"{base}/foods"
units = f"{base}/units"
labels = f"{base}/labels"