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

View File

@ -31,6 +31,7 @@ export interface CheckAppConfig {
emailReady: boolean; emailReady: boolean;
baseUrlSet: boolean; baseUrlSet: boolean;
isSiteSecure: boolean; isSiteSecure: boolean;
ldapReady: boolean;
} }
export class AdminAboutAPI extends BaseAPI { 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"; const prefix = "/api";
interface UserCreate { export interface UserCreate {
username: string; username: string;
fullName: string; fullName: string;
email: string; email: string;
@ -21,7 +21,7 @@ export interface UserToken {
createdAt: Date; createdAt: Date;
} }
interface UserRead extends UserToken { export interface UserRead extends UserToken {
id: number; id: number;
groupId: number; groupId: number;
favoriteRecipes: any[]; 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" style="z-index: 2; position: sticky"
> >
<BaseDialog <BaseDialog
ref="deleteRecipieConfirm" v-model="deleteDialog"
:title="$t('recipe.delete-recipe')" :title="$t('recipe.delete-recipe')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
@ -112,6 +112,7 @@ export default {
}, },
data() { data() {
return { return {
deleteDialog: false,
edit: false, edit: false,
}; };
}, },
@ -160,7 +161,7 @@ export default {
this.$emit(JSON_EVENT); this.$emit(JSON_EVENT);
break; break;
case DELETE_EVENT: case DELETE_EVENT:
this.$refs.deleteRecipieConfirm.open(); this.deleteDialog = true;
break; break;
default: default:
break; break;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,18 @@
<template> <template>
<v-card color="background" flat class="pb-2"> <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> <v-icon v-if="icon !== ''" left>
{{ icon }} {{ icon }}
</v-icon> </v-icon>
{{ title }} {{ title }}
</v-card-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"> <p class="pb-0 mb-0">
<slot /> <slot />
</p> </p>
</v-card-text> </v-card-text>
<v-divider class="my-4"></v-divider> <v-divider class="my-3"></v-divider>
</v-card> </v-card>
</template> </template>

View File

@ -69,9 +69,14 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
import { computed } from "vue-demi";
export default defineComponent({ export default defineComponent({
name: "BaseDialog", name: "BaseDialog",
props: { props: {
value: {
type: Boolean,
default: false,
},
color: { color: {
type: String, type: String,
default: "primary", default: "primary",
@ -105,9 +110,22 @@ export default defineComponent({
type: Boolean, type: Boolean,
}, },
}, },
setup(props, context) {
const dialog = computed<Boolean>({
get() {
return props.value;
},
set(val) {
context.emit("input", val);
},
});
return {
dialog,
};
},
data() { data() {
return { return {
dialog: false,
submitted: false, submitted: false,
}; };
}, },
@ -122,6 +140,8 @@ export default defineComponent({
watch: { watch: {
determineClose() { determineClose() {
this.submitted = false; this.submitted = false;
// @ts-ignore
this.dialog = false; this.dialog = false;
}, },
dialog(val) { dialog(val) {
@ -139,10 +159,19 @@ export default defineComponent({
this.submitted = true; this.submitted = true;
}, },
open() { open() {
// @ts-ignore
this.dialog = true; this.dialog = true;
this.logDeprecatedProp("open");
}, },
close() { close() {
// @ts-ignore
this.dialog = false; 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 --> <!-- Delete Dialog -->
<BaseDialog <BaseDialog
ref="domDeleteConfirmation" v-model="deleteDialog"
:title="$t('settings.backup.delete-backup')" :title="$t('settings.backup.delete-backup')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
@ -19,7 +19,7 @@
<!-- Import Dialog --> <!-- Import Dialog -->
<BaseDialog <BaseDialog
ref="domImportDialog" v-model="importDialog"
:title="selected.name" :title="selected.name"
:icon="$globals.icons.database" :icon="$globals.icons.database"
:submit-text="$t('general.import')" :submit-text="$t('general.import')"
@ -38,6 +38,7 @@
<BaseButton class="mr-2" @click="createBackup(null)" /> <BaseButton class="mr-2" @click="createBackup(null)" />
<!-- Backup Creation Dialog --> <!-- Backup Creation Dialog -->
<BaseDialog <BaseDialog
v-model="createDialog"
:title="$t('settings.backup.create-heading')" :title="$t('settings.backup.create-heading')"
:icon="$globals.icons.database" :icon="$globals.icons.database"
:submit-text="$t('general.create')" :submit-text="$t('general.create')"
@ -82,7 +83,7 @@
class="mx-1" class="mx-1"
delete delete
@click.stop=" @click.stop="
domDeleteConfirmation.open(); deleteDialog = true;
deleteTarget = item.name; deleteTarget = item.name;
" "
/> />
@ -105,7 +106,7 @@
</template> </template>
<script lang="ts"> <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 AdminBackupImportOptions from "@/components/Domain/Admin/AdminBackupImportOptions.vue";
import { useBackups } from "~/composables/use-backups"; import { useBackups } from "~/composables/use-backups";
@ -118,9 +119,10 @@ export default defineComponent({
const { selected, backups, backupOptions, deleteTarget, refreshBackups, importBackup, createBackup, deleteBackup } = const { selected, backups, backupOptions, deleteTarget, refreshBackups, importBackup, createBackup, deleteBackup } =
useBackups(); useBackups();
const domDeleteConfirmation = ref(null);
const domImportDialog = ref(null);
const state = reactive({ const state = reactive({
deleteDialog: false,
createDialog: false,
importDialog: false,
search: "", search: "",
headers: [ headers: [
{ text: i18n.t("general.name"), value: "name" }, { text: i18n.t("general.name"), value: "name" },
@ -135,8 +137,7 @@ export default defineComponent({
return; return;
} }
selected.value.name = data.name; selected.value.name = data.name;
// @ts-ignore - Calling Child Method state.importDialog = true;
domImportDialog.value.open();
} }
const backupsFileNameDownload = (fileName: string) => `api/backups/${fileName}/download`; const backupsFileNameDownload = (fileName: string) => `api/backups/${fileName}/download`;
@ -150,8 +151,6 @@ export default defineComponent({
deleteBackup, deleteBackup,
setSelected, setSelected,
deleteTarget, deleteTarget,
domDeleteConfirmation,
domImportDialog,
importBackup, importBackup,
refreshBackups, refreshBackups,
backupsFileNameDownload, 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 // TODO: Edit Group
<template> <template>
<v-container fluid> <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> <BaseCardSectionTitle title="Group Management"> </BaseCardSectionTitle>
<section> <section>
<v-toolbar flat color="background" class="justify-between"> <v-toolbar flat color="background" class="justify-between">
<BaseDialog <BaseButton @click="openDialog"> {{ $t("general.create") }} </BaseButton>
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>
</v-toolbar> </v-toolbar>
<v-data-table <v-data-table
:headers="headers" :headers="headers"
:items="groups || []" :items="groups || []"
@ -26,10 +39,8 @@
hide-default-footer hide-default-footer
disable-pagination disable-pagination
:search="search" :search="search"
@click:row="handleRowClick"
> >
<template #item.mealplans="{ item }">
{{ item.mealplans.length }}
</template>
<template #item.shoppingLists="{ item }"> <template #item.shoppingLists="{ item }">
{{ item.shoppingLists.length }} {{ item.shoppingLists.length }}
</template> </template>
@ -37,28 +48,23 @@
{{ item.users.length }} {{ item.users.length }}
</template> </template>
<template #item.webhookEnable="{ item }"> <template #item.webhookEnable="{ item }">
{{ item.webhookEnabled ? $t("general.yes") : $t("general.no") }} {{ item.webhooks.length > 0 ? $t("general.yes") : $t("general.no") }}
</template> </template>
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteGroup(item.id)"> <v-btn
<template #activator="{ open }"> :disabled="item && item.users.length > 0"
<v-btn :disabled="item && item.users.length > 0" class="mr-1" small color="error" @click="open"> class="mr-1"
<v-icon small left> icon
{{ $globals.icons.delete }} color="error"
</v-icon> @click.stop="
{{ $t("general.delete") }} confirmDialog = true;
</v-btn> deleteTarget = item.id;
<v-btn small color="success" @click="updateUser(item)"> "
<v-icon small left class="mr-2"> >
{{ $globals.icons.edit }} <v-icon>
</v-icon> {{ $globals.icons.delete }}
{{ $t("general.edit") }} </v-icon>
</v-btn> </v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</template> </template>
</v-data-table> </v-data-table>
<v-divider></v-divider> <v-divider></v-divider>
@ -67,7 +73,8 @@
</template> </template>
<script lang="ts"> <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 { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups"; import { useGroups } from "~/composables/use-groups";
@ -78,6 +85,9 @@ export default defineComponent({
const { groups, refreshAllGroups, deleteGroup, createGroup } = useGroups(); const { groups, refreshAllGroups, deleteGroup, createGroup } = useGroups();
const state = reactive({ const state = reactive({
createDialog: false,
confirmDialog: false,
deleteTarget: 0,
search: "", search: "",
headers: [ headers: [
{ {
@ -89,9 +99,8 @@ export default defineComponent({
{ text: i18n.t("general.name"), value: "name" }, { text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("user.total-users"), value: "users" }, { text: i18n.t("user.total-users"), value: "users" },
{ text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" }, { 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" }, { text: i18n.t("shopping-list.shopping-lists"), value: "shoppingLists" },
{ value: "actions" }, { text: i18n.t("general.delete"), value: "actions" },
], ],
updateMode: false, updateMode: false,
createUserForm: { 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() { head() {
return { return {

View File

@ -31,7 +31,7 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<div class="d-flex pa-2"> <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> </div>
</v-form> </v-form>
</v-container> </v-container>

View File

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

View File

@ -128,6 +128,7 @@ export default defineComponent({
emailReady: false, emailReady: false,
baseUrlSet: false, baseUrlSet: false,
isSiteSecure: false, isSiteSecure: false,
ldapReady: false,
}); });
const api = useUserApi(); const api = useUserApi();
@ -140,10 +141,10 @@ export default defineComponent({
appConfig.value = data; appConfig.value = data;
} }
appConfig.value.isSiteSecure = isLocalhostorHttps(); appConfig.value.isSiteSecure = isLocalHostOrHttps();
}); });
function isLocalhostorHttps() { function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:"; return window.location.hostname === "localhost" || window.location.protocol === "https:";
} }
@ -152,15 +153,21 @@ export default defineComponent({
{ {
status: appConfig.value.baseUrlSet, status: appConfig.value.baseUrlSet,
text: "Server Side Base URL", 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", successText: "Server Side URL does not match the default",
}, },
{ {
status: appConfig.value.isSiteSecure, status: appConfig.value.isSiteSecure,
text: "Secure Site", 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", 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> <v-container fluid>
<BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle> <BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle>
<v-toolbar flat color="background"> <v-toolbar flat color="background">
<!-- New/Edit Food Dialog -->
<BaseDialog <BaseDialog
ref="domFoodDialog" v-model="newFoodDialog"
:title="dialog.title" :title="dialog.title"
:icon="$globals.icons.units" :icon="$globals.icons.units"
:submit-text="dialog.text" :submit-text="dialog.text"
@ -18,12 +19,25 @@
</v-card-text> </v-card-text>
</BaseDialog> </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 <BaseButton
class="mr-1" class="mr-1"
@click=" @click="
create = true; create = true;
actions.resetWorking(); actions.resetWorking();
domFoodDialog.open(); newFoodDialog = true;
" "
></BaseButton> ></BaseButton>
<BaseButton secondary @click="filter = !filter"> Filter </BaseButton> <BaseButton secondary @click="filter = !filter"> Filter </BaseButton>
@ -45,17 +59,17 @@
@click=" @click="
create = false; create = false;
actions.setWorking(item); actions.setWorking(item);
domFoodDialog.open(); newFoodDialog = true;
"
></BaseButton>
<BaseButton
delete
small
@click="
deleteFoodDialog = true;
deleteTarget = item.id;
" "
></BaseButton> ></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> </div>
</template> </template>
</v-data-table> </v-data-table>
@ -90,6 +104,9 @@ export default defineComponent({
}); });
const state = reactive({ const state = reactive({
deleteFoodDialog: false,
newFoodDialog: false,
deleteTarget: 0,
headers: [ headers: [
{ text: "Id", value: "id" }, { text: "Id", value: "id" },
{ text: "Name", value: "name" }, { text: "Name", value: "name" },

View File

@ -1,30 +1,42 @@
<template> <template>
<v-container fluid> <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> <BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle>
<v-toolbar flat color="background"> <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 <BaseButton
class="mr-1" class="mr-1"
@click=" @click="
create = true; create = true;
actions.resetWorking(); actions.resetWorking();
domUnitDialog.open(); createUnitDialog = true;
" "
></BaseButton> ></BaseButton>
<BaseButton secondary @click="filter = !filter"> Filter </BaseButton> <BaseButton secondary @click="filter = !filter"> Filter </BaseButton>
@ -46,17 +58,17 @@
@click=" @click="
create = false; create = false;
actions.setWorking(item); actions.setWorking(item);
domUnitDialog.open(); createUnitDialog = true;
"
></BaseButton>
<BaseButton
delete
small
@click="
deleteUnitDialog = true;
deleteUnitTarget = item.id;
" "
></BaseButton> ></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> </div>
</template> </template>
</v-data-table> </v-data-table>
@ -91,6 +103,9 @@ export default defineComponent({
}); });
const state = reactive({ const state = reactive({
createUnitDialog: false,
deleteUnitDialog: false,
deleteUnitTarget: 0,
headers: [ headers: [
{ text: "Id", value: "id" }, { text: "Id", value: "id" },
{ text: "Name", value: "name" }, { text: "Name", value: "name" },

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256" 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() settings = get_app_settings()
to_encode = data.copy() to_encode = data.copy()
@ -78,7 +78,7 @@ def user_from_ldap(db: Database, session, username: str, password: str) -> Priva
return user 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() settings = get_app_settings()
db = get_database(session) db = get_database(session)

View File

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

View File

@ -1,16 +1,15 @@
from fastapi import APIRouter
from mealie.routes.routers import AdminAPIRouter from mealie.routes.routers import AdminAPIRouter
from mealie.services._base_http_service.router_factory import RouterFactory 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 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 = AdminAPIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"]) router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"]) 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(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_email.router, tags=["Admin: Email"])
router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"]) router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"])

View File

@ -47,5 +47,6 @@ async def check_app_config():
return CheckAppConfig( return CheckAppConfig(
email_ready=settings.SMTP_ENABLE, email_ready=settings.SMTP_ENABLE,
ldap_ready=settings.LDAP_ENABLED,
base_url_set=url_set, 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.config import get_app_settings
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
logger = get_logger(__name__) logger = get_logger()
def log_wrapper(request: Request, e): def log_wrapper(request: Request, e):
logger.error("Start 422 Error".center(60, "-")) logger.error("Start 422 Error".center(60, "-"))
logger.error(f"{request.method} {request.url}") logger.error(f"{request.method} {request.url}")
logger.error(f"error is {e}") logger.error(f"error is {e}")

View File

@ -27,4 +27,5 @@ class AdminAboutInfo(AppInfo):
class CheckAppConfig(CamelModel): class CheckAppConfig(CamelModel):
email_ready: bool = False email_ready: bool = False
ldap_ready: bool = False
base_url_set: 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.schema.user.user import UserIn, UserOut
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import AdminHttpService 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( class AdminUserService(
CrudHttpMixins[UserOut, UserIn, UserIn], CrudHttpMixins[UserOut, UserIn, UserIn],
AdminHttpService[int, UserOut], AdminHttpService[int, UserOut],
): ):
event_func = create_recipe_event event_func = create_user_event
_schema = UserOut _schema = UserOut
@cached_property @cached_property

View File

@ -1,19 +1,13 @@
from tests.pre_test import settings # isort:skip from tests.pre_test import settings # isort:skip
import json
import requests
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pytest import fixture from pytest import fixture
from mealie.app import app from mealie.app import app
from mealie.db.db_setup import SessionLocal, generate_session from mealie.db.db_setup import SessionLocal, generate_session
from mealie.db.init_db import main 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.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() main()
@ -39,11 +33,6 @@ def api_client():
pass pass
@fixture(scope="session")
def api_routes():
return AppRoutes()
@fixture(scope="session") @fixture(scope="session")
def test_image_jpg(): def test_image_jpg():
return TEST_DATA.joinpath("images", "test_image.jpg") return TEST_DATA.joinpath("images", "test_image.jpg")
@ -52,132 +41,3 @@ def test_image_jpg():
@fixture(scope="session") @fixture(scope="session")
def test_image_png(): def test_image_png():
return TEST_DATA.joinpath("images", "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 fastapi.testclient import TestClient
from tests.app_routes import AppRoutes from tests.utils.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser

View File

@ -1,8 +1,7 @@
import json
from fastapi.testclient import TestClient 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 from tests.utils.fixture_schemas import TestUser
@ -12,35 +11,69 @@ class Routes:
def item(id: str) -> str: def item(id: str) -> str:
return f"{Routes.base}/{id}" return f"{Routes.base}/{id}"
def user(id: str) -> str:
def test_create_group(api_client: TestClient, admin_token): return f"api/admin/users/{id}"
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_token)
assert response.status_code == 201
def test_user_cant_create_group(api_client: TestClient, unique_user: TestUser): def test_home_group_not_deletable(api_client: TestClient, admin_user: TestUser):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=unique_user.token) response = api_client.delete(Routes.item(1), headers=admin_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)
assert response.status_code == 400 assert response.status_code == 400
def test_delete_group(api_client: TestClient, admin_token): def test_admin_group_routes_are_restricted(api_client: TestClient, unique_user: TestUser):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_token) 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 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 assert response.status_code == 200
# Ensure Group is Deleted as_json = response.json()
response = api_client.get(Routes.base, headers=admin_token)
for g in response.json(): assert as_json["name"] == update_payload["name"]
assert g["id"] != group_id 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 import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes from tests.utils.app_routes import AppRoutes
@pytest.fixture @pytest.fixture

View File

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

View File

@ -4,7 +4,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from slugify import slugify 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.fixture_schemas import TestUser
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases 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 fastapi.testclient import TestClient
from pytest import fixture from pytest import fixture
from tests.app_routes import AppRoutes from tests.utils.app_routes import AppRoutes
@fixture @fixture

View File

@ -5,7 +5,7 @@ from fastapi.testclient import TestClient
from mealie.core.config import get_app_dirs from mealie.core.config import get_app_dirs
app_dirs = 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( def test_update_user_image(

View File

@ -2,7 +2,7 @@ import json
from fastapi.testclient import TestClient 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): 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, 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 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 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 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: if ignore_keys is None:
ignore_keys = ["id", "group_id"] ignore_keys = ["id", "group_id", "groupId"]
for key, value in dict1.items(): for key, value in dict1.items():
if key in ignore_keys: 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}"}