mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-06-03 13:44:55 -04:00
feat: Improve Public URL Readability (#2482)
* added support for group slugs * modified frontend to use links with group slug * fixed test refs * unused import --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
parent
99372aa2b6
commit
095edef95e
@ -0,0 +1,56 @@
|
|||||||
|
"""added group slug
|
||||||
|
|
||||||
|
Revision ID: 04ac51cbe9a4
|
||||||
|
Revises: b3dbb554ba53
|
||||||
|
Create Date: 2023-08-06 21:00:34.582905
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from slugify import slugify
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from mealie.db.models.group.group import Group
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "04ac51cbe9a4"
|
||||||
|
down_revision = "b3dbb554ba53"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def populate_group_slugs(session: Session):
|
||||||
|
groups: list[Group] = session.query(Group).all()
|
||||||
|
seen_slugs: set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
original_name = group.name
|
||||||
|
attempts = 0
|
||||||
|
while True:
|
||||||
|
slug = slugify(group.name)
|
||||||
|
if slug not in seen_slugs:
|
||||||
|
break
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
group.name = f"{original_name} ({attempts})"
|
||||||
|
|
||||||
|
seen_slugs.add(slug)
|
||||||
|
group.slug = slug
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("groups", sa.Column("slug", sa.String(), nullable=True))
|
||||||
|
op.create_index(op.f("ix_groups_slug"), "groups", ["slug"], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
session = Session(bind=op.get_bind())
|
||||||
|
populate_group_slugs(session)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f("ix_groups_slug"), table_name="groups")
|
||||||
|
op.drop_column("groups", "slug")
|
||||||
|
# ### end Alembic commands ###
|
@ -496,6 +496,22 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { copyText } = useCopy();
|
const { copyText } = useCopy();
|
||||||
|
const groupSlug = ref<string>("");
|
||||||
|
|
||||||
|
async function setGroupSlug() {
|
||||||
|
if (!props.groupId) {
|
||||||
|
groupSlug.value = props.groupId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data} = await api.groups.getOne(props.groupId);
|
||||||
|
if (!data) {
|
||||||
|
groupSlug.value = props.groupId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupSlug.value = data.slug;
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Print is handled as an event in the parent component
|
// Note: Print is handled as an event in the parent component
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
@ -525,13 +541,18 @@ export default defineComponent({
|
|||||||
share: () => {
|
share: () => {
|
||||||
state.shareDialog = true;
|
state.shareDialog = true;
|
||||||
},
|
},
|
||||||
publicUrl: () => {
|
publicUrl: async () => {
|
||||||
if (!props.groupId) {
|
if (!props.groupId) {
|
||||||
alert.error("Unknown group ID");
|
alert.error("Unknown group ID");
|
||||||
console.error("prop `groupId` is required when requesting a public URL");
|
console.error("prop `groupId` is required when requesting a public URL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
copyText(`${window.location.origin}/explore/recipes/${props.groupId}/${props.slug}`);
|
|
||||||
|
if (!groupSlug.value) {
|
||||||
|
await setGroupSlug();
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(`${window.location.origin}/explore/recipes/${groupSlug.value}/${props.slug}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,11 +4,11 @@ import { Recipe } from "~/lib/api/types/recipe";
|
|||||||
const prefix = "/api";
|
const prefix = "/api";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
recipe: (groupId: string, recipeSlug: string) => `${prefix}/explore/recipes/${groupId}/${recipeSlug}`,
|
recipe: (groupSlug: string, recipeSlug: string) => `${prefix}/explore/recipes/${groupSlug}/${recipeSlug}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ExploreApi extends BaseAPI {
|
export class ExploreApi extends BaseAPI {
|
||||||
async recipe(groupId: string, recipeSlug: string) {
|
async recipe(groupSlug: string, recipeSlug: string) {
|
||||||
return await this.requests.get<Recipe>(routes.recipe(groupId, recipeSlug));
|
return await this.requests.get<Recipe>(routes.recipe(groupSlug, recipeSlug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,7 @@ export interface GroupBase {
|
|||||||
export interface GroupInDB {
|
export interface GroupInDB {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
slug: string;
|
||||||
categories?: CategoryBase[];
|
categories?: CategoryBase[];
|
||||||
webhooks?: unknown[];
|
webhooks?: unknown[];
|
||||||
users?: UserOut[];
|
users?: UserOut[];
|
||||||
|
@ -18,7 +18,7 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const groupId = route.value.params.groupId;
|
const groupSlug = route.value.params.groupSlug;
|
||||||
const slug = route.value.params.slug;
|
const slug = route.value.params.slug;
|
||||||
const api = usePublicApi();
|
const api = usePublicApi();
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ export default defineComponent({
|
|||||||
const { recipeMeta } = useRecipeMeta();
|
const { recipeMeta } = useRecipeMeta();
|
||||||
|
|
||||||
const recipe = useAsync(async () => {
|
const recipe = useAsync(async () => {
|
||||||
const { data, error } = await api.explore.recipe(groupId, slug);
|
const { data, error } = await api.explore.recipe(groupSlug, slug);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("error loading recipe -> ", error);
|
console.error("error loading recipe -> ", error);
|
@ -32,6 +32,7 @@ class Group(SqlAlchemyBase, BaseMixins):
|
|||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||||
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
|
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
|
||||||
|
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True)
|
||||||
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
|
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
|
||||||
categories: Mapped[Category] = orm.relationship(
|
categories: Mapped[Category] = orm.relationship(
|
||||||
Category, secondary=group_to_categories, single_parent=True, uselist=True
|
Category, secondary=group_to_categories, single_parent=True, uselist=True
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import cast
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
from slugify import slugify
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from mealie.db.models.group import Group
|
from mealie.db.models.group import Group
|
||||||
from mealie.db.models.recipe.category import Category
|
from mealie.db.models.recipe.category import Category
|
||||||
@ -8,19 +14,55 @@ from mealie.db.models.recipe.tag import Tag
|
|||||||
from mealie.db.models.recipe.tool import Tool
|
from mealie.db.models.recipe.tool import Tool
|
||||||
from mealie.db.models.users.users import User
|
from mealie.db.models.users.users import User
|
||||||
from mealie.schema.group.group_statistics import GroupStatistics
|
from mealie.schema.group.group_statistics import GroupStatistics
|
||||||
from mealie.schema.user.user import GroupInDB
|
from mealie.schema.user.user import GroupBase, GroupInDB
|
||||||
|
|
||||||
from ..db.models._model_base import SqlAlchemyBase
|
from ..db.models._model_base import SqlAlchemyBase
|
||||||
from .repository_generic import RepositoryGeneric
|
from .repository_generic import RepositoryGeneric
|
||||||
|
|
||||||
|
|
||||||
class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
|
class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
|
||||||
|
def create(self, data: GroupBase | dict) -> GroupInDB:
|
||||||
|
if isinstance(data, GroupBase):
|
||||||
|
data = data.dict()
|
||||||
|
|
||||||
|
max_attempts = 10
|
||||||
|
original_name = cast(str, data["name"])
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data["slug"] = slugify(data["name"])
|
||||||
|
return super().create(data)
|
||||||
|
except IntegrityError:
|
||||||
|
self.session.rollback()
|
||||||
|
attempts += 1
|
||||||
|
if attempts >= max_attempts:
|
||||||
|
raise
|
||||||
|
|
||||||
|
data["name"] = f"{original_name} ({attempts})"
|
||||||
|
|
||||||
|
def create_many(self, data: Iterable[GroupInDB | dict]) -> list[GroupInDB]:
|
||||||
|
# since create uses special logic for resolving slugs, we don't want to use the standard create_many method
|
||||||
|
return [self.create(new_group) for new_group in data]
|
||||||
|
|
||||||
def get_by_name(self, name: str) -> GroupInDB | None:
|
def get_by_name(self, name: str) -> GroupInDB | None:
|
||||||
dbgroup = self.session.execute(select(self.model).filter_by(name=name)).scalars().one_or_none()
|
dbgroup = self.session.execute(select(self.model).filter_by(name=name)).scalars().one_or_none()
|
||||||
if dbgroup is None:
|
if dbgroup is None:
|
||||||
return None
|
return None
|
||||||
return self.schema.from_orm(dbgroup)
|
return self.schema.from_orm(dbgroup)
|
||||||
|
|
||||||
|
def get_by_slug_or_id(self, slug_or_id: str | UUID) -> GroupInDB | None:
|
||||||
|
if isinstance(slug_or_id, str):
|
||||||
|
try:
|
||||||
|
slug_or_id = UUID(slug_or_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(slug_or_id, UUID):
|
||||||
|
return self.get_one(slug_or_id)
|
||||||
|
else:
|
||||||
|
return self.get_one(slug_or_id, key="slug")
|
||||||
|
|
||||||
def statistics(self, group_id: UUID4) -> GroupStatistics:
|
def statistics(self, group_id: UUID4) -> GroupStatistics:
|
||||||
def model_count(model: type[SqlAlchemyBase]) -> int:
|
def model_count(model: type[SqlAlchemyBase]) -> int:
|
||||||
stmt = select(func.count(model.id)).filter_by(group_id=group_id)
|
stmt = select(func.count(model.id)).filter_by(group_id=group_id)
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import UUID4
|
|
||||||
|
|
||||||
from mealie.routes._base import controller
|
from mealie.routes._base import controller
|
||||||
from mealie.routes._base.base_controllers import BasePublicController
|
from mealie.routes._base.base_controllers import BasePublicController
|
||||||
@ -10,14 +9,14 @@ router = APIRouter(prefix="/explore", tags=["Explore: Recipes"])
|
|||||||
|
|
||||||
@controller(router)
|
@controller(router)
|
||||||
class PublicRecipesController(BasePublicController):
|
class PublicRecipesController(BasePublicController):
|
||||||
@router.get("/recipes/{group_id}/{recipe_slug}", response_model=Recipe)
|
@router.get("/recipes/{group_slug}/{recipe_slug}", response_model=Recipe)
|
||||||
def get_recipe(self, group_id: UUID4, recipe_slug: str) -> Recipe:
|
def get_recipe(self, group_slug: str, recipe_slug: str) -> Recipe:
|
||||||
group = self.repos.groups.get_one(group_id)
|
group = self.repos.groups.get_by_slug_or_id(group_slug)
|
||||||
|
|
||||||
if not group or group.preferences.private_group:
|
if not group or group.preferences.private_group:
|
||||||
raise HTTPException(404, "group not found")
|
raise HTTPException(404, "group not found")
|
||||||
|
|
||||||
recipe = self.repos.recipes.by_group(group_id).get_one(recipe_slug)
|
recipe = self.repos.recipes.by_group(group.id).get_one(recipe_slug)
|
||||||
|
|
||||||
if not recipe or not recipe.settings.public:
|
if not recipe or not recipe.settings.public:
|
||||||
raise HTTPException(404, "recipe not found")
|
raise HTTPException(404, "recipe not found")
|
||||||
|
@ -181,6 +181,7 @@ class PrivateUser(UserOut):
|
|||||||
class UpdateGroup(GroupBase):
|
class UpdateGroup(GroupBase):
|
||||||
id: UUID4
|
id: UUID4
|
||||||
name: str
|
name: str
|
||||||
|
slug: str
|
||||||
categories: list[CategoryBase] | None = []
|
categories: list[CategoryBase] | None = []
|
||||||
|
|
||||||
webhooks: list[Any] = []
|
webhooks: list[Any] = []
|
||||||
|
@ -34,6 +34,8 @@ def test_public_recipe_success(
|
|||||||
test_case: PublicRecipeTestCase,
|
test_case: PublicRecipeTestCase,
|
||||||
):
|
):
|
||||||
group = database.groups.get_one(unique_user.group_id)
|
group = database.groups.get_one(unique_user.group_id)
|
||||||
|
assert group
|
||||||
|
|
||||||
group.preferences.private_group = test_case.private_group
|
group.preferences.private_group = test_case.private_group
|
||||||
database.group_preferences.update(group.id, group.preferences)
|
database.group_preferences.update(group.id, group.preferences)
|
||||||
|
|
||||||
@ -42,9 +44,10 @@ def test_public_recipe_success(
|
|||||||
database.recipes.update(random_recipe.slug, random_recipe)
|
database.recipes.update(random_recipe.slug, random_recipe)
|
||||||
|
|
||||||
# Try to access recipe
|
# Try to access recipe
|
||||||
|
recipe_group = database.groups.get_by_slug_or_id(random_recipe.group_id)
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
api_routes.explore_recipes_group_id_recipe_slug(
|
api_routes.explore_recipes_group_slug_recipe_slug(
|
||||||
random_recipe.group_id,
|
recipe_group.slug,
|
||||||
random_recipe.slug,
|
random_recipe.slug,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
24
tests/unit_tests/repository_tests/test_group_repository.py
Normal file
24
tests/unit_tests/repository_tests/test_group_repository.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
|
from tests.utils.factories import random_int, random_string
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_resolve_similar_names(database: AllRepositories):
|
||||||
|
base_group_name = random_string()
|
||||||
|
groups = database.groups.create_many({"name": base_group_name} for _ in range(random_int(3, 10)))
|
||||||
|
|
||||||
|
seen_names = set()
|
||||||
|
seen_slugs = set()
|
||||||
|
for group in groups:
|
||||||
|
assert group.name not in seen_names
|
||||||
|
assert group.slug not in seen_slugs
|
||||||
|
seen_names.add(group.name)
|
||||||
|
seen_slugs.add(group.slug)
|
||||||
|
|
||||||
|
assert base_group_name in group.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_get_by_slug_or_id(database: AllRepositories):
|
||||||
|
groups = [database.groups.create({"name": random_string()}) for _ in range(random_int(3, 10))]
|
||||||
|
for group in groups:
|
||||||
|
assert database.groups.get_by_slug_or_id(group.id) == group
|
||||||
|
assert database.groups.get_by_slug_or_id(group.slug) == group
|
@ -225,9 +225,9 @@ def comments_item_id(item_id):
|
|||||||
return f"{prefix}/comments/{item_id}"
|
return f"{prefix}/comments/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
def explore_recipes_group_id_recipe_slug(group_id, recipe_slug):
|
def explore_recipes_group_slug_recipe_slug(group_slug, recipe_slug):
|
||||||
"""`/api/explore/recipes/{group_id}/{recipe_slug}`"""
|
"""`/api/explore/recipes/{group_slug}/{recipe_slug}`"""
|
||||||
return f"{prefix}/explore/recipes/{group_id}/{recipe_slug}"
|
return f"{prefix}/explore/recipes/{group_slug}/{recipe_slug}"
|
||||||
|
|
||||||
|
|
||||||
def foods_item_id(item_id):
|
def foods_item_id(item_id):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user