mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-13 05:45:22 -05:00
Feature/shareable recipes (#866)
* simplify context menu * move computed to comp-api * feat: ✨ create share tokens for recipes for sharing recieps to non-users * feat: ✨ shareable recipe links with og tags
This commit is contained in:
@@ -13,6 +13,7 @@ from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.comment import RecipeComment
|
||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.shared import RecipeShareTokenModel
|
||||
from mealie.db.models.recipe.tag import Tag
|
||||
from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.server.task import ServerTaskModel
|
||||
@@ -29,6 +30,7 @@ from mealie.schema.group.webhook import ReadWebhook
|
||||
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
|
||||
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
|
||||
from mealie.schema.recipe.recipe_share_token import RecipeShareToken
|
||||
from mealie.schema.recipe.recipe_tool import RecipeTool
|
||||
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
|
||||
from mealie.schema.server import ServerTask
|
||||
@@ -99,6 +101,10 @@ class Database:
|
||||
def tags(self) -> TagsDataAccessModel:
|
||||
return TagsDataAccessModel(self.session, pk_slug, Tag, RecipeTagResponse)
|
||||
|
||||
@cached_property
|
||||
def recipe_share_tokens(self) -> AccessModel[RecipeShareToken, RecipeShareTokenModel]:
|
||||
return AccessModel(self.session, pk_id, RecipeShareTokenModel, RecipeShareToken)
|
||||
|
||||
# ================================================================
|
||||
# Site Items
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from .instruction import RecipeInstruction
|
||||
from .note import Note
|
||||
from .nutrition import Nutrition
|
||||
from .settings import RecipeSettings
|
||||
from .shared import RecipeShareTokenModel
|
||||
from .tag import Tag, recipes2tags
|
||||
from .tool import recipes_to_tools
|
||||
|
||||
@@ -87,6 +88,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
collection_class=ordering_list("position"),
|
||||
)
|
||||
|
||||
share_tokens = orm.relationship(RecipeShareTokenModel, back_populates="recipe")
|
||||
|
||||
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
|
||||
|
||||
# Mealie Specific
|
||||
|
||||
27
mealie/db/models/recipe/shared.py
Normal file
27
mealie/db/models/recipe/shared.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
def defaut_expires_at_time() -> datetime:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
|
||||
class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_share_tokens"
|
||||
id = sa.Column(GUID, primary_key=True, default=uuid4)
|
||||
|
||||
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
|
||||
|
||||
recipe_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"), nullable=False)
|
||||
recipe = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
|
||||
|
||||
expires_at = sa.Column(sa.DateTime, nullable=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
@@ -9,6 +9,7 @@ from . import (
|
||||
groups,
|
||||
parser,
|
||||
recipe,
|
||||
shared,
|
||||
shopping_lists,
|
||||
tags,
|
||||
tools,
|
||||
@@ -23,6 +24,7 @@ router.include_router(auth.router)
|
||||
router.include_router(users.router)
|
||||
router.include_router(groups.router)
|
||||
router.include_router(recipe.router)
|
||||
router.include_router(shared.router)
|
||||
router.include_router(comments.router)
|
||||
router.include_router(parser.router)
|
||||
router.include_router(unit_and_foods.router)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, recipe_export
|
||||
from . import (
|
||||
all_recipe_routes,
|
||||
bulk_actions,
|
||||
comments,
|
||||
image_and_assets,
|
||||
recipe_crud_routes,
|
||||
recipe_export,
|
||||
shared_routes,
|
||||
)
|
||||
|
||||
prefix = "/recipes"
|
||||
|
||||
@@ -14,3 +22,4 @@ router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe
|
||||
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"])
|
||||
router.include_router(bulk_actions.export_router, prefix=prefix, tags=["Recipe: Bulk Exports"])
|
||||
router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"])
|
||||
|
||||
21
mealie/routes/recipe/shared_routes.py
Normal file
21
mealie/routes/recipe/shared_routes.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.db.database import get_database
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.schema.recipe import Recipe
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/shared/{token_id}", response_model=Recipe)
|
||||
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)):
|
||||
db = get_database(session)
|
||||
|
||||
token_summary = db.recipe_share_tokens.get_one(token_id)
|
||||
|
||||
if token_summary is None:
|
||||
return None
|
||||
|
||||
return token_summary.recipe
|
||||
23
mealie/routes/shared/__init__.py
Normal file
23
mealie/routes/shared/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import Depends
|
||||
|
||||
from mealie.routes.routers import UserAPIRouter
|
||||
from mealie.services._base_http_service.router_factory import RouterFactory
|
||||
from mealie.services.shared.recipe_shared_service import RecipeShareTokenSummary, SharedRecipeService
|
||||
|
||||
router = UserAPIRouter(prefix="/shared")
|
||||
|
||||
shared_router = RouterFactory(SharedRecipeService, prefix="/recipes", tags=["Shared: Recipes"])
|
||||
|
||||
|
||||
@shared_router.get("", response_model=list[RecipeShareTokenSummary])
|
||||
def get_all_shared(
|
||||
recipe_id: int = None,
|
||||
shared_recipe_service: SharedRecipeService = Depends(SharedRecipeService.private),
|
||||
):
|
||||
"""
|
||||
Get all shared recipes
|
||||
"""
|
||||
return shared_recipe_service.get_all(recipe_id)
|
||||
|
||||
|
||||
router.include_router(shared_router)
|
||||
34
mealie/schema/recipe/recipe_share_token.py
Normal file
34
mealie/schema/recipe/recipe_share_token.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4, Field
|
||||
|
||||
from .recipe import Recipe
|
||||
|
||||
|
||||
def defaut_expires_at_time() -> datetime:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
|
||||
class RecipeShareTokenCreate(CamelModel):
|
||||
recipe_id: int
|
||||
expires_at: datetime = Field(default_factory=defaut_expires_at_time)
|
||||
|
||||
|
||||
class RecipeShareTokenSave(RecipeShareTokenCreate):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class RecipeShareTokenSummary(RecipeShareTokenSave):
|
||||
id: UUID4
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class RecipeShareToken(RecipeShareTokenSummary):
|
||||
recipe: Recipe
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
0
mealie/services/shared/__init__.py
Normal file
0
mealie/services/shared/__init__.py
Normal file
51
mealie/services/shared/recipe_shared_service.py
Normal file
51
mealie/services/shared/recipe_shared_service.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from functools import cached_property
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.recipe.recipe_share_token import (
|
||||
RecipeShareToken,
|
||||
RecipeShareTokenCreate,
|
||||
RecipeShareTokenSave,
|
||||
RecipeShareTokenSummary,
|
||||
)
|
||||
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
from mealie.services.events import create_recipe_event
|
||||
|
||||
|
||||
class SharedRecipeService(
|
||||
CrudHttpMixins[RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenCreate],
|
||||
UserHttpService[UUID4, RecipeShareToken],
|
||||
):
|
||||
event_func = create_recipe_event
|
||||
_restrict_by_group = False
|
||||
_schema = RecipeShareToken
|
||||
|
||||
@cached_property
|
||||
def dal(self):
|
||||
return self.db.recipe_share_tokens
|
||||
|
||||
def populate_item(self, id: UUID4) -> RecipeShareToken:
|
||||
self.item = self.dal.get_one(id)
|
||||
return self.item
|
||||
|
||||
def get_all(self, recipe_id=None) -> list[RecipeShareTokenSummary]:
|
||||
# sourcery skip: assign-if-exp, inline-immediately-returned-variable
|
||||
if recipe_id:
|
||||
return self.db.recipe_share_tokens.multi_query(
|
||||
{"group_id": self.group_id, "recipe_id": recipe_id},
|
||||
override_schema=RecipeShareTokenSummary,
|
||||
)
|
||||
else:
|
||||
return self.db.recipe_share_tokens.multi_query(
|
||||
{"group_id": self.group_id}, override_schema=RecipeShareTokenSummary
|
||||
)
|
||||
|
||||
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
|
||||
save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id)
|
||||
return self._create_one(save_data)
|
||||
|
||||
def delete_one(self, item_id: UUID4 = None) -> None:
|
||||
item_id = item_id or self.item.id
|
||||
|
||||
return self.dal.delete(item_id)
|
||||
Reference in New Issue
Block a user