feat(backend): refactor/fix group management for admins (#838)

* fix(frontend): 🐛 update dialog implementation to simplify state management

* test(backend):  refactor test fixtures + admin group tests

* chore(backend): 🔨 add launcher.json for python debugging (tests)

* fix typing

* feat(backend):  refactor/fix group management for admins

* feat(frontend):  add/fix admin group management

* add LDAP checker

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-25 14:17:02 -09:00 committed by GitHub
parent 0db8a58963
commit 791aa8c610
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 881 additions and 331 deletions

33
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,33 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: FastAPI",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": ["mealie.app:app"],
"justMyCode": false,
"jinja": true
},
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "Debug Tests",
"type": "python",
"request": "test",
"console": "integratedTerminal",
"justMyCode": false,
"env": { "PYTEST_ADDOPTS": "--no-cov" }
}
]
}

View File

@ -1,6 +1,7 @@
import { AdminAboutAPI } from "./admin/admin-about";
import { AdminTaskAPI } from "./admin/admin-tasks";
import { AdminUsersApi } from "./admin/admin-users";
import { AdminGroupsApi } from "./admin/admin-groups";
import { ApiRequestInstance } from "~/types/api";
export class AdminAPI {
@ -8,6 +9,7 @@ export class AdminAPI {
public about: AdminAboutAPI;
public serverTasks: AdminTaskAPI;
public users: AdminUsersApi;
public groups: AdminGroupsApi;
constructor(requests: ApiRequestInstance) {
if (AdminAPI.instance instanceof AdminAPI) {
@ -17,6 +19,7 @@ export class AdminAPI {
this.about = new AdminAboutAPI(requests);
this.serverTasks = new AdminTaskAPI(requests);
this.users = new AdminUsersApi(requests);
this.groups = new AdminGroupsApi(requests);
Object.freeze(this);
AdminAPI.instance = this;

View File

@ -31,6 +31,7 @@ export interface CheckAppConfig {
emailReady: boolean;
baseUrlSet: boolean;
isSiteSecure: boolean;
ldapReady: boolean;
}
export class AdminAboutAPI extends BaseAPI {

View File

@ -0,0 +1,54 @@
import { BaseCRUDAPI } from "../_base";
import { UserRead } from "./admin-users";
const prefix = "/api";
export interface Token {
name: string;
id: number;
createdAt: Date;
}
export interface Preferences {
privateGroup: boolean;
firstDayOfWeek: number;
recipePublic: boolean;
recipeShowNutrition: boolean;
recipeShowAssets: boolean;
recipeLandscapeView: boolean;
recipeDisableComments: boolean;
recipeDisableAmount: boolean;
groupId: number;
id: number;
}
export interface GroupCreate {
name: string;
}
export interface GroupRead extends GroupCreate {
id: number;
categories: any[];
webhooks: any[];
users: UserRead[];
preferences: Preferences;
}
export interface AdminGroupUpdate {
name: string;
id: number;
preferences: Preferences;
}
const routes = {
adminUsers: `${prefix}/admin/groups`,
adminUsersId: (id: number) => `${prefix}/admin/groups/${id}`,
};
export class AdminGroupsApi extends BaseCRUDAPI<GroupRead, GroupCreate> {
baseRoute: string = routes.adminUsers;
itemRoute = routes.adminUsersId;
async updateOne(id: number, payload: AdminGroupUpdate) {
return await this.requests.put<GroupRead>(this.itemRoute(id), payload);
}
}

View File

@ -2,7 +2,7 @@ import { BaseCRUDAPI } from "../_base";
const prefix = "/api";
interface UserCreate {
export interface UserCreate {
username: string;
fullName: string;
email: string;
@ -21,7 +21,7 @@ export interface UserToken {
createdAt: Date;
}
interface UserRead extends UserToken {
export interface UserRead extends UserToken {
id: number;
groupId: number;
favoriteRecipes: any[];

View File

@ -0,0 +1,99 @@
<template>
<div v-if="preferences">
<BaseCardSectionTitle title="General Preferences"></BaseCardSectionTitle>
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" label="Private Group"></v-checkbox>
<v-select
v-model="preferences.firstDayOfWeek"
:prepend-icon="$globals.icons.calendarWeekBegin"
:items="allDays"
item-text="name"
item-value="value"
:label="$t('settings.first-day-of-week')"
/>
<BaseCardSectionTitle class="mt-5" title="Group Recipe Preferences"></BaseCardSectionTitle>
<template v-for="(_, key) in preferences">
<v-checkbox
v-if="labels[key]"
:key="key"
v-model="preferences[key]"
class="mt-n4"
:label="labels[key]"
></v-checkbox>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
value: {
type: Object,
required: true,
},
},
setup(props, context) {
const { i18n } = useContext();
const labels = {
recipePublic: "Allow users outside of your group to see your recipes",
recipeShowNutrition: "Show nutrition information",
recipeShowAssets: "Show recipe assets",
recipeLandscapeView: "Default to landscape view",
recipeDisableComments: "Disable recipe comments from users in your group",
recipeDisableAmount: "Disable organizing recipe ingredients by units and food",
};
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,
},
];
const preferences = computed({
get() {
return props.value;
},
set(val) {
context.emit("input", val);
},
});
return {
allDays,
labels,
preferences,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -8,7 +8,7 @@
style="z-index: 2; position: sticky"
>
<BaseDialog
ref="deleteRecipieConfirm"
v-model="deleteDialog"
:title="$t('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
@ -112,6 +112,7 @@ export default {
},
data() {
return {
deleteDialog: false,
edit: false,
};
},
@ -160,7 +161,7 @@ export default {
this.$emit(JSON_EVENT);
break;
case DELETE_EVENT:
this.$refs.deleteRecipieConfirm.open();
this.deleteDialog = true;
break;
default:
break;

View File

@ -34,9 +34,14 @@
</v-card>
<div class="d-flex ml-auto mt-2">
<v-spacer></v-spacer>
<BaseDialog :title="$t('asset.new-asset')" :icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset">
<template #activator="{ open }">
<BaseButton v-if="edit" small create @click="open" />
<BaseDialog
v-model="newAssetDialog"
:title="$t('asset.new-asset')"
:icon="getIconDefinition(newAsset.icon).icon"
@submit="addAsset"
>
<template #activator>
<BaseButton v-if="edit" small create @click="newAssetDialog = true" />
</template>
<v-card-text class="pt-4">
<v-text-field v-model="newAsset.name" dense :label="$t('general.name')"></v-text-field>
@ -94,6 +99,7 @@ export default defineComponent({
const api = useUserApi();
const state = reactive({
newAssetDialog: false,
fileObject: {} as File,
newAsset: {
name: "",

View File

@ -1,7 +1,7 @@
<template>
<div class="text-center">
<BaseDialog
ref="domConfirmDelete"
v-model="recipeDeleteDialog"
:title="$t('recipe.delete-recipe')"
color="error"
:icon="$globals.icons.alertCircle"
@ -12,7 +12,7 @@
</v-card-text>
</BaseDialog>
<BaseDialog
ref="domMealplanDialog"
v-model="mealplannerDialog"
title="Add Recipe to Mealplan"
color="primary"
:icon="$globals.icons.calendar"
@ -74,7 +74,7 @@
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { useClipboard, useShare } from "@vueuse/core";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
@ -152,6 +152,8 @@ export default defineComponent({
const api = useUserApi();
const state = reactive({
recipeDeleteDialog: false,
mealplannerDialog: false,
loading: false,
menuItems: [] as ContextMenuItem[],
newMealdate: "",
@ -232,8 +234,6 @@ export default defineComponent({
const router = useRouter();
const domConfirmDelete = ref(null);
async function deleteRecipe() {
await api.recipes.deleteOne(props.slug);
context.emit("delete", props.slug);
@ -264,7 +264,6 @@ export default defineComponent({
}
}
const domMealplanDialog = ref(null);
async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: state.newMealdate,
@ -284,11 +283,15 @@ export default defineComponent({
// Note: Print is handled as an event in the parent component
const eventHandlers: { [key: string]: Function } = {
// @ts-ignore - Doens't know about open()
delete: () => domConfirmDelete?.value?.open(),
delete: () => {
state.recipeDeleteDialog = true;
},
edit: () => router.push(`/recipe/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
// @ts-ignore - Doens't know about open()
mealplanner: () => domMealplanDialog?.value?.open(),
mealplanner: () => {
state.mealplannerDialog = true;
},
share: handleShareEvent,
};
@ -310,8 +313,6 @@ export default defineComponent({
contextMenuEventHandler,
deleteRecipe,
addRecipeToPlan,
domConfirmDelete,
domMealplanDialog,
icon,
planTypeOptions,
};

View File

@ -28,9 +28,9 @@
</v-chip>
</template>
<template #append-outer="">
<BaseDialog title="Create New Tool" @submit="actions.createOne()">
<template #activator="{ open }">
<v-btn icon @click="open">
<BaseDialog v-model="createDialog" title="Create New Tool" @submit="actions.createOne()">
<template #activator>
<v-btn icon @click="createDialog = true">
<v-icon> {{ $globals.icons.create }}</v-icon>
</v-btn>
</template>
@ -46,7 +46,7 @@
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { computed } from "vue-demi";
import { Tool } from "~/api/class-interfaces/tools";
import { useTools } from "~/composables/recipes";
@ -65,6 +65,8 @@ export default defineComponent({
setup(props, context) {
const { tools, actions, workingToolData } = useTools();
const createDialog = ref(false);
const recipeTools = computed({
get: () => {
return props.value;
@ -75,10 +77,11 @@ export default defineComponent({
});
return {
workingToolData,
actions,
tools,
createDialog,
recipeTools,
tools,
workingToolData,
};
},
});

View File

@ -32,6 +32,7 @@
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
rounded
class="rounded-lg"
:autofocus="index === 0"
dense
:label="inputField.label"
:name="inputField.varName"

View File

@ -1,17 +1,18 @@
<template>
<v-card color="background" flat class="pb-2">
<v-card-title class="headline py-0">
<v-card-title class="headline pl-0 py-0">
<v-icon v-if="icon !== ''" left>
{{ icon }}
</v-icon>
{{ title }}
</v-card-title>
<v-card-text v-if="$slots.default" class="pt-2">
<v-card-text v-if="$slots.default" class="pt-2 pl-0">
<p class="pb-0 mb-0">
<slot />
</p>
</v-card-text>
<v-divider class="my-4"></v-divider>
<v-divider class="my-3"></v-divider>
</v-card>
</template>

View File

@ -69,9 +69,14 @@
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { computed } from "vue-demi";
export default defineComponent({
name: "BaseDialog",
props: {
value: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
@ -105,9 +110,22 @@ export default defineComponent({
type: Boolean,
},
},
setup(props, context) {
const dialog = computed<Boolean>({
get() {
return props.value;
},
set(val) {
context.emit("input", val);
},
});
return {
dialog,
};
},
data() {
return {
dialog: false,
submitted: false,
};
},
@ -122,6 +140,8 @@ export default defineComponent({
watch: {
determineClose() {
this.submitted = false;
// @ts-ignore
this.dialog = false;
},
dialog(val) {
@ -139,10 +159,19 @@ export default defineComponent({
this.submitted = true;
},
open() {
// @ts-ignore
this.dialog = true;
this.logDeprecatedProp("open");
},
close() {
// @ts-ignore
this.dialog = false;
this.logDeprecatedProp("close");
},
logDeprecatedProp(val: string) {
console.warn(
`[BaseDialog] The method '${val}' is deprecated. Please use v-model="value" to manage state instead.`
);
},
},
});

View File

@ -6,7 +6,7 @@
<!-- Delete Dialog -->
<BaseDialog
ref="domDeleteConfirmation"
v-model="deleteDialog"
:title="$t('settings.backup.delete-backup')"
color="error"
:icon="$globals.icons.alertCircle"
@ -19,7 +19,7 @@
<!-- Import Dialog -->
<BaseDialog
ref="domImportDialog"
v-model="importDialog"
:title="selected.name"
:icon="$globals.icons.database"
:submit-text="$t('general.import')"
@ -38,6 +38,7 @@
<BaseButton class="mr-2" @click="createBackup(null)" />
<!-- Backup Creation Dialog -->
<BaseDialog
v-model="createDialog"
:title="$t('settings.backup.create-heading')"
:icon="$globals.icons.database"
:submit-text="$t('general.create')"
@ -82,7 +83,7 @@
class="mx-1"
delete
@click.stop="
domDeleteConfirmation.open();
deleteDialog = true;
deleteTarget = item.name;
"
/>
@ -105,7 +106,7 @@
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, ref } from "@nuxtjs/composition-api";
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import AdminBackupImportOptions from "@/components/Domain/Admin/AdminBackupImportOptions.vue";
import { useBackups } from "~/composables/use-backups";
@ -118,9 +119,10 @@ export default defineComponent({
const { selected, backups, backupOptions, deleteTarget, refreshBackups, importBackup, createBackup, deleteBackup } =
useBackups();
const domDeleteConfirmation = ref(null);
const domImportDialog = ref(null);
const state = reactive({
deleteDialog: false,
createDialog: false,
importDialog: false,
search: "",
headers: [
{ text: i18n.t("general.name"), value: "name" },
@ -135,8 +137,7 @@ export default defineComponent({
return;
}
selected.value.name = data.name;
// @ts-ignore - Calling Child Method
domImportDialog.value.open();
state.importDialog = true;
}
const backupsFileNameDownload = (fileName: string) => `api/backups/${fileName}/download`;
@ -150,8 +151,6 @@ export default defineComponent({
deleteBackup,
setSelected,
deleteTarget,
domDeleteConfirmation,
domImportDialog,
importBackup,
refreshBackups,
backupsFileNameDownload,

View File

@ -0,0 +1,96 @@
<template>
<v-container v-if="group" class="narrow-container">
<BasePageTitle>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
</template>
<template #title> Admin Group Management </template>
Changes to this group will be reflected immediately.
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<v-card-text> Group Id: {{ group.id }} </v-card-text>
<v-form v-if="!userError" ref="refGroupEditForm" @submit.prevent="handleSubmit">
<v-card outlined>
<v-card-text>
<v-text-field v-model="group.name" label="Group Name"> </v-text-field>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto"> {{ $t("general.update") }}</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useRoute, onMounted, ref } from "@nuxtjs/composition-api";
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { alert } from "~/composables/use-toast";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
export default defineComponent({
components: {
GroupPreferencesEditor,
},
layout: "admin",
setup() {
const { userForm } = useUserForm();
const { groups } = useGroups();
const route = useRoute();
const groupId = route.value.params.id;
// ==============================================
// New User Form
const refGroupEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const group = ref({});
const userError = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.groups.getOne(groupId);
if (error?.response?.status === 404) {
alert.error("User Not Found");
userError.value = true;
}
if (data) {
// @ts-ignore
group.value = data;
}
});
async function handleSubmit() {
if (!refGroupEditForm.value?.validate()) {
return;
}
// @ts-ignore
const { response, data } = await adminApi.groups.updateOne(group.value.id, group.value);
if (response?.status === 200 && data) {
// @ts-ignore
group.value = data;
}
}
return {
group,
userError,
userForm,
refGroupEditForm,
handleSubmit,
groups,
validators,
};
},
});
</script>

View File

@ -1,23 +1,36 @@
// TODO: Edit Group
<template>
<v-container fluid>
<BaseDialog
v-model="createDialog"
:title="$t('group.create-group')"
:icon="$globals.icons.group"
@submit="createGroup(createUserForm.data)"
>
<template #activator> </template>
<v-card-text>
<AutoForm v-model="createUserForm.data" :update-mode="updateMode" :items="createUserForm.items" />
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="confirmDialog"
:title="$t('general.confirm')"
color="error"
@confirm="deleteGroup(deleteTarget)"
>
<template #activator> </template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle title="Group Management"> </BaseCardSectionTitle>
<section>
<v-toolbar flat color="background" class="justify-between">
<BaseDialog
ref="refUserDialog"
top
:title="$t('group.create-group')"
@submit="createGroup(createUserForm.data)"
>
<template #activator="{ open }">
<BaseButton @click="open"> {{ $t("group.create-group") }} </BaseButton>
</template>
<v-card-text>
<AutoForm v-model="createUserForm.data" :update-mode="updateMode" :items="createUserForm.items" />
</v-card-text>
</BaseDialog>
<BaseButton @click="openDialog"> {{ $t("general.create") }} </BaseButton>
</v-toolbar>
<v-data-table
:headers="headers"
:items="groups || []"
@ -26,10 +39,8 @@
hide-default-footer
disable-pagination
:search="search"
@click:row="handleRowClick"
>
<template #item.mealplans="{ item }">
{{ item.mealplans.length }}
</template>
<template #item.shoppingLists="{ item }">
{{ item.shoppingLists.length }}
</template>
@ -37,28 +48,23 @@
{{ item.users.length }}
</template>
<template #item.webhookEnable="{ item }">
{{ item.webhookEnabled ? $t("general.yes") : $t("general.no") }}
{{ item.webhooks.length > 0 ? $t("general.yes") : $t("general.no") }}
</template>
<template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteGroup(item.id)">
<template #activator="{ open }">
<v-btn :disabled="item && item.users.length > 0" class="mr-1" small color="error" @click="open">
<v-icon small left>
{{ $globals.icons.delete }}
</v-icon>
{{ $t("general.delete") }}
</v-btn>
<v-btn small color="success" @click="updateUser(item)">
<v-icon small left class="mr-2">
{{ $globals.icons.edit }}
</v-icon>
{{ $t("general.edit") }}
</v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<v-btn
:disabled="item && item.users.length > 0"
class="mr-1"
icon
color="error"
@click.stop="
confirmDialog = true;
deleteTarget = item.id;
"
>
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
</v-data-table>
<v-divider></v-divider>
@ -67,7 +73,8 @@
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { Group } from "~/api/class-interfaces/groups";
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
@ -78,6 +85,9 @@ export default defineComponent({
const { groups, refreshAllGroups, deleteGroup, createGroup } = useGroups();
const state = reactive({
createDialog: false,
confirmDialog: false,
deleteTarget: 0,
search: "",
headers: [
{
@ -89,9 +99,8 @@ export default defineComponent({
{ text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("user.total-users"), value: "users" },
{ text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" },
{ text: i18n.t("user.total-mealplans"), value: "mealplans" },
{ text: i18n.t("shopping-list.shopping-lists"), value: "shoppingLists" },
{ value: "actions" },
{ text: i18n.t("general.delete"), value: "actions" },
],
updateMode: false,
createUserForm: {
@ -109,7 +118,18 @@ export default defineComponent({
},
});
return { ...toRefs(state), groups, refreshAllGroups, deleteGroup, createGroup };
function openDialog() {
state.createDialog = true;
state.createUserForm.data.name = "";
}
const router = useRouter();
function handleRowClick(item: Group) {
router.push("/admin/manage/groups/" + item.id);
}
return { ...toRefs(state), groups, refreshAllGroups, deleteGroup, createGroup, openDialog, handleRowClick };
},
head() {
return {

View File

@ -31,7 +31,7 @@
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" class="ml-auto"></BaseButton>
<BaseButton type="submit" edit class="ml-auto"> {{ $t("general.update") }}</BaseButton>
</div>
</v-form>
</v-container>

View File

@ -1,6 +1,12 @@
// TODO: Edit User
<template>
<v-container fluid>
<BaseDialog v-model="deleteDialog" :title="$t('general.confirm')" color="error" @confirm="deleteUser(deleteTarget)">
<template #activator> </template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section>
<v-toolbar color="background" flat class="justify-between">
@ -25,18 +31,19 @@
</v-icon>
</template>
<template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteUser(item.id)">
<template #activator="{ open }">
<v-btn icon :disabled="item.id == 1" color="error" @click.stop="open">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<v-btn
icon
:disabled="item.id == 1"
color="error"
@click.stop="
deleteDialog = true;
deleteTarget = item.id;
"
>
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
</v-data-table>
<v-divider></v-divider>
@ -61,6 +68,8 @@ export default defineComponent({
const router = useRouter();
const state = reactive({
deleteDialog: false,
deleteTarget: 0,
search: "",
});

View File

@ -128,6 +128,7 @@ export default defineComponent({
emailReady: false,
baseUrlSet: false,
isSiteSecure: false,
ldapReady: false,
});
const api = useUserApi();
@ -140,10 +141,10 @@ export default defineComponent({
appConfig.value = data;
}
appConfig.value.isSiteSecure = isLocalhostorHttps();
appConfig.value.isSiteSecure = isLocalHostOrHttps();
});
function isLocalhostorHttps() {
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
}
@ -152,15 +153,21 @@ export default defineComponent({
{
status: appConfig.value.baseUrlSet,
text: "Server Side Base URL",
errorText: "Error - `BASE_URL` still default on API Server",
errorText: "`BASE_URL` still default on API Server",
successText: "Server Side URL does not match the default",
},
{
status: appConfig.value.isSiteSecure,
text: "Secure Site",
errorText: "Error - Serve via localhost or secure with https.",
errorText: "Serve via localhost or secure with https.",
successText: "Site is accessed by localhost or https",
},
{
status: appConfig.value.ldapReady,
text: "LDAP Ready",
errorText: "Not all LDAP Values are configured",
successText: "Required LDAP variables are all set.",
},
];
});

View File

@ -2,8 +2,9 @@
<v-container fluid>
<BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle>
<v-toolbar flat color="background">
<!-- New/Edit Food Dialog -->
<BaseDialog
ref="domFoodDialog"
v-model="newFoodDialog"
:title="dialog.title"
:icon="$globals.icons.units"
:submit-text="dialog.text"
@ -18,12 +19,25 @@
</v-card-text>
</BaseDialog>
<!-- Delete Food Dialog -->
<BaseDialog
v-model="deleteFoodDialog"
:title="$t('general.confirm')"
color="error"
@confirm="actions.deleteOne(deleteTarget)"
>
<template #activator> </template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseButton
class="mr-1"
@click="
create = true;
actions.resetWorking();
domFoodDialog.open();
newFoodDialog = true;
"
></BaseButton>
<BaseButton secondary @click="filter = !filter"> Filter </BaseButton>
@ -45,17 +59,17 @@
@click="
create = false;
actions.setWorking(item);
domFoodDialog.open();
newFoodDialog = true;
"
></BaseButton>
<BaseButton
delete
small
@click="
deleteFoodDialog = true;
deleteTarget = item.id;
"
></BaseButton>
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="actions.deleteOne(item.id)">
<template #activator="{ open }">
<BaseButton delete small @click="open"></BaseButton>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</div>
</template>
</v-data-table>
@ -90,6 +104,9 @@ export default defineComponent({
});
const state = reactive({
deleteFoodDialog: false,
newFoodDialog: false,
deleteTarget: 0,
headers: [
{ text: "Id", value: "id" },
{ text: "Name", value: "name" },

View File

@ -1,30 +1,42 @@
<template>
<v-container fluid>
<!-- Create/Edit Unit Dialog -->
<BaseDialog
v-model="createUnitDialog"
:title="dialog.title"
:icon="$globals.icons.units"
:submit-text="dialog.text"
:keep-open="!validForm"
@submit="create ? actions.createOne(domCreateUnitForm) : actions.updateOne()"
>
<v-card-text>
<v-form ref="domCreateUnitForm">
<v-text-field v-model="workingUnitData.name" label="Name" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="workingUnitData.abbreviation" label="Abbreviation"></v-text-field>
<v-text-field v-model="workingUnitData.description" label="Description"></v-text-field>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Delete Unit Dialog -->
<BaseDialog
v-model="deleteUnitDialog"
:title="$t('general.confirm')"
color="error"
@confirm="actions.deleteOne(item.id)"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle>
<v-toolbar flat color="background">
<BaseDialog
ref="domUnitDialog"
:title="dialog.title"
:icon="$globals.icons.units"
:submit-text="dialog.text"
:keep-open="!validForm"
@submit="create ? actions.createOne(domCreateUnitForm) : actions.updateOne()"
>
<v-card-text>
<v-form ref="domCreateUnitForm">
<v-text-field v-model="workingUnitData.name" label="Name" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="workingUnitData.abbreviation" label="Abbreviation"></v-text-field>
<v-text-field v-model="workingUnitData.description" label="Description"></v-text-field>
</v-form>
</v-card-text>
</BaseDialog>
<BaseButton
class="mr-1"
@click="
create = true;
actions.resetWorking();
domUnitDialog.open();
createUnitDialog = true;
"
></BaseButton>
<BaseButton secondary @click="filter = !filter"> Filter </BaseButton>
@ -46,17 +58,17 @@
@click="
create = false;
actions.setWorking(item);
domUnitDialog.open();
createUnitDialog = true;
"
></BaseButton>
<BaseButton
delete
small
@click="
deleteUnitDialog = true;
deleteUnitTarget = item.id;
"
></BaseButton>
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="actions.deleteOne(item.id)">
<template #activator="{ open }">
<BaseButton delete small @click="open"></BaseButton>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</div>
</template>
</v-data-table>
@ -91,6 +103,9 @@ export default defineComponent({
});
const state = reactive({
createUnitDialog: false,
deleteUnitDialog: false,
deleteUnitTarget: 0,
headers: [
{ text: "Id", value: "id" },
{ text: "Name", value: "name" },

View File

@ -2,7 +2,7 @@
<v-container>
<!-- Create Meal Dialog -->
<BaseDialog
ref="domMealDialog"
v-model="createMealDialog"
:title="$t('meal-plan.create-a-new-meal-plan')"
color="primary"
:icon="$globals.icons.foods"
@ -202,7 +202,7 @@
</template>
<script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
import { computed, defineComponent, reactive, toRefs, watch } from "@nuxtjs/composition-api";
import { isSameDay, addDays, subDays, parseISO, format } from "date-fns";
import { SortableEvent } from "sortablejs"; // eslint-disable-line
import draggable from "vuedraggable";
@ -222,6 +222,7 @@ export default defineComponent({
useRecipes(true, true);
const state = reactive({
createMealDialog: false,
edit: false,
hover: {},
pickerMenu: null,
@ -300,7 +301,6 @@ export default defineComponent({
// =====================================================
// New Meal Dialog
const domMealDialog = ref(null);
const dialog = reactive({
loading: false,
error: false,
@ -326,7 +326,7 @@ export default defineComponent({
function openDialog(date: Date) {
newMeal.date = format(date, "yyyy-MM-dd");
// @ts-ignore
domMealDialog.value.open();
state.createMealDialog = true;
}
function resetDialog() {
@ -360,7 +360,6 @@ export default defineComponent({
backOneWeek,
days,
dialog,
domMealDialog,
forwardOneWeek,
loading,
mealplans,

View File

@ -4,7 +4,7 @@
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-members.svg')"></v-img>
</template>
<template #title> Manage Memebers </template>
<template #title> Manage Members </template>
Manage the permissions of the members in your groups. <b> Manage </b> allows the user to access the
data-management page <b> Invite </b> allows the user to generate invitation links for other users. Group owners
cannot change their own permissions.

View File

@ -1,8 +1,9 @@
<template>
<v-container fluid>
<!-- Dialog Object -->
<!-- Base Dialog Object -->
<BaseDialog
ref="domDialog"
v-model="dialog.state"
width="650px"
:icon="dialog.icon"
:title="dialog.title"
@ -23,6 +24,7 @@
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.export"> TODO: Export Stuff Here </v-card-text>
</BaseDialog>
<BasePageTitle divider>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-recipes.svg')"></v-img>
@ -207,9 +209,8 @@ export default defineComponent({
// ============================================================
// Dialog Management
const domDialog = ref(null);
const dialog = reactive({
state: false,
title: "Tag Recipes",
mode: MODES.tag,
tag: "",
@ -243,15 +244,13 @@ export default defineComponent({
dialog.title = titles[mode];
dialog.callback = callbacks[mode];
dialog.icon = icons[mode];
// @ts-ignore
domDialog.value.open();
dialog.state = true;
}
return {
toSetTags,
toSetCategories,
openDialog,
domDialog,
dialog,
MODES,
headers,

View File

@ -16,7 +16,7 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
settings = get_app_settings()
to_encode = data.copy()
@ -78,7 +78,7 @@ def user_from_ldap(db: Database, session, username: str, password: str) -> Priva
return user
def authenticate_user(session, email: str, password: str) -> PrivateUser | False:
def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
settings = get_app_settings()
db = get_database(session)

View File

@ -99,8 +99,8 @@ class AppSettings(BaseSettings):
self.LDAP_BIND_TEMPLATE,
self.LDAP_ADMIN_FILTER,
}
return "" not in required and None not in required and self.LDAP_AUTH_ENABLED
not_none = None not in required
return self.LDAP_AUTH_ENABLED and not_none
class Config:
arbitrary_types_allowed = True

View File

@ -1,16 +1,15 @@
from fastapi import APIRouter
from mealie.routes.routers import AdminAPIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.admin.admin_group_service import AdminGroupService
from mealie.services.admin.admin_user_service import AdminUserService
from . import admin_about, admin_email, admin_group, admin_log, admin_server_tasks
from . import admin_about, admin_email, admin_log, admin_server_tasks
router = AdminAPIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"])
router.include_router(admin_group.router, tags=["Admin: Group"])
router.include_router(RouterFactory(AdminUserService, prefix="/users", tags=["Admin: Users"]))
router.include_router(RouterFactory(AdminGroupService, prefix="/groups", tags=["Admin: Groups"]))
router.include_router(admin_email.router, tags=["Admin: Email"])
router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"])

View File

@ -47,5 +47,6 @@ async def check_app_config():
return CheckAppConfig(
email_ready=settings.SMTP_ENABLE,
ldap_ready=settings.LDAP_ENABLED,
base_url_set=url_set,
)

View File

@ -5,11 +5,10 @@ from fastapi.responses import JSONResponse
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
logger = get_logger(__name__)
logger = get_logger()
def log_wrapper(request: Request, e):
logger.error("Start 422 Error".center(60, "-"))
logger.error(f"{request.method} {request.url}")
logger.error(f"error is {e}")

View File

@ -27,4 +27,5 @@ class AdminAboutInfo(AppInfo):
class CheckAppConfig(CamelModel):
email_ready: bool = False
ldap_ready: bool = False
base_url_set: bool = False

View File

@ -0,0 +1,9 @@
from fastapi_camelcase import CamelModel
from .group_preferences import UpdateGroupPreferences
class GroupAdminUpdate(CamelModel):
id: int
name: str
preferences: UpdateGroupPreferences

18
mealie/schema/mapper.py Normal file
View File

@ -0,0 +1,18 @@
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
U = TypeVar("U", bound=BaseModel)
def mapper(source: U, dest: T, **kwargs) -> Generic[T]:
"""
Map a source model to a destination model. Only top-level fields are mapped.
"""
for field in source.__fields__:
if field in dest.__fields__:
setattr(dest, field, getattr(source, field))
return dest

View File

@ -0,0 +1,59 @@
from __future__ import annotations
from functools import cached_property
from fastapi import HTTPException, status
from mealie.schema.group.group import GroupAdminUpdate
from mealie.schema.mapper import mapper
from mealie.schema.response import ErrorResponse
from mealie.schema.user.user import GroupBase, GroupInDB
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import AdminHttpService
from mealie.services.events import create_group_event
from mealie.services.group_services.group_utils import create_new_group
class AdminGroupService(
CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate],
AdminHttpService[int, GroupInDB],
):
event_func = create_group_event
_schema = GroupInDB
@cached_property
def dal(self):
return self.db.groups
def populate_item(self, id: int) -> GroupInDB:
self.item = self.dal.get_one(id)
return self.item
def get_all(self) -> list[GroupInDB]:
return self.dal.get_all()
def create_one(self, data: GroupBase) -> GroupInDB:
return create_new_group(self.db, data)
def update_one(self, data: GroupAdminUpdate, item_id: int = None) -> GroupInDB:
target_id = item_id or data.id
if data.preferences:
preferences = self.db.group_preferences.get_one(value=target_id, key="group_id")
preferences = mapper(data.preferences, preferences)
self.item.preferences = self.db.group_preferences.update(preferences.id, preferences)
if data.name not in ["", self.item.name]:
self.item.name = data.name
self.item = self.dal.update(target_id, self.item)
return self.item
def delete_one(self, id: int = None) -> GroupInDB:
if len(self.item.users) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(message="Cannot delete group with users").dict(),
)
return self._delete_one(id)

View File

@ -5,14 +5,14 @@ from functools import cached_property
from mealie.schema.user.user import UserIn, UserOut
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import AdminHttpService
from mealie.services.events import create_recipe_event
from mealie.services.events import create_user_event
class AdminUserService(
CrudHttpMixins[UserOut, UserIn, UserIn],
AdminHttpService[int, UserOut],
):
event_func = create_recipe_event
event_func = create_user_event
_schema = UserOut
@cached_property

View File

@ -1,19 +1,13 @@
from tests.pre_test import settings # isort:skip
import json
import requests
from fastapi.testclient import TestClient
from pytest import fixture
from mealie.app import app
from mealie.db.db_setup import SessionLocal, generate_session
from mealie.db.init_db import main
from tests.app_routes import AppRoutes
from tests.fixtures import * # noqa: F403 F401
from tests.test_config import TEST_DATA
from tests.utils.factories import random_email, random_string, user_registration_factory
from tests.utils.fixture_schemas import TestUser
from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases
main()
@ -39,11 +33,6 @@ def api_client():
pass
@fixture(scope="session")
def api_routes():
return AppRoutes()
@fixture(scope="session")
def test_image_jpg():
return TEST_DATA.joinpath("images", "test_image.jpg")
@ -52,132 +41,3 @@ def test_image_jpg():
@fixture(scope="session")
def test_image_png():
return TEST_DATA.joinpath("images", "test_image.png")
def login(form_data, api_client: requests, api_routes: AppRoutes):
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 200
token = json.loads(response.text).get("access_token")
return {"Authorization": f"Bearer {token}"}
@fixture(scope="session")
def admin_token(api_client: requests, api_routes: AppRoutes):
form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD}
return login(form_data, api_client, api_routes)
@fixture(scope="session")
def g2_user(admin_token, api_client: requests, api_routes: AppRoutes):
# Create the user
create_data = {
"fullName": random_string(),
"username": random_string(),
"email": random_email(),
"password": "useruser",
"group": "New Group",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.groups, json={"name": "New Group"}, headers=admin_token)
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
# Log in as this user
form_data = {"username": create_data["email"], "password": "useruser"}
token = login(form_data, api_client, api_routes)
self_response = api_client.get(api_routes.users_self, headers=token)
assert self_response.status_code == 200
user_id = json.loads(self_response.text).get("id")
group_id = json.loads(self_response.text).get("groupId")
return TestUser(user_id=user_id, group_id=group_id, token=token, email=create_data["email"])
@fixture(scope="session")
def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
# Create the user
create_data = {
"fullName": random_string(),
"username": random_string(),
"email": random_email(),
"password": "useruser",
"group": "Home",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
# Log in as this user
form_data = {"username": create_data["email"], "password": "useruser"}
return login(form_data, api_client, api_routes)
@fixture(scope="session")
def raw_recipe():
return get_raw_recipe()
@fixture(scope="session")
def raw_recipe_no_image():
return get_raw_no_image()
@fixture(scope="session")
def recipe_store():
return get_recipe_test_cases()
@fixture(scope="module")
def unique_user(api_client: TestClient, api_routes: AppRoutes):
registration = user_registration_factory()
response = api_client.post("/api/users/register", json=registration.dict(by_alias=True))
assert response.status_code == 201
form_data = {"username": registration.username, "password": registration.password}
token = login(form_data, api_client, api_routes)
user_data = api_client.get(api_routes.users_self, headers=token).json()
assert token is not None
try:
yield TestUser(
group_id=user_data.get("groupId"), user_id=user_data.get("id"), email=user_data.get("email"), token=token
)
finally:
# TODO: Delete User after test
pass
@fixture(scope="session")
def admin_user(api_client: TestClient, api_routes: AppRoutes):
form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD}
token = login(form_data, api_client, api_routes)
user_data = api_client.get(api_routes.users_self, headers=token).json()
assert token is not None
assert user_data.get("admin") is True
assert user_data.get("groupId") is not None
assert user_data.get("id") is not None
try:
yield TestUser(
group_id=user_data.get("groupId"), user_id=user_data.get("id"), email=user_data.get("email"), token=token
)
finally:
# TODO: Delete User after test
pass

4
tests/fixtures/__init__.py vendored Normal file
View File

@ -0,0 +1,4 @@
from .fixture_admin import *
from .fixture_recipe import *
from .fixture_routes import *
from .fixture_users import *

41
tests/fixtures/fixture_admin.py vendored Normal file
View File

@ -0,0 +1,41 @@
import requests
from pytest import fixture
from starlette.testclient import TestClient
from mealie.core.config import get_app_settings
from tests import utils
@fixture(scope="session")
def admin_token(api_client: requests, api_routes: utils.AppRoutes):
settings = get_app_settings()
form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD}
return utils.login(form_data, api_client, api_routes)
@fixture(scope="session")
def admin_user(api_client: TestClient, api_routes: utils.AppRoutes):
settings = get_app_settings()
form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD}
token = utils.login(form_data, api_client, api_routes)
user_data = api_client.get(api_routes.users_self, headers=token).json()
assert token is not None
assert user_data.get("admin") is True
assert user_data.get("groupId") is not None
assert user_data.get("id") is not None
try:
yield utils.TestUser(
group_id=user_data.get("groupId"),
user_id=user_data.get("id"),
email=user_data.get("email"),
token=token,
)
finally:
# TODO: Delete User after test
pass

18
tests/fixtures/fixture_recipe.py vendored Normal file
View File

@ -0,0 +1,18 @@
from pytest import fixture
from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases
@fixture(scope="session")
def raw_recipe():
return get_raw_recipe()
@fixture(scope="session")
def raw_recipe_no_image():
return get_raw_no_image()
@fixture(scope="session")
def recipe_store():
return get_recipe_test_cases()

8
tests/fixtures/fixture_routes.py vendored Normal file
View File

@ -0,0 +1,8 @@
from pytest import fixture
from tests import utils
@fixture(scope="session")
def api_routes():
return utils.AppRoutes()

91
tests/fixtures/fixture_users.py vendored Normal file
View File

@ -0,0 +1,91 @@
import json
import requests
from pytest import fixture
from starlette.testclient import TestClient
from tests import utils
@fixture(scope="module")
def g2_user(admin_token, api_client: requests, api_routes: utils.AppRoutes):
# Create the user
create_data = {
"fullName": utils.random_string(),
"username": utils.random_string(),
"email": utils.random_email(),
"password": "useruser",
"group": "New Group",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.groups, json={"name": "New Group"}, headers=admin_token)
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
# Log in as this user
form_data = {"username": create_data["email"], "password": "useruser"}
token = utils.login(form_data, api_client, api_routes)
self_response = api_client.get(api_routes.users_self, headers=token)
assert self_response.status_code == 200
user_id = json.loads(self_response.text).get("id")
group_id = json.loads(self_response.text).get("groupId")
try:
yield utils.TestUser(user_id=user_id, group_id=group_id, token=token, email=create_data["email"])
finally:
# TODO: Delete User after test
pass
@fixture(scope="module")
def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
registration = utils.user_registration_factory()
response = api_client.post("/api/users/register", json=registration.dict(by_alias=True))
assert response.status_code == 201
form_data = {"username": registration.username, "password": registration.password}
token = utils.login(form_data, api_client, api_routes)
user_data = api_client.get(api_routes.users_self, headers=token).json()
assert token is not None
try:
yield utils.TestUser(
group_id=user_data.get("groupId"),
user_id=user_data.get("id"),
email=user_data.get("email"),
token=token,
)
finally:
# TODO: Delete User after test
pass
@fixture(scope="session")
def user_token(admin_token, api_client: requests, api_routes: utils.AppRoutes):
# Create the user
create_data = {
"fullName": utils.random_string(),
"username": utils.random_string(),
"email": utils.random_email(),
"password": "useruser",
"group": "Home",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
# Log in as this user
form_data = {"username": create_data["email"], "password": "useruser"}
return utils.login(form_data, api_client, api_routes)

View File

@ -2,7 +2,7 @@ import json
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
from tests.utils.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser

View File

@ -1,8 +1,7 @@
import json
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.assertion_helpers import assert_ignore_keys
from tests.utils.factories import random_bool, random_string
from tests.utils.fixture_schemas import TestUser
@ -12,35 +11,69 @@ class Routes:
def item(id: str) -> str:
return f"{Routes.base}/{id}"
def test_create_group(api_client: TestClient, admin_token):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_token)
assert response.status_code == 201
def user(id: str) -> str:
return f"api/admin/users/{id}"
def test_user_cant_create_group(api_client: TestClient, unique_user: TestUser):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=unique_user.token)
assert response.status_code == 403
def test_home_group_not_deletable(api_client: TestClient, admin_token):
response = api_client.delete(Routes.item(1), headers=admin_token)
def test_home_group_not_deletable(api_client: TestClient, admin_user: TestUser):
response = api_client.delete(Routes.item(1), headers=admin_user.token)
assert response.status_code == 400
def test_delete_group(api_client: TestClient, admin_token):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_token)
def test_admin_group_routes_are_restricted(api_client: TestClient, unique_user: TestUser):
response = api_client.get(Routes.base, headers=unique_user.token)
assert response.status_code == 403
response = api_client.post(Routes.base, json={}, headers=unique_user.token)
assert response.status_code == 403
response = api_client.get(Routes.item(1), headers=unique_user.token)
assert response.status_code == 403
response = api_client.get(Routes.user(1), headers=unique_user.token)
assert response.status_code == 403
def test_admin_create_group(api_client: TestClient, admin_user: TestUser):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_user.token)
assert response.status_code == 201
group_id = json.loads(response.text)["id"]
response = api_client.delete(Routes.item(group_id), headers=admin_token)
def test_admin_update_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
update_payload = {
"id": unique_user.group_id,
"name": "New Name",
"preferences": {
"privateGroup": random_bool(),
"firstDayOfWeek": 2,
"recipePublic": random_bool(),
"recipeShowNutrition": random_bool(),
"recipeShowAssets": random_bool(),
"recipeLandscapeView": random_bool(),
"recipeDisableComments": random_bool(),
"recipeDisableAmount": random_bool(),
},
}
response = api_client.put(Routes.item(unique_user.group_id), json=update_payload, headers=admin_user.token)
assert response.status_code == 200
# Ensure Group is Deleted
response = api_client.get(Routes.base, headers=admin_token)
as_json = response.json()
for g in response.json():
assert g["id"] != group_id
assert as_json["name"] == update_payload["name"]
assert_ignore_keys(as_json["preferences"], update_payload["preferences"])
def test_admin_delete_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
# Delete User
response = api_client.delete(Routes.user(unique_user.user_id), headers=admin_user.token)
assert response.status_code == 200
# Delete Group
response = api_client.delete(Routes.item(unique_user.group_id), headers=admin_user.token)
assert response.status_code == 200
# Ensure Group is Deleted
response = api_client.get(Routes.item(unique_user.user_id), headers=admin_user.token)
assert response.status_code == 404

View File

@ -3,7 +3,7 @@ import json
import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
from tests.utils.app_routes import AppRoutes
@pytest.fixture

View File

@ -8,8 +8,8 @@ from fastapi.testclient import TestClient
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from tests.app_routes import AppRoutes
from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR
from tests.utils.app_routes import AppRoutes
@pytest.fixture(scope="session")

View File

@ -4,7 +4,7 @@ import pytest
from fastapi.testclient import TestClient
from slugify import slugify
from tests.app_routes import AppRoutes
from tests.utils.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases

View File

@ -3,7 +3,7 @@ import json
from fastapi.testclient import TestClient
from pytest import fixture
from tests.app_routes import AppRoutes
from tests.utils.app_routes import AppRoutes
@fixture

View File

@ -5,7 +5,7 @@ from fastapi.testclient import TestClient
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from tests.app_routes import AppRoutes
from tests.utils.app_routes import AppRoutes
def test_update_user_image(

View File

@ -2,7 +2,7 @@ import json
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
from tests.utils.app_routes import AppRoutes
def test_failed_login(api_client: TestClient, api_routes: AppRoutes):

View File

@ -0,0 +1,4 @@
from .app_routes import *
from .factories import *
from .fixture_schemas import *
from .user_login import *

View File

@ -1,14 +1,14 @@
def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list) -> None:
def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list = None) -> 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']
Note: ignore_keys defaults to ['id', 'group_id', 'groupId']
"""
if ignore_keys is None:
ignore_keys = ["id", "group_id"]
ignore_keys = ["id", "group_id", "groupId"]
for key, value in dict1.items():
if key in ignore_keys:

12
tests/utils/user_login.py Normal file
View File

@ -0,0 +1,12 @@
import json
import requests
from .app_routes import AppRoutes
def login(form_data, api_client: requests, api_routes: AppRoutes):
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 200
token = json.loads(response.text).get("access_token")
return {"Authorization": f"Bearer {token}"}