mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
feat: Additional Household Permissions (#4158)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
parent
b1820f9b23
commit
fd0257c1b8
@ -0,0 +1,75 @@
|
|||||||
|
"""added household recipe lock setting and household management user permission
|
||||||
|
|
||||||
|
Revision ID: be568e39ffdf
|
||||||
|
Revises: feecc8ffb956
|
||||||
|
Create Date: 2024-09-02 21:39:49.210355
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "be568e39ffdf"
|
||||||
|
down_revision = "feecc8ffb956"
|
||||||
|
branch_labels: str | tuple[str, ...] | None = None
|
||||||
|
depends_on: str | tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def populate_defaults():
|
||||||
|
if op.get_context().dialect.name == "postgresql":
|
||||||
|
TRUE = "TRUE"
|
||||||
|
FALSE = "FALSE"
|
||||||
|
else:
|
||||||
|
TRUE = "1"
|
||||||
|
FALSE = "0"
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
UPDATE household_preferences
|
||||||
|
SET lock_recipe_edits_from_other_households = {TRUE}
|
||||||
|
WHERE lock_recipe_edits_from_other_households IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
UPDATE users
|
||||||
|
SET can_manage_household = {FALSE}
|
||||||
|
WHERE can_manage_household IS NULL AND admin = {FALSE}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
UPDATE users
|
||||||
|
SET can_manage_household = {TRUE}
|
||||||
|
WHERE can_manage_household IS NULL AND admin = {TRUE}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column(
|
||||||
|
"household_preferences",
|
||||||
|
sa.Column("lock_recipe_edits_from_other_households", sa.Boolean(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("users", sa.Column("can_manage_household", sa.Boolean(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
populate_defaults()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("users", "can_manage_household")
|
||||||
|
op.drop_column("household_preferences", "lock_recipe_edits_from_other_households")
|
||||||
|
# ### end Alembic commands ###
|
@ -1,7 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="preferences">
|
<div v-if="preferences">
|
||||||
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle>
|
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
|
||||||
<v-checkbox v-model="preferences.privateHousehold" class="mt-n4" :label="$t('household.private-household')"></v-checkbox>
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="preferences.privateHousehold"
|
||||||
|
hide-details
|
||||||
|
dense
|
||||||
|
:label="$t('household.private-household')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("household.private-household-description") }}
|
||||||
|
</p>
|
||||||
|
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
|
||||||
|
hide-details
|
||||||
|
dense
|
||||||
|
:label="$t('household.lock-recipe-edits-from-other-households')"
|
||||||
|
/>
|
||||||
|
<div class="ml-8">
|
||||||
|
<p class="text-subtitle-2 my-0 py-0">
|
||||||
|
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="preferences.firstDayOfWeek"
|
v-model="preferences.firstDayOfWeek"
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||||
@ -12,20 +38,25 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle>
|
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle>
|
||||||
<template v-for="(_, key) in preferences">
|
<div class="preference-container">
|
||||||
<v-checkbox
|
<div v-for="p in recipePreferences" :key="p.key">
|
||||||
v-if="labels[key]"
|
<v-checkbox
|
||||||
:key="key"
|
v-model="preferences[p.key]"
|
||||||
v-model="preferences[key]"
|
hide-details
|
||||||
class="mt-n4"
|
dense
|
||||||
:label="labels[key]"
|
:label="p.label"
|
||||||
></v-checkbox>
|
/>
|
||||||
</template>
|
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||||
|
{{ p.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
@ -37,14 +68,44 @@ export default defineComponent({
|
|||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const { i18n } = useContext();
|
||||||
|
|
||||||
const labels = {
|
type Preference = {
|
||||||
recipePublic: i18n.tc("household.allow-users-outside-of-your-household-to-see-your-recipes"),
|
key: keyof ReadHouseholdPreferences;
|
||||||
recipeShowNutrition: i18n.tc("group.show-nutrition-information"),
|
label: string;
|
||||||
recipeShowAssets: i18n.tc("group.show-recipe-assets"),
|
description: string;
|
||||||
recipeLandscapeView: i18n.tc("group.default-to-landscape-view"),
|
}
|
||||||
recipeDisableComments: i18n.tc("group.disable-users-from-commenting-on-recipes"),
|
|
||||||
recipeDisableAmount: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
const recipePreferences: Preference[] = [
|
||||||
};
|
{
|
||||||
|
key: "recipePublic",
|
||||||
|
label: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||||
|
description: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeShowNutrition",
|
||||||
|
label: i18n.tc("group.show-nutrition-information"),
|
||||||
|
description: i18n.tc("group.show-nutrition-information-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeShowAssets",
|
||||||
|
label: i18n.tc("group.show-recipe-assets"),
|
||||||
|
description: i18n.tc("group.show-recipe-assets-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeLandscapeView",
|
||||||
|
label: i18n.tc("group.default-to-landscape-view"),
|
||||||
|
description: i18n.tc("group.default-to-landscape-view-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeDisableComments",
|
||||||
|
label: i18n.tc("group.disable-users-from-commenting-on-recipes"),
|
||||||
|
description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeDisableAmount",
|
||||||
|
label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
||||||
|
description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const allDays = [
|
const allDays = [
|
||||||
{
|
{
|
||||||
@ -88,12 +149,18 @@ export default defineComponent({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
allDays,
|
allDays,
|
||||||
labels,
|
|
||||||
preferences,
|
preferences,
|
||||||
|
recipePreferences,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="css">
|
||||||
|
.preference-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:use-items="{
|
:use-items="{
|
||||||
delete: false,
|
delete: false,
|
||||||
edit: true,
|
edit: false,
|
||||||
download: true,
|
download: true,
|
||||||
mealplanner: true,
|
mealplanner: true,
|
||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:use-items="{
|
:use-items="{
|
||||||
delete: false,
|
delete: false,
|
||||||
edit: true,
|
edit: false,
|
||||||
download: true,
|
download: true,
|
||||||
mealplanner: true,
|
mealplanner: true,
|
||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
|
@ -69,7 +69,8 @@ import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
|||||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||||
import { useStaticRoutes } from "~/composables/api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
|
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||||
@ -100,7 +101,15 @@ export default defineComponent({
|
|||||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const { canEditRecipe } = useRecipePermissions(props.recipe, user);
|
|
||||||
|
const recipeHousehold = ref<HouseholdSummary>();
|
||||||
|
if (user) {
|
||||||
|
const userApi = useUserApi();
|
||||||
|
userApi.groups.fetchHousehold(props.recipe.householdId).then(({ data }) => {
|
||||||
|
recipeHousehold.value = data || undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
|
||||||
|
|
||||||
function printRecipe() {
|
function printRecipe() {
|
||||||
window.print();
|
window.print();
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { ref, Ref } from "@nuxtjs/composition-api";
|
||||||
import { useRecipePermissions } from "./use-recipe-permissions";
|
import { useRecipePermissions } from "./use-recipe-permissions";
|
||||||
|
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { UserOut } from "~/lib/api/types/user";
|
import { UserOut } from "~/lib/api/types/user";
|
||||||
|
|
||||||
@ -32,35 +34,76 @@ describe("test use recipe permissions", () => {
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createRecipeHousehold = (overrides: Partial<HouseholdSummary>, lockRecipeEdits = false): Ref<HouseholdSummary> => (
|
||||||
|
ref({
|
||||||
|
id: commonHouseholdId,
|
||||||
|
groupId: commonGroupId,
|
||||||
|
name: "My Household",
|
||||||
|
slug: "my-household",
|
||||||
|
preferences: {
|
||||||
|
id: "my-household-preferences-id",
|
||||||
|
lockRecipeEditsFromOtherHouseholds: lockRecipeEdits,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
test("when user is null, cannot edit", () => {
|
test("when user is null, cannot edit", () => {
|
||||||
const result = useRecipePermissions(createRecipe({}), null);
|
const result = useRecipePermissions(createRecipe({}), createRecipeHousehold({}), null);
|
||||||
expect(result.canEditRecipe.value).toBe(false);
|
expect(result.canEditRecipe.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when user is recipe owner, can edit", () => {
|
test("when user is recipe owner, can edit", () => {
|
||||||
const result = useRecipePermissions(createRecipe({}), createUser({}));
|
const result = useRecipePermissions(createRecipe({}), ref(), createUser({}));
|
||||||
expect(result.canEditRecipe.value).toBe(true);
|
expect(result.canEditRecipe.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when user is not recipe owner, is correct group and household, and recipe is unlocked, can edit", () => {
|
test(
|
||||||
const result = useRecipePermissions(
|
"when user is not recipe owner, is correct group and household, recipe is unlocked, and household is unlocked, can edit",
|
||||||
createRecipe({}),
|
() => {
|
||||||
createUser({ id: "other-user-id" }),
|
const result = useRecipePermissions(
|
||||||
);
|
createRecipe({}),
|
||||||
expect(result.canEditRecipe.value).toBe(true);
|
createRecipeHousehold({}),
|
||||||
});
|
createUser({ id: "other-user-id" }),
|
||||||
|
);
|
||||||
|
expect(result.canEditRecipe.value).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"when user is not recipe owner, is correct group and household, recipe is unlocked, but household is locked, can edit",
|
||||||
|
() => {
|
||||||
|
const result = useRecipePermissions(
|
||||||
|
createRecipe({}),
|
||||||
|
createRecipeHousehold({}, true),
|
||||||
|
createUser({ id: "other-user-id" }),
|
||||||
|
);
|
||||||
|
expect(result.canEditRecipe.value).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
test("when user is not recipe owner, and user is other group, cannot edit", () => {
|
test("when user is not recipe owner, and user is other group, cannot edit", () => {
|
||||||
const result = useRecipePermissions(
|
const result = useRecipePermissions(
|
||||||
createRecipe({}),
|
createRecipe({}),
|
||||||
|
createRecipeHousehold({}),
|
||||||
createUser({ id: "other-user-id", groupId: "other-group-id"}),
|
createUser({ id: "other-user-id", groupId: "other-group-id"}),
|
||||||
);
|
);
|
||||||
expect(result.canEditRecipe.value).toBe(false);
|
expect(result.canEditRecipe.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when user is not recipe owner, and user is other household, cannot edit", () => {
|
test("when user is not recipe owner, and user is other household, and household is unlocked, can edit", () => {
|
||||||
const result = useRecipePermissions(
|
const result = useRecipePermissions(
|
||||||
createRecipe({}),
|
createRecipe({}),
|
||||||
|
createRecipeHousehold({}),
|
||||||
|
createUser({ id: "other-user-id", householdId: "other-household-id" }),
|
||||||
|
);
|
||||||
|
expect(result.canEditRecipe.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when user is not recipe owner, and user is other household, and household is locked, cannot edit", () => {
|
||||||
|
const result = useRecipePermissions(
|
||||||
|
createRecipe({}),
|
||||||
|
createRecipeHousehold({}, true),
|
||||||
createUser({ id: "other-user-id", householdId: "other-household-id" }),
|
createUser({ id: "other-user-id", householdId: "other-household-id" }),
|
||||||
);
|
);
|
||||||
expect(result.canEditRecipe.value).toBe(false);
|
expect(result.canEditRecipe.value).toBe(false);
|
||||||
@ -69,13 +112,14 @@ describe("test use recipe permissions", () => {
|
|||||||
test("when user is not recipe owner, and recipe is locked, cannot edit", () => {
|
test("when user is not recipe owner, and recipe is locked, cannot edit", () => {
|
||||||
const result = useRecipePermissions(
|
const result = useRecipePermissions(
|
||||||
createRecipe({}, true),
|
createRecipe({}, true),
|
||||||
|
createRecipeHousehold({}),
|
||||||
createUser({ id: "other-user-id"}),
|
createUser({ id: "other-user-id"}),
|
||||||
);
|
);
|
||||||
expect(result.canEditRecipe.value).toBe(false);
|
expect(result.canEditRecipe.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("when user is recipe owner, and recipe is locked, can edit", () => {
|
test("when user is recipe owner, and recipe is locked, and household is locked, can edit", () => {
|
||||||
const result = useRecipePermissions(createRecipe({}, true), createUser({}));
|
const result = useRecipePermissions(createRecipe({}, true), createRecipeHousehold({}, true), createUser({}));
|
||||||
expect(result.canEditRecipe.value).toBe(true);
|
expect(result.canEditRecipe.value).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,34 +1,44 @@
|
|||||||
import { computed } from "@nuxtjs/composition-api";
|
import { computed, Ref } from "@nuxtjs/composition-api";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { UserOut } from "~/lib/api/types/user";
|
import { UserOut } from "~/lib/api/types/user";
|
||||||
|
|
||||||
export function useRecipePermissions(recipe: Recipe, user: UserOut | null) {
|
export function useRecipePermissions(
|
||||||
const canEditRecipe = computed(() => {
|
recipe: Recipe,
|
||||||
// Check recipe owner
|
recipeHousehold: Ref<HouseholdSummary | undefined>,
|
||||||
if (!user?.id) {
|
user: UserOut | null,
|
||||||
return false;
|
) {
|
||||||
}
|
const canEditRecipe = computed(() => {
|
||||||
if (user.id === recipe.userId) {
|
// Check recipe owner
|
||||||
return true;
|
if (!user?.id) {
|
||||||
}
|
return false;
|
||||||
|
|
||||||
// Check group and household
|
|
||||||
if (user.groupId !== recipe.groupId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (user.householdId !== recipe.householdId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check recipe
|
|
||||||
if (recipe.settings?.locked) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
canEditRecipe,
|
|
||||||
}
|
}
|
||||||
|
if (user.id === recipe.userId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check group and household
|
||||||
|
if (user.groupId !== recipe.groupId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.householdId !== recipe.householdId) {
|
||||||
|
if (!recipeHousehold.value?.preferences) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (recipeHousehold.value?.preferences.lockRecipeEditsFromOtherHouseholds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check recipe
|
||||||
|
if (recipe.settings?.locked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
canEditRecipe,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,8 @@ export const useHouseholdSelf = function () {
|
|||||||
if (data) {
|
if (data) {
|
||||||
householdSelfRef.value.preferences = data;
|
householdSelfRef.value.preferences = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return data || undefined;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -65,6 +65,12 @@ export const useUserForm = () => {
|
|||||||
type: fieldTypes.BOOLEAN,
|
type: fieldTypes.BOOLEAN,
|
||||||
rules: ["required"],
|
rules: ["required"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: i18n.tc("user.user-can-manage-household"),
|
||||||
|
varName: "canManageHousehold",
|
||||||
|
type: fieldTypes.BOOLEAN,
|
||||||
|
rules: ["required"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: i18n.tc("user.enable-advanced-features"),
|
label: i18n.tc("user.enable-advanced-features"),
|
||||||
varName: "advanced",
|
varName: "advanced",
|
||||||
|
@ -241,13 +241,14 @@
|
|||||||
"manage-members": "Manage Members",
|
"manage-members": "Manage Members",
|
||||||
"manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
|
"manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
|
"manage-household": "Manage Household",
|
||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"looking-to-update-your-profile": "Looking to Update Your Profile?",
|
"looking-to-update-your-profile": "Looking to Update Your Profile?",
|
||||||
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your group. These can be changed for individual recipes in the recipe settings menu.",
|
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your group. These can be changed for individual recipes in the recipe settings menu.",
|
||||||
"default-recipe-preferences": "Default Recipe Preferences",
|
"default-recipe-preferences": "Default Recipe Preferences",
|
||||||
"group-preferences": "Group Preferences",
|
"group-preferences": "Group Preferences",
|
||||||
"private-group": "Private Group",
|
"private-group": "Private Group",
|
||||||
"private-group-description": "Setting your group to private will default all public view options to default. This overrides any individual households or recipes public view settings.",
|
"private-group-description": "Setting your group to private will disable all public view options. This overrides any individual public view settings",
|
||||||
"enable-public-access": "Enable Public Access",
|
"enable-public-access": "Enable Public Access",
|
||||||
"enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in",
|
"enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in",
|
||||||
"allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
|
"allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
|
||||||
@ -285,7 +286,9 @@
|
|||||||
"admin-household-management-text": "Changes to this household will be reflected immediately.",
|
"admin-household-management-text": "Changes to this household will be reflected immediately.",
|
||||||
"household-id-value": "Household Id: {0}",
|
"household-id-value": "Household Id: {0}",
|
||||||
"private-household": "Private Household",
|
"private-household": "Private Household",
|
||||||
"private-household-description": "Setting your household to private will default all public view options to default. This overrides any individual recipes public view settings.",
|
"private-household-description": "Setting your household to private will disable all public view options. This overrides any individual public view settings",
|
||||||
|
"lock-recipe-edits-from-other-households": "Lock recipe edits from other households",
|
||||||
|
"lock-recipe-edits-from-other-households-description": "When enabled only users in your household can edit recipes created by your household",
|
||||||
"household-recipe-preferences": "Household Recipe Preferences",
|
"household-recipe-preferences": "Household Recipe Preferences",
|
||||||
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your household. These can be changed for individual recipes in the recipe settings menu.",
|
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your household. These can be changed for individual recipes in the recipe settings menu.",
|
||||||
"allow-users-outside-of-your-household-to-see-your-recipes": "Allow users outside of your household to see your recipes",
|
"allow-users-outside-of-your-household-to-see-your-recipes": "Allow users outside of your household to see your recipes",
|
||||||
@ -987,6 +990,7 @@
|
|||||||
"administrator": "Administrator",
|
"administrator": "Administrator",
|
||||||
"user-can-invite-other-to-group": "User can invite others to group",
|
"user-can-invite-other-to-group": "User can invite others to group",
|
||||||
"user-can-manage-group": "User can manage group",
|
"user-can-manage-group": "User can manage group",
|
||||||
|
"user-can-manage-household": "User can manage household",
|
||||||
"user-can-organize-group-data": "User can organize group data",
|
"user-can-organize-group-data": "User can organize group data",
|
||||||
"enable-advanced-features": "Enable advanced features",
|
"enable-advanced-features": "Enable advanced features",
|
||||||
"it-looks-like-this-is-your-first-time-logging-in": "It looks like this is your first time logging in.",
|
"it-looks-like-this-is-your-first-time-logging-in": "It looks like this is your first time logging in.",
|
||||||
|
@ -16,6 +16,7 @@ export interface AdminAboutInfo {
|
|||||||
oidcRedirect: boolean;
|
oidcRedirect: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
enableOpenai: boolean;
|
enableOpenai: boolean;
|
||||||
|
enableOpenaiImageServices: boolean;
|
||||||
versionLatest: string;
|
versionLatest: string;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
apiDocs: boolean;
|
apiDocs: boolean;
|
||||||
|
@ -15,6 +15,7 @@ export interface CreateGroupRecipeAction {
|
|||||||
}
|
}
|
||||||
export interface CreateHouseholdPreferences {
|
export interface CreateHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
recipeShowNutrition?: boolean;
|
recipeShowNutrition?: boolean;
|
||||||
@ -185,6 +186,7 @@ export interface HouseholdInDB {
|
|||||||
}
|
}
|
||||||
export interface ReadHouseholdPreferences {
|
export interface ReadHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
recipeShowNutrition?: boolean;
|
recipeShowNutrition?: boolean;
|
||||||
@ -241,6 +243,7 @@ export interface SaveGroupRecipeAction {
|
|||||||
}
|
}
|
||||||
export interface SaveHouseholdPreferences {
|
export interface SaveHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
recipeShowNutrition?: boolean;
|
recipeShowNutrition?: boolean;
|
||||||
@ -267,6 +270,7 @@ export interface SaveWebhook {
|
|||||||
}
|
}
|
||||||
export interface SetPermissions {
|
export interface SetPermissions {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
canManageHousehold?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
@ -649,6 +653,7 @@ export interface UpdateHouseholdAdmin {
|
|||||||
}
|
}
|
||||||
export interface UpdateHouseholdPreferences {
|
export interface UpdateHouseholdPreferences {
|
||||||
privateHousehold?: boolean;
|
privateHousehold?: boolean;
|
||||||
|
lockRecipeEditsFromOtherHouseholds?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
recipePublic?: boolean;
|
recipePublic?: boolean;
|
||||||
recipeShowNutrition?: boolean;
|
recipeShowNutrition?: boolean;
|
||||||
|
@ -62,4 +62,159 @@ export interface OpenAIIngredient {
|
|||||||
export interface OpenAIIngredients {
|
export interface OpenAIIngredients {
|
||||||
ingredients?: OpenAIIngredient[];
|
ingredients?: OpenAIIngredient[];
|
||||||
}
|
}
|
||||||
|
export interface OpenAIRecipe {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The name or title of the recipe. If you're unable to determine the name of the recipe, you should
|
||||||
|
* make your best guess based upon the ingredients and instructions provided.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* A long description of the recipe. This should be a string that describes the recipe in a few words
|
||||||
|
* or sentences. If the recipe doesn't have a description, you should return None.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
description: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The yield of the recipe. For instance, if the recipe makes 12 cookies, the yield is "12 cookies".
|
||||||
|
* If the recipe makes 2 servings, the yield is "2 servings". Typically yield consists of a number followed
|
||||||
|
* by the word "serving" or "servings", but it can be any string that describes the yield. If the yield
|
||||||
|
* isn't specified, you should return None.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
recipe_yield?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The total time it takes to make the recipe. This should be a string that describes a duration of time,
|
||||||
|
* such as "1 hour and 30 minutes", "90 minutes", or "1.5 hours". If the recipe has multiple times, choose
|
||||||
|
* the longest time. If the recipe doesn't specify a total time or duration, or it specifies a prep time or
|
||||||
|
* perform time but not a total time, you should return None. Do not duplicate times between total time, prep
|
||||||
|
* time and perform time.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
total_time?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The time it takes to prepare the recipe. This should be a string that describes a duration of time,
|
||||||
|
* such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the prep time should be
|
||||||
|
* less than the total time. If the recipe doesn't specify a prep time, you should return None. If the recipe
|
||||||
|
* supplies only one time, it should be the total time. Do not duplicate times between total time, prep
|
||||||
|
* time and coperformok time.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
prep_time?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The time it takes to cook the recipe. This should be a string that describes a duration of time,
|
||||||
|
* such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the perform time should be
|
||||||
|
* less than the total time. If the recipe doesn't specify a perform time, you should return None. If the
|
||||||
|
* recipe specifies a cook time, active time, or other time besides total or prep, you should use that
|
||||||
|
* time as the perform time. If the recipe supplies only one time, it should be the total time, and not the
|
||||||
|
* perform time. Do not duplicate times between total time, prep time and perform time.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
perform_time?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||||
|
* recipe. If the recipe has no ingredients, you should return an empty list.
|
||||||
|
*
|
||||||
|
* Often times, but not always, ingredients are separated by line breaks. Use these as a guide to
|
||||||
|
* separate ingredients.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
ingredients?: OpenAIRecipeIngredient[];
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||||
|
* recipe. If the recipe has no ingredients, you should return an empty list.
|
||||||
|
*
|
||||||
|
* Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
|
||||||
|
* Use these as a guide to separate instructions. They also may be separated by numbers or words, such as
|
||||||
|
* "1.", "2.", "Step 1", "Step 2", "First", "Second", etc.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
instructions?: OpenAIRecipeInstruction[];
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* A list of notes found in the recipe. Notes should be inserted in the order they appear in the recipe.
|
||||||
|
* They may appear anywhere on the recipe, though they are typically found under the instructions.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
notes?: OpenAIRecipeNotes[];
|
||||||
|
}
|
||||||
|
export interface OpenAIRecipeIngredient {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The title of the section of the recipe that the ingredient is found in. Recipes may not specify
|
||||||
|
* ingredient sections, in which case this should be left blank.
|
||||||
|
* Only the first item in the section should have this set,
|
||||||
|
* whereas subsuquent items should have their titles left blank (unless they start a new section).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
title?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The text of the ingredient. This should represent the entire ingredient, such as "1 cup of flour" or
|
||||||
|
* "2 cups of onions, chopped". If the ingredient is completely blank, skip it and do not add the ingredient,
|
||||||
|
* since this field is required.
|
||||||
|
*
|
||||||
|
* If the ingredient has no text, but has a title, include the title on the
|
||||||
|
* next ingredient instead.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
export interface OpenAIRecipeInstruction {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The title of the section of the recipe that the instruction is found in. Recipes may not specify
|
||||||
|
* instruction sections, in which case this should be left blank.
|
||||||
|
* Only the first instruction in the section should have this set,
|
||||||
|
* whereas subsuquent instructions should have their titles left blank (unless they start a new section).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
title?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The text of the instruction. This represents one step in the recipe, such as "Preheat the oven to 350",
|
||||||
|
* or "Sauté the onions for 20 minutes". Sometimes steps can be longer, such as "Bring a large pot of lightly
|
||||||
|
* salted water to a boil. Add ditalini pasta and cook for 8 minutes or until al dente; drain.".
|
||||||
|
*
|
||||||
|
* Sometimes, but not always, recipes will include their number in front of the text, such as
|
||||||
|
* "1.", "2.", or "Step 1", "Step 2", or "First", "Second". In the case where they are directly numbered
|
||||||
|
* ("1.", "2.", "Step one", "Step 1", "Step two", "Step 2", etc.), you should not include the number in
|
||||||
|
* the text. However, if they use words ("First", "Second", etc.), then those should be included.
|
||||||
|
*
|
||||||
|
* If the instruction is completely blank, skip it and do not add the instruction, since this field is
|
||||||
|
* required. If the ingredient has no text, but has a title, include the title on the next
|
||||||
|
* instruction instead.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
export interface OpenAIRecipeNotes {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The title of the note. Notes may not specify a title, and just have a body of text. In this case,
|
||||||
|
* title should be left blank, and all content should go in the note text. If the note title is just
|
||||||
|
* "note" or "info", you should ignore it and leave the title blank.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
title?: string | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The text of the note. This should represent the entire note, such as "This recipe is great for
|
||||||
|
* a summer picnic" or "This recipe is a family favorite". They may also include additional prep
|
||||||
|
* instructions such as "to make this recipe gluten free, use gluten free flour", or "you may prepare
|
||||||
|
* the dough the night before and refrigerate it until ready to bake".
|
||||||
|
*
|
||||||
|
* If the note is completely blank, skip it and do not add the note, since this field is required.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
export interface OpenAIBase {}
|
export interface OpenAIBase {}
|
||||||
|
@ -114,6 +114,7 @@ export interface PrivateUser {
|
|||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
|
canManageHousehold?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
groupSlug: string;
|
groupSlug: string;
|
||||||
@ -189,6 +190,7 @@ export interface UserBase {
|
|||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
|
canManageHousehold?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
}
|
}
|
||||||
export interface UserIn {
|
export interface UserIn {
|
||||||
@ -203,6 +205,7 @@ export interface UserIn {
|
|||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
|
canManageHousehold?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
@ -218,6 +221,7 @@ export interface UserOut {
|
|||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
|
canManageHousehold?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
groupSlug: string;
|
groupSlug: string;
|
||||||
|
@ -15,7 +15,8 @@ const routes = {
|
|||||||
groupsSelf: `${prefix}/groups/self`,
|
groupsSelf: `${prefix}/groups/self`,
|
||||||
preferences: `${prefix}/groups/preferences`,
|
preferences: `${prefix}/groups/preferences`,
|
||||||
storage: `${prefix}/groups/storage`,
|
storage: `${prefix}/groups/storage`,
|
||||||
households: `${prefix}/households`,
|
households: `${prefix}/groups/households`,
|
||||||
|
householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
|
||||||
membersHouseholdId: (householdId: string | number | null) => {
|
membersHouseholdId: (householdId: string | number | null) => {
|
||||||
return householdId ?
|
return householdId ?
|
||||||
`${prefix}/households/members?householdId=${householdId}` :
|
`${prefix}/households/members?householdId=${householdId}` :
|
||||||
@ -50,6 +51,10 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate
|
|||||||
return await this.requests.get<HouseholdSummary[]>(routes.households);
|
return await this.requests.get<HouseholdSummary[]>(routes.households);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchHousehold(householdId: string | number) {
|
||||||
|
return await this.requests.get<HouseholdSummary>(routes.householdsId(householdId));
|
||||||
|
}
|
||||||
|
|
||||||
async storage() {
|
async storage() {
|
||||||
return await this.requests.get<GroupStorage>(routes.storage);
|
return await this.requests.get<GroupStorage>(routes.storage);
|
||||||
}
|
}
|
||||||
|
11
frontend/middleware/can-manage-household-only.ts
Normal file
11
frontend/middleware/can-manage-household-only.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
interface CanManageRedirectParams {
|
||||||
|
$auth: any
|
||||||
|
redirect: (path: string) => void
|
||||||
|
}
|
||||||
|
export default function ({ $auth, redirect }: CanManageRedirectParams) {
|
||||||
|
// If the user is not allowed to manage group settings redirect to the home page
|
||||||
|
if (!$auth.user?.canManageHousehold) {
|
||||||
|
console.warn("User is not allowed to manage household settings");
|
||||||
|
return redirect("/");
|
||||||
|
}
|
||||||
|
}
|
@ -53,7 +53,7 @@ import { VForm } from "~/types/vuetify";
|
|||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
HouseholdPreferencesEditor,
|
HouseholdPreferencesEditor,
|
||||||
},
|
},
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
setup() {
|
setup() {
|
||||||
@ -94,11 +94,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
const { response, data } = await userApi.households.updateOne(household.value.id, household.value);
|
const { response, data } = await userApi.households.updateOne(household.value.id, household.value);
|
||||||
if (response?.status === 200 && data) {
|
if (response?.status === 200 && data) {
|
||||||
if (household.value.slug !== data.slug) {
|
|
||||||
// the slug updated, which invalidates the nav URLs
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
household.value = data;
|
household.value = data;
|
||||||
|
alert.success(i18n.tc("settings.settings-updated"));
|
||||||
} else {
|
} else {
|
||||||
alert.error(i18n.tc("settings.settings-update-failed"));
|
alert.error(i18n.tc("settings.settings-update-failed"));
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="narrow-container">
|
<v-container v-if="household" class="narrow-container">
|
||||||
<BasePageTitle class="mb-5">
|
<BasePageTitle class="mb-5">
|
||||||
<template #header>
|
<template #header>
|
||||||
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
|
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
|
||||||
@ -7,70 +7,38 @@
|
|||||||
<template #title> {{ $t("profile.household-settings") }} </template>
|
<template #title> {{ $t("profile.household-settings") }} </template>
|
||||||
{{ $t("profile.household-description") }}
|
{{ $t("profile.household-description") }}
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
|
<v-form ref="refHouseholdEditForm" @submit.prevent="handleSubmit">
|
||||||
<section v-if="household">
|
<v-card outlined>
|
||||||
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
|
<v-card-text>
|
||||||
<div class="mb-6">
|
<HouseholdPreferencesEditor v-if="household.preferences" v-model="household.preferences" />
|
||||||
<v-checkbox
|
</v-card-text>
|
||||||
v-model="household.preferences.privateHousehold"
|
</v-card>
|
||||||
hide-details
|
<div class="d-flex pa-2">
|
||||||
dense
|
<BaseButton type="submit" edit class="ml-auto"> {{ $t("general.update") }}</BaseButton>
|
||||||
:label="$t('household.private-household')"
|
|
||||||
@change="householdActions.updatePreferences()"
|
|
||||||
/>
|
|
||||||
<div class="ml-8">
|
|
||||||
<p class="text-subtitle-2 my-0 py-0">
|
|
||||||
{{ $t("household.private-household-description") }}
|
|
||||||
</p>
|
|
||||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<v-select
|
</v-form>
|
||||||
v-model="household.preferences.firstDayOfWeek"
|
|
||||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
|
||||||
:items="allDays"
|
|
||||||
item-text="name"
|
|
||||||
item-value="value"
|
|
||||||
:label="$t('settings.first-day-of-week')"
|
|
||||||
@change="householdActions.updatePreferences()"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="household">
|
|
||||||
<BaseCardSectionTitle class="mt-10" :title="$tc('group.default-recipe-preferences')">
|
|
||||||
{{ $t("household.default-recipe-preferences-description") }}
|
|
||||||
</BaseCardSectionTitle>
|
|
||||||
|
|
||||||
<div class="preference-container">
|
|
||||||
<div v-for="p in preferencesEditor" :key="p.key">
|
|
||||||
<v-checkbox
|
|
||||||
v-model="household.preferences[p.key]"
|
|
||||||
hide-details
|
|
||||||
dense
|
|
||||||
:label="p.label"
|
|
||||||
@change="householdActions.updatePreferences()"
|
|
||||||
/>
|
|
||||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
|
||||||
{{ p.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
|
||||||
|
import { VForm } from "~/types/vuetify";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
middleware: ["auth", "can-manage-only"],
|
components: {
|
||||||
|
HouseholdPreferencesEditor,
|
||||||
|
},
|
||||||
|
middleware: ["auth", "can-manage-household-only"],
|
||||||
setup() {
|
setup() {
|
||||||
const { household, actions: householdActions } = useHouseholdSelf();
|
const { household, actions: householdActions } = useHouseholdSelf();
|
||||||
|
|
||||||
const { i18n } = useContext();
|
const { i18n } = useContext();
|
||||||
|
|
||||||
|
const refHouseholdEditForm = ref<VForm | null>(null);
|
||||||
|
|
||||||
type Preference = {
|
type Preference = {
|
||||||
key: keyof ReadHouseholdPreferences;
|
key: keyof ReadHouseholdPreferences;
|
||||||
value: boolean;
|
value: boolean;
|
||||||
@ -153,11 +121,27 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!refHouseholdEditForm.value?.validate() || !household.value?.preferences) {
|
||||||
|
console.log(refHouseholdEditForm.value?.validate());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await householdActions.updatePreferences();
|
||||||
|
if (data) {
|
||||||
|
alert.success(i18n.tc("settings.settings-updated"));
|
||||||
|
} else {
|
||||||
|
alert.error(i18n.tc("settings.settings-update-failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
household,
|
household,
|
||||||
householdActions,
|
householdActions,
|
||||||
allDays,
|
allDays,
|
||||||
preferencesEditor,
|
preferencesEditor,
|
||||||
|
refHouseholdEditForm,
|
||||||
|
handleSubmit,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head() {
|
head() {
|
||||||
|
@ -31,6 +31,17 @@
|
|||||||
<template #item.admin="{ item }">
|
<template #item.admin="{ item }">
|
||||||
{{ item.admin ? $t('user.admin') : $t('user.user') }}
|
{{ item.admin ? $t('user.admin') : $t('user.user') }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #item.manageHousehold="{ item }">
|
||||||
|
<div class="d-flex justify-center">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="item.canManageHousehold"
|
||||||
|
:disabled="item.id === $auth.user.id || item.admin"
|
||||||
|
class=""
|
||||||
|
style="max-width: 30px"
|
||||||
|
@change="setPermissions(item)"
|
||||||
|
></v-checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #item.manage="{ item }">
|
<template #item.manage="{ item }">
|
||||||
<div class="d-flex justify-center">
|
<div class="d-flex justify-center">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
@ -94,6 +105,7 @@ export default defineComponent({
|
|||||||
{ text: i18n.t("group.manage"), value: "manage", sortable: false, align: "center" },
|
{ text: i18n.t("group.manage"), value: "manage", sortable: false, align: "center" },
|
||||||
{ text: i18n.t("settings.organize"), value: "organize", sortable: false, align: "center" },
|
{ text: i18n.t("settings.organize"), value: "organize", sortable: false, align: "center" },
|
||||||
{ text: i18n.t("group.invite"), value: "invite", sortable: false, align: "center" },
|
{ text: i18n.t("group.invite"), value: "invite", sortable: false, align: "center" },
|
||||||
|
{ text: i18n.t("group.manage-household"), value: "manageHousehold", sortable: false, align: "center" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function refreshMembers() {
|
async function refreshMembers() {
|
||||||
@ -107,6 +119,7 @@ export default defineComponent({
|
|||||||
const payload = {
|
const payload = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
canInvite: user.canInvite,
|
canInvite: user.canInvite,
|
||||||
|
canManageHousehold: user.canManageHousehold,
|
||||||
canManage: user.canManage,
|
canManage: user.canManage,
|
||||||
canOrganize: user.canOrganize,
|
canOrganize: user.canOrganize,
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container v-if="user">
|
<v-container v-if="user">
|
||||||
<section class="d-flex flex-column align-center mt-4">
|
<section class="d-flex flex-column align-center mt-4">
|
||||||
<UserAvatar size="96" :user-id="$auth.user.id" />
|
<UserAvatar size="96" :user-id="user.id" />
|
||||||
|
|
||||||
<h2 class="headline">{{ $t('profile.welcome-user', [user.fullName]) }}</h2>
|
<h2 class="headline">{{ $t('profile.welcome-user', [user.fullName]) }}</h2>
|
||||||
<p class="subtitle-1 mb-0 text-center">
|
<p class="subtitle-1 mb-0 text-center">
|
||||||
@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-card flat color="transparent" width="100%" max-width="600px">
|
<v-card flat color="transparent" width="100%" max-width="600px">
|
||||||
<v-card-actions class="d-flex justify-center my-4">
|
<v-card-actions class="d-flex justify-center my-4">
|
||||||
<v-btn v-if="$auth.user.canInvite" outlined rounded @click="getSignupLink()">
|
<v-btn v-if="user.canInvite" outlined rounded @click="getSignupLink()">
|
||||||
<v-icon left>
|
<v-icon left>
|
||||||
{{ $globals.icons.createAlt }}
|
{{ $globals.icons.createAlt }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@ -113,7 +113,7 @@
|
|||||||
<p>{{ $t('profile.household-description') }}</p>
|
<p>{{ $t('profile.household-description') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<v-row tag="section">
|
<v-row tag="section">
|
||||||
<v-col v-if="$auth.user.canManage" cols="12" sm="12" md="6">
|
<v-col v-if="user.canManageHousehold" cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: $tc('profile.household-settings'), to: `/household` }"
|
:link="{ text: $tc('profile.household-settings'), to: `/household` }"
|
||||||
:image="require('~/static/svgs/manage-group-settings.svg')"
|
:image="require('~/static/svgs/manage-group-settings.svg')"
|
||||||
@ -165,13 +165,13 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
</section>
|
</section>
|
||||||
<v-divider class="my-7" />
|
<v-divider class="my-7" />
|
||||||
<section v-if="$auth.user.canManage || $auth.user.canOrganize || $auth.user.advanced">
|
<section v-if="user.canManage || user.canOrganize || user.advanced">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="headline">{{ $t('group.group') }}</h3>
|
<h3 class="headline">{{ $t('group.group') }}</h3>
|
||||||
<p>{{ $t('profile.group-description') }}</p>
|
<p>{{ $t('profile.group-description') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<v-row tag="section">
|
<v-row tag="section">
|
||||||
<v-col v-if="$auth.user.canManage" cols="12" sm="12" md="6">
|
<v-col v-if="user.canManage" cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: $tc('profile.group-settings'), to: `/group` }"
|
:link="{ text: $tc('profile.group-settings'), to: `/group` }"
|
||||||
:image="require('~/static/svgs/manage-group-settings.svg')"
|
:image="require('~/static/svgs/manage-group-settings.svg')"
|
||||||
@ -180,8 +180,7 @@
|
|||||||
{{ $t('profile.group-settings-description') }}
|
{{ $t('profile.group-settings-description') }}
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
<!-- $auth.user.canOrganize should not be null because of the auth middleware -->
|
<v-col v-if="user.canOrganize" cols="12" sm="12" md="6">
|
||||||
<v-col v-if="$auth.user.canOrganize" cols="12" sm="12" md="6">
|
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: $tc('profile.manage-data'), to: `/group/data/foods` }"
|
:link="{ text: $tc('profile.manage-data'), to: `/group/data/foods` }"
|
||||||
:image="require('~/static/svgs/manage-recipes.svg')"
|
:image="require('~/static/svgs/manage-recipes.svg')"
|
||||||
|
@ -22,6 +22,7 @@ class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
|
|||||||
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
||||||
|
|
||||||
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||||
|
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||||
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
||||||
|
|
||||||
# Recipe Defaults
|
# Recipe Defaults
|
||||||
|
@ -68,6 +68,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||||
|
|
||||||
# Group Permissions
|
# Group Permissions
|
||||||
|
can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
can_organize: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
can_organize: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
@ -108,6 +109,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||||||
exclude={
|
exclude={
|
||||||
"password",
|
"password",
|
||||||
"admin",
|
"admin",
|
||||||
|
"can_manage_household",
|
||||||
"can_manage",
|
"can_manage",
|
||||||
"can_invite",
|
"can_invite",
|
||||||
"can_organize",
|
"can_organize",
|
||||||
@ -186,22 +188,27 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||||||
def update_password(self, password):
|
def update_password(self, password):
|
||||||
self.password = password
|
self.password = password
|
||||||
|
|
||||||
def _set_permissions(self, admin, can_manage=False, can_invite=False, can_organize=False, **_):
|
def _set_permissions(
|
||||||
|
self, admin, can_manage_household=False, can_manage=False, can_invite=False, can_organize=False, **_
|
||||||
|
):
|
||||||
"""Set user permissions based on the admin flag and the passed in kwargs
|
"""Set user permissions based on the admin flag and the passed in kwargs
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
admin (bool):
|
admin (bool):
|
||||||
|
can_manage_household (bool):
|
||||||
can_manage (bool):
|
can_manage (bool):
|
||||||
can_invite (bool):
|
can_invite (bool):
|
||||||
can_organize (bool):
|
can_organize (bool):
|
||||||
"""
|
"""
|
||||||
self.admin = admin
|
self.admin = admin
|
||||||
if self.admin:
|
if self.admin:
|
||||||
|
self.can_manage_household = True
|
||||||
self.can_manage = True
|
self.can_manage = True
|
||||||
self.can_invite = True
|
self.can_invite = True
|
||||||
self.can_organize = True
|
self.can_organize = True
|
||||||
self.advanced = True
|
self.advanced = True
|
||||||
else:
|
else:
|
||||||
|
self.can_manage_household = can_manage_household
|
||||||
self.can_manage = can_manage
|
self.can_manage = can_manage
|
||||||
self.can_invite = can_invite
|
self.can_invite = can_invite
|
||||||
self.can_organize = can_organize
|
self.can_organize = can_organize
|
||||||
|
@ -20,6 +20,11 @@ class OperationChecks:
|
|||||||
# =========================================
|
# =========================================
|
||||||
# User Permission Checks
|
# User Permission Checks
|
||||||
|
|
||||||
|
def can_manage_household(self) -> bool:
|
||||||
|
if not self.user.can_manage_household:
|
||||||
|
raise self.ForbiddenException
|
||||||
|
return True
|
||||||
|
|
||||||
def can_manage(self) -> bool:
|
def can_manage(self) -> bool:
|
||||||
if not self.user.can_manage:
|
if not self.user.can_manage:
|
||||||
raise self.ForbiddenException
|
raise self.ForbiddenException
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import HTTPException, Query
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
from mealie.routes._base.base_controllers import BaseUserController
|
from mealie.routes._base.base_controllers import BaseUserController
|
||||||
@ -10,6 +10,7 @@ from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGr
|
|||||||
from mealie.schema.group.group_statistics import GroupStorage
|
from mealie.schema.group.group_statistics import GroupStorage
|
||||||
from mealie.schema.household.household import HouseholdSummary
|
from mealie.schema.household.household import HouseholdSummary
|
||||||
from mealie.schema.response.pagination import PaginationQuery
|
from mealie.schema.response.pagination import PaginationQuery
|
||||||
|
from mealie.schema.response.responses import ErrorResponse
|
||||||
from mealie.schema.user.user import GroupSummary, UserSummary
|
from mealie.schema.user.user import GroupSummary, UserSummary
|
||||||
from mealie.services.group_services.group_service import GroupService
|
from mealie.services.group_services.group_service import GroupService
|
||||||
|
|
||||||
@ -42,6 +43,16 @@ class GroupSelfServiceController(BaseUserController):
|
|||||||
households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items
|
households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items
|
||||||
return [household.cast(HouseholdSummary) for household in households]
|
return [household.cast(HouseholdSummary) for household in households]
|
||||||
|
|
||||||
|
@router.get("/households/{slug}", response_model=HouseholdSummary)
|
||||||
|
def get_group_household(self, slug: str):
|
||||||
|
"""Returns a single household belonging to the current group"""
|
||||||
|
|
||||||
|
household = self.repos.households.get_by_slug_or_id(slug)
|
||||||
|
if not household:
|
||||||
|
raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found"))
|
||||||
|
|
||||||
|
return household.cast(HouseholdSummary)
|
||||||
|
|
||||||
@router.get("/preferences", response_model=ReadGroupPreferences)
|
@router.get("/preferences", response_model=ReadGroupPreferences)
|
||||||
def get_group_preferences(self):
|
def get_group_preferences(self):
|
||||||
return self.group.preferences
|
return self.group.preferences
|
||||||
|
@ -41,6 +41,8 @@ class HouseholdSelfServiceController(BaseUserController):
|
|||||||
|
|
||||||
@router.put("/preferences", response_model=ReadHouseholdPreferences)
|
@router.put("/preferences", response_model=ReadHouseholdPreferences)
|
||||||
def update_household_preferences(self, new_pref: UpdateHouseholdPreferences):
|
def update_household_preferences(self, new_pref: UpdateHouseholdPreferences):
|
||||||
|
self.checks.can_manage_household()
|
||||||
|
|
||||||
return self.repos.household_preferences.update(self.household_id, new_pref)
|
return self.repos.household_preferences.update(self.household_id, new_pref)
|
||||||
|
|
||||||
@router.put("/permissions", response_model=UserOut)
|
@router.put("/permissions", response_model=UserOut)
|
||||||
@ -60,6 +62,7 @@ class HouseholdSelfServiceController(BaseUserController):
|
|||||||
|
|
||||||
target_user.can_invite = permissions.can_invite
|
target_user.can_invite = permissions.can_invite
|
||||||
target_user.can_manage = permissions.can_manage
|
target_user.can_manage = permissions.can_manage
|
||||||
|
target_user.can_manage_household = permissions.can_manage_household
|
||||||
target_user.can_organize = permissions.can_organize
|
target_user.can_organize = permissions.can_organize
|
||||||
|
|
||||||
return self.repos.users.update(permissions.user_id, target_user)
|
return self.repos.users.update(permissions.user_id, target_user)
|
||||||
|
@ -5,6 +5,7 @@ from mealie.schema._mealie import MealieModel
|
|||||||
|
|
||||||
class SetPermissions(MealieModel):
|
class SetPermissions(MealieModel):
|
||||||
user_id: UUID4
|
user_id: UUID4
|
||||||
|
can_manage_household: bool = False
|
||||||
can_manage: bool = False
|
can_manage: bool = False
|
||||||
can_invite: bool = False
|
can_invite: bool = False
|
||||||
can_organize: bool = False
|
can_organize: bool = False
|
||||||
|
@ -5,6 +5,7 @@ from mealie.schema._mealie import MealieModel
|
|||||||
|
|
||||||
class UpdateHouseholdPreferences(MealieModel):
|
class UpdateHouseholdPreferences(MealieModel):
|
||||||
private_household: bool = True
|
private_household: bool = True
|
||||||
|
lock_recipe_edits_from_other_households: bool = True
|
||||||
first_day_of_week: int = 0
|
first_day_of_week: int = 0
|
||||||
|
|
||||||
# Recipe Defaults
|
# Recipe Defaults
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
# This file is auto-generated by gen_schema_exports.py
|
# This file is auto-generated by gen_schema_exports.py
|
||||||
|
from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes
|
||||||
from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
|
from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OpenAIIngredient",
|
"OpenAIIngredient",
|
||||||
"OpenAIIngredients",
|
"OpenAIIngredients",
|
||||||
|
"OpenAIRecipe",
|
||||||
|
"OpenAIRecipeIngredient",
|
||||||
|
"OpenAIRecipeInstruction",
|
||||||
|
"OpenAIRecipeNotes",
|
||||||
]
|
]
|
||||||
|
@ -106,6 +106,7 @@ class UserBase(MealieModel):
|
|||||||
|
|
||||||
can_invite: bool = False
|
can_invite: bool = False
|
||||||
can_manage: bool = False
|
can_manage: bool = False
|
||||||
|
can_manage_household: bool = False
|
||||||
can_organize: bool = False
|
can_organize: bool = False
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
from_attributes=True,
|
from_attributes=True,
|
||||||
|
@ -73,7 +73,11 @@ class RecipeService(RecipeServiceBase):
|
|||||||
|
|
||||||
# Check if this user has permission to edit this recipe
|
# Check if this user has permission to edit this recipe
|
||||||
if self.household.id != recipe.household_id:
|
if self.household.id != recipe.household_id:
|
||||||
return False
|
other_household = self.repos.households.get_one(recipe.household_id)
|
||||||
|
if not (other_household and other_household.preferences):
|
||||||
|
return False
|
||||||
|
if other_household.preferences.lock_recipe_edits_from_other_households:
|
||||||
|
return False
|
||||||
if recipe.settings.locked:
|
if recipe.settings.locked:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -135,7 +139,7 @@ class RecipeService(RecipeServiceBase):
|
|||||||
|
|
||||||
return Recipe(**additional_attrs)
|
return Recipe(**additional_attrs)
|
||||||
|
|
||||||
def get_one(self, slug_or_id: str | UUID) -> Recipe | None:
|
def get_one(self, slug_or_id: str | UUID) -> Recipe:
|
||||||
if isinstance(slug_or_id, str):
|
if isinstance(slug_or_id, str):
|
||||||
try:
|
try:
|
||||||
slug_or_id = UUID(slug_or_id)
|
slug_or_id = UUID(slug_or_id)
|
||||||
@ -301,10 +305,10 @@ class RecipeService(RecipeServiceBase):
|
|||||||
data_service.write_image(f.read(), "webp")
|
data_service.write_image(f.read(), "webp")
|
||||||
return recipe
|
return recipe
|
||||||
|
|
||||||
def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe:
|
def duplicate_one(self, old_slug_or_id: str | UUID, dup_data: RecipeDuplicate) -> Recipe:
|
||||||
"""Duplicates a recipe and returns the new recipe."""
|
"""Duplicates a recipe and returns the new recipe."""
|
||||||
|
|
||||||
old_recipe = self._get_recipe(old_slug)
|
old_recipe = self.get_one(old_slug_or_id)
|
||||||
new_recipe_data = old_recipe.model_dump(exclude={"id", "name", "slug", "image", "comments"}, round_trip=True)
|
new_recipe_data = old_recipe.model_dump(exclude={"id", "name", "slug", "image", "comments"}, round_trip=True)
|
||||||
new_recipe = Recipe.model_validate(new_recipe_data)
|
new_recipe = Recipe.model_validate(new_recipe_data)
|
||||||
|
|
||||||
@ -356,7 +360,7 @@ class RecipeService(RecipeServiceBase):
|
|||||||
|
|
||||||
return new_recipe
|
return new_recipe
|
||||||
|
|
||||||
def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
|
def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe:
|
||||||
"""
|
"""
|
||||||
gets the recipe from the database and performs a check to see if the user can update the recipe.
|
gets the recipe from the database and performs a check to see if the user can update the recipe.
|
||||||
If the user can't update the recipe, an exception is raised.
|
If the user can't update the recipe, an exception is raised.
|
||||||
@ -367,14 +371,14 @@ class RecipeService(RecipeServiceBase):
|
|||||||
- _if_ the user is locking the recipe, that they can lock the recipe (user is the owner)
|
- _if_ the user is locking the recipe, that they can lock the recipe (user is the owner)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
slug (str): recipe slug
|
slug_or_id (str | UUID): recipe slug or id
|
||||||
new_data (Recipe): the new recipe data
|
new_data (Recipe): the new recipe data
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
exceptions.PermissionDenied (403)
|
exceptions.PermissionDenied (403)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
recipe = self._get_recipe(slug)
|
recipe = self.get_one(slug_or_id)
|
||||||
|
|
||||||
if recipe is None or recipe.settings is None:
|
if recipe is None or recipe.settings is None:
|
||||||
raise exceptions.NoEntryFound("Recipe not found.")
|
raise exceptions.NoEntryFound("Recipe not found.")
|
||||||
@ -388,38 +392,35 @@ class RecipeService(RecipeServiceBase):
|
|||||||
|
|
||||||
return recipe
|
return recipe
|
||||||
|
|
||||||
def update_one(self, slug: str, update_data: Recipe) -> Recipe:
|
def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe:
|
||||||
recipe = self._pre_update_check(slug, update_data)
|
recipe = self._pre_update_check(slug_or_id, update_data)
|
||||||
|
|
||||||
new_data = self.repos.recipes.update(slug, update_data)
|
new_data = self.group_recipes.update(recipe.slug, update_data)
|
||||||
self.check_assets(new_data, recipe.slug)
|
self.check_assets(new_data, recipe.slug)
|
||||||
return new_data
|
return new_data
|
||||||
|
|
||||||
def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
|
def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe:
|
||||||
recipe: Recipe | None = self._pre_update_check(slug, patch_data)
|
recipe: Recipe | None = self._pre_update_check(slug_or_id, patch_data)
|
||||||
recipe = self._get_recipe(slug)
|
recipe = self.get_one(slug_or_id)
|
||||||
|
|
||||||
if recipe is None:
|
new_data = self.group_recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True))
|
||||||
raise exceptions.NoEntryFound("Recipe not found.")
|
|
||||||
|
|
||||||
new_data = self.repos.recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True))
|
|
||||||
|
|
||||||
self.check_assets(new_data, recipe.slug)
|
self.check_assets(new_data, recipe.slug)
|
||||||
return new_data
|
return new_data
|
||||||
|
|
||||||
def update_last_made(self, slug: str, timestamp: datetime) -> Recipe:
|
def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe:
|
||||||
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
|
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
|
||||||
# or if the user belongs to a different household
|
# or if the user belongs to a different household
|
||||||
recipe = self._get_recipe(slug)
|
recipe = self.get_one(slug_or_id)
|
||||||
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})
|
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})
|
||||||
|
|
||||||
def delete_one(self, slug) -> Recipe:
|
def delete_one(self, slug_or_id: str | UUID) -> Recipe:
|
||||||
recipe = self._get_recipe(slug)
|
recipe = self.get_one(slug_or_id)
|
||||||
|
|
||||||
if not self.can_update(recipe):
|
if not self.can_update(recipe):
|
||||||
raise exceptions.PermissionDenied("You do not have permission to delete this recipe.")
|
raise exceptions.PermissionDenied("You do not have permission to delete this recipe.")
|
||||||
|
|
||||||
data = self.repos.recipes.delete(recipe.id, "id")
|
data = self.group_recipes.delete(recipe.id, "id")
|
||||||
self.delete_assets(data)
|
self.delete_assets(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -39,6 +39,7 @@ class RegistrationService:
|
|||||||
household=household,
|
household=household,
|
||||||
can_invite=new_group,
|
can_invite=new_group,
|
||||||
can_manage=new_group,
|
can_manage=new_group,
|
||||||
|
can_manage_household=new_group,
|
||||||
can_organize=new_group,
|
can_organize=new_group,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ def test_admin_update_household(api_client: TestClient, admin_user: TestUser, un
|
|||||||
"name": "New Name",
|
"name": "New Name",
|
||||||
"preferences": {
|
"preferences": {
|
||||||
"privateHousehold": random_bool(),
|
"privateHousehold": random_bool(),
|
||||||
|
"lockRecipeEditsFromOtherHouseholds": random_bool(),
|
||||||
"firstDayOfWeek": 2,
|
"firstDayOfWeek": 2,
|
||||||
"recipePublic": random_bool(),
|
"recipePublic": random_bool(),
|
||||||
"recipeShowNutrition": random_bool(),
|
"recipeShowNutrition": random_bool(),
|
||||||
|
@ -33,16 +33,19 @@ def test_get_group_members_filtered(api_client: TestClient, unique_user: TestUse
|
|||||||
assert str(h2_user.user_id) in all_ids
|
assert str(h2_user.user_id) in all_ids
|
||||||
|
|
||||||
|
|
||||||
def test_get_households(api_client: TestClient, admin_user: TestUser):
|
def test_get_households(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
|
||||||
households = [admin_user.repos.households.create({"name": random_string()}) for _ in range(5)]
|
households = [
|
||||||
response = api_client.get(api_routes.groups_households, headers=admin_user.token)
|
unfiltered_database.households.create({"name": random_string(), "group_id": unique_user.group_id})
|
||||||
|
for _ in range(5)
|
||||||
|
]
|
||||||
|
response = api_client.get(api_routes.groups_households, headers=unique_user.token)
|
||||||
response_ids = [item["id"] for item in response.json()]
|
response_ids = [item["id"] for item in response.json()]
|
||||||
for household in households:
|
for household in households:
|
||||||
assert str(household.id) in response_ids
|
assert str(household.id) in response_ids
|
||||||
|
|
||||||
|
|
||||||
def test_get_households_filtered(unfiltered_database: AllRepositories, api_client: TestClient, admin_user: TestUser):
|
def test_get_households_filtered(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
|
||||||
group_1_id = admin_user.group_id
|
group_1_id = unique_user.group_id
|
||||||
group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id)
|
group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id)
|
||||||
|
|
||||||
group_1_households = [
|
group_1_households = [
|
||||||
@ -54,9 +57,24 @@ def test_get_households_filtered(unfiltered_database: AllRepositories, api_clien
|
|||||||
for _ in range(random_int(2, 5))
|
for _ in range(random_int(2, 5))
|
||||||
]
|
]
|
||||||
|
|
||||||
response = api_client.get(api_routes.groups_households, headers=admin_user.token)
|
response = api_client.get(api_routes.groups_households, headers=unique_user.token)
|
||||||
response_ids = [item["id"] for item in response.json()]
|
response_ids = [item["id"] for item in response.json()]
|
||||||
for household in group_1_households:
|
for household in group_1_households:
|
||||||
assert str(household.id) in response_ids
|
assert str(household.id) in response_ids
|
||||||
for household in group_2_households:
|
for household in group_2_households:
|
||||||
assert str(household.id) not in response_ids
|
assert str(household.id) not in response_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_household(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
|
||||||
|
group_1_id = unique_user.group_id
|
||||||
|
group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id)
|
||||||
|
|
||||||
|
group_1_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_1_id})
|
||||||
|
group_2_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_2_id})
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.groups_households_slug(group_1_household.slug), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == str(group_1_household.id)
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.groups_households_slug(group_2_household.slug), headers=unique_user.token)
|
||||||
|
assert response.status_code == 404
|
||||||
|
@ -31,17 +31,33 @@ def test_preferences_in_household(api_client: TestClient, unique_user: TestUser)
|
|||||||
assert household["preferences"]["recipeShowNutrition"] in {True, False}
|
assert household["preferences"]["recipeShowNutrition"] in {True, False}
|
||||||
|
|
||||||
|
|
||||||
def test_update_preferences(api_client: TestClient, unique_user: TestUser) -> None:
|
def test_update_preferences_no_permission(api_client: TestClient, user_tuple: list[TestUser]) -> None:
|
||||||
|
unique_user, other_user = user_tuple
|
||||||
|
|
||||||
|
user = other_user.repos.users.get_one(unique_user.user_id)
|
||||||
|
assert user
|
||||||
|
user.can_manage_household = False
|
||||||
|
other_user.repos.users.update(user.id, user)
|
||||||
|
|
||||||
new_data = UpdateHouseholdPreferences(recipe_public=random_bool(), recipe_show_nutrition=random_bool())
|
new_data = UpdateHouseholdPreferences(recipe_public=random_bool(), recipe_show_nutrition=random_bool())
|
||||||
|
|
||||||
response = api_client.put(api_routes.households_preferences, json=new_data.model_dump(), headers=unique_user.token)
|
response = api_client.put(api_routes.households_preferences, json=new_data.model_dump(), headers=unique_user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_preferences(api_client: TestClient, user_tuple: list[TestUser]) -> None:
|
||||||
|
unique_user, other_user = user_tuple
|
||||||
|
|
||||||
|
user = other_user.repos.users.get_one(unique_user.user_id)
|
||||||
|
assert user
|
||||||
|
user.can_manage_household = True
|
||||||
|
other_user.repos.users.update(user.id, user)
|
||||||
|
|
||||||
|
new_data = UpdateHouseholdPreferences(recipe_public=random_bool(), recipe_show_nutrition=random_bool())
|
||||||
|
response = api_client.put(api_routes.households_preferences, json=new_data.model_dump(), headers=unique_user.token)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
preferences = response.json()
|
preferences = response.json()
|
||||||
|
|
||||||
assert preferences is not None
|
assert preferences is not None
|
||||||
assert preferences["recipePublic"] == new_data.recipe_public
|
assert preferences["recipePublic"] == new_data.recipe_public
|
||||||
assert preferences["recipeShowNutrition"] == new_data.recipe_show_nutrition
|
assert preferences["recipeShowNutrition"] == new_data.recipe_show_nutrition
|
||||||
|
|
||||||
assert_ignore_keys(new_data.model_dump(by_alias=True), preferences, ["id", "householdId"])
|
assert_ignore_keys(new_data.model_dump(by_alias=True), preferences, ["id", "householdId"])
|
||||||
|
@ -7,10 +7,11 @@ from tests.utils.factories import random_bool
|
|||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
def get_permissions_payload(user_id: str, can_manage=None) -> dict:
|
def get_permissions_payload(user_id: str, can_manage=None, can_manage_household=None) -> dict:
|
||||||
return {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"can_manage": random_bool() if can_manage is None else can_manage,
|
"can_manage": random_bool() if can_manage is None else can_manage,
|
||||||
|
"can_manage_household": random_bool(),
|
||||||
"can_invite": random_bool(),
|
"can_invite": random_bool(),
|
||||||
"can_organize": random_bool(),
|
"can_organize": random_bool(),
|
||||||
}
|
}
|
||||||
|
@ -86,13 +86,20 @@ def test_get_one_recipe_from_another_household(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("is_private_household", [True, False])
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
|
||||||
@pytest.mark.parametrize("use_patch", [True, False])
|
@pytest.mark.parametrize("use_patch", [True, False])
|
||||||
def test_prevent_updates_to_recipes_from_other_households(
|
def test_update_recipes_in_other_households(
|
||||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool, use_patch: bool
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
h2_user: TestUser,
|
||||||
|
is_private_household: bool,
|
||||||
|
household_lock_recipe_edits: bool,
|
||||||
|
use_patch: bool,
|
||||||
):
|
):
|
||||||
household = unique_user.repos.households.get_one(h2_user.household_id)
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
assert household and household.preferences
|
assert household and household.preferences
|
||||||
household.preferences.private_household = is_private_household
|
household.preferences.private_household = is_private_household
|
||||||
|
household.preferences.lock_recipe_edits_from_other_households = household_lock_recipe_edits
|
||||||
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
original_name = random_string()
|
original_name = random_string()
|
||||||
@ -110,23 +117,39 @@ def test_prevent_updates_to_recipes_from_other_households(
|
|||||||
updated_name = random_string()
|
updated_name = random_string()
|
||||||
recipe["name"] = updated_name
|
recipe["name"] = updated_name
|
||||||
client_func = api_client.patch if use_patch else api_client.put
|
client_func = api_client.patch if use_patch else api_client.put
|
||||||
response = client_func(api_routes.recipes_slug(recipe["slug"]), json=recipe, headers=unique_user.token)
|
response = client_func(api_routes.recipes_slug(recipe["id"]), json=recipe, headers=unique_user.token)
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
# confirm the recipe is unchanged
|
if household_lock_recipe_edits:
|
||||||
response = api_client.get(api_routes.recipes_slug(recipe["slug"]), headers=unique_user.token)
|
assert response.status_code == 403
|
||||||
assert response.status_code == 200
|
|
||||||
updated_recipe = response.json()
|
# confirm the recipe is unchanged
|
||||||
assert updated_recipe["name"] == original_name != updated_name
|
response = api_client.get(api_routes.recipes_slug(recipe["id"]), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
updated_recipe = response.json()
|
||||||
|
assert updated_recipe["name"] == original_name != updated_name
|
||||||
|
else:
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# confirm the recipe was updated
|
||||||
|
response = api_client.get(api_routes.recipes_slug(recipe["id"]), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
updated_recipe = response.json()
|
||||||
|
assert updated_recipe["name"] == updated_name != original_name
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("is_private_household", [True, False])
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
def test_prevent_deletes_to_recipes_from_other_households(
|
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
|
||||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
def test_delete_recipes_from_other_households(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
h2_user: TestUser,
|
||||||
|
is_private_household: bool,
|
||||||
|
household_lock_recipe_edits: bool,
|
||||||
):
|
):
|
||||||
household = unique_user.repos.households.get_one(h2_user.household_id)
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
assert household and household.preferences
|
assert household and household.preferences
|
||||||
household.preferences.private_household = is_private_household
|
household.preferences.private_household = is_private_household
|
||||||
|
household.preferences.lock_recipe_edits_from_other_households = household_lock_recipe_edits
|
||||||
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
||||||
@ -141,21 +164,34 @@ def test_prevent_deletes_to_recipes_from_other_households(
|
|||||||
assert recipe_json["id"] == h2_recipe_id
|
assert recipe_json["id"] == h2_recipe_id
|
||||||
|
|
||||||
response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
|
response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
|
||||||
assert response.status_code == 403
|
if household_lock_recipe_edits:
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
# confirm the recipe still exists
|
# confirm the recipe still exists
|
||||||
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["id"] == h2_recipe_id
|
assert response.json()["id"] == h2_recipe_id
|
||||||
|
else:
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# confirm the recipe was deleted
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("is_private_household", [True, False])
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
|
||||||
def test_user_can_update_last_made_on_other_household(
|
def test_user_can_update_last_made_on_other_household(
|
||||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
h2_user: TestUser,
|
||||||
|
is_private_household: bool,
|
||||||
|
household_lock_recipe_edits: bool,
|
||||||
):
|
):
|
||||||
household = unique_user.repos.households.get_one(h2_user.household_id)
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
assert household and household.preferences
|
assert household and household.preferences
|
||||||
household.preferences.private_household = is_private_household
|
household.preferences.private_household = is_private_household
|
||||||
|
household.preferences.lock_recipe_edits_from_other_households = household_lock_recipe_edits
|
||||||
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
||||||
|
@ -292,6 +292,11 @@ def foods_item_id(item_id):
|
|||||||
return f"{prefix}/foods/{item_id}"
|
return f"{prefix}/foods/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def groups_households_slug(slug):
|
||||||
|
"""`/api/groups/households/{slug}`"""
|
||||||
|
return f"{prefix}/groups/households/{slug}"
|
||||||
|
|
||||||
|
|
||||||
def groups_labels_item_id(item_id):
|
def groups_labels_item_id(item_id):
|
||||||
"""`/api/groups/labels/{item_id}`"""
|
"""`/api/groups/labels/{item_id}`"""
|
||||||
return f"{prefix}/groups/labels/{item_id}"
|
return f"{prefix}/groups/labels/{item_id}"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user