feat: Additional Household Permissions (#4158)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-17 10:48:14 -05:00 committed by GitHub
parent b1820f9b23
commit fd0257c1b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 690 additions and 185 deletions

View File

@ -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 ###

View File

@ -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>

View File

@ -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,

View File

@ -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,

View File

@ -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();

View File

@ -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);
}); });
}); });

View File

@ -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,
}
} }

View File

@ -36,6 +36,8 @@ export const useHouseholdSelf = function () {
if (data) { if (data) {
householdSelfRef.value.preferences = data; householdSelfRef.value.preferences = data;
} }
return data || undefined;
}, },
}; };

View File

@ -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",

View File

@ -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.",

View File

@ -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;

View File

@ -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;

View File

@ -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 {}

View File

@ -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;

View File

@ -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);
} }

View 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("/");
}
}

View File

@ -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"));
} }

View File

@ -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() {

View File

@ -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,
}; };

View File

@ -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')"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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",
] ]

View File

@ -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,

View File

@ -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

View File

@ -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,
) )

View File

@ -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(),

View File

@ -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

View File

@ -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"])

View File

@ -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(),
} }

View File

@ -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)

View File

@ -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}"