mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-11 03:20:14 -04:00
feat: ✨ add user recipe export functionality (#845)
* feat(frontend): ✨ add user recipe export functionality * remove depreciated folders * change/remove depreciated folders * add testing variable in config * add GUID support for group_id * improve testing feedback on 422 errors * remove/cleanup files/folders * initial user export support * delete unused css * update backup page UI * remove depreciated settings * feat: ✨ export download links * fix #813 * remove top level statements * show footer * add export purger to scheduler * update purge glob * fix meal-planner lockout * feat: ✨ add bulk delete/purge exports * style(frontend): 💄 update UI for site settings * feat: ✨ add version checker * update documentation Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
@@ -37,6 +37,7 @@ def start_scheduler():
|
||||
tasks.purge_group_registration,
|
||||
tasks.auto_backup,
|
||||
tasks.purge_password_reset_tokens,
|
||||
tasks.purge_group_data_exports,
|
||||
)
|
||||
|
||||
SchedulerRegistry.register_hourly()
|
||||
|
||||
41
mealie/core/release_checker.py
Normal file
41
mealie/core/release_checker.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
|
||||
_LAST_RESET = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_latest_github_release() -> str:
|
||||
"""
|
||||
Gets the latest release from GitHub.
|
||||
|
||||
Returns:
|
||||
str: The latest release from GitHub.
|
||||
"""
|
||||
|
||||
url = "https://api.github.com/repos/hay-kot/mealie/releases/latest"
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()["tag_name"]
|
||||
|
||||
|
||||
def get_latest_version() -> str:
|
||||
"""
|
||||
Gets the latest release version.
|
||||
|
||||
Returns:
|
||||
str: The latest release version.
|
||||
"""
|
||||
MAX_DAYS_OLD = 1 # reset cache after 1 day
|
||||
|
||||
global _LAST_RESET
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
if not _LAST_RESET or now - _LAST_RESET > datetime.timedelta(days=MAX_DAYS_OLD):
|
||||
_LAST_RESET = now
|
||||
get_latest_github_release.cache_clear()
|
||||
|
||||
return get_latest_github_release()
|
||||
@@ -2,30 +2,33 @@ from pathlib import Path
|
||||
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, data_dir) -> None:
|
||||
self.DATA_DIR: Path = data_dir
|
||||
self.IMG_DIR: Path = data_dir.joinpath("img")
|
||||
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
|
||||
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
|
||||
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
|
||||
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
|
||||
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
|
||||
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
|
||||
self.USER_DIR: Path = data_dir.joinpath("users")
|
||||
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
|
||||
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.DATA_DIR = data_dir
|
||||
self.BACKUP_DIR = data_dir.joinpath("backups")
|
||||
self.USER_DIR = data_dir.joinpath("users")
|
||||
self.RECIPE_DATA_DIR = data_dir.joinpath("recipes")
|
||||
self.TEMPLATE_DIR = data_dir.joinpath("templates")
|
||||
|
||||
self.GROUPS_DIR = self.DATA_DIR.joinpath("groups")
|
||||
|
||||
# Deprecated
|
||||
self._TEMP_DIR = data_dir.joinpath(".temp")
|
||||
self._IMG_DIR = data_dir.joinpath("img")
|
||||
self.ensure_directories()
|
||||
|
||||
@property
|
||||
def IMG_DIR(self):
|
||||
return self._IMG_DIR
|
||||
|
||||
@property
|
||||
def TEMP_DIR(self):
|
||||
return self._TEMP_DIR
|
||||
|
||||
def ensure_directories(self):
|
||||
required_dirs = [
|
||||
self.IMG_DIR,
|
||||
self.GROUPS_DIR,
|
||||
self.BACKUP_DIR,
|
||||
self.DEBUG_DIR,
|
||||
self.MIGRATION_DIR,
|
||||
self.TEMPLATE_DIR,
|
||||
self.NEXTCLOUD_DIR,
|
||||
self.CHOWDOWN_DIR,
|
||||
self.RECIPE_DATA_DIR,
|
||||
self.USER_DIR,
|
||||
]
|
||||
|
||||
@@ -102,6 +102,11 @@ class AppSettings(BaseSettings):
|
||||
not_none = None not in required
|
||||
return self.LDAP_AUTH_ENABLED and not_none
|
||||
|
||||
# ===============================================
|
||||
# Testing Config
|
||||
|
||||
TESTING: bool = False
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
||||
from mealie.db.models.event import Event, EventNotification
|
||||
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
|
||||
from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
||||
@@ -21,6 +22,7 @@ from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.invite_token import ReadInviteToken
|
||||
from mealie.schema.group.webhook import ReadWebhook
|
||||
@@ -42,6 +44,7 @@ from .user_access_model import UserDataAccessModel
|
||||
pk_id = "id"
|
||||
pk_slug = "slug"
|
||||
pk_token = "token"
|
||||
pk_group_id = "group_id"
|
||||
|
||||
|
||||
class CategoryDataAccessModel(AccessModel):
|
||||
@@ -143,7 +146,11 @@ class Database:
|
||||
|
||||
@cached_property
|
||||
def group_preferences(self) -> AccessModel[ReadGroupPreferences, GroupPreferencesModel]:
|
||||
return AccessModel(self.session, "group_id", GroupPreferencesModel, ReadGroupPreferences)
|
||||
return AccessModel(self.session, pk_group_id, GroupPreferencesModel, ReadGroupPreferences)
|
||||
|
||||
@cached_property
|
||||
def group_exports(self) -> AccessModel[GroupDataExport, GroupDataExportsModel]:
|
||||
return AccessModel(self.session, pk_id, GroupDataExportsModel, GroupDataExport)
|
||||
|
||||
@cached_property
|
||||
def meals(self) -> MealDataAccessModel:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.db.models.group import GroupMealPlan
|
||||
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
|
||||
@@ -7,7 +8,7 @@ from ._access_model import AccessModel
|
||||
|
||||
|
||||
class MealDataAccessModel(AccessModel[ReadPlanEntry, GroupMealPlan]):
|
||||
def get_slice(self, start: date, end: date, group_id: int) -> list[ReadPlanEntry]:
|
||||
def get_slice(self, start: date, end: date, group_id: UUID) -> list[ReadPlanEntry]:
|
||||
start = start.strftime("%Y-%m-%d")
|
||||
end = end.strftime("%Y-%m-%d")
|
||||
qry = self.session.query(GroupMealPlan).filter(
|
||||
@@ -17,7 +18,7 @@ class MealDataAccessModel(AccessModel[ReadPlanEntry, GroupMealPlan]):
|
||||
|
||||
return [self.schema.from_orm(x) for x in qry.all()]
|
||||
|
||||
def get_today(self, group_id: int) -> list[ReadPlanEntry]:
|
||||
def get_today(self, group_id: UUID) -> list[ReadPlanEntry]:
|
||||
today = date.today()
|
||||
qry = self.session.query(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
|
||||
|
||||
|
||||
@@ -62,14 +62,19 @@ class RecipeDataAccessModel(AccessModel[Recipe, RecipeModel]):
|
||||
override_schema=override_schema,
|
||||
)
|
||||
|
||||
def summary(self, group_id, start=0, limit=99999) -> Any:
|
||||
def summary(self, group_id, start=0, limit=99999, load_foods=False) -> Any:
|
||||
args = [
|
||||
joinedload(RecipeModel.recipe_category),
|
||||
joinedload(RecipeModel.tags),
|
||||
joinedload(RecipeModel.tools),
|
||||
]
|
||||
|
||||
if load_foods:
|
||||
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)))
|
||||
|
||||
return (
|
||||
self.session.query(RecipeModel)
|
||||
.options(
|
||||
joinedload(RecipeModel.recipe_category),
|
||||
joinedload(RecipeModel.tags),
|
||||
joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)),
|
||||
)
|
||||
.options(*args)
|
||||
.filter(RecipeModel.group_id == group_id)
|
||||
.offset(start)
|
||||
.limit(limit)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .auto_init import auto_init
|
||||
from .guid import GUID
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .cookbook import *
|
||||
from .exports import *
|
||||
from .group import *
|
||||
from .invite_tokens import *
|
||||
from .mealplan import *
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils import auto_init, guid
|
||||
from ..recipe.category import Category, cookbooks_to_categories
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class CookBook(SqlAlchemyBase, BaseMixins):
|
||||
slug = Column(String, nullable=False)
|
||||
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True)
|
||||
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group_id = Column(guid.GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="cookbooks")
|
||||
|
||||
@auto_init()
|
||||
|
||||
24
mealie/db/models/group/exports.py
Normal file
24
mealie/db/models/group/exports.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class GroupDataExportsModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_data_exports"
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
|
||||
group = orm.relationship("Group", back_populates="data_exports", single_parent=True)
|
||||
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
|
||||
|
||||
name = Column(String, nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
path = Column(String, nullable=False)
|
||||
size = Column(String, nullable=False)
|
||||
expires = Column(String, nullable=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
@@ -1,15 +1,17 @@
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.server.task import ServerTaskModel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils import GUID, auto_init
|
||||
from ..group.invite_tokens import GroupInviteToken
|
||||
from ..group.webhooks import GroupWebhooksModel
|
||||
from ..recipe.category import Category, group2categories
|
||||
from ..server.task import ServerTaskModel
|
||||
from .cookbook import CookBook
|
||||
from .mealplan import GroupMealPlan
|
||||
from .preferences import GroupPreferencesModel
|
||||
@@ -19,6 +21,7 @@ settings = get_app_settings()
|
||||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
id = sa.Column(GUID, primary_key=True, default=uuid.uuid4)
|
||||
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
||||
users = orm.relationship("User", back_populates="group")
|
||||
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
|
||||
@@ -48,11 +51,21 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
webhooks = orm.relationship(GroupWebhooksModel, **common_args)
|
||||
cookbooks = orm.relationship(CookBook, **common_args)
|
||||
server_tasks = orm.relationship(ServerTaskModel, **common_args)
|
||||
data_exports = orm.relationship("GroupDataExportsModel", **common_args)
|
||||
shopping_lists = orm.relationship("ShoppingList", **common_args)
|
||||
group_reports = orm.relationship("ReportModel", **common_args)
|
||||
|
||||
class Config:
|
||||
exclude = {"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens", "mealplans"}
|
||||
exclude = {
|
||||
"users",
|
||||
"webhooks",
|
||||
"shopping_lists",
|
||||
"cookbooks",
|
||||
"preferences",
|
||||
"invite_tokens",
|
||||
"mealplans",
|
||||
"data_exports",
|
||||
}
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils import auto_init, guid
|
||||
|
||||
|
||||
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
||||
@@ -9,7 +9,7 @@ class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
||||
token = Column(String, index=True, nullable=False, unique=True)
|
||||
uses_left = Column(Integer, nullable=False, default=1)
|
||||
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group_id = Column(guid.GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="invite_tokens")
|
||||
|
||||
@auto_init()
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import Column, Date, ForeignKey, String, orm
|
||||
from sqlalchemy.sql.sqltypes import Integer
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
|
||||
@@ -13,7 +13,7 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins):
|
||||
title = Column(String, index=True, nullable=False)
|
||||
text = Column(String, nullable=False)
|
||||
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), index=True)
|
||||
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
|
||||
group = orm.relationship("Group", back_populates="mealplans")
|
||||
|
||||
recipe_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
|
||||
|
||||
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_preferences"
|
||||
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
|
||||
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="preferences")
|
||||
|
||||
private_group: bool = sa.Column(sa.Boolean, default=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, orm
|
||||
from sqlalchemy import Column, ForeignKey, orm
|
||||
from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
@@ -12,14 +12,14 @@ from .._model_utils.guid import GUID
|
||||
|
||||
class ReportEntryModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "report_entries"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
|
||||
success = Column(Boolean, default=False)
|
||||
message = Column(String, nullable=True)
|
||||
exception = Column(String, nullable=True)
|
||||
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
report_id = Column(GUID(), ForeignKey("group_reports.id"), nullable=False)
|
||||
report_id = Column(GUID, ForeignKey("group_reports.id"), nullable=False)
|
||||
report = orm.relationship("ReportModel", back_populates="entries")
|
||||
|
||||
@auto_init()
|
||||
@@ -29,7 +29,7 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class ReportModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_reports"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
|
||||
name = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False)
|
||||
@@ -39,7 +39,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins):
|
||||
entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan")
|
||||
|
||||
# Relationships
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group_id = Column(GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="group_reports", single_parent=True)
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils.guid import GUID
|
||||
from .group import Group
|
||||
|
||||
|
||||
@@ -29,7 +30,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "shopping_lists"
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group_id = Column(GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="shopping_lists")
|
||||
|
||||
name = Column(String)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
@@ -10,7 +9,7 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
group = orm.relationship("Group", back_populates="webhooks", single_parent=True)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), index=True)
|
||||
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
|
||||
|
||||
enabled = Column(Boolean, default=False)
|
||||
name = Column(String)
|
||||
|
||||
@@ -4,7 +4,9 @@ from slugify import slugify
|
||||
from sqlalchemy.orm import validates
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
@@ -12,7 +14,7 @@ logger = root_logger.get_logger()
|
||||
group2categories = sa.Table(
|
||||
"group2categories",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")),
|
||||
sa.Column("group_id", GUID, sa.ForeignKey("groups.id")),
|
||||
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
class RecipeComment(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_comments"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
text = Column(String)
|
||||
|
||||
# Recipe Link
|
||||
|
||||
@@ -49,7 +49,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
|
||||
food = orm.relationship(IngredientFoodModel, uselist=False)
|
||||
quantity = Column(Integer)
|
||||
|
||||
reference_id = Column(GUID()) # Reference Links
|
||||
reference_id = Column(GUID) # Reference Links
|
||||
|
||||
# Extras
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from .._model_utils.guid import GUID
|
||||
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_ingredient_ref_link"
|
||||
instruction_id = Column(Integer, ForeignKey("recipe_instructions.id"))
|
||||
reference_id = Column(GUID())
|
||||
reference_id = Column(GUID)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -19,7 +19,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class RecipeInstruction(SqlAlchemyBase):
|
||||
__tablename__ = "recipe_instructions"
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
id = Column(GUID, primary_key=True, default=uuid4)
|
||||
parent_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
position = Column(Integer)
|
||||
type = Column(String, default="")
|
||||
|
||||
@@ -6,6 +6,8 @@ import sqlalchemy.orm as orm
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
from sqlalchemy.orm import validates
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..users import users_to_favorites
|
||||
@@ -43,13 +45,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
slug = sa.Column(sa.String, index=True)
|
||||
|
||||
# ID Relationships
|
||||
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
|
||||
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
|
||||
|
||||
user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
|
||||
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
|
||||
|
||||
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe")
|
||||
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan")
|
||||
|
||||
favorited_by = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, orm
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, orm
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_utils import auto_init
|
||||
|
||||
@@ -12,7 +13,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins):
|
||||
status = Column(String, nullable=False)
|
||||
log = Column(String, nullable=True)
|
||||
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group_id = Column(GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="server_tasks")
|
||||
|
||||
@auto_init()
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..group import Group
|
||||
from .user_to_favorite import users_to_favorites
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "long_live_tokens"
|
||||
@@ -32,7 +31,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
admin = Column(Boolean, default=False)
|
||||
advanced = Column(Boolean, default=False)
|
||||
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group_id = Column(GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="users")
|
||||
|
||||
# Group Permissions
|
||||
@@ -40,17 +39,15 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
can_invite = Column(Boolean, default=False)
|
||||
can_organize = Column(Boolean, default=False)
|
||||
|
||||
tokens: list[LongLiveToken] = orm.relationship(
|
||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
sp_args = {
|
||||
"back_populates": "user",
|
||||
"cascade": "all, delete, delete-orphan",
|
||||
"single_parent": True,
|
||||
}
|
||||
|
||||
comments: list = orm.relationship(
|
||||
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
||||
password_reset_tokens = orm.relationship(
|
||||
"PasswordResetModel", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
tokens = orm.relationship(LongLiveToken, **sp_args)
|
||||
comments = orm.relationship("RecipeComment", **sp_args)
|
||||
password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args)
|
||||
|
||||
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
|
||||
@@ -65,11 +62,14 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
"can_invite",
|
||||
"can_organize",
|
||||
"group",
|
||||
"username",
|
||||
}
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, session, full_name, password, group: str = settings.DEFAULT_GROUP, **kwargs) -> None:
|
||||
def __init__(self, session, full_name, password, group: str = None, **kwargs) -> None:
|
||||
if group is None:
|
||||
settings = get_app_settings()
|
||||
group = settings.DEFAULT_GROUP
|
||||
|
||||
self.group = Group.get_ref(session, group)
|
||||
|
||||
self.favorite_recipes = []
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.release_checker import get_latest_version
|
||||
from mealie.core.settings.static import APP_VERSION
|
||||
from mealie.db.database import get_database
|
||||
from mealie.db.db_setup import generate_session
|
||||
@@ -18,6 +19,7 @@ async def get_app_info():
|
||||
return AdminAboutInfo(
|
||||
production=settings.PRODUCTION,
|
||||
version=APP_VERSION,
|
||||
versionLatest=get_latest_version(),
|
||||
demo_status=settings.IS_DEMO,
|
||||
api_port=settings.API_PORT,
|
||||
api_docs=settings.API_DOCS,
|
||||
@@ -49,4 +51,5 @@ async def check_app_config():
|
||||
email_ready=settings.SMTP_ENABLE,
|
||||
ldap_ready=settings.LDAP_ENABLED,
|
||||
base_url_set=url_set,
|
||||
is_up_to_date=get_latest_version() == APP_VERSION,
|
||||
)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
from fastapi import BackgroundTasks, Depends, HTTPException, status
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.db.database import get_database
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.routers import AdminAPIRouter
|
||||
from mealie.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup
|
||||
from mealie.services.events import create_group_event
|
||||
|
||||
router = AdminAPIRouter(prefix="/groups")
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupInDB])
|
||||
async def get_all_groups(session: Session = Depends(generate_session)):
|
||||
"""Returns a list of all groups in the database"""
|
||||
db = get_database(session)
|
||||
return db.groups.get_all()
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
|
||||
async def create_group(
|
||||
background_tasks: BackgroundTasks,
|
||||
group_data: GroupBase,
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Creates a Group in the Database"""
|
||||
db = get_database(session)
|
||||
|
||||
try:
|
||||
new_group = db.groups.create(group_data.dict())
|
||||
background_tasks.add_task(create_group_event, "Group Created", f"'{group_data.name}' created", session)
|
||||
return new_group
|
||||
except Exception:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_group_data(id: int, group_data: UpdateGroup, session: Session = Depends(generate_session)):
|
||||
"""Updates a User Group"""
|
||||
db = get_database(session)
|
||||
db.groups.update(id, group_data.dict())
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_user_group(
|
||||
background_tasks: BackgroundTasks,
|
||||
id: int,
|
||||
current_user: PrivateUser = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Removes a user group from the database"""
|
||||
db = get_database(session)
|
||||
|
||||
if id == 1:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="DEFAULT_GROUP")
|
||||
|
||||
group: GroupInDB = db.groups.get(id)
|
||||
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_NOT_FOUND")
|
||||
|
||||
if group.users != []:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GROUP_WITH_USERS")
|
||||
|
||||
background_tasks.add_task(
|
||||
create_group_event, "Group Deleted", f"'{group.name}' deleted by {current_user.full_name}", session
|
||||
)
|
||||
|
||||
db.groups.delete(id)
|
||||
@@ -18,7 +18,7 @@ def log_wrapper(request: Request, e):
|
||||
def register_debug_handler(app: FastAPI):
|
||||
settings = get_app_settings()
|
||||
|
||||
if settings.PRODUCTION:
|
||||
if settings.PRODUCTION and not settings.TESTING:
|
||||
return
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
|
||||
@@ -13,3 +13,4 @@ router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Reci
|
||||
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
|
||||
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"])
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from mealie.core.dependencies.dependencies import temporary_zip_path
|
||||
from mealie.core.security import create_file_token
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.recipe.recipe_bulk_actions import (
|
||||
AssignCategories,
|
||||
AssignTags,
|
||||
@@ -38,7 +41,10 @@ def bulk_delete_recipes(
|
||||
bulk_service.delete_recipes(delete_recipes.recipes)
|
||||
|
||||
|
||||
@router.post("/export", response_class=FileResponse)
|
||||
export_router = APIRouter(prefix="/bulk-actions")
|
||||
|
||||
|
||||
@export_router.post("/export")
|
||||
def bulk_export_recipes(
|
||||
export_recipes: ExportRecipes,
|
||||
temp_path=Depends(temporary_zip_path),
|
||||
@@ -46,4 +52,26 @@ def bulk_export_recipes(
|
||||
):
|
||||
bulk_service.export_recipes(temp_path, export_recipes.recipes)
|
||||
|
||||
return FileResponse(temp_path, filename="recipes.zip")
|
||||
# return FileResponse(temp_path, filename="recipes.zip")
|
||||
|
||||
|
||||
@export_router.get("/export/download")
|
||||
def get_exported_data_token(path: Path, _: RecipeBulkActions = Depends(RecipeBulkActions.private)):
|
||||
# return FileResponse(temp_path, filename="recipes.zip")
|
||||
"""Returns a token to download a file"""
|
||||
|
||||
return {"fileToken": create_file_token(path)}
|
||||
|
||||
|
||||
@export_router.get("/export", response_model=list[GroupDataExport])
|
||||
def get_exported_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)):
|
||||
return bulk_service.get_exports()
|
||||
|
||||
# return FileResponse(temp_path, filename="recipes.zip")
|
||||
|
||||
|
||||
@export_router.delete("/export/purge")
|
||||
def purge_export_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)):
|
||||
"""Remove all exports data, including items on disk without database entry"""
|
||||
amountDelete = bulk_service.purge_exports()
|
||||
return {"message": f"{amountDelete} exports deleted"}
|
||||
|
||||
@@ -21,8 +21,13 @@ logger = get_logger()
|
||||
|
||||
|
||||
@user_router.get("", response_model=list[RecipeSummary])
|
||||
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)):
|
||||
json_compatible_item_data = jsonable_encoder(service.get_all(start, limit))
|
||||
async def get_all(
|
||||
start: int = 0,
|
||||
limit: int = None,
|
||||
load_foods: bool = False,
|
||||
service: RecipeService = Depends(RecipeService.private),
|
||||
):
|
||||
json_compatible_item_data = jsonable_encoder(service.get_all(start, limit, load_foods))
|
||||
return JSONResponse(content=json_compatible_item_data)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class AppInfo(CamelModel):
|
||||
|
||||
|
||||
class AdminAboutInfo(AppInfo):
|
||||
versionLatest: str
|
||||
api_port: int
|
||||
api_docs: bool
|
||||
db_type: str
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import validator
|
||||
from slugify import slugify
|
||||
@@ -28,11 +30,11 @@ class UpdateCookBook(CreateCookBook):
|
||||
|
||||
|
||||
class SaveCookBook(CreateCookBook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class ReadCookBook(UpdateCookBook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
categories: list[CategoryBase] = []
|
||||
|
||||
class Config:
|
||||
@@ -40,7 +42,7 @@ class ReadCookBook(UpdateCookBook):
|
||||
|
||||
|
||||
class RecipeCookBook(ReadCookBook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
categories: list[RecipeCategoryResponse]
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
from .group_preferences import UpdateGroupPreferences
|
||||
|
||||
|
||||
class GroupAdminUpdate(CamelModel):
|
||||
id: int
|
||||
id: UUID4
|
||||
name: str
|
||||
preferences: UpdateGroupPreferences
|
||||
|
||||
17
mealie/schema/group/group_exports.py
Normal file
17
mealie/schema/group/group_exports.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
|
||||
class GroupDataExport(CamelModel):
|
||||
id: UUID4
|
||||
group_id: UUID4
|
||||
name: str
|
||||
filename: str
|
||||
path: str
|
||||
size: str
|
||||
expires: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
@@ -15,7 +17,7 @@ class UpdateGroupPreferences(CamelModel):
|
||||
|
||||
|
||||
class CreateGroupPreferences(UpdateGroupPreferences):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class ReadGroupPreferences(CreateGroupPreferences):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
@@ -7,14 +9,14 @@ class CreateInviteToken(CamelModel):
|
||||
|
||||
class SaveInviteToken(CamelModel):
|
||||
uses_left: int
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
token: str
|
||||
|
||||
|
||||
class ReadInviteToken(CamelModel):
|
||||
token: str
|
||||
uses_left: int
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
@@ -9,7 +11,7 @@ class CreateWebhook(CamelModel):
|
||||
|
||||
|
||||
class SaveWebhook(CreateWebhook):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class ReadWebhook(SaveWebhook):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import validator
|
||||
@@ -33,11 +34,11 @@ class CreatePlanEntry(CamelModel):
|
||||
|
||||
class UpdatePlanEntry(CreatePlanEntry):
|
||||
id: int
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
|
||||
class SavePlanEntry(CreatePlanEntry):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import BaseModel, Field, validator
|
||||
@@ -59,7 +60,7 @@ class RecipeSummary(CamelModel):
|
||||
id: Optional[int]
|
||||
|
||||
user_id: int = 0
|
||||
group_id: int = 0
|
||||
group_id: UUID = Field(default_factory=uuid4)
|
||||
|
||||
name: Optional[str]
|
||||
slug: str = ""
|
||||
@@ -74,6 +75,7 @@ class RecipeSummary(CamelModel):
|
||||
description: Optional[str] = ""
|
||||
recipe_category: Optional[list[RecipeTag]] = []
|
||||
tags: Optional[list[RecipeTag]] = []
|
||||
tools: list[RecipeTool] = []
|
||||
rating: Optional[int]
|
||||
org_url: Optional[str] = Field(None, alias="orgURL")
|
||||
|
||||
@@ -86,23 +88,28 @@ class RecipeSummary(CamelModel):
|
||||
orm_mode = True
|
||||
|
||||
@validator("tags", always=True, pre=True)
|
||||
def validate_tags(cats: list[Any], values):
|
||||
def validate_tags(cats: list[Any]):
|
||||
if isinstance(cats, list) and cats and isinstance(cats[0], str):
|
||||
return [RecipeTag(name=c, slug=slugify(c)) for c in cats]
|
||||
return cats
|
||||
|
||||
@validator("recipe_category", always=True, pre=True)
|
||||
def validate_categories(cats: list[Any], values):
|
||||
def validate_categories(cats: list[Any]):
|
||||
if isinstance(cats, list) and cats and isinstance(cats[0], str):
|
||||
return [RecipeCategory(name=c, slug=slugify(c)) for c in cats]
|
||||
return cats
|
||||
|
||||
@validator("group_id", always=True, pre=True)
|
||||
def validate_group_id(group_id: list[Any]):
|
||||
if isinstance(group_id, int):
|
||||
return uuid4()
|
||||
return group_id
|
||||
|
||||
|
||||
class Recipe(RecipeSummary):
|
||||
recipe_ingredient: Optional[list[RecipeIngredient]] = []
|
||||
recipe_instructions: Optional[list[RecipeStep]] = []
|
||||
nutrition: Optional[Nutrition]
|
||||
tools: list[RecipeTool] = []
|
||||
|
||||
# Mealie Specific
|
||||
settings: Optional[RecipeSettings] = RecipeSettings()
|
||||
|
||||
@@ -37,7 +37,7 @@ class ReportEntryOut(ReportEntryCreate):
|
||||
class ReportCreate(CamelModel):
|
||||
timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
|
||||
category: ReportCategory
|
||||
group_id: int
|
||||
group_id: UUID4
|
||||
name: str
|
||||
status: ReportSummaryStatus = ReportSummaryStatus.in_progress
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
import enum
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import Field
|
||||
@@ -18,7 +19,7 @@ class ServerTaskStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class ServerTaskCreate(CamelModel):
|
||||
group_id: int
|
||||
group_id: UUID
|
||||
name: ServerTaskNames = ServerTaskNames.default
|
||||
created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
|
||||
status: ServerTaskStatus = ServerTaskStatus.running
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
from pydantic.types import constr
|
||||
from pydantic.utils import GetterDict
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.config import get_app_dirs, get_app_settings
|
||||
from mealie.db.models.users import User
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
@@ -87,7 +90,7 @@ class UserIn(UserBase):
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
group: str
|
||||
group_id: int
|
||||
group_id: UUID4
|
||||
tokens: Optional[list[LongLiveTokenOut]]
|
||||
favorite_recipes: Optional[list[str]] = []
|
||||
|
||||
@@ -119,14 +122,14 @@ class UserFavorites(UserBase):
|
||||
|
||||
class PrivateUser(UserOut):
|
||||
password: str
|
||||
group_id: int
|
||||
group_id: UUID4
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class UpdateGroup(GroupBase):
|
||||
id: int
|
||||
id: UUID4
|
||||
name: str
|
||||
categories: Optional[list[CategoryBase]] = []
|
||||
|
||||
@@ -141,6 +144,26 @@ class GroupInDB(UpdateGroup):
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@staticmethod
|
||||
def get_directory(id: UUID4) -> Path:
|
||||
dir = get_app_dirs().GROUPS_DIR / str(id)
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
return dir
|
||||
|
||||
@staticmethod
|
||||
def get_export_directory(id: UUID) -> Path:
|
||||
dir = GroupInDB.get_directory(id) / "export"
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
return dir
|
||||
|
||||
@property
|
||||
def directory(self) -> Path:
|
||||
return GroupInDB.get_directory(self.id)
|
||||
|
||||
@property
|
||||
def exports(self) -> Path:
|
||||
return GroupInDB.get_export_directory(self.id)
|
||||
|
||||
|
||||
class LongLiveTokenInDB(CreateToken):
|
||||
id: int
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.group.group import GroupAdminUpdate
|
||||
from mealie.schema.mapper import mapper
|
||||
@@ -16,7 +17,7 @@ from mealie.services.group_services.group_utils import create_new_group
|
||||
|
||||
class AdminGroupService(
|
||||
CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate],
|
||||
AdminHttpService[int, GroupInDB],
|
||||
AdminHttpService[UUID4, GroupInDB],
|
||||
):
|
||||
event_func = create_group_event
|
||||
_schema = GroupInDB
|
||||
@@ -25,7 +26,7 @@ class AdminGroupService(
|
||||
def dal(self):
|
||||
return self.db.groups
|
||||
|
||||
def populate_item(self, id: int) -> GroupInDB:
|
||||
def populate_item(self, id: UUID4) -> GroupInDB:
|
||||
self.item = self.dal.get_one(id)
|
||||
return self.item
|
||||
|
||||
@@ -35,13 +36,13 @@ class AdminGroupService(
|
||||
def create_one(self, data: GroupBase) -> GroupInDB:
|
||||
return create_new_group(self.db, data)
|
||||
|
||||
def update_one(self, data: GroupAdminUpdate, item_id: int = None) -> GroupInDB:
|
||||
def update_one(self, data: GroupAdminUpdate, item_id: UUID4 = None) -> GroupInDB:
|
||||
target_id = item_id or data.id
|
||||
|
||||
if data.preferences:
|
||||
preferences = self.db.group_preferences.get_one(value=target_id, key="group_id")
|
||||
preferences = mapper(data.preferences, preferences)
|
||||
self.item.preferences = self.db.group_preferences.update(preferences.id, preferences)
|
||||
self.item.preferences = self.db.group_preferences.update(target_id, preferences)
|
||||
|
||||
if data.name not in ["", self.item.name]:
|
||||
self.item.name = data.name
|
||||
@@ -49,11 +50,13 @@ class AdminGroupService(
|
||||
|
||||
return self.item
|
||||
|
||||
def delete_one(self, id: int = None) -> GroupInDB:
|
||||
def delete_one(self, id: UUID4 = None) -> GroupInDB:
|
||||
target_id = id or self.item.id
|
||||
|
||||
if len(self.item.users) > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorResponse(message="Cannot delete group with users").dict(),
|
||||
)
|
||||
|
||||
return self._delete_one(id)
|
||||
return self._delete_one(target_id)
|
||||
|
||||
2
mealie/services/exporter/__init__.py
Normal file
2
mealie/services/exporter/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .exporter import *
|
||||
from .recipe_exporter import *
|
||||
91
mealie/services/exporter/_abc_exporter.py
Normal file
91
mealie/services/exporter/_abc_exporter.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import zipfile
|
||||
from abc import abstractmethod, abstractproperty
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.database import Database
|
||||
from mealie.schema.reports.reports import ReportEntryCreate
|
||||
|
||||
from .._base_service import BaseService
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportedItem:
|
||||
"""
|
||||
Exported items are the items provided by items() call in an concrete exporter class
|
||||
Where the items are used to write data to the zip file. Models should derive from the
|
||||
BaseModel class OR provide a .json method that returns a json string.
|
||||
"""
|
||||
|
||||
model: BaseModel
|
||||
name: str
|
||||
|
||||
|
||||
class ABCExporter(BaseService):
|
||||
write_dir_to_zip: Callable[[Path, str, Optional[list[str]]], None]
|
||||
|
||||
def __init__(self, db: Database, group_id: UUID) -> None:
|
||||
self.logger = get_logger()
|
||||
self.db = db
|
||||
self.group_id = group_id
|
||||
|
||||
super().__init__()
|
||||
|
||||
@abstractproperty
|
||||
def destination_dir(self) -> str:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def items(self) -> Iterator[ExportedItem]:
|
||||
...
|
||||
|
||||
def _post_export_hook(self, _: BaseModel) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def export(self, zip: zipfile.ZipFile) -> list[ReportEntryCreate]:
|
||||
"""
|
||||
Export takes in a zip file and exports the recipes to it. Note that the zip
|
||||
file open/close is NOT handled by this method. You must handle it yourself.
|
||||
|
||||
Args:
|
||||
zip (zipfile.ZipFile): Zip file destination
|
||||
|
||||
Returns:
|
||||
list[ReportEntryCreate]: [description] ???!?!
|
||||
"""
|
||||
self.write_dir_to_zip = self.write_dir_to_zip_func(zip)
|
||||
|
||||
for item in self.items():
|
||||
if item is None:
|
||||
self.logger.error("Failed to export item. no item found")
|
||||
continue
|
||||
|
||||
zip.writestr(f"{self.destination_dir}/{item.name}/{item.name}.json", item.model.json())
|
||||
|
||||
self._post_export_hook(item.model)
|
||||
|
||||
self.write_dir_to_zip = None
|
||||
|
||||
def write_dir_to_zip_func(self, zip: zipfile.ZipFile):
|
||||
"""Returns a recursive function that writes a directory to a zip file.
|
||||
|
||||
Args:
|
||||
zip (zipfile.ZipFile):
|
||||
"""
|
||||
|
||||
def func(source_dir: Path, dest_dir: str, ignore_ext: set[str] = None) -> None:
|
||||
ignore_ext = ignore_ext or set()
|
||||
|
||||
for source_file in source_dir.iterdir():
|
||||
if source_file.is_dir():
|
||||
func(source_file, f"{dest_dir}/{source_file.name}")
|
||||
elif source_file.suffix not in ignore_ext:
|
||||
zip.write(source_file, f"{dest_dir}/{source_file.name}")
|
||||
|
||||
return func
|
||||
51
mealie/services/exporter/exporter.py
Normal file
51
mealie/services/exporter/exporter.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import datetime
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from mealie.db.database import Database
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.user import GroupInDB
|
||||
from mealie.utils.fs_stats import pretty_size
|
||||
|
||||
from .._base_service import BaseService
|
||||
from ._abc_exporter import ABCExporter
|
||||
|
||||
|
||||
class Exporter(BaseService):
|
||||
def __init__(self, group_id: UUID, temp_zip: Path, exporters: list[ABCExporter]) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.group_id = group_id
|
||||
self.temp_path = temp_zip
|
||||
self.exporters = exporters
|
||||
|
||||
def run(self, db: Database) -> GroupDataExport:
|
||||
# Create Zip File
|
||||
self.temp_path.touch()
|
||||
|
||||
# Open Zip File
|
||||
with zipfile.ZipFile(self.temp_path, "w") as zip:
|
||||
for exporter in self.exporters:
|
||||
exporter.export(zip)
|
||||
|
||||
export_id = uuid4()
|
||||
|
||||
export_path = GroupInDB.get_export_directory(self.group_id) / f"{export_id}.zip"
|
||||
|
||||
shutil.copy(self.temp_path, export_path)
|
||||
|
||||
group_data_export = GroupDataExport(
|
||||
id=export_id,
|
||||
group_id=self.group_id,
|
||||
path=str(export_path),
|
||||
name="Data Export",
|
||||
size=pretty_size(export_path.stat().st_size),
|
||||
filename=export_path.name,
|
||||
expires=datetime.datetime.now() + datetime.timedelta(days=1),
|
||||
)
|
||||
|
||||
db.group_exports.create(group_data_export)
|
||||
|
||||
return group_data_export
|
||||
41
mealie/services/exporter/recipe_exporter.py
Normal file
41
mealie/services/exporter/recipe_exporter.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from typing import Iterator
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.db.database import Database
|
||||
from mealie.schema.recipe import Recipe
|
||||
|
||||
from ._abc_exporter import ABCExporter, ExportedItem
|
||||
|
||||
|
||||
class RecipeExporter(ABCExporter):
|
||||
def __init__(self, db: Database, group_id: UUID, recipes: list[str]) -> None:
|
||||
"""
|
||||
RecipeExporter is used to export a list of recipes to a zip file. The zip
|
||||
file is then saved to a temporary directory and then available for a one-time
|
||||
download.
|
||||
|
||||
Args:
|
||||
db (Database):
|
||||
group_id (int):
|
||||
recipes (list[str]): Recipe Slugs
|
||||
"""
|
||||
super().__init__(db, group_id)
|
||||
self.recipes = recipes
|
||||
|
||||
@property
|
||||
def destination_dir(self) -> str:
|
||||
return "recipes"
|
||||
|
||||
def items(self) -> Iterator[ExportedItem]:
|
||||
for slug in self.recipes:
|
||||
yield ExportedItem(
|
||||
name=slug,
|
||||
model=self.db.recipes.multi_query({"slug": slug, "group_id": self.group_id}, limit=1)[0],
|
||||
)
|
||||
|
||||
def _post_export_hook(self, item: Recipe) -> None:
|
||||
"""Copy recipe directory contents into the zip folder"""
|
||||
recipe_dir = item.directory
|
||||
|
||||
if recipe_dir.exists():
|
||||
self.write_dir_to_zip(recipe_dir, f"{self.destination_dir}/{item.slug}", {".json"})
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from mealie.db.data_access_layer.access_model_factory import Database
|
||||
from mealie.schema.group.group_preferences import CreateGroupPreferences
|
||||
from mealie.schema.user.user import GroupBase, GroupInDB
|
||||
@@ -6,7 +8,8 @@ from mealie.schema.user.user import GroupBase, GroupInDB
|
||||
def create_new_group(db: Database, g_base: GroupBase, g_preferences: CreateGroupPreferences = None) -> GroupInDB:
|
||||
created_group = db.groups.create(g_base)
|
||||
|
||||
g_preferences = g_preferences or CreateGroupPreferences(group_id=0) # Assign Temporary ID before group is created
|
||||
# Assign Temporary ID before group is created
|
||||
g_preferences = g_preferences or CreateGroupPreferences(group_id=uuid4())
|
||||
|
||||
g_preferences.group_id = created_group.id
|
||||
|
||||
|
||||
@@ -99,6 +99,9 @@ def sizeof_fmt(file_path: Path, decimal_places=2):
|
||||
|
||||
|
||||
def move_all_images():
|
||||
if not app_dirs.IMG_DIR.exists():
|
||||
return
|
||||
|
||||
for image_file in app_dirs.IMG_DIR.iterdir():
|
||||
if image_file.is_file():
|
||||
if image_file.name == ".DS_Store":
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.db.database import Database
|
||||
@@ -25,7 +26,7 @@ class BaseMigrator(BaseService):
|
||||
report_id: int
|
||||
report: ReportOut
|
||||
|
||||
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: int):
|
||||
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID):
|
||||
self.archive = archive
|
||||
self.db = db
|
||||
self.session = session
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from mealie.db.database import Database
|
||||
|
||||
@@ -10,7 +11,7 @@ from .utils.migration_helpers import MigrationReaders, import_image, split_by_co
|
||||
|
||||
|
||||
class ChowdownMigrator(BaseMigrator):
|
||||
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: int):
|
||||
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID):
|
||||
super().__init__(archive, db, session, user_id, group_id)
|
||||
|
||||
self.key_aliases = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from slugify import slugify
|
||||
|
||||
@@ -39,7 +40,7 @@ class NextcloudDir:
|
||||
|
||||
|
||||
class NextcloudMigrator(BaseMigrator):
|
||||
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: int):
|
||||
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID):
|
||||
super().__init__(archive, db, session, user_id, group_id)
|
||||
|
||||
self.key_aliases = [
|
||||
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.recipe import CategoryBase, Recipe
|
||||
from mealie.schema.recipe.recipe_category import TagBase
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
from mealie.services.events import create_recipe_event
|
||||
from mealie.services.exporter import Exporter, RecipeExporter
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -18,8 +20,36 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
|
||||
def populate_item(self, _: int) -> Recipe:
|
||||
return
|
||||
|
||||
def export_recipes(self, temp_path: Path, recipes: list[str]) -> None:
|
||||
return
|
||||
def export_recipes(self, temp_path: Path, slugs: list[str]) -> None:
|
||||
recipe_exporter = RecipeExporter(self.db, self.group_id, slugs)
|
||||
exporter = Exporter(self.group_id, temp_path, [recipe_exporter])
|
||||
|
||||
exporter.run(self.db)
|
||||
|
||||
def get_exports(self) -> list[GroupDataExport]:
|
||||
return self.db.group_exports.multi_query({"group_id": self.group_id})
|
||||
|
||||
def purge_exports(self) -> int:
|
||||
all_exports = self.get_exports()
|
||||
|
||||
exports_deleted = 0
|
||||
for export in all_exports:
|
||||
try:
|
||||
Path(export.path).unlink(missing_ok=True)
|
||||
self.db.group_exports.delete(export.id)
|
||||
exports_deleted += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete export {export.id}")
|
||||
logger.error(e)
|
||||
|
||||
group = self.db.groups.get_one(self.group_id)
|
||||
|
||||
for match in group.directory.glob("**/export/*zip"):
|
||||
if match.is_file():
|
||||
match.unlink()
|
||||
exports_deleted += 1
|
||||
|
||||
return exports_deleted
|
||||
|
||||
def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None:
|
||||
for slug in recipes:
|
||||
|
||||
@@ -59,14 +59,18 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
if not self.item.settings.public and not self.user:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def get_all(self, start=0, limit=None):
|
||||
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
|
||||
def get_all(self, start=0, limit=None, load_foods=False) -> list[RecipeSummary]:
|
||||
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit, load_foods=load_foods)
|
||||
|
||||
new_items = []
|
||||
|
||||
for item in items:
|
||||
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
|
||||
new_item = item.__dict__
|
||||
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
|
||||
|
||||
if load_foods:
|
||||
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
|
||||
|
||||
new_items.append(new_item)
|
||||
|
||||
return [RecipeSummary.construct(**x) for x in new_items]
|
||||
|
||||
@@ -15,7 +15,7 @@ CWD = Path(__file__).parent
|
||||
|
||||
app_dirs = get_app_dirs()
|
||||
TEMP_DATA = app_dirs.DATA_DIR / ".temp"
|
||||
SCHEDULER_DB = TEMP_DATA / "scheduler.db"
|
||||
SCHEDULER_DB = CWD / ".scheduler.db"
|
||||
SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}"
|
||||
|
||||
MINUTES_DAY = 1440
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .auto_backup import *
|
||||
from .purge_events import *
|
||||
from .purge_group_exports import *
|
||||
from .purge_password_reset import *
|
||||
from .purge_registration import *
|
||||
from .webhooks import *
|
||||
|
||||
46
mealie/services/scheduler/tasks/purge_group_exports.py
Normal file
46
mealie/services/scheduler/tasks/purge_group_exports.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.core.config import get_app_dirs
|
||||
from mealie.db.db_setup import create_session
|
||||
from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
|
||||
ONE_DAY_AS_MINUTES = 1440
|
||||
|
||||
|
||||
def purge_group_data_exports(max_minutes_old=ONE_DAY_AS_MINUTES):
|
||||
"""Purges all group exports after x days"""
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
logger.info("purging group data exports")
|
||||
limit = datetime.datetime.now() - datetime.timedelta(minutes=max_minutes_old)
|
||||
session = create_session()
|
||||
|
||||
results = session.query(GroupDataExportsModel).filter(GroupDataExportsModel.expires <= limit)
|
||||
|
||||
total_removed = 0
|
||||
for result in results:
|
||||
session.delete(result)
|
||||
Path(result.path).unlink(missing_ok=True)
|
||||
total_removed += 1
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
logger.info(f"finished purging group data exports. {total_removed} exports removed from group data")
|
||||
|
||||
|
||||
def purge_excess_files() -> None:
|
||||
"""Purges all files in the uploads directory that are older than 2 days"""
|
||||
directories = get_app_dirs()
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
limit = datetime.datetime.now() - datetime.timedelta(minutes=ONE_DAY_AS_MINUTES * 2)
|
||||
|
||||
for file in directories.GROUPS_DIR.glob("**/export/*.zip"):
|
||||
if file.stat().st_mtime < limit:
|
||||
file.unlink()
|
||||
logger.info(f"excess group file removed '{file}'")
|
||||
|
||||
logger.info("finished purging excess files")
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
@@ -69,7 +71,7 @@ class RegistrationService(PublicHttpService[int, str]):
|
||||
group_data = GroupBase(name=self.registration.group)
|
||||
|
||||
group_preferences = CreateGroupPreferences(
|
||||
group_id=0,
|
||||
group_id=uuid4(),
|
||||
private_group=self.registration.private,
|
||||
first_day_of_week=0,
|
||||
recipe_public=not self.registration.private,
|
||||
|
||||
Reference in New Issue
Block a user