mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-23 18:55:15 -05:00
feat(backend): ✨ start multi-tenant support (WIP) (#680)
* fix ts types * feat(code-generation): ♻️ update code-generation formats * new scope * add step button * fix linter error * update code-generation tags * feat(backend): ✨ start multi-tenant support * feat(backend): ✨ group invitation token generation and signup * refactor(backend): ♻️ move group admin actions to admin router * set url base to include `/admin` * feat(frontend): ✨ generate user sign-up links * test(backend): ✅ refactor test-suite to further decouple tests (WIP) * feat(backend): 🐛 assign owner on backup import for recipes * fix(backend): 🐛 assign recipe owner on migration from other service Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
@@ -52,6 +52,21 @@ class BaseAccessModel(Generic[T, D]):
|
||||
|
||||
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()]
|
||||
|
||||
def multi_query(
|
||||
self,
|
||||
session: Session,
|
||||
query_by: dict[str, str],
|
||||
start=0,
|
||||
limit: int = None,
|
||||
override_schema=None,
|
||||
) -> list[T]:
|
||||
eff_schema = override_schema or self.schema
|
||||
|
||||
return [
|
||||
eff_schema.from_orm(x)
|
||||
for x in session.query(self.sql_model).filter_by(**query_by).offset(start).limit(limit).all()
|
||||
]
|
||||
|
||||
def get_all_limit_columns(self, session: Session, fields: list[str], limit: int = None) -> list[D]:
|
||||
"""Queries the database for the selected model. Restricts return responses to the
|
||||
keys specified under "fields"
|
||||
@@ -105,11 +120,6 @@ class BaseAccessModel(Generic[T, D]):
|
||||
eff_schema = override_schema or self.schema
|
||||
return eff_schema.from_orm(result)
|
||||
|
||||
def get_many(
|
||||
self, session: Session, value: str, key: str = None, limit=1, any_case=False, override_schema=None
|
||||
) -> list[T]:
|
||||
pass
|
||||
|
||||
def get(
|
||||
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
|
||||
) -> T | list[T]:
|
||||
|
||||
@@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
|
||||
from mealie.db.models.event import Event, EventNotification
|
||||
from mealie.db.models.group import Group
|
||||
from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.shopping_list import ShoppingList
|
||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
||||
@@ -22,6 +23,7 @@ from mealie.schema.cookbook import ReadCookBook
|
||||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.invite_token import ReadInviteToken
|
||||
from mealie.schema.group.webhook import ReadWebhook
|
||||
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
|
||||
from mealie.schema.recipe import (
|
||||
@@ -87,6 +89,7 @@ class DatabaseAccessLayer:
|
||||
|
||||
# Group Data
|
||||
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
|
||||
self.group_tokens = BaseAccessModel("token", GroupInviteToken, ReadInviteToken)
|
||||
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
|
||||
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
|
||||
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from .cookbook import *
|
||||
from .group import *
|
||||
from .invite_tokens import *
|
||||
from .preferences import *
|
||||
from .shopping_list import *
|
||||
from .webhooks import *
|
||||
|
||||
@@ -3,6 +3,7 @@ import sqlalchemy.orm as orm
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import settings
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
@@ -14,11 +15,13 @@ from .preferences import GroupPreferencesModel
|
||||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
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)
|
||||
|
||||
invite_tokens = orm.relationship(
|
||||
GroupInviteToken, back_populates="group", cascade="all, delete-orphan", uselist=True
|
||||
)
|
||||
preferences = orm.relationship(
|
||||
GroupPreferencesModel,
|
||||
back_populates="group",
|
||||
@@ -27,13 +30,16 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Recipes
|
||||
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True)
|
||||
|
||||
# CRUD From Others
|
||||
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
|
||||
webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
|
||||
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
|
||||
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
|
||||
|
||||
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences"})
|
||||
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens"})
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
17
mealie/db/models/group/invite_tokens.py
Normal file
17
mealie/db/models/group/invite_tokens.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
|
||||
|
||||
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "invite_tokens"
|
||||
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 = orm.relationship("Group", back_populates="invite_tokens")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_):
|
||||
pass
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import validates
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..users import users_to_favorites
|
||||
from .api_extras import ApiExtras
|
||||
from .assets import RecipeAsset
|
||||
from .category import recipes2categories
|
||||
@@ -22,6 +23,19 @@ from .tool import Tool
|
||||
|
||||
class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipes"
|
||||
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
|
||||
|
||||
slug = sa.Column(sa.String, index=True)
|
||||
|
||||
# ID Relationships
|
||||
group_id = sa.Column(sa.Integer, 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])
|
||||
|
||||
favorited_by: list = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
|
||||
|
||||
# General Recipe Properties
|
||||
name = sa.Column(sa.String, nullable=False)
|
||||
description = sa.Column(sa.String)
|
||||
@@ -57,7 +71,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
|
||||
|
||||
# Mealie Specific
|
||||
slug = sa.Column(sa.String, index=True, unique=True)
|
||||
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
|
||||
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
|
||||
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
|
||||
@@ -69,10 +82,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
date_added = sa.Column(sa.Date, default=date.today)
|
||||
date_updated = sa.Column(sa.DateTime)
|
||||
|
||||
# Favorited By
|
||||
favorited_by_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
|
||||
favorited_by = orm.relationship("User", back_populates="favorite_recipes")
|
||||
|
||||
class Config:
|
||||
get_attr = "slug"
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .user_to_favorite import *
|
||||
from .users import *
|
||||
|
||||
10
mealie/db/models/users/user_to_favorite.py
Normal file
10
mealie/db/models/users/user_to_favorite.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, Table
|
||||
|
||||
from .._model_base import SqlAlchemyBase
|
||||
|
||||
users_to_favorites = Table(
|
||||
"users_to_favorites",
|
||||
SqlAlchemyBase.metadata,
|
||||
Column("user_id", Integer, ForeignKey("users.id")),
|
||||
Column("recipe_id", Integer, ForeignKey("recipes.id")),
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.core.config import settings
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models.group import Group
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from ..group import Group
|
||||
from .user_to_favorite import users_to_favorites
|
||||
|
||||
|
||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
@@ -33,6 +34,8 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="users")
|
||||
|
||||
# Recipes
|
||||
|
||||
tokens: list[LongLiveToken] = orm.relationship(
|
||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
@@ -41,7 +44,10 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
||||
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
|
||||
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
|
||||
|
||||
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -65,9 +71,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
self.password = password
|
||||
self.advanced = advanced
|
||||
|
||||
self.favorite_recipes = [
|
||||
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
|
||||
]
|
||||
self.favorite_recipes = []
|
||||
|
||||
if self.username is None:
|
||||
self.username = full_name
|
||||
@@ -99,10 +103,6 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
if password:
|
||||
self.password = password
|
||||
|
||||
self.favorite_recipes = [
|
||||
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
|
||||
]
|
||||
|
||||
def update_password(self, password):
|
||||
self.password = password
|
||||
|
||||
|
||||
Reference in New Issue
Block a user