category/tag CRUD

This commit is contained in:
hay-kot 2021-04-25 20:40:41 -08:00
commit 10c86773ea
12 changed files with 315 additions and 37 deletions

View file

@ -8,6 +8,7 @@ const categoryURLs = {
getAll: `${prefix}`, getAll: `${prefix}`,
getCategory: category => `${prefix}/${category}`, getCategory: category => `${prefix}/${category}`,
deleteCategory: category => `${prefix}/${category}`, deleteCategory: category => `${prefix}/${category}`,
updateCategory: category => `${prefix}/${category}`,
}; };
export const categoryAPI = { export const categoryAPI = {
@ -24,6 +25,15 @@ export const categoryAPI = {
let response = await apiReq.get(categoryURLs.getCategory(category)); let response = await apiReq.get(categoryURLs.getCategory(category));
return response.data; return response.data;
}, },
async update(name, newName, overrideRequest = false) {
let response = await apiReq.put(categoryURLs.updateCategory(name), {
name: newName,
});
if (!overrideRequest) {
store.dispatch("requestCategories");
}
return response.data;
},
async delete(category) { async delete(category) {
let response = await apiReq.delete(categoryURLs.deleteCategory(category)); let response = await apiReq.delete(categoryURLs.deleteCategory(category));
store.dispatch("requestCategories"); store.dispatch("requestCategories");
@ -37,6 +47,7 @@ const tagURLs = {
getAll: `${tagPrefix}`, getAll: `${tagPrefix}`,
getTag: tag => `${tagPrefix}/${tag}`, getTag: tag => `${tagPrefix}/${tag}`,
deleteTag: tag => `${tagPrefix}/${tag}`, deleteTag: tag => `${tagPrefix}/${tag}`,
updateTag: tag => `${tagPrefix}/${tag}`,
}; };
export const tagAPI = { export const tagAPI = {
@ -53,6 +64,15 @@ export const tagAPI = {
let response = await apiReq.get(tagURLs.getTag(tag)); let response = await apiReq.get(tagURLs.getTag(tag));
return response.data; return response.data;
}, },
async update(name, newName, overrideRequest = false) {
let response = await apiReq.put(tagURLs.updateTag(name), { name: newName });
if (!overrideRequest) {
store.dispatch("requestTags");
}
return response.data;
},
async delete(tag) { async delete(tag) {
let response = await apiReq.delete(tagURLs.deleteTag(tag)); let response = await apiReq.delete(tagURLs.deleteTag(tag));
store.dispatch("requestTags"); store.dispatch("requestTags");

View file

@ -1,21 +1,34 @@
<template> <template>
<div> <div>
<v-dialog v-model="dialog" :width="modalWidth + 'px'"> <v-dialog v-model="dialog" :width="modalWidth + 'px'">
<v-app-bar dark :color="color" class="mt-n1 mb-2"> <v-card class="pb-2">
<v-icon large left v-if="!loading"> <v-app-bar dark :color="color" class="mt-n1 mb-2">
{{ titleIcon }} <v-icon large left v-if="!loading">
</v-icon> {{ titleIcon }}
<v-progress-circular </v-icon>
v-else <v-progress-circular
indeterminate v-else
color="white" indeterminate
large color="white"
class="mr-2" large
> class="mr-2"
</v-progress-circular> >
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> </v-progress-circular>
<v-spacer></v-spacer> <v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
</v-app-bar> <v-spacer></v-spacer>
</v-app-bar>
<slot> </slot>
<v-card-actions>
<v-btn text color="grey" @click="dialog = false">
Cancel
</v-btn>
<v-spacer></v-spacer>
<v-btn color="success" @click="$emit('submit')">
Submit
</v-btn>
</v-card-actions>
<slot name="below-actions"> </slot>
</v-card>
</v-dialog> </v-dialog>
</div> </div>
</template> </template>
@ -39,6 +52,7 @@ export default {
data() { data() {
return { return {
dialog: false, dialog: false,
loading: false,
}; };
}, },
methods: { methods: {

View file

@ -163,6 +163,11 @@ export default {
to: "/admin/settings", to: "/admin/settings",
title: this.$t("settings.site-settings"), title: this.$t("settings.site-settings"),
}, },
{
icon: "mdi-tools",
to: "/admin/toolbox",
title: this.$t("settings.toolbox.toolbox"),
},
{ {
icon: "mdi-account-group", icon: "mdi-account-group",
to: "/admin/manage-users", to: "/admin/manage-users",

View file

@ -218,6 +218,10 @@
"test-webhooks": "Test Webhooks", "test-webhooks": "Test Webhooks",
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at", "the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at",
"webhook-url": "Webhook URL" "webhook-url": "Webhook URL"
},
"toolbox": {
"toolbox": "Toolbox",
"new-name": "New Name"
} }
}, },
"user": { "user": {

View file

@ -0,0 +1,169 @@
<template>
<v-card outlined class="mt-n1">
<base-dialog
ref="renameDialog"
title-icon="mdi-tag"
:title="renameTarget.title"
modal-width="800"
@submit="renameFromDialog(renameTarget.slug, renameTarget.newName)"
>
<v-form ref="renameForm">
<v-card-text>
<v-text-field
:placeholder="$t('settings.toolbox.new-name')"
:rules="[existsRule]"
v-model="renameTarget.newName"
></v-text-field>
</v-card-text>
</v-form>
<template slot="below-actions">
<v-card-title class="headline">
{{ renameTarget.recipes.length || 0 }} Recipes Effected
</v-card-title>
<MobileRecipeCard
class="ml-2 mr-2 mt-2 mb-2"
v-for="recipe in renameTarget.recipes"
:key="recipe.slug"
:slug="recipe.slug"
:name="recipe.name"
:description="recipe.description"
:rating="recipe.rating"
:route="false"
:tags="recipe.tags"
/>
</template>
</base-dialog>
<v-app-bar flat>
<v-spacer> </v-spacer>
<v-btn @click="titleCaseAll" small color="success">
Title Case All
</v-btn>
</v-app-bar>
<v-card-text>
<v-row>
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="item in allItems"
:key="item.id"
>
<v-card>
<v-card-actions>
<v-card-title class="py-1">{{ item.name }}</v-card-title>
<v-spacer></v-spacer>
<v-btn small text color="info" @click="openRename(item)"
>Rename</v-btn
>
<v-btn small text color="error" @click="deleteItem(item.slug)"
>Delete
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
export default {
mixins: [validators],
components: {
BaseDialog,
MobileRecipeCard,
},
props: {
isTags: {
default: true,
},
},
data() {
return {
renameTarget: {
title: "",
name: "",
slug: "",
newName: "",
recipes: [],
},
};
},
computed: {
allItems() {
return this.isTags
? this.$store.getters.getAllTags
: this.$store.getters.getAllCategories;
},
},
methods: {
async openRename(item) {
let fromAPI = {};
if (this.isTags) {
fromAPI = await api.tags.getRecipesInTag(item.slug);
} else {
fromAPI = await api.categories.getRecipesInCategory(item.slug);
}
this.renameTarget = {
title: `Rename ${item.name}`,
name: item.name,
slug: item.slug,
newName: "",
recipes: fromAPI.recipes,
};
this.$refs.renameDialog.open();
},
async deleteItem(name) {
if (this.isTags) {
await api.tags.delete(name);
} else {
await api.categories.delete(name);
}
},
renameFromDialog(name, newName) {
if (this.$refs.renameForm.validate()) {
this.rename(name, newName);
}
},
async rename(name, newName) {
if (this.isTags) {
await api.tags.update(name, newName);
} else {
await api.categories.update(name, newName);
}
},
titleCase(lowerName) {
return lowerName.replace(/(?:^|\s|-)\S/g, x => x.toUpperCase());
},
async titleCaseAll() {
const renameList = this.allItems.map(x => ({
slug: x.slug,
name: x.name,
newName: this.titleCase(x.name),
}));
if (this.isTags) {
renameList.forEach(async element => {
await api.tags.update(element.slug, element.newName, true);
});
this.$store.dispatch("requestTags");
} else {
renameList.forEach(async element => {
await api.categories.update(element.slug, element.newName, true);
});
this.$store.dispatch("requestCategories");
}
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,15 +1,47 @@
<template> <template>
<div> <div>
<v-card flat>
<v-tabs
v-model="tab"
background-color="primary"
centered
dark
icons-and-text
>
<v-tabs-slider></v-tabs-slider>
</div> <v-tab>
{{ $t("recipe.categories") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
<v-tab>
{{ $t("recipe.tags") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item><CategoryTagEditor :is-tags="false"/></v-tab-item>
<v-tab-item><CategoryTagEditor :is-tags="true" /> </v-tab-item>
</v-tabs-items>
</v-card>
</div>
</template> </template>
<script> <script>
export default { import CategoryTagEditor from "./CategoryTagEditor";
export default {
} components: {
CategoryTagEditor,
},
data() {
return {
tab: 0,
};
},
};
</script> </script>
<style lang="scss" scoped> <style>
</style> </style>

View file

@ -7,9 +7,10 @@ import Profile from "@/pages/Admin/Profile";
import ManageUsers from "@/pages/Admin/ManageUsers"; import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings"; import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About"; import About from "@/pages/Admin/About";
import Toolbox from "@/pages/Admin/Toolbox";
import { store } from "../store"; import { store } from "../store";
export const adminRoutes = { export const adminRoutes = {
path: "/admin", path: "/admin",
component: Admin, component: Admin,
beforeEnter: (to, _from, next) => { beforeEnter: (to, _from, next) => {
@ -72,6 +73,13 @@ export const adminRoutes = {
title: "settings.site-settings", title: "settings.site-settings",
}, },
}, },
{
path: "toolbox",
component: Toolbox,
meta: {
title: "settings.toolbox.toolbox",
},
},
{ {
path: "about", path: "about",
component: About, component: About,

View file

@ -11,28 +11,28 @@ site_settings2categories = sa.Table(
"site_settings2categoories", "site_settings2categoories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")), sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
group2categories = sa.Table( group2categories = sa.Table(
"group2categories", "group2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")), sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
recipes2categories = sa.Table( recipes2categories = sa.Table(
"recipes2categories", "recipes2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
custom_pages2categories = sa.Table( custom_pages2categories = sa.Table(
"custom_pages2categories", "custom_pages2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")), sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
@ -52,6 +52,9 @@ class Category(SqlAlchemyBase):
self.name = name.strip() self.name = name.strip()
self.slug = slugify(name) self.slug = slugify(name)
def update(self, name, session=None) -> None:
self.__init__(name, session)
@staticmethod @staticmethod
def get_ref(session, slug: str): def get_ref(session, slug: str):
return session.query(Category).filter(Category.slug == slug).one() return session.query(Category).filter(Category.slug == slug).one()

View file

@ -11,7 +11,7 @@ recipes2tags = sa.Table(
"recipes2tags", "recipes2tags",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")), sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
) )
@ -31,6 +31,9 @@ class Tag(SqlAlchemyBase):
self.name = name.strip() self.name = name.strip()
self.slug = slugify(self.name) self.slug = slugify(self.name)
def update(self, name, session=None) -> None:
self.__init__(name, session)
@staticmethod @staticmethod
def create_if_not_exist(session, name: str = None): def create_if_not_exist(session, name: str = None):
test_slug = slugify(name) test_slug = slugify(name)

View file

@ -33,6 +33,18 @@ def get_all_recipes_by_category(category: str, session: Session = Depends(genera
return db.categories.get(session, category) return db.categories.get(session, category)
@router.put("/{category}", response_model=RecipeCategoryResponse)
async def update_recipe_category(
category: str,
new_category: CategoryIn,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Updates an existing Tag in the database """
return db.categories.update(session, category, new_category.dict())
@router.delete("/{category}") @router.delete("/{category}")
async def delete_recipe_category( async def delete_recipe_category(
category: str, session: Session = Depends(generate_session), current_user=Depends(get_current_user) category: str, session: Session = Depends(generate_session), current_user=Depends(get_current_user)

View file

@ -29,21 +29,21 @@ async def create_recipe_tag(
return db.tags.create(session, tag.dict()) return db.tags.create(session, tag.dict())
@router.put("")
async def update_recipe_tag(
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
):
""" Creates a Tag in the database """
return "NOT IMPLEMENTED"
@router.get("/{tag}", response_model=RecipeTagResponse) @router.get("/{tag}", response_model=RecipeTagResponse)
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)): def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
""" Returns a list of recipes associated with the provided tag. """ """ Returns a list of recipes associated with the provided tag. """
return db.tags.get(session, tag) return db.tags.get(session, tag)
@router.put("/{tag}", response_model=RecipeTagResponse)
async def update_recipe_tag(
tag: str, new_tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
):
""" Updates an existing Tag in the database """
return db.tags.update(session, tag, new_tag.dict())
@router.delete("/{tag}") @router.delete("/{tag}")
async def delete_recipe_tag( async def delete_recipe_tag(
tag: str, session: Session = Depends(generate_session), current_user=Depends(get_current_user) tag: str, session: Session = Depends(generate_session), current_user=Depends(get_current_user)

View file

@ -2,6 +2,7 @@ from typing import List, Optional
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from pydantic.utils import GetterDict
class CategoryIn(CamelModel): class CategoryIn(CamelModel):
@ -15,6 +16,13 @@ class CategoryBase(CategoryIn):
class Config: class Config:
orm_mode = True orm_mode = True
@classmethod
def getter_dict(_cls, name_orm):
return {
**GetterDict(name_orm),
"total_recipes": len(name_orm.recipes),
}
class RecipeCategoryResponse(CategoryBase): class RecipeCategoryResponse(CategoryBase):
recipes: Optional[List[Recipe]] recipes: Optional[List[Recipe]]