feat(backend): migrate site-settings to groups (#673)

* feat(frontend):  add user registration page (WIP)

* feat(backend):  add user registration (WIP)

* test(backend):  add validator testing for registration schema

* feat(backend):  continued work on user sign-up

* feat(backend):  add signup flow and user/group settings

* test(backend):  user-creation tests and small refactor of existing tests

* fix(backend):  fix failing group tests

* style: 🎨 fix lint issues

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-09-05 22:05:29 -08:00 committed by GitHub
parent e179dcdb10
commit 3c504e7048
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1665 additions and 841 deletions

View File

@ -8,6 +8,8 @@ const routes = {
groupsSelf: `${prefix}/groups/self`,
categories: `${prefix}/groups/categories`,
preferences: `${prefix}/groups/preferences`,
groupsId: (id: string | number) => `${prefix}/groups/${id}`,
};
@ -21,13 +23,34 @@ export interface CreateGroup {
name: string;
}
export interface UpdatePreferences {
privateGroup: boolean;
firstDayOfWeek: number;
recipePublic: boolean;
recipeShowNutrition: boolean;
recipeShowAssets: boolean;
recipeLandscapeView: boolean;
recipeDisableComments: boolean;
recipeDisableAmount: boolean;
}
export interface Preferences extends UpdatePreferences {
id: number;
group_id: number;
}
export interface Group extends CreateGroup {
id: number;
preferences: Preferences;
}
export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
baseRoute = routes.groups;
itemRoute = routes.groupsId;
/** Returns the Group Data for the Current User
*/
async getCurrentUserGroup() {
return await this.requests.get(routes.groupsSelf);
return await this.requests.get<Group>(routes.groupsSelf);
}
async getCategories() {
@ -37,4 +60,12 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
async setCategories(payload: Category[]) {
return await this.requests.put<Category[]>(routes.categories, payload);
}
async getPreferences() {
return await this.requests.get<Preferences>(routes.preferences);
}
async setPreferences(payload: UpdatePreferences) {
return await this.requests.put<Preferences>(routes.preferences, payload);
}
}

View File

@ -0,0 +1,25 @@
import { BaseAPI } from "./_base";
export interface RegisterPayload {
group: string;
groupToken: string;
email: string;
password: string;
passwordConfirm: string;
advanced: boolean;
private: boolean;
}
const prefix = "/api";
const routes = {
register: `${prefix}/users/register`,
};
export class RegisterAPI extends BaseAPI {
/** Returns a list of avaiable .zip files for import into Mealie.
*/
async register(payload: RegisterPayload) {
return await this.requests.post<any>(routes.register, payload);
}
}

View File

@ -13,6 +13,7 @@ import { UnitAPI } from "./class-interfaces/recipe-units";
import { CookbookAPI } from "./class-interfaces/cookbooks";
import { WebhooksAPI } from "./class-interfaces/group-webhooks";
import { AdminAboutAPI } from "./class-interfaces/admin-about";
import { RegisterAPI } from "./class-interfaces/user-registration";
import { ApiRequestInstance } from "~/types/api";
class AdminAPI {
@ -46,6 +47,7 @@ class Api {
public units: UnitAPI;
public cookbooks: CookbookAPI;
public groupWebhooks: WebhooksAPI;
public register: RegisterAPI;
// Utils
public upload: UploadFile;
@ -67,6 +69,7 @@ class Api {
this.groups = new GroupAPI(requests);
this.cookbooks = new CookbookAPI(requests);
this.groupWebhooks = new WebhooksAPI(requests);
this.register = new RegisterAPI(requests);
// Admin
this.events = new EventsAPI(requests);

View File

@ -61,13 +61,7 @@
</v-fade-transition>
</v-card-title>
<v-card-text v-if="edit">
<v-textarea
:key="generateKey('instructions', index)"
v-model="value[index]['text']"
auto-grow
dense
rows="4"
>
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
</v-textarea>
</v-card-text>
<v-expand-transition>

View File

@ -134,9 +134,9 @@
</template>
<script>
import { ref } from "@nuxtjs/composition-api";
import { validators } from "@/composables/use-validators";
import { fieldTypes } from "@/composables/forms";
import { ref } from "@nuxtjs/composition-api";
const BLUR_EVENT = "blur";

View File

@ -138,9 +138,9 @@
</template>
<script>
import { ref } from "@nuxtjs/composition-api";
import { validators } from "@/composables/use-validators";
import { fieldTypes } from "@/composables/forms";
import { ref } from "@nuxtjs/composition-api";
const BLUR_EVENT = "blur";

View File

@ -3,7 +3,38 @@ import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { CreateGroup } from "~/api/class-interfaces/groups";
export const useGroup = function () {
export const useGroupSelf = function () {
const api = useApiSingleton();
const actions = {
get() {
const group = useAsync(async () => {
const { data } = await api.groups.getCurrentUserGroup();
return data;
}, useAsyncKey());
return group;
},
async updatePreferences() {
if (!group.value) {
return;
}
const { data } = await api.groups.setPreferences(group.value.preferences);
if (data) {
group.value.preferences = data;
}
},
};
const group = actions.get();
return { actions, group };
};
export const useGroupCategories = function () {
const api = useApiSingleton();
const actions = {
@ -61,7 +92,6 @@ export const useGroups = function () {
}
async function createGroup(payload: CreateGroup) {
console.log(payload);
loading.value = true;
const { data } = await api.groups.createOne(payload);

View File

@ -1,13 +1,10 @@
<template>
<v-app dark>
<!-- <TheSnackbar /> -->
<AppSidebar
v-model="sidebar"
absolute
:top-link="topLinks"
:secondary-links="$auth.user.admin ? adminLinks : null"
:bottom-links="$auth.user.admin ? bottomLinks : null"
:bottom-links="bottomLinks"
:user="{ data: true }"
:secondary-header="$t('user.admin')"
@input="sidebar = !sidebar"
@ -30,7 +27,7 @@
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/AppHeader.vue";
import AppSidebar from "@/components/Layout/AppSidebar.vue";
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
@ -40,103 +37,110 @@ export default defineComponent({
middleware: "auth",
auth: true,
setup() {
return {};
},
data() {
// @ts-ignore - $globals not found in type definition
const { $globals, i18n } = useContext();
const sidebar = ref(null);
const topLinks = [
{
icon: $globals.icons.viewDashboard,
to: "/admin/dashboard",
title: i18n.t("sidebar.dashboard"),
},
{
icon: $globals.icons.cog,
to: "/admin/site-settings",
title: i18n.t("sidebar.site-settings"),
},
{
icon: $globals.icons.tools,
to: "/admin/toolbox",
title: i18n.t("sidebar.toolbox"),
children: [
{
icon: $globals.icons.bellAlert,
to: "/admin/toolbox/notifications",
title: i18n.t("events.notification"),
},
{
icon: $globals.icons.foods,
to: "/admin/toolbox/foods",
title: "Manage Foods",
},
{
icon: $globals.icons.units,
to: "/admin/toolbox/units",
title: "Manage Units",
},
{
icon: $globals.icons.tags,
to: "/admin/toolbox/categories",
title: i18n.t("sidebar.tags"),
},
{
icon: $globals.icons.tags,
to: "/admin/toolbox/tags",
title: i18n.t("sidebar.categories"),
},
{
icon: $globals.icons.broom,
to: "/admin/toolbox/organize",
title: i18n.t("settings.organize"),
},
],
},
{
icon: $globals.icons.group,
to: "/admin/manage-users",
title: i18n.t("sidebar.manage-users"),
children: [
{
icon: $globals.icons.user,
to: "/admin/manage-users/all-users",
title: i18n.t("user.users"),
},
{
icon: $globals.icons.group,
to: "/admin/manage-users/all-groups",
title: i18n.t("group.groups"),
},
],
},
{
icon: $globals.icons.import,
to: "/admin/migrations",
title: i18n.t("sidebar.migrations"),
},
{
icon: $globals.icons.database,
to: "/admin/backups",
title: i18n.t("sidebar.backups"),
},
];
const bottomLinks = [
{
icon: $globals.icons.heart,
title: i18n.t("about.support"),
href: "https://github.com/sponsors/hay-kot",
},
{
icon: $globals.icons.information,
title: i18n.t("about.about"),
to: "/admin/about",
},
];
return {
sidebar: null,
topLinks: [
{
icon: this.$globals.icons.viewDashboard,
to: "/admin/dashboard",
title: this.$t("sidebar.dashboard"),
},
{
icon: this.$globals.icons.cog,
to: "/admin/site-settings",
title: this.$t("sidebar.site-settings"),
},
{
icon: this.$globals.icons.tools,
to: "/admin/toolbox",
title: this.$t("sidebar.toolbox"),
children: [
{
icon: this.$globals.icons.bellAlert,
to: "/admin/toolbox/notifications",
title: this.$t("events.notification"),
},
{
icon: this.$globals.icons.foods,
to: "/admin/toolbox/foods",
title: "Manage Foods",
},
{
icon: this.$globals.icons.units,
to: "/admin/toolbox/units",
title: "Manage Units",
},
{
icon: this.$globals.icons.tags,
to: "/admin/toolbox/categories",
title: this.$t("sidebar.tags"),
},
{
icon: this.$globals.icons.tags,
to: "/admin/toolbox/tags",
title: this.$t("sidebar.categories"),
},
{
icon: this.$globals.icons.broom,
to: "/admin/toolbox/organize",
title: this.$t("settings.organize"),
},
],
},
{
icon: this.$globals.icons.group,
to: "/admin/manage-users",
title: this.$t("sidebar.manage-users"),
children: [
{
icon: this.$globals.icons.user,
to: "/admin/manage-users/all-users",
title: this.$t("user.users"),
},
{
icon: this.$globals.icons.group,
to: "/admin/manage-users/all-groups",
title: this.$t("group.groups"),
},
],
},
{
icon: this.$globals.icons.import,
to: "/admin/migrations",
title: this.$t("sidebar.migrations"),
},
{
icon: this.$globals.icons.database,
to: "/admin/backups",
title: this.$t("sidebar.backups"),
},
],
bottomLinks: [
{
icon: this.$globals.icons.heart,
title: this.$t("about.support"),
href: "https://github.com/sponsors/hay-kot",
},
{
icon: this.$globals.icons.information,
title: this.$t("about.about"),
to: "/admin/about",
},
],
sidebar,
topLinks,
bottomLinks,
};
},
});
</script>
<style scoped>
</style>+

View File

@ -1,6 +1,6 @@
<template>
<v-app dark>
<!-- <TheSnackbar /> -->
<TheSnackbar />
<AppHeader :menu="false"> </AppHeader>
<v-main>
@ -17,9 +17,10 @@
import { defineComponent } from "@nuxtjs/composition-api";
import AppFooter from "@/components/Layout/AppFooter.vue";
import AppHeader from "@/components/Layout/AppHeader.vue";
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
export default defineComponent({
components: { AppHeader, AppFooter },
components: { AppHeader, AppFooter, TheSnackbar },
setup() {
return {};
},

View File

@ -8,7 +8,7 @@
:top-link="topLinks"
secondary-header="Cookbooks"
:secondary-links="cookbookLinks || []"
:bottom-links="$auth.user.admin ? bottomLink : []"
:bottom-links="isAdmin ? bottomLink : []"
@input="sidebar = !sidebar"
/>
@ -37,11 +37,13 @@ import { useCookbooks } from "~/composables/use-group-cookbooks";
export default defineComponent({
components: { AppHeader, AppSidebar, AppFloatingButton },
// @ts-ignore
// middleware: process.env.GLOBAL_MIDDLEWARE,
middleware: "auth",
setup() {
const { cookbooks } = useCookbooks();
// @ts-ignore
const { $globals } = useContext();
const { $globals, $auth } = useContext();
const isAdmin = computed(() => $auth.user?.admin);
const cookbookLinks = computed(() => {
if (!cookbooks.value) return [];
@ -53,7 +55,7 @@ export default defineComponent({
};
});
});
return { cookbookLinks };
return { cookbookLinks, isAdmin };
},
data() {
return {

View File

@ -56,7 +56,7 @@ export default {
// https://go.nuxtjs.dev/pwa
"@nuxtjs/pwa",
// https://i18n.nuxtjs.org/setup
"nuxt-i18n",
"@nuxtjs/i18n",
// https://auth.nuxtjs.org/guide/setup
"@nuxtjs/auth-next",
// https://github.com/nuxt-community/proxy-module
@ -81,8 +81,8 @@ export default {
auth: {
redirect: {
login: "/user/login",
logout: "/",
login: "/login",
logout: "/login",
callback: "/login",
home: "/",
},

View File

@ -18,6 +18,7 @@
"@mdi/js": "^5.9.55",
"@nuxtjs/auth-next": "5.0.0-1624817847.21691f1",
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/i18n": "^7.0.3",
"@nuxtjs/proxy": "^2.1.0",
"@nuxtjs/pwa": "^3.3.5",
"@vue/composition-api": "^1.0.5",
@ -25,7 +26,6 @@
"core-js": "^3.15.1",
"fuse.js": "^6.4.6",
"nuxt": "^2.15.7",
"nuxt-i18n": "^6.28.0",
"vuedraggable": "^2.24.3",
"vuetify": "^2.5.5"
},
@ -33,7 +33,7 @@
"@babel/eslint-parser": "^7.14.7",
"@nuxt/types": "^2.15.7",
"@nuxt/typescript-build": "^2.1.0",
"@nuxtjs/composition-api": "^0.26.0",
"@nuxtjs/composition-api": "^0.28.0",
"@nuxtjs/eslint-config-typescript": "^6.0.1",
"@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/vuetify": "^1.12.1",
@ -50,4 +50,4 @@
"resolutions": {
"vite": "2.3.8"
}
}
}

View File

@ -95,8 +95,8 @@
</template>
<script lang="ts">
import AdminBackupImportOptions from "@/components/Domain/Admin/AdminBackupImportOptions.vue";
import { defineComponent, reactive, toRefs, useContext, ref } from "@nuxtjs/composition-api";
import AdminBackupImportOptions from "@/components/Domain/Admin/AdminBackupImportOptions.vue";
import { useBackups } from "~/composables/use-backups";
export default defineComponent({

View File

@ -23,8 +23,8 @@
</template>
<script lang="ts">
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook } from "~/composables/use-group-cookbooks";
export default defineComponent({
components: { RecipeCardSection },

View File

@ -178,10 +178,9 @@
</v-btn>
</v-form>
</v-card-text>
<v-btn v-if="$config.ALLOW_SIGNUP" class="mx-auto" text to="/user/sign-up"> Sign Up </v-btn>
<v-btn v-if="$config.ALLOW_SIGNUP" class="mx-auto" text to="/register"> Register </v-btn>
<v-btn v-else class="mx-auto" text disabled> Invite Only </v-btn>
</v-card>
<!-- <v-col class="fill-height"> </v-col> -->
</v-container>
</template>

170
frontend/pages/register.vue Normal file
View File

@ -0,0 +1,170 @@
<template>
<v-container fill-height fluid class="d-flex justify-center align-start narrow-container">
<v-card color="background d-flex flex-column align-center" flat width="700px">
<v-card-title class="headline"> User Registration </v-card-title>
<v-card-text>
<v-form ref="domRegisterForm" @submit.prevent="register()">
<ToggleState>
<template #activator="{ toggle }">
<div class="d-flex justify-center my-2">
<v-btn-toggle tile mandatory group color="primary">
<v-btn small @click="toggle(false)"> Create a Group </v-btn>
<v-btn small @click="toggle(true)"> Join a Group </v-btn>
</v-btn-toggle>
</div>
</template>
<template #default="{ state }">
<v-text-field
v-if="!state"
v-model="form.group"
filled
rounded
autofocus
validate-on-blur
class="rounded-lg"
:prepend-icon="$globals.icons.group"
:rules="[tokenOrGroup]"
label="New Group Name"
/>
<v-text-field
v-else
v-model="form.groupToken"
filled
rounded
validate-on-blur
:rules="[tokenOrGroup]"
class="rounded-lg"
:prepend-icon="$globals.icons.group"
label="Group Token"
/>
</template>
</ToggleState>
<v-text-field
v-model="form.email"
filled
rounded
class="rounded-lg"
validate-on-blur
:prepend-icon="$globals.icons.email"
label="Email"
:rules="[validators.required, validators.email]"
/>
<v-text-field
v-model="form.username"
filled
rounded
class="rounded-lg"
:prepend-icon="$globals.icons.user"
label="Username"
:rules="[validators.required]"
/>
<v-text-field
v-model="form.password"
filled
rounded
class="rounded-lg"
:prepend-icon="$globals.icons.lock"
name="password"
label="Password"
type="password"
:rules="[validators.required]"
/>
<v-text-field
v-model="form.passwordConfirm"
filled
rounded
validate-on-blur
class="rounded-lg"
:prepend-icon="$globals.icons.lock"
name="password"
label="Confirm Password"
type="password"
:rules="[validators.required, passwordMatch]"
/>
<div class="mt-n4 px-8">
<v-checkbox v-model="form.private" label="Keep My Recipes Private"></v-checkbox>
<p class="text-caption mt-n4">
Sets your group and all recipes defaults to private. You can always change this later.
</p>
<v-checkbox v-model="form.advanced" label="Enable Advanced Content"></v-checkbox>
<p class="text-caption mt-n4">
Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you
can always change this later
</p>
</div>
<div class="d-flex flex-column justify-center">
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
Register
</v-btn>
<v-btn class="mx-auto my-2" text to="/login"> Login </v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { validators } from "@/composables/use-validators";
import { useApiSingleton } from "~/composables/use-api";
import { alert } from "~/composables/use-toast";
export default defineComponent({
layout: "basic",
setup() {
const api = useApiSingleton();
const state = reactive({
loggingIn: false,
success: false,
});
const allowSignup = computed(() => process.env.AllOW_SIGNUP);
const router = useRouter();
// @ts-ignore
const domRegisterForm = ref<VForm>(null);
const form = reactive({
group: "",
groupToken: "",
email: "",
username: "",
password: "",
passwordConfirm: "",
advanced: false,
private: false,
});
const passwordMatch = () => form.password === form.passwordConfirm || "Passwords do not match";
const tokenOrGroup = () => form.group !== "" || form.groupToken !== "" || "Group name or token must be given";
async function register() {
if (!domRegisterForm.value?.validate()) {
return;
}
const { data, response } = await api.register.register(form);
if (response?.status === 201) {
state.success = true;
alert.success("Registration Success");
router.push("/user/login");
}
console.log(data, response);
}
return {
domRegisterForm,
validators,
allowSignup,
form,
...toRefs(state),
passwordMatch,
tokenOrGroup,
register,
};
},
});
</script>

View File

@ -50,8 +50,8 @@
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { useCookbooks } from "@/composables/use-group-cookbooks";
import draggable from "vuedraggable";
import { useCookbooks } from "@/composables/use-group-cookbooks";
export default defineComponent({
components: { draggable },

View File

@ -1,39 +1,139 @@
<template>
<v-container>
<BasePageTitle divider>
<v-container class="narrow-container">
<BasePageTitle class="mb-5">
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
</template>
<template #title> Group Settings </template>
These items are shared within your group. Editing one of them will change it for the whole group!
</BasePageTitle>
<v-card tag="section" outlined>
<v-card-text>
<BaseCardSectionTitle title="Mealplan Categories">
Set the categories below for the ones that you want to be included in your mealplan random generation.
<div class="mt-2">
<BaseButton save @click="actions.updateAll()" />
</div>
</BaseCardSectionTitle>
<DomainRecipeCategoryTagSelector v-if="categories" v-model="categories" />
</v-card-text>
</v-card>
<section>
<BaseCardSectionTitle title="Mealplan Categories">
Set the categories below for the ones that you want to be included in your mealplan random generation.
</BaseCardSectionTitle>
<DomainRecipeCategoryTagSelector v-if="categories" v-model="categories" />
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton save @click="actions.updateAll()" />
</v-card-actions>
</section>
<section v-if="group">
<BaseCardSectionTitle class="mt-10" title="Group Preferences"></BaseCardSectionTitle>
<v-checkbox
v-model="group.preferences.privateGroup"
class="mt-n4"
label="Private Group"
@change="groupActions.updatePreferences()"
></v-checkbox>
<v-select
v-model="group.preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays"
item-text="name"
item-value="value"
:label="$t('settings.first-day-of-week')"
@change="groupActions.updatePreferences()"
/>
</section>
<section v-if="group">
<BaseCardSectionTitle class="mt-10" title="Default Recipe Preferences">
These are the default settings when a new recipe is created in your group. These can be changed for indivdual
recipes in the recipe settings menu.
</BaseCardSectionTitle>
<v-checkbox
v-model="group.preferences.recipePublic"
class="mt-n4"
label="Allow users outside of your group to see your recipes"
@change="groupActions.updatePreferences()"
></v-checkbox>
<v-checkbox
v-model="group.preferences.recipeShowNutrition"
class="mt-n4"
label="Show nutrition information"
@change="groupActions.updatePreferences()"
></v-checkbox>
<v-checkbox
v-model="group.preferences.recipeShowAssets"
class="mt-n4"
label="Show recipe assets"
@change="groupActions.updatePreferences()"
></v-checkbox>
<v-checkbox
v-model="group.preferences.recipeLandscapeView"
class="mt-n4"
label="Default to landscape view"
@change="groupActions.updatePreferences()"
></v-checkbox>
<v-checkbox
v-model="group.preferences.recipeDisableComments"
class="mt-n4"
label="Allow recipe comments from users in your group"
@change="groupActions.updatePreferences()"
></v-checkbox>
<v-checkbox
v-model="group.preferences.recipeDisableAmount"
class="mt-n4"
label="Enable organizing recipe ingredients by units and food"
@change="groupActions.updatePreferences()"
></v-checkbox>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { useGroup } from "~/composables/use-groups";
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { useGroupCategories, useGroupSelf } from "~/composables/use-groups";
export default defineComponent({
setup() {
const { categories, actions } = useGroup();
const { categories, actions } = useGroupCategories();
const { group, actions: groupActions } = useGroupSelf();
const { i18n } = useContext();
const allDays = [
{
name: i18n.t("general.sunday"),
value: 0,
},
{
name: i18n.t("general.monday"),
value: 1,
},
{
name: i18n.t("general.tuesday"),
value: 2,
},
{
name: i18n.t("general.wednesday"),
value: 3,
},
{
name: i18n.t("general.thursday"),
value: 4,
},
{
name: i18n.t("general.friday"),
value: 5,
},
{
name: i18n.t("general.saturday"),
value: 6,
},
];
return {
categories,
actions,
group,
groupActions,
allDays,
};
},
});
</script>

View File

@ -82,6 +82,18 @@
</template>
</ToggleState>
</section>
<section>
<BaseCardSectionTitle class="mt-10" title="Preferences"> </BaseCardSectionTitle>
<v-checkbox
v-model="userCopy.advanced"
class="mt-n4"
label="Show advanced features (API Keys, Webhooks, and Data Management)"
@change="updateUser"
></v-checkbox>
<div class="d-flex justify-center mt-5">
<v-btn outlined class="rounded-xl" to="/user/group"> Looking for Privacy Settings? </v-btn>
</div>
</section>
</v-container>
</template>

View File

@ -27,6 +27,7 @@
</v-col>
<v-col cols="12" sm="12" md="6">
<UserProfileLinkCard
v-if="user.advanced"
:link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }"
:image="require('~/static/svgs/manage-api-tokens.svg')"
>
@ -63,6 +64,7 @@
</v-col>
<v-col cols="12" sm="12" md="6">
<UserProfileLinkCard
v-if="user.advanced"
:link="{ text: 'Manage Webhooks', to: '/user/group/webhooks' }"
:image="require('~/static/svgs/manage-webhooks.svg')"
>

View File

@ -16,7 +16,7 @@
"~/*": ["./*"],
"@/*": ["./*"]
},
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "nuxt-i18n", "@nuxtjs/auth-next"]
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next"]
},
"exclude": ["node_modules", ".nuxt", "dist"]
}

View File

@ -1,14 +1,17 @@
import Vue from "vue";
import "@nuxt/types";
declare module "vue/types/vue" {
interface Vue {
$globals: any;
}
interface Vue {
$globals: any;
}
}
declare module "vue/types/options" {
interface ComponentOptions<V extends Vue> {
$globals?: any;
}
interface ComponentOptions<V extends Vue> {
$globals?: any;
}
interface ComponentOptions<V extends UseContextReturn> {
$globals?: any;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.group.webhooks import GroupWebhooksModel
from mealie.db.models.mealplan import MealPlan
@ -20,6 +21,7 @@ from mealie.schema.admin import SiteSettings as SiteSettingsSchema
from mealie.schema.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
from mealie.schema.recipe import (
@ -89,3 +91,4 @@ class DatabaseAccessLayer:
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
self.cookbooks = BaseAccessModel(pk_id, CookBook, ReadCookBook)
self.group_preferences = BaseAccessModel("group_id", GroupPreferencesModel, ReadGroupPreferences)

View File

@ -24,13 +24,11 @@ def default_recipe_unit_init(db: DatabaseAccessLayer, session: Session) -> None:
for unit in get_default_units():
try:
db.ingredient_units.create(session, unit)
print("Ingredient Unit Created")
except Exception as e:
print(e)
for food in get_default_foods():
try:
db.ingredient_foods.create(session, food)
print("Ingredient Food Created")
except Exception as e:
print(e)

View File

@ -8,7 +8,9 @@ from mealie.db.database import db
from mealie.db.db_setup import create_session, engine
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.schema.admin import SiteSettings
from mealie.schema.user.user import GroupBase
from mealie.services.events import create_general_event
from mealie.services.group_services.group_mixins import create_new_group
logger = root_logger.get_logger("init_db")
@ -38,9 +40,8 @@ def default_settings_init(session: Session):
def default_group_init(session: Session):
default_group = {"name": settings.DEFAULT_GROUP}
logger.info("Generating Default Group")
db.groups.create(session, default_group)
create_new_group(session, GroupBase(name=settings.DEFAULT_GROUP))
def default_user_init(session: Session):

View File

@ -82,8 +82,6 @@ def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics
except Exception:
get_attr = "id"
print(get_attr)
if relation_dir == ONETOMANY.name and use_list:
instances = handle_one_to_many_list(get_attr, relation_cls, val)
setattr(self, key, instances)

View File

@ -9,6 +9,7 @@ from .._model_utils import auto_init
from ..group.webhooks import GroupWebhooksModel
from ..recipe.category import Category, group2categories
from .cookbook import CookBook
from .preferences import GroupPreferencesModel
class Group(SqlAlchemyBase, BaseMixins):
@ -16,7 +17,15 @@ class Group(SqlAlchemyBase, BaseMixins):
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True)
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
preferences = orm.relationship(
GroupPreferencesModel,
back_populates="group",
uselist=False,
single_parent=True,
cascade="all, delete-orphan",
)
# CRUD From Others
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
@ -24,7 +33,7 @@ class Group(SqlAlchemyBase, BaseMixins):
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks"})
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences"})
def __init__(self, **_) -> None:
pass

View File

@ -0,0 +1,26 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_preferences"
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="preferences")
private_group: bool = sa.Column(sa.Boolean, default=True)
first_day_of_week = sa.Column(sa.Integer, default=0)
# Recipe Defaults
recipe_public: bool = sa.Column(sa.Boolean, default=True)
recipe_show_nutrition: bool = sa.Column(sa.Boolean, default=False)
recipe_show_assets: bool = sa.Column(sa.Boolean, default=False)
recipe_landscape_view: bool = sa.Column(sa.Boolean, default=False)
recipe_disable_comments: bool = sa.Column(sa.Boolean, default=False)
recipe_disable_amount: bool = sa.Column(sa.Boolean, default=False)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@ -28,6 +28,7 @@ class User(SqlAlchemyBase, BaseMixins):
email = Column(String, unique=True, index=True)
password = Column(String)
admin = Column(Boolean, default=False)
advanced = Column(Boolean, default=False)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
@ -51,6 +52,7 @@ class User(SqlAlchemyBase, BaseMixins):
favorite_recipes: list[str] = None,
group: str = settings.DEFAULT_GROUP,
admin=False,
advanced=False,
**_
) -> None:
@ -61,6 +63,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.group = Group.get_ref(session, group)
self.admin = admin
self.password = password
self.advanced = advanced
self.favorite_recipes = [
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
@ -69,13 +72,26 @@ class User(SqlAlchemyBase, BaseMixins):
if self.username is None:
self.username = full_name
def update(self, full_name, email, group, admin, username, session=None, favorite_recipes=None, password=None, **_):
def update(
self,
full_name,
email,
group,
admin,
username,
session=None,
favorite_recipes=None,
password=None,
advanced=False,
**_
):
favorite_recipes = favorite_recipes or []
self.username = username
self.full_name = full_name
self.email = email
self.group = Group.get_ref(session, group)
self.admin = admin
self.advanced = advanced
if self.username is None:
self.username = full_name

View File

@ -1,7 +1,6 @@
import operator
import shutil
from pathlib import Path
from pprint import pprint
from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm.session import Session
@ -97,8 +96,6 @@ def import_database(
rebase=import_data.rebase,
)
pprint(db_import)
background_tasks.add_task(create_backup_event, "Database Restore", f"Restore File: {file_name}", session)
return db_import

View File

@ -1,8 +1,7 @@
from fastapi import APIRouter
from mealie.services.base_http_service import RouterFactory
from mealie.services.cookbook.cookbook_service import CookbookService
from mealie.services.group.webhook_service import WebhookService
from mealie.services._base_http_service import RouterFactory
from mealie.services.group_services import CookbookService, WebhookService
from . import categories, crud, self_service

View File

@ -2,7 +2,7 @@ from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.services.group.group_service import GroupSelfService
from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"])

View File

@ -1,14 +1,27 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.user.user import GroupInDB
from mealie.services.group.group_service import GroupSelfService
from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/self", tags=["Groups: Self Service"])
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@user_router.get("", response_model=GroupInDB)
async def get_logged_in_user_group(g_self_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
@user_router.get("/self", response_model=GroupInDB)
async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
""" Returns the Group Data for the Current User """
return g_self_service.item
return g_service.item
@user_router.put("/preferences", response_model=ReadGroupPreferences)
def update_group_preferences(
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
return g_service.update_preferences(new_pref).preferences
@user_router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.item.preferences

View File

@ -1,12 +1,14 @@
from fastapi import APIRouter
from . import api_tokens, crud, favorites, images, passwords, sign_up
from . import api_tokens, crud, favorites, images, passwords, registration, sign_up
# Must be used because of the way FastAPI works with nested routes
user_prefix = "/users"
router = APIRouter()
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
router.include_router(sign_up.admin_router, prefix=user_prefix, tags=["Users: Sign-Up"])
router.include_router(sign_up.public_router, prefix=user_prefix, tags=["Users: Sign-Up"])

View File

@ -7,7 +7,7 @@ from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.user import ChangePassword
from mealie.services.user.user_service import UserService
from mealie.services.user_services import UserService
user_router = UserAPIRouter(prefix="")

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends, status
from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import UserOut
from mealie.services.user_services.registration_service import RegistrationService
router = APIRouter(prefix="/register")
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def reset_user_password(
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
):
return registration_service.register_user(data)

View File

@ -0,0 +1,25 @@
from fastapi_camelcase import CamelModel
class UpdateGroupPreferences(CamelModel):
private_group: bool = False
first_day_of_week: int = 0
# Recipe Defaults
recipe_public: bool = True
recipe_show_nutrition: bool = False
recipe_show_assets: bool = False
recipe_landscape_view: bool = False
recipe_disable_comments: bool = False
recipe_disable_amount: bool = False
class CreateGroupPreferences(UpdateGroupPreferences):
group_id: int
class ReadGroupPreferences(CreateGroupPreferences):
id: int
class Config:
orm_mode = True

View File

@ -0,0 +1,29 @@
from fastapi_camelcase import CamelModel
from pydantic import validator
from pydantic.types import constr
class CreateUserRegistration(CamelModel):
group: str = None
group_token: str = None
email: constr(to_lower=True, strip_whitespace=True)
username: constr(to_lower=True, strip_whitespace=True)
password: str
password_confirm: str
advanced: bool = False
private: bool = False
@validator("password_confirm")
@classmethod
def passwords_match(cls, value, values):
if "password" in values and value != values["password"]:
raise ValueError("passwords do not match")
return value
@validator("group_token", always=True)
@classmethod
def group_or_token(cls, value, values):
if bool(value) is False and bool(values["group"]) is False:
raise ValueError("group or group_token must be provided")
return value

View File

@ -6,8 +6,8 @@ from pydantic.types import constr
from pydantic.utils import GetterDict
from mealie.core.config import settings
from mealie.db.models.group import Group
from mealie.db.models.users import User
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary
from ..meal_plan import MealPlanOut, ShoppingListOut
@ -50,8 +50,9 @@ class UserBase(CamelModel):
username: Optional[str]
full_name: Optional[str] = None
email: constr(to_lower=True, strip_whitespace=True)
admin: bool
admin: bool = False
group: Optional[str]
advanced: bool = False
favorite_recipes: Optional[list[str]] = []
class Config:
@ -128,16 +129,11 @@ class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanOut]]
shopping_lists: Optional[list[ShoppingListOut]]
preferences: Optional[ReadGroupPreferences] = None
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, orm_model: Group):
return {
**GetterDict(orm_model),
}
class LongLiveTokenInDB(CreateToken):
id: int

View File

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

View File

@ -1,2 +1,3 @@
from .cookbook_service import *
from .group_service import *
from .webhook_service import *

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
from mealie.utils.error_messages import ErrorMessages

View File

@ -0,0 +1,16 @@
from mealie.db.database import get_database
from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.user import GroupBase, GroupInDB
def create_new_group(session, g_base: GroupBase, g_preferences: CreateGroupPreferences = None) -> GroupInDB:
db = get_database()
created_group = db.groups.create(session, g_base)
g_preferences = g_preferences or CreateGroupPreferences(group_id=0)
g_preferences.group_id = created_group.id
db.group_preferences.create(session, g_preferences)
return created_group

View File

@ -4,9 +4,10 @@ from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
@ -41,8 +42,11 @@ class GroupSelfService(UserHttpService[int, str]):
return self.item
def update_categories(self, new_categories: list[CategoryBase]):
if not self.item:
return
self.item.categories = new_categories
return self.db.groups.update(self.session, self.group_id, self.item)
def update_preferences(self, new_preferences: UpdateGroupPreferences):
self.db.group_preferences.update(self.session, self.group_id, new_preferences)
return self.populate_item()

View File

@ -5,7 +5,7 @@ from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.schema.group import ReadWebhook
from mealie.schema.group.webhook import CreateWebhook, SaveWebhook
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)

View File

@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.services.base_http_service.http_services import PublicHttpService
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.services.events import create_recipe_event
logger = get_logger(module=__name__)

View File

@ -55,7 +55,6 @@ def _exec_crf_test(input_text):
def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]):
print(list_of_ingrdeint_text)
crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text])
crf_models = [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))]
@ -82,6 +81,3 @@ def convert_crf_models_to_ingredients(crf_models: list[CRFIngredient]):
if __name__ == "__main__":
crf_models = convert_list_to_crf_model(INGREDIENT_TEXT)
ingredients = convert_crf_models_to_ingredients(crf_models)
for ingredient in ingredients:
print(ingredient.input)

View File

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

View File

@ -0,0 +1,61 @@
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password
from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.services.events import create_user_event
from mealie.services.group_services.group_mixins import create_new_group
logger = get_logger(module=__name__)
class RegistrationService(PublicHttpService[int, str]):
event_func = create_user_event
def populate_item() -> None:
pass
def register_user(self, registration: CreateUserRegistration) -> PrivateUser:
self.registration = registration
logger.info(f"Registering user {registration.username}")
if registration.group:
group = self._create_new_group()
else:
group = self._existing_group_ref()
return self._create_new_user(group)
def _create_new_user(self, group: GroupInDB) -> PrivateUser:
new_user = UserIn(
email=self.registration.email,
username=self.registration.username,
password=hash_password(self.registration.password),
full_name=self.registration.username,
advanced=self.registration.advanced,
group=group.name,
)
return self.db.users.create(self.session, new_user)
def _create_new_group(self) -> GroupInDB:
group_data = GroupBase(name=self.registration.group)
group_preferences = CreateGroupPreferences(
group_id=0,
private_group=self.registration.private,
first_day_of_week=0,
recipe_public=not self.registration.private,
recipe_show_nutrition=self.registration.advanced,
recipe_show_assets=self.registration.advanced,
recipe_landscape_view=False,
recipe_disable_comments=self.registration.advanced,
recipe_disable_amount=self.registration.advanced,
)
return create_new_group(self.session, group_data, group_preferences)
def _existing_group_ref(self) -> GroupInDB:
pass

View File

@ -3,7 +3,7 @@ from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password, verify_password
from mealie.schema.user.user import ChangePassword, PrivateUser
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_user_event
logger = get_logger(module=__name__)

View File

@ -1,43 +0,0 @@
import json
import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
@pytest.fixture()
def page_data():
return {"name": "My New Page", "description": "", "position": 0, "categories": [], "groupId": 1}
def test_create_cookbook(api_client: TestClient, api_routes: AppRoutes, admin_token, page_data):
response = api_client.post(api_routes.group_cookbook, json=page_data, headers=admin_token)
assert response.status_code == 200
def test_read_cookbook(api_client: TestClient, api_routes: AppRoutes, page_data, admin_token):
response = api_client.get(api_routes.group_cookbook_id(1), headers=admin_token)
page_data["id"] = 1
page_data["slug"] = "my-new-page"
assert json.loads(response.text) == page_data
def test_update_cookbook(api_client: TestClient, api_routes: AppRoutes, page_data, admin_token):
page_data["id"] = 1
page_data["name"] = "My New Name"
response = api_client.put(api_routes.group_cookbook_id(1), json=page_data, headers=admin_token)
assert response.status_code == 200
def test_delete_cookbook(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.group_cookbook_id(1), headers=admin_token)
assert response.status_code == 200
response = api_client.get(api_routes.group_cookbook_id(1), headers=admin_token)
assert response.status_code == 404

View File

@ -4,6 +4,7 @@ import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
from tests.utils.assertion_helpers import assert_ignore_keys
@pytest.fixture
@ -41,8 +42,10 @@ def test_update_group(api_client: TestClient, api_routes: AppRoutes, admin_token
# Validate Changes
response = api_client.get(api_routes.groups, headers=admin_token)
all_groups = json.loads(response.text)
id_2 = filter(lambda x: x["id"] == 2, all_groups)
assert next(id_2) == new_data
assert_ignore_keys(new_data, next(id_2), ["preferences"])
def test_home_group_not_deletable(api_client: TestClient, api_routes: AppRoutes, admin_token):

View File

@ -13,7 +13,7 @@ def backup_data():
"force": True,
"recipes": True,
"settings": False, # ! Broken
"groups": True,
"groups": False, # ! Also Broken
"users": True,
}

View File

@ -0,0 +1,46 @@
import pytest
from fastapi.testclient import TestClient
class Routes:
base = "/api/groups/cookbooks"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
@pytest.fixture()
def page_data():
return {"name": "My New Page", "description": "", "position": 0, "categories": [], "groupId": 1}
def test_create_cookbook(api_client: TestClient, admin_token, page_data):
response = api_client.post(Routes.base, json=page_data, headers=admin_token)
assert response.status_code == 200
def test_read_cookbook(api_client: TestClient, page_data, admin_token):
response = api_client.get(Routes.item(1), headers=admin_token)
page_data["id"] = 1
page_data["slug"] = "my-new-page"
assert response.json() == page_data
def test_update_cookbook(api_client: TestClient, page_data, admin_token):
page_data["id"] = 1
page_data["name"] = "My New Name"
response = api_client.put(Routes.item(1), json=page_data, headers=admin_token)
assert response.status_code == 200
def test_delete_cookbook(api_client: TestClient, admin_token):
response = api_client.delete(Routes.item(1), headers=admin_token)
assert response.status_code == 200
response = api_client.get(Routes.item(1), headers=admin_token)
assert response.status_code == 404

View File

@ -0,0 +1,32 @@
from fastapi.testclient import TestClient
from mealie.schema.user.registration import CreateUserRegistration
class Routes:
base = "/api/users/register"
auth_token = "/api/auth/token"
def test_user_registration_new_group(api_client: TestClient):
registration = CreateUserRegistration(
group="New Group Name",
email="email@email.com",
username="fake-user-name",
password="fake-password",
password_confirm="fake-password",
advanced=False,
private=False,
)
response = api_client.post(Routes.base, json=registration.dict(by_alias=True))
assert response.status_code == 201
# Login
form_data = {"username": "email@email.com", "password": "fake-password"}
response = api_client.post(Routes.auth_token, form_data)
assert response.status_code == 200
token = response.json().get("access_token")
assert token is not None

View File

@ -0,0 +1,51 @@
from fastapi.testclient import TestClient
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from tests.utils.assertion_helpers import assert_ignore_keys
class Routes:
base = "/api/groups/self"
preferences = "/api/groups/preferences"
def test_get_preferences(api_client: TestClient, admin_token) -> None:
response = api_client.get(Routes.preferences, headers=admin_token)
assert response.status_code == 200
preferences = response.json()
# Spot Check Defaults
assert preferences["recipePublic"] is True
assert preferences["recipeShowNutrition"] is False
def test_preferences_in_group(api_client: TestClient, admin_token) -> None:
response = api_client.get(Routes.base, headers=admin_token)
assert response.status_code == 200
group = response.json()
assert group["preferences"] is not None
# Spot Check
assert group["preferences"]["recipePublic"] is True
assert group["preferences"]["recipeShowNutrition"] is False
def test_update_preferences(api_client: TestClient, admin_token) -> None:
new_data = UpdateGroupPreferences(recipe_public=False, recipe_show_nutrition=True)
response = api_client.put(Routes.preferences, json=new_data.dict(), headers=admin_token)
assert response.status_code == 200
preferences = response.json()
assert preferences is not None
assert preferences["recipePublic"] is False
assert preferences["recipeShowNutrition"] is True
assert_ignore_keys(new_data.dict(by_alias=True), preferences, ["id", "groupId"])

View File

@ -0,0 +1,71 @@
import pytest
from mealie.schema.user.registration import CreateUserRegistration
def test_create_user_registration() -> None:
CreateUserRegistration(
group="Home",
group_token=None,
email="SomeValidEmail@email.com",
username="SomeValidUsername",
password="SomeValidPassword",
password_confirm="SomeValidPassword",
advanced=False,
private=True,
)
CreateUserRegistration(
group=None,
group_token="asdfadsfasdfasdfasdf",
email="SomeValidEmail@email.com",
username="SomeValidUsername",
password="SomeValidPassword",
password_confirm="SomeValidPassword",
advanced=False,
private=True,
)
@pytest.mark.parametrize("group, group_token", [(None, None), ("", None), (None, "")])
def test_group_or_token_validator(group, group_token) -> None:
with pytest.raises(ValueError):
CreateUserRegistration(
group=group,
group_token=group_token,
email="SomeValidEmail@email.com",
username="SomeValidUsername",
password="SomeValidPassword",
password_confirm="SomeValidPassword",
advanced=False,
private=True,
)
def test_group_no_args_passed() -> None:
with pytest.raises(ValueError):
CreateUserRegistration(
email="SomeValidEmail@email.com",
username="SomeValidUsername",
password="SomeValidPassword",
password_confirm="SomeValidPassword",
advanced=False,
private=True,
)
def test_password_validator() -> None:
with pytest.raises(ValueError):
CreateUserRegistration(
group=None,
group_token="asdfadsfasdfasdfasdf",
email="SomeValidEmail@email.com",
username="SomeValidUsername",
password="SomeValidPassword",
password_confirm="PasswordDefNotMatch",
advanced=False,
private=True,
)
test_create_user_registration()

View File

@ -0,0 +1,17 @@
def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list) -> None:
"""
Itterates through a list of keys and checks if they are in the the provided ignore_keys list,
if they are not in the ignore_keys list, it checks the value of the key in the provided against
the value provided in dict2. If the value of the key in dict1 is not equal to the value of the
key in dict2, The assertion fails. Useful for testing id / group_id agnostic data
Note: ignore_keys defaults to ['id', 'group_id']
"""
if ignore_keys is None:
ignore_keys = ["id", "group_id"]
for key, value in dict1.items():
if key in ignore_keys:
continue
else:
assert value == dict2[key]