feat: timeline event for mealplans (#2050)

* added related user to mealplans

* made timeline event message actually optional

* added task to create events for mealplan recipes

* replaced fk constraint ops with bulk ops

* fixed event creation and adjusted query range

* indentation is hard

* added missing recipe id query filter

* added tests
This commit is contained in:
Michael Genson
2023-02-11 13:08:53 -06:00
committed by GitHub
parent 9e77a9f367
commit 5f7ac92c96
11 changed files with 371 additions and 4 deletions

View File

@@ -56,6 +56,7 @@ async def start_scheduler():
tasks.purge_group_registration,
tasks.purge_password_reset_tokens,
tasks.purge_group_data_exports,
tasks.create_mealplan_timeline_events,
)
SchedulerRegistry.register_minutely(

View File

@@ -14,6 +14,7 @@ if TYPE_CHECKING:
from group import Group
from ..recipe import RecipeModel
from ..users import User
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
@@ -47,6 +48,8 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins):
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans")
user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(

View File

@@ -13,6 +13,7 @@ from .user_to_favorite import users_to_favorites
if TYPE_CHECKING:
from ..group import Group
from ..group.mealplan import GroupMealPlan
from ..recipe import RecipeComment, RecipeModel, RecipeTimelineEvent
from .password_reset import PasswordResetModel
@@ -68,6 +69,9 @@ class User(SqlAlchemyBase, BaseMixins):
owned_recipes: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id]
)
mealplans: Mapped[Optional["GroupMealPlan"]] = orm.relationship(
"GroupMealPlan", order_by="GroupMealPlan.date", **sp_args
)
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"

View File

@@ -83,7 +83,13 @@ class GroupMealplanController(BaseCrudController):
try:
recipe = random_recipes[0]
return self.mixins.create_one(
SavePlanEntry(date=data.date, entry_type=data.entry_type, recipe_id=recipe.id, group_id=self.group_id)
SavePlanEntry(
date=data.date,
entry_type=data.entry_type,
recipe_id=recipe.id,
group_id=self.group_id,
user_id=self.user.id,
)
)
except IndexError as e:
raise HTTPException(
@@ -118,7 +124,7 @@ class GroupMealplanController(BaseCrudController):
@router.post("", response_model=ReadPlanEntry, status_code=201)
def create_one(self, data: CreatePlanEntry):
data = mapper.cast(data, SavePlanEntry, group_id=self.group.id)
data = mapper.cast(data, SavePlanEntry, group_id=self.group.id, user_id=self.user.id)
result = self.mixins.create_one(data)
self.publish_event(

View File

@@ -40,10 +40,12 @@ class CreatePlanEntry(MealieModel):
class UpdatePlanEntry(CreatePlanEntry):
id: int
group_id: UUID
user_id: UUID | None
class SavePlanEntry(CreatePlanEntry):
group_id: UUID
user_id: UUID | None
class Config:
orm_mode = True

View File

@@ -20,7 +20,7 @@ class RecipeTimelineEventIn(MealieModel):
subject: str
event_type: TimelineEventType
message: str | None = Field(alias="eventMessage")
message: str | None = Field(None, alias="eventMessage")
image: str | None = None
timestamp: datetime = datetime.now()

View File

@@ -1,3 +1,4 @@
from .create_timeline_events import create_mealplan_timeline_events
from .post_webhooks import post_group_webhooks
from .purge_group_exports import purge_group_data_exports
from .purge_password_reset import purge_password_reset_tokens
@@ -5,6 +6,7 @@ from .purge_registration import purge_group_registration
from .reset_locked_users import locked_user_reset
__all__ = [
"create_mealplan_timeline_events",
"post_group_webhooks",
"purge_password_reset_tokens",
"purge_group_data_exports",

View File

@@ -0,0 +1,120 @@
from datetime import datetime, time, timedelta, timezone
from pydantic import UUID4
from mealie.db.db_setup import session_context
from mealie.repos.all_repositories import get_repositories
from mealie.schema.meal_plan.new_meal import PlanEntryType
from mealie.schema.recipe.recipe import Recipe, RecipeSummary
from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventCreate,
TimelineEventType,
)
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.event_types import (
EventOperation,
EventRecipeData,
EventRecipeTimelineEventData,
EventTypes,
)
def create_mealplan_timeline_events(group_id: UUID4 | None = None):
event_time = datetime.now(timezone.utc)
with session_context() as session:
repos = get_repositories(session)
event_bus_service = EventBusService(session=session, group_id=group_id)
timeline_events_to_create: list[RecipeTimelineEventCreate] = []
recipes_to_update: dict[UUID4, RecipeSummary] = {}
recipe_id_to_slug_map: dict[UUID4, str] = {}
if group_id is None:
# if not specified, we check all groups
groups_data = repos.groups.page_all(PaginationQuery(page=1, per_page=-1))
group_ids = [group.id for group in groups_data.items]
else:
group_ids = [group_id]
for group_id in group_ids:
mealplans = repos.meals.get_today(group_id)
for mealplan in mealplans:
if not (mealplan.recipe and mealplan.user_id):
continue
user = repos.users.get_one(mealplan.user_id)
if not user:
continue
# TODO: make this translatable
if mealplan.entry_type == PlanEntryType.side:
event_subject = f"{user.full_name} made this as a side"
else:
event_subject = f"{user.full_name} made this for {mealplan.entry_type.value}"
query_start_time = datetime.combine(datetime.now(timezone.utc).date(), time.min)
query_end_time = query_start_time + timedelta(days=1)
query = PaginationQuery(
query_filter=(
f'recipe_id = "{mealplan.recipe_id}" '
f'AND timestamp >= "{query_start_time.isoformat()}" '
f'AND timestamp < "{query_end_time.isoformat()}" '
f'AND subject = "{event_subject}"'
)
)
# if this event already exists, don't create it again
events = repos.recipe_timeline_events.page_all(pagination=query)
if events.items:
continue
# bump up the last made date
last_made = mealplan.recipe.last_made
if (
not last_made or last_made.date() < event_time.date()
) and mealplan.recipe_id not in recipes_to_update:
recipes_to_update[mealplan.recipe_id] = mealplan.recipe
timeline_events_to_create.append(
RecipeTimelineEventCreate(
user_id=user.id,
subject=event_subject,
event_type=TimelineEventType.info,
timestamp=event_time,
recipe_id=mealplan.recipe_id,
)
)
recipe_id_to_slug_map[mealplan.recipe_id] = mealplan.recipe.slug
if not timeline_events_to_create:
return
# TODO: use bulk operations
for event in timeline_events_to_create:
new_event = repos.recipe_timeline_events.create(event)
event_bus_service.dispatch(
integration_id=DEFAULT_INTEGRATION_ID,
group_id=group_id, # type: ignore
event_type=EventTypes.recipe_updated,
document_data=EventRecipeTimelineEventData(
operation=EventOperation.create,
recipe_slug=recipe_id_to_slug_map[new_event.recipe_id],
recipe_timeline_event_id=new_event.id,
),
)
for recipe in recipes_to_update.values():
recipe.last_made = event_time
repos.recipes.update(recipe.slug, recipe.cast(Recipe))
event_bus_service.dispatch(
integration_id=DEFAULT_INTEGRATION_ID,
group_id=group_id, # type: ignore
event_type=EventTypes.recipe_updated,
document_data=EventRecipeData(operation=EventOperation.update, recipe_slug=recipe.slug),
)