Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Apply default tag on recipe import (by URL) #2930

Draft
wants to merge 22 commits into
base: mealie-next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Add creation tag to group preferences

Revision ID: 0ea6eb8eaa44
Revises: ba1e4a6cfe99
Create Date: 2024-01-04 12:40:03.062671

"""
import sqlalchemy as sa

import mealie.db.migration_types
from alembic import op

# revision identifiers, used by Alembic.
revision = "0ea6eb8eaa44"
down_revision = "ba1e4a6cfe99"
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
batch_op.add_column(sa.Column("recipe_creation_tag", mealie.db.migration_types.GUID(), nullable=True))
batch_op.create_foreign_key("fk_groupprefs_tags", "tags", ["recipe_creation_tag"], ["id"])


def downgrade():
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
batch_op.drop_constraint("fk_groupprefs_tags", type_="foreignkey")
batch_op.drop_column("recipe_creation_tag")
85 changes: 85 additions & 0 deletions docs/docs/contributors/developers-guide/database-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Development: Database Changes

This document is open to improvement; please share any insights you have/develop.

## Overview

When modifying the database, you will most likely need to change the files under `/mealie/db/models/`.
How exactly you need to modify it is of course highly contextual to the change you're making.

## Using Alembic to generate upgrade script

In your dev container you can run something like (change the message) `alembic revision --autogenerate -m "Add creation tag to group preferences"` to have Alembic generate an upgrade script for you.

The script Alembic generates, will be limited! (Perhaps there's a way to resolve that? Haven't looked into it yet)
For example, Alembic generated a script _similar_ to this (it has been modified already to have accurate foreign key names, for instance):

```Python
"""Add creation tag to group preferences

Revision ID: 0ea6eb8eaa44
Revises: ba1e4a6cfe99
Create Date: 2024-01-04 12:40:03.062671

"""
import sqlalchemy as sa

import mealie.db.migration_types
from alembic import op

# revision identifiers, used by Alembic.
revision = "0ea6eb8eaa44"
down_revision = "ba1e4a6cfe99"
branch_labels = None
depends_on = None


def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column(
"group_preferences", sa.Column("recipe_creation_tag", mealie.db.migration_types.GUID(), nullable=True)
)
op.create_foreign_key("fk_groupprefs_tags", "group_preferences", "tags", ["recipe_creation_tag"], ["id"])
### end Alembic commands ###


def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("fk_groupprefs_tags", "group_preferences", type_="foreignkey")
op.drop_column("group_preferences", "recipe_creation_tag")
### end Alembic commands ###
```

But when trying to actually use that upgrade script, it becomes clear that our SQLite database doesn't like them. The minor modification needed looks like:

```Python
"""Add creation tag to group preferences

Revision ID: 0ea6eb8eaa44
Revises: ba1e4a6cfe99
Create Date: 2024-01-04 12:40:03.062671

"""
import sqlalchemy as sa

import mealie.db.migration_types
from alembic import op

# revision identifiers, used by Alembic.
revision = "0ea6eb8eaa44"
down_revision = "ba1e4a6cfe99"
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
batch_op.add_column(sa.Column("recipe_creation_tag", mealie.db.migration_types.GUID(), nullable=True))
batch_op.create_foreign_key("fk_groupprefs_tags", "tags", ["recipe_creation_tag"], ["id"])


def downgrade():
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
batch_op.drop_constraint("fk_groupprefs_tags", type_="foreignkey")
batch_op.drop_column("recipe_creation_tag")
```
17 changes: 17 additions & 0 deletions frontend/components/Domain/Group/GroupPreferencesEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
item-value="value"
:label="$t('settings.first-day-of-week')"
/>
<v-select
v-model="preferences.recipeCreationTag"
:prepend-icon="$globals.icons.tags"
:items="allTags"
item-text="name"
:return-object="false"
item-value="id"
clearable="true"
:label="$t('group.default-tag-for-recipe-import')"
/>

<BaseCardSectionTitle class="mt-5" :title="$tc('group.group-recipe-preferences')"></BaseCardSectionTitle>
<template v-for="(_, key) in preferences">
Expand All @@ -26,6 +36,7 @@

<script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import { useTagStore } from "~/composables/store";

export default defineComponent({
props: {
Expand Down Expand Up @@ -77,6 +88,11 @@ export default defineComponent({
},
];

const { actions } = useTagStore();
// `items` was always coming out of useTagStore null, despite
// looking like it would be populated, so performing getAll
const allTags = actions.getAll();

const preferences = computed({
get() {
return props.value;
Expand All @@ -88,6 +104,7 @@ export default defineComponent({

return {
allDays,
allTags,
labels,
preferences,
};
Expand Down
1 change: 1 addition & 0 deletions frontend/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@
"show-nutrition-information-description": "When enabled the nutrition information will be shown on the recipe if available. If there is no nutrition information available, the nutrition information will not be shown",
"show-recipe-assets": "Show recipe assets",
"show-recipe-assets-description": "When enabled the recipe assets will be shown on the recipe if available",
"default-tag-for-recipe-import": "Tag applied on recipe creation by URL import, in bulk or single (blank to disable)",
"default-to-landscape-view": "Default to landscape view",
"default-to-landscape-view-description": "When enabled the recipe header section will be shown in landscape view",
"disable-users-from-commenting-on-recipes": "Disable users from commenting on recipes",
Expand Down
3 changes: 3 additions & 0 deletions frontend/lib/api/types/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface CreateGroupPreferences {
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
recipeCreationTag?: string;
groupId: string;
}
export interface CreateInviteToken {
Expand Down Expand Up @@ -61,6 +62,7 @@ export interface UpdateGroupPreferences {
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
recipeCreationTag?: string;
}
export interface GroupDataExport {
id: string;
Expand Down Expand Up @@ -213,6 +215,7 @@ export interface ReadGroupPreferences {
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
recipeCreationTag?: string;
groupId: string;
id: string;
}
Expand Down
1 change: 1 addition & 0 deletions frontend/lib/api/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface ReadGroupPreferences {
recipeLandscapeView?: boolean;
recipeDisableComments?: boolean;
recipeDisableAmount?: boolean;
recipeCreationTag?: string;
groupId: string;
id: string;
}
Expand Down
18 changes: 18 additions & 0 deletions frontend/pages/group/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
:label="$t('settings.first-day-of-week')"
@change="groupActions.updatePreferences()"
/>
<v-select
v-model="group.preferences.recipeCreationTag"
:prepend-icon="$globals.icons.tags"
:items="allTags"
item-text="name"
:return-object="false"
item-value="id"
:clearable="true"
:label="$t('group.default-tag-for-recipe-import')"
@change="groupActions.updatePreferences()"
/>
</section>

<section v-if="group">
Expand Down Expand Up @@ -63,6 +74,7 @@
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useGroupSelf } from "~/composables/use-groups";
import { ReadGroupPreferences } from "~/lib/api/types/group";
import { useTagStore } from "~/composables/store";

export default defineComponent({
setup() {
Expand Down Expand Up @@ -152,10 +164,16 @@ export default defineComponent({
},
];

const { actions } = useTagStore();
// `items` was always coming out of useTagStore null, despite
// looking like it would be populated, so performing getAll
const allTags = actions.getAll();

return {
group,
groupActions,
allDays,
allTags,
preferencesEditor,
};
},
Expand Down
1 change: 1 addition & 0 deletions mealie/db/models/group/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_creation_tag: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("tags.id"), nullable=True, index=False)

@auto_init()
def __init__(self, **_) -> None:
Expand Down
8 changes: 7 additions & 1 deletion mealie/routes/recipe/recipe_crud_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,15 @@ async def parse_recipe_url(self, req: ScrapeRecipe):

if req.include_tags:
ctx = ScraperContext(self.user.id, self.group_id, self.repos)

recipe.tags = extras.use_tags(ctx) # type: ignore

# Append a default tag to the recipe, based on group settings
if self.group.preferences is not None:
if self.group.preferences.recipe_creation_tag is not None:
if recipe.tags is None:
recipe.tags = []
recipe.tags.append(self.repos.tags.get_one(self.group.preferences.recipe_creation_tag))

new_recipe = self.service.create_one(recipe)

if new_recipe:
Expand Down
1 change: 1 addition & 0 deletions mealie/schema/group/group_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class UpdateGroupPreferences(MealieModel):
recipe_landscape_view: bool = False
recipe_disable_comments: bool = False
recipe_disable_amount: bool = True
recipe_creation_tag: UUID4 = None


class CreateGroupPreferences(UpdateGroupPreferences):
Expand Down
20 changes: 5 additions & 15 deletions mealie/schema/recipe/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,11 @@
from .recipe_nutrition import Nutrition
from .recipe_settings import RecipeSettings
from .recipe_step import RecipeStep
from .recipe_tag import RecipeTag

app_dirs = get_app_dirs()


class RecipeTag(MealieModel):
id: UUID4 | None = None
name: str
slug: str

_searchable_properties: ClassVar[list[str]] = ["name"]

class Config:
orm_mode = True


class RecipeTagPagination(PaginationBase):
items: list[RecipeTag]

Expand Down Expand Up @@ -216,10 +206,10 @@ def validate_ingredients(recipe_ingredient, values):
return recipe_ingredient

@validator("tags", always=True, pre=True, allow_reuse=True)
def validate_tags(cats: list[Any]): # type: ignore
if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return cats
def validate_tags(tags: list[Any]): # type: ignore
if isinstance(tags, list) and tags and isinstance(tags[0], str):
return [RecipeTag(id=uuid4(), name=t, slug=slugify(t)) for t in tags]
return tags

@validator("recipe_category", always=True, pre=True, allow_reuse=True)
def validate_categories(cats: list[Any]): # type: ignore
Expand Down
16 changes: 16 additions & 0 deletions mealie/schema/recipe/recipe_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import ClassVar

from pydantic import UUID4

from mealie.schema._mealie import MealieModel


class RecipeTag(MealieModel):
id: UUID4 | None = None
name: str
slug: str

_searchable_properties: ClassVar[list[str]] = ["name"]

class Config:
orm_mode = True
15 changes: 15 additions & 0 deletions mealie/services/scraper/recipe_bulk_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import CreateRecipeByUrlBulk, Recipe
from mealie.schema.recipe.recipe_tag import RecipeTag
from mealie.schema.reports.reports import (
ReportCategory,
ReportCreate,
Expand Down Expand Up @@ -103,6 +104,20 @@ async def _do(url: str) -> Recipe | None:
if b.categories:
recipe.recipe_category = b.categories

# Append a default tag to the recipe, based on group settings
if self.group.preferences is not None:
if self.group.preferences.recipe_creation_tag is not None:
if recipe.tags is None:
recipe.tags = []
tag_out = self.repos.tags.get_one(self.group.preferences.recipe_creation_tag)
if tag_out is not None:
recipe_tag = RecipeTag(
id=tag_out.id,
name=tag_out.name,
slug=tag_out.slug,
)
recipe.tags.append(recipe_tag)

try:
self.service.create_one(recipe)
except Exception as e:
Expand Down
14 changes: 12 additions & 2 deletions tests/integration_tests/admin_tests/test_admin_group_actions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient

from tests.utils import api_routes
from mealie.repos.repository_factory import AllRepositories
from tests.utils import api_routes, jsonify
from tests.utils.assertion_helpers import assert_ignore_keys
from tests.utils.factories import random_bool, random_string
from tests.utils.fixture_schemas import TestUser
Expand Down Expand Up @@ -30,7 +31,15 @@ def test_admin_create_group(api_client: TestClient, admin_user: TestUser):
assert response.status_code == 201


def test_admin_update_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
def test_admin_update_group(
database: AllRepositories, api_client: TestClient, admin_user: TestUser, unique_user: TestUser
):
# Postgres enforces foreign key on the test (good!),
# whereas SQLite does not, so we need to create a tag first.
tag = database.tags.by_group(unique_user.group_id).create(
{"name": random_string(), "group_id": unique_user.group_id}
)

update_payload = {
"id": unique_user.group_id,
"name": "New Name",
Expand All @@ -43,6 +52,7 @@ def test_admin_update_group(api_client: TestClient, admin_user: TestUser, unique
"recipeLandscapeView": random_bool(),
"recipeDisableComments": random_bool(),
"recipeDisableAmount": random_bool(),
"recipeCreationTag": jsonify(tag.id),
},
}

Expand Down