feature/favorite-recipes (#443)

* add favorites options

* bump dependencies

* add badges to all cards

* typo

* remove console.log

* fix site-loader viewport

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-29 15:54:18 -08:00 committed by GitHub
parent 57f7ea3750
commit 6f38fcf81b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 365 additions and 82 deletions

View file

@ -74,6 +74,9 @@ class AppRoutes:
def groups_id(self, id):
return f"{self.prefix}/groups/{id}"
def meal_plans_id(self, id):
return f"{self.prefix}/meal-plans/{id}"
def meal_plans_id_shopping_list(self, id):
return f"{self.prefix}/meal-plans/{id}/shopping-list"
@ -122,6 +125,12 @@ class AppRoutes:
def users_id(self, id):
return f"{self.prefix}/users/{id}"
def users_id_favorites(self, id):
return f"{self.prefix}/users/{id}/favorites/"
def users_id_favorites_slug(self, id, slug):
return f"{self.prefix}/users/{id}/favorites/{slug}"
def users_id_image(self, id):
return f"{self.prefix}/users/{id}/image"

View file

@ -17673,7 +17673,6 @@
"integrity": "sha512-8q67ORQ9O0Ms0nlqsXTVhaBefRBaLrzPxOewAZhdcO7onHwcO5/wRdWtHhZgfpCZlhY7NogkU16z3WnorSSkEA==",
"dev": true,
"requires": {
"@babel/core": "^7.11.0",
"@babel/helper-compilation-targets": "^7.9.6",
"@babel/helper-module-imports": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3",
@ -17686,7 +17685,6 @@
"@vue/babel-plugin-jsx": "^1.0.3",
"@vue/babel-preset-jsx": "^1.2.4",
"babel-plugin-dynamic-import-node": "^2.3.3",
"core-js": "^3.6.5",
"core-js-compat": "^3.6.5",
"semver": "^6.1.0"
}
@ -17849,7 +17847,8 @@
"version": "4.5.12",
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.12.tgz",
"integrity": "sha512-STgbvNv/3iHAKArc18b/qjN7RX1FTrfxPeHH26GOr/A8lJes7+CSluZZ8E5R7Zr/vL0zOqOkUVDAjFXVf4zWQA==",
"dev": true
"dev": true,
"requires": {}
},
"@vue/cli-service": {
"version": "4.5.12",
@ -18141,7 +18140,8 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz",
"integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==",
"dev": true
"dev": true,
"requires": {}
},
"@vue/web-component-wrapper": {
"version": "1.3.0",
@ -18361,7 +18361,8 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz",
"integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==",
"dev": true
"dev": true,
"requires": {}
},
"acorn-walk": {
"version": "7.2.0",
@ -18400,13 +18401,15 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
"dev": true
"dev": true,
"requires": {}
},
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true
"dev": true,
"requires": {}
},
"alphanum-sort": {
"version": "1.0.2",
@ -28209,7 +28212,8 @@
"vuetify": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.11.tgz",
"integrity": "sha512-xFNwr95tFRfbyGNg5DBuUkWaKazMBr+ptzoSSL4PGrI0qItY5Vuusxh+ETPtjUXxwz76v5zVtGvF5rWvGQjy7A=="
"integrity": "sha512-xFNwr95tFRfbyGNg5DBuUkWaKazMBr+ptzoSSL4PGrI0qItY5Vuusxh+ETPtjUXxwz76v5zVtGvF5rWvGQjy7A==",
"requires": {}
},
"vuetify-loader": {
"version": "1.7.2",
@ -28259,7 +28263,8 @@
"vuex": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz",
"integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw=="
"integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==",
"requires": {}
},
"vuex-persistedstate": {
"version": "4.0.0-beta.3",

View file

@ -53,6 +53,7 @@ export default {
this.$store.dispatch("requestRecentRecipes");
this.$store.dispatch("refreshToken");
this.$store.dispatch("requestCurrentGroup");
this.$store.dispatch("requestUserData");
this.$store.dispatch("requestCategories");
this.$store.dispatch("requestTags");
this.darkModeSystemCheck();

View file

@ -49,7 +49,7 @@ const apiReq = {
return handleResponse(response, getSuccessText);
},
get: function(url, data, getErrorText = defaultErrorText) {
get: async function(url, data, getErrorText = defaultErrorText) {
return axios.get(url, data).catch(function(error) {
handleError(error, getErrorText);
});

View file

@ -54,6 +54,7 @@ export const API_ROUTES = {
categoriesCategory: (category) => `${prefix}/categories/${category}`,
debugLogNum: (num) => `${prefix}/debug/log/${num}`,
groupsId: (id) => `${prefix}/groups/${id}`,
mealPlansId: (id) => `${prefix}/meal-plans/${id}`,
mealPlansIdShoppingList: (id) => `${prefix}/meal-plans/${id}/shopping-list`,
mealPlansPlanId: (plan_id) => `${prefix}/meal-plans/${plan_id}`,
mediaRecipesRecipeSlugAssetsFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/assets/${file_name}`,
@ -70,6 +71,8 @@ export const API_ROUTES = {
themesId: (id) => `${prefix}/themes/${id}`,
usersApiTokensTokenId: (token_id) => `${prefix}/users-tokens/${token_id}`,
usersId: (id) => `${prefix}/users/${id}`,
usersIdFavorites: (id) => `${prefix}/users/${id}/favorites`,
usersIdFavoritesSlug: (id, slug) => `${prefix}/users/${id}/favorites/${slug}`,
usersIdImage: (id) => `${prefix}/users/${id}/image`,
usersIdPassword: (id) => `${prefix}/users/${id}/password`,
usersIdResetPassword: (id) => `${prefix}/users/${id}/reset-password`,

View file

@ -1,4 +1,5 @@
import { baseURL } from "./api-utils";
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
import axios from "axios";
import i18n from "@/i18n.js";
@ -91,6 +92,27 @@ export const userAPI = {
const response = await apiReq.delete(usersURLs.userAPIDelete(id));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async getFavorites(id) {
const response = await apiReq.get(API_ROUTES.usersIdFavorites(id));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async addFavorite(id, slug) {
const response = await apiReq.post(API_ROUTES.usersIdFavoritesSlug(id, slug));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async removeFavorite(id, slug) {
const response = await apiReq.delete(API_ROUTES.usersIdFavoritesSlug(id, slug));
return response.data;
},
};
const deleteErrorText = response => {

View file

@ -87,8 +87,7 @@ export default {
this.clear();
this.$store.commit("setToken", response.data.access_token);
this.$emit("logged-in");
let user = await api.users.self();
this.$store.commit("setUserData", user);
this.$store.dispatch("requestUserData");
}
this.loading = false;

View file

@ -106,14 +106,7 @@ export default {
},
};
},
watch: {
value(val) {
console.log(val);
},
},
mounted() {
console.log(this.value);
},
methods: {
getImage(slug) {
if (slug) {
@ -161,7 +154,6 @@ export default {
this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
}
console.log("Hello World");
this.customMeal = { name: "", slug: null, description: "" };
},
},

View file

@ -28,9 +28,7 @@ export default {
props: {
mealPlan: Object,
},
mounted() {
console.log(this.mealPlan);
},
methods: {
formatDate(timestamp) {
let dateObject = new Date(timestamp);

View file

@ -0,0 +1,54 @@
<template>
<v-btn
small
@click.prevent="toggleFavorite"
v-if="isFavorite || showAlways"
:color="isFavorite && buttonStyle ? 'secondary' : 'primary'"
:icon="!buttonStyle"
:fab="buttonStyle"
>
<v-icon :small="!buttonStyle" color="secondary">
{{ isFavorite ? "mdi-heart" : "mdi-heart-outline" }}
</v-icon>
</v-btn>
</template>
<script>
import { api } from "@/api";
export default {
props: {
slug: {
default: "",
},
showAlways: {
type: Boolean,
default: false,
},
buttonStyle: {
type: Boolean,
default: false,
},
},
computed: {
user() {
return this.$store.getters.getUserData;
},
isFavorite() {
return this.user.favoriteRecipes.indexOf(this.slug) !== -1;
},
},
methods: {
async toggleFavorite() {
if (!this.isFavorite) {
await api.users.addFavorite(this.user.id, this.slug);
} else {
await api.users.removeFavorite(this.user.id, this.slug);
}
this.$store.dispatch("requestUserData");
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -23,6 +23,7 @@
<v-list-item-title class=" mb-1">{{ name }} </v-list-item-title>
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
<div class="d-flex justify-center align-center">
<FavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
color="secondary"
class="ml-auto"
@ -42,10 +43,12 @@
</template>
<script>
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
import ContextMenu from "@/components/Recipe/ContextMenu";
import { api } from "@/api";
export default {
components: {
FavoriteBadge,
ContextMenu,
},
props: {
@ -71,6 +74,11 @@ export default {
return api.recipes.recipeSmallImage(slug, this.image);
},
},
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
};
</script>

View file

@ -23,6 +23,7 @@
</v-card-title>
<v-card-actions>
<FavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<Rating :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :isCategory="false" />
@ -33,18 +34,14 @@
</template>
<script>
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips";
import ContextMenu from "@/components/Recipe/ContextMenu";
import CardImage from "@/components/Recipe/CardImage";
import Rating from "@/components/Recipe/Parts/Rating";
import { api } from "@/api";
export default {
components: {
RecipeChips,
ContextMenu,
Rating,
CardImage,
},
components: { FavoriteBadge, RecipeChips, ContextMenu, Rating, CardImage },
props: {
name: String,
slug: String,
@ -64,6 +61,11 @@ export default {
fallBackImage: false,
};
},
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
methods: {
getImage(slug) {
return api.recipes.recipeSmallImage(slug, this.image);

View file

@ -95,7 +95,7 @@
</div>
<div v-intersect="bumpList" class="d-flex">
<v-expand-x-transition>
<SiteLoader v-if="loading" :loading="loading" :size="150" />
<SiteLoader v-if="loading" :loading="loading" />
</v-expand-x-transition>
</div>
</div>

View file

@ -3,7 +3,7 @@
<slot v-bind="{ open, close }"> </slot>
<v-dialog
v-model="dialog"
:width="isMobile ? undefined : '700'"
:width="isMobile ? undefined : '65%'"
:height="isMobile ? undefined : '0'"
:fullscreen="isMobile"
content-class="top-dialog"

View file

@ -1,14 +1,23 @@
<template>
<v-progress-circular class="mx-auto" :width="size / 20" :size="size" color="primary lighten-2" indeterminate>
<div class="text-center">
<v-icon :size="size / 2" color="primary lighten-2">
{{ $globals.icons.primary }}
</v-icon>
<div>
Loading Recipes
<div class="mx-auto">
<v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate>
<div class="text-center">
<v-icon :size="size.icon" color="primary lighten-2">
{{ $globals.icons.primary }}
</v-icon>
<div v-if="large" class="text-small">
<slot>
{{ small ? "" : "Loading Recipes" }}
</slot>
</div>
</div>
</v-progress-circular>
<div v-if="!large" class="text-small">
<slot>
{{ small ? "" : "Loading Recipes" }}
</slot>
</div>
</v-progress-circular>
</div>
</template>
<script>
@ -17,8 +26,39 @@ export default {
loading: {
default: true,
},
size: {
default: 200,
small: {
type: Boolean,
default: false,
},
medium: {
type: Boolean,
default: true,
},
large: {
type: Boolean,
default: false,
},
},
computed: {
size() {
if (this.small) {
return {
width: 2,
icon: 30,
size: 50,
};
} else if (this.large) {
return {
width: 4,
icon: 120,
size: 200,
};
}
return {
width: 3,
icon: 75,
size: 125,
};
},
},
};

View file

@ -15,6 +15,17 @@
<v-list-item-subtitle> {{ user.admin ? $t("user.admin") : $t("user.user") }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item dense v-if="isLoggedIn" :to="`/user/${user.id}/favorites`">
<v-list-item-icon>
<v-icon>
mdi-heart
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> Favorites </v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<v-divider></v-divider>
@ -30,7 +41,7 @@
<!-- Version List Item -->
<v-list nav dense class="fixedBottom" v-if="!isMain">
<v-list-item href="https://github.com/sponsors/hay-kot" target="_target">
<v-list-item-icon >
<v-list-item-icon>
<v-icon color="pink">
mdi-heart
</v-icon>

View file

@ -1,7 +1,9 @@
const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const validators = {
data() {
return {
emailRule: v => !v || /^[^@\s]+@[^@\s.]+.[^@.\s]+$/.test(v) || this.$t("user.e-mail-must-be-valid"),
emailRule: v => !v || EMAIL_REGEX.test(v) || this.$t("user.e-mail-must-be-valid"),
existsRule: value => !!value || this.$t("general.field-required"),

View file

@ -163,7 +163,6 @@ export default {
return;
}
this.loading = true;
const response = await api.users.update(this.user);
if (response) {
this.$store.commit("setToken", response.data.access_token);

View file

@ -1,7 +1,7 @@
<template>
<v-container>
<CardSection
title-icon=""
title-icon="mdi-test"
v-if="siteSettings.showRecent"
:title="$t('page.recent')"
:recipes="recentRecipes"

View file

@ -104,7 +104,6 @@ export default {
async requestMeals() {
const response = await api.mealPlans.all();
this.plannedMeals = response.data;
console.log(this.plannedMeals);
},
mealPlanURL(uid) {
return window.location.origin + "/meal-plan?id=" + uid;

View file

@ -51,7 +51,6 @@ export default {
} else {
this.mealPlan = await api.mealPlans.thisWeek();
}
console.log(this.mealPlans);
if (!this.mealPlan) {
utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet"));
}

View file

@ -12,6 +12,7 @@
class="d-print-none"
:key="imageKey"
>
<FavoriteBadge class="ma-1" button-style v-if="loggedIn" :slug="recipeDetails.slug" show-always />
<RecipeTimeCard
:class="isMobile ? undefined : 'force-bottom'"
:prepTime="recipeDetails.prepTime"
@ -49,6 +50,7 @@
<script>
import { api } from "@/api";
import FavoriteBadge from "@/components/Recipe/FavoriteBadge";
import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "@/components/Recipe/RecipeViewer";
import PrintView from "@/components/Recipe/PrintView";
@ -68,6 +70,7 @@ export default {
RecipeTimeCard,
PrintView,
NoRecipe,
FavoriteBadge,
},
mixins: [user],
inject: {
@ -127,6 +130,9 @@ export default {
},
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},

View file

@ -8,7 +8,7 @@
@sort="assignSorted"
/>
<v-row class="d-flex">
<SiteLoader class="mx-auto" v-if="loading" :loading="loading" :size="200" />
<SiteLoader class="mx-auto" v-if="loading" :loading="loading" />
</v-row>
</v-container>
</template>

View file

@ -0,0 +1,49 @@
<template>
<v-container>
<CardSection
:sortable="true"
:title-icon="$globals.icons.user"
:title="userData.username"
:recipes="shownRecipes"
@sort="assignSorted"
/>
</v-container>
</template>
<script>
import { api } from "@/api";
import CardSection from "@/components/UI/CardSection";
export default {
components: {
CardSection,
},
data() {
return {
title: "",
userData: {},
sortedResults: [],
};
},
computed: {
shownRecipes() {
if (this.sortedResults.length > 0) {
return this.sortedResults;
} else {
return this.userData.favoriteRecipes;
}
},
},
async mounted() {
this.userData = await api.users.getFavorites(this.$route.params.id);
this.sortedResults = [];
},
methods: {
assignSorted(val) {
this.sortedResults = val.slice();
},
},
};
</script>
<style></style>

View file

@ -3,11 +3,13 @@ const NewRecipe = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipe
const CustomPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CustomPage");
const AllRecipes = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/AllRecipes");
const CategoryTagPage = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/CategoryTagPage");
const Favorites = () => import(/* webpackChunkName: "recipes" */ "@/pages/Recipes/Favorites");
import { api } from "@/api";
export const recipeRoutes = [
// Recipes
{ path: "/recipes/all", component: AllRecipes },
{ path: "/user/:id/favorites", component: Favorites },
{ path: "/recipes/tag/:tag", component: CategoryTagPage },
{ path: "/recipes/tag", component: CategoryTagPage },
{ path: "/recipes/category", component: CategoryTagPage },

View file

@ -41,7 +41,6 @@ const actions = {
this.commit("setRecentRecipes", hash);
},
async requestAllRecipes({ getters }) {
console.log("All Recipes");
const all = getters.getAllRecipes;
const payload = await api.recipes.allSummary(all.length, 9999);
const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e]));

View file

@ -54,9 +54,11 @@ const mutations = {
};
const actions = {
async requestUserData({ commit }) {
const userData = await api.users.self();
commit("setUserData", userData);
async requestUserData({ getters, commit }) {
if (getters.getIsLoggedIn) {
const userData = await api.users.self();
commit("setUserData", userData);
}
},
async resetTheme({ commit }) {

View file

@ -78,7 +78,7 @@ class BaseDocument:
return session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
def get(
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
) -> Union[BaseModel, list[BaseModel]]:
"""Retrieves an entry from the database by matching a key/value pair. If no
key is provided the class objects primary key will be used to match against.
@ -91,6 +91,7 @@ class BaseDocument:
Returns:
dict or list[dict]:
"""
if match_key is None:
match_key = self.primary_key
@ -103,12 +104,14 @@ class BaseDocument:
else:
result = session.query(self.sql_model).filter_by(**{match_key: match_value}).limit(limit).all()
eff_schema = override_schema or self.schema
if limit == 1:
try:
return self.schema.from_orm(result[0])
return eff_schema.from_orm(result[0])
except IndexError:
return None
return [self.schema.from_orm(x) for x in result]
return [eff_schema.from_orm(x) for x in result]
def create(self, session: Session, document: dict) -> BaseModel:
"""Creates a new database entry for the given SQL Alchemy Model.

View file

@ -19,7 +19,7 @@ class EventNotification(SqlAlchemyBase, BaseMixins):
user = Column(Boolean, default=False)
def __init__(
self, name, notification_url, type, general, recipe, backup, scheduled, migration, group, user, *args, **kwargs
self, name, notification_url, type, general, recipe, backup, scheduled, migration, group, user, **_
) -> None:
self.name = name
self.notification_url = notification_url
@ -41,7 +41,7 @@ class Event(SqlAlchemyBase, BaseMixins):
time_stamp = Column(DateTime)
category = Column(String)
def __init__(self, title, text, time_stamp, category, *args, **kwargs) -> None:
def __init__(self, title, text, time_stamp, category, **_) -> None:
self.title = title
self.text = text
self.time_stamp = time_stamp

View file

@ -1,4 +1,5 @@
import sqlalchemy.ext.declarative as dec
from requests import Session
SqlAlchemyBase = dec.declarative_base()
@ -6,3 +7,7 @@ SqlAlchemyBase = dec.declarative_base()
class BaseMixins:
def update(self, *args, **kwarg):
self.__init__(*args, **kwarg)
def get_ref(cls_type, session: Session, match_value: str, match_attr: str = "id"):
eff_ref = getattr(cls_type, match_attr)
return session.query(cls_type).filter(eff_ref == match_value).one_or_none()

View file

@ -68,6 +68,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
date_added = sa.Column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime)
# Favorited By
favorited_by_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
favorited_by = orm.relationship("User", back_populates="favorite_recipes")
@validates("name")
def validate_name(self, key, name):
assert name != ""
@ -99,8 +103,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
extras: dict = None,
assets: list = None,
settings: dict = None,
*args,
**kwargs
**_
) -> None:
self.name = name
self.description = description
@ -138,6 +141,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.date_added = date_added
self.date_updated = datetime.datetime.now()
def update(self, *args, **kwargs):
def update(self, **_):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
self.__init__(*args, **kwargs)
self.__init__(**_)

View file

@ -42,7 +42,7 @@ class CustomPage(SqlAlchemyBase, BaseMixins):
slug = sa.Column(sa.String, nullable=False)
categories = orm.relationship("Category", secondary=custom_pages2categories, single_parent=True)
def __init__(self, session=None, name=None, slug=None, position=0, categories=[], *args, **kwargs) -> None:
def __init__(self, session=None, name=None, slug=None, position=0, categories=[], **_) -> None:
self.name = name
self.slug = slug
self.position = position

View file

@ -9,7 +9,7 @@ class SiteThemeModel(SqlAlchemyBase, BaseMixins):
name = Column(String, nullable=False, unique=True)
colors = orm.relationship("ThemeColorsModel", uselist=False, single_parent=True, cascade="all, delete-orphan")
def __init__(self, name: str, colors: dict, *arg, **kwargs) -> None:
def __init__(self, name: str, colors: dict, **_) -> None:
self.name = name
self.colors = ThemeColorsModel(**colors)

View file

@ -1,6 +1,7 @@
from mealie.core.config import settings
from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
@ -22,11 +23,7 @@ class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
full_name = Column(String, index=True)
username = Column(
String,
index=True,
unique=True,
)
username = Column(String, index=True, unique=True)
email = Column(String, unique=True, index=True)
password = Column(String)
group_id = Column(Integer, ForeignKey("groups.id"))
@ -36,21 +33,38 @@ class User(SqlAlchemyBase, BaseMixins):
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
def __init__(
self, session, full_name, email, password, group: str = settings.DEFAULT_GROUP, admin=False, **_
self,
session,
full_name,
email,
password,
favorite_recipes: list[str] = None,
group: str = settings.DEFAULT_GROUP,
admin=False,
**_
) -> None:
group = group or settings.DEFAULT_GROUP
favorite_recipes = favorite_recipes or []
self.full_name = full_name
self.email = email
self.group = Group.get_ref(session, group)
self.admin = admin
self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(RecipeModel, session=session, match_value=x, match_attr="slug")
for x in favorite_recipes
]
if self.username is None:
self.username = full_name
def update(self, full_name, email, group, admin, username, session=None, id=None, password=None, *args, **kwargs):
def update(self, full_name, email, group, admin, username, session=None, favorite_recipes=None, password=None, **_):
favorite_recipes = favorite_recipes or []
self.username = username
self.full_name = full_name
self.email = email
@ -63,6 +77,11 @@ class User(SqlAlchemyBase, BaseMixins):
if password:
self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(RecipeModel, session=session, match_value=x, match_attr="slug")
for x in favorite_recipes
]
def update_password(self, password):
self.password = password

View file

@ -8,7 +8,7 @@ from mealie.core.security import get_password_hash, verify_password
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserInDB, UserOut
from mealie.schema.user import ChangePassword, UserBase, UserFavorites, UserIn, UserInDB, UserOut
from mealie.services.events import create_user_event
from sqlalchemy.orm.session import Session
@ -19,7 +19,7 @@ router = APIRouter(prefix="/api/users", tags=["Users"])
async def create_user(
background_tasks: BackgroundTasks,
new_user: UserIn,
current_user=Depends(get_current_user),
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
@ -45,24 +45,21 @@ async def get_all_users(
@router.get("/self", response_model=UserOut)
async def get_logged_in_user(
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
return current_user.dict()
@router.get("/{id}", response_model=UserOut)
@router.get("/{id}", response_model=UserOut, dependencies=[Depends(get_current_user)])
async def get_user_by_id(
id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
return db.users.get(session, id)
@router.put("/{id}/reset-password")
@router.put("/{id}/reset-password", dependencies=[Depends(get_current_user)])
async def reset_user_password(
id: int,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
@ -97,11 +94,10 @@ async def get_user_image(id: str):
return False
@router.post("/{id}/image")
@router.post("/{id}/image", dependencies=[Depends(get_current_user)])
async def update_user_image(
id: str,
profile_image: UploadFile = File(...),
current_user: UserInDB = Depends(get_current_user),
):
""" Updates a User Image """
@ -139,6 +135,41 @@ async def update_password(
db.users.update_password(session, id, new_password)
@router.get("/{id}/favorites", response_model=UserFavorites)
async def get_favorites(id: str, session: Session = Depends(generate_session)):
""" Adds a Recipe to the users favorites """
return db.users.get(session, id, override_schema=UserFavorites)
@router.post("/{id}/favorites/{slug}")
async def add_favorite(
slug: str,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Adds a Recipe to the users favorites """
current_user.favorite_recipes.append(slug)
db.users.update(session, current_user.id, current_user)
@router.delete("/{id}/favorites/{slug}")
async def remove_favorite(
slug: str,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Adds a Recipe to the users favorites """
current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug]
db.users.update(session, current_user.id, current_user)
return
@router.delete("/{id}")
async def delete_user(
background_tasks: BackgroundTasks,

5
mealie/schema/helpers.py Normal file
View file

@ -0,0 +1,5 @@
from fastapi_camelcase import CamelModel
class RecipeSlug(CamelModel):
slug: str

View file

@ -6,6 +6,7 @@ from mealie.db.models.group import Group
from mealie.db.models.users import User
from mealie.schema.category import CategoryBase
from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import RecipeSummary
from mealie.schema.shopping_list import ShoppingListOut
from pydantic.types import constr
from pydantic.utils import GetterDict
@ -48,6 +49,7 @@ class UserBase(CamelModel):
email: constr(to_lower=True, strip_whitespace=True)
admin: bool
group: Optional[str]
favorite_recipes: Optional[list[str]] = []
class Config:
orm_mode = True
@ -76,6 +78,22 @@ class UserOut(UserBase):
id: int
group: str
tokens: Optional[list[LongLiveTokenOut]]
favorite_recipes: Optional[list[str]] = []
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, ormModel: User):
return {
**GetterDict(ormModel),
"group": ormModel.group.name,
"favorite_recipes": [x.slug for x in ormModel.favorite_recipes],
}
class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = []
class Config:
orm_mode = True
@ -90,7 +108,6 @@ class UserOut(UserBase):
class UserInDB(UserOut):
password: str
pass
class Config:
orm_mode = True

File diff suppressed because one or more lines are too long