Feature/user photo storage (#877)

* add default assets for user profile

* add recipe avatar

* change user_id to UUID

* add profile image upload

* setup image cache keys

* cleanup tests and add image tests

* purge user data on delete

* new user repository tests

* add user_id validator for int -> UUID conversion

* delete depreciated route

* force set content type

* refactor tests to use temp directory

* validate parent exists before createing

* set user_id to correct type

* update instruction id

* reset primary key on migration
This commit is contained in:
Hayden
2021-12-18 19:04:36 -09:00
committed by GitHub
parent a2f8f27193
commit ea7c4771ee
64 changed files with 433 additions and 181 deletions

View File

View File

@@ -0,0 +1,5 @@
from pathlib import Path
CWD = Path(__file__).parent
recipes_markdown = CWD / "recipes.md"

View File

@@ -0,0 +1,24 @@
![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }}
{{ recipe.description }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }} {% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }} {% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

View File

@@ -0,0 +1,7 @@
from pathlib import Path
CWD = Path(__file__).parent
img_random_1 = CWD / "random_1.webp"
img_random_2 = CWD / "random_2.webp"
img_random_3 = CWD / "random_3.webp"

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import dotenv
from mealie.core.settings.settings import app_settings_constructor
from mealie.core.settings import app_settings_constructor
from .settings import AppDirectories, AppSettings
from .settings.static import APP_VERSION, DB_VERSION
@@ -18,11 +18,15 @@ ENV = BASE_DIR.joinpath(".env")
dotenv.load_dotenv(ENV)
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
TESTING = os.getenv("TESTING", "True").lower() in ["true", "1"]
def determine_data_dir() -> Path:
global PRODUCTION
global BASE_DIR
global PRODUCTION, TESTING, BASE_DIR
if TESTING:
return BASE_DIR.joinpath("tests/.temp")
if PRODUCTION:
return Path("/app/data")

View File

@@ -1,4 +1,5 @@
import shutil
import tempfile
from pathlib import Path
from typing import Optional
from uuid import uuid4
@@ -90,7 +91,7 @@ async def get_admin_user(current_user=Depends(get_current_user)) -> PrivateUser:
def validate_long_live_token(session: Session, client_token: str, id: int) -> PrivateUser:
db = get_database(session)
tokens: list[LongLiveTokenInDB] = db.api_tokens.get(id, "parent_id", limit=9999)
tokens: list[LongLiveTokenInDB] = db.api_tokens.get(id, "user_id", limit=9999)
for token in tokens:
token: LongLiveTokenInDB
@@ -150,3 +151,21 @@ async def temporary_dir() -> Path:
yield temp_path
finally:
shutil.rmtree(temp_path)
def temporary_file(ext: str = "") -> Path:
"""
Returns a temporary file with the specified extension
"""
def func() -> Path:
temp_path = app_dirs.TEMP_DIR.joinpath(uuid4().hex + ext)
temp_path.touch()
with tempfile.NamedTemporaryFile(mode="w+b", suffix=ext) as f:
try:
yield f
finally:
temp_path.unlink(missing_ok=True)
return func

View File

@@ -24,7 +24,7 @@ def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire})
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)

View File

@@ -57,9 +57,9 @@ class PostgresProvider(AbstractDBProvider, BaseSettings):
def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_encoding="utf-8") -> AbstractDBProvider:
if provider_name == "sqlite":
return SQLiteProvider(data_dir=data_dir)
elif provider_name == "postgres":
if provider_name == "postgres":
return PostgresProvider(_env_file=env_file, _env_file_encoding=env_encoding)
elif provider_name == "sqlite":
return SQLiteProvider(data_dir=data_dir)
else:
return

View File

@@ -1,5 +1,8 @@
import shutil
from pathlib import Path
from mealie.assets import templates
class AppDirectories:
def __init__(self, data_dir: Path) -> None:
@@ -35,3 +38,9 @@ class AppDirectories:
for dir in required_dirs:
dir.mkdir(parents=True, exist_ok=True)
# Boostrap Templates
markdown_template = self.TEMPLATE_DIR.joinpath("recipes.md")
if not markdown_template.exists():
shutil.copyfile(templates.recipes_markdown, markdown_template)

View File

@@ -16,6 +16,7 @@ def determine_secrets(data_dir: Path, production: bool) -> str:
with open(secrets_file, "r") as f:
return f.read()
else:
data_dir.mkdir(parents=True, exist_ok=True)
with open(secrets_file, "w") as f:
new_secret = secrets.token_hex(32)
f.write(new_secret)

View File

@@ -1,4 +1,3 @@
import os
from pathlib import Path
APP_VERSION = "v1.0.0b"
@@ -6,5 +5,3 @@ DB_VERSION = "v1.0.0b"
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent.parent
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any, Callable, Generic, TypeVar, Union
from uuid import UUID
from pydantic import UUID4
from sqlalchemy import func
from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session
@@ -39,7 +40,7 @@ class AccessModel(Generic[T, D]):
def subscribe(self, func: Callable) -> None:
self.observers.append(func)
def by_user(self, user_id: int) -> AccessModel:
def by_user(self, user_id: UUID4) -> AccessModel:
self.limit_by_user = True
self.user_id = user_id
return self

View File

@@ -1,5 +1,8 @@
from mealie.db.models.users import User
from mealie.schema.user.user import PrivateUser
import random
import shutil
from mealie.assets import users as users_assets
from mealie.schema.user.user import PrivateUser, User
from ._access_model import AccessModel
@@ -11,3 +14,23 @@ class UserDataAccessModel(AccessModel[PrivateUser, User]):
self.session.commit()
return self.schema.from_orm(entry)
def create(self, user: PrivateUser):
new_user = super().create(user)
# Select Random Image
all_images = [
users_assets.img_random_1,
users_assets.img_random_2,
users_assets.img_random_3,
]
random_image = random.choice(all_images)
shutil.copy(random_image, new_user.directory() / "profile.webp")
return new_user
def delete(self, id: str) -> User:
entry = super().delete(id)
# Delete the user's directory
shutil.rmtree(PrivateUser.get_directory(id))
return entry

View File

@@ -39,7 +39,9 @@ def main():
db = get_database(session)
try:
init_user = db.users.get("1", "id")
init_user = db.users.get_all()
if not init_user:
raise Exception("No users found in database")
except Exception:
init_db(db)
return

View File

@@ -13,6 +13,10 @@ class GUID(TypeDecorator):
impl = CHAR
cache_ok = True
@staticmethod
def generate():
return uuid.uuid4()
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(UUID())

View File

@@ -1,5 +1,3 @@
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
@@ -8,7 +6,7 @@ from .._model_utils import GUID, auto_init
class GroupDataExportsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_data_exports"
id = Column(GUID, primary_key=True, default=uuid4)
id = Column(GUID, primary_key=True, default=GUID.generate)
group = orm.relationship("Group", back_populates="data_exports", single_parent=True)
group_id = Column(GUID, ForeignKey("groups.id"), index=True)

View File

@@ -1,5 +1,4 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, orm
from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
@@ -12,7 +11,7 @@ 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=GUID.generate)
success = Column(Boolean, default=False)
message = Column(String, nullable=True)
@@ -29,7 +28,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=GUID.generate)
name = Column(String, nullable=False)
status = Column(String, nullable=False)

View File

@@ -1,5 +1,3 @@
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
@@ -9,7 +7,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=GUID.generate)
text = Column(String)
# Recipe Link
@@ -17,7 +15,7 @@ class RecipeComment(SqlAlchemyBase, BaseMixins):
recipe = orm.relationship("RecipeModel", back_populates="comments")
# User Link
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(GUID, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id])
@auto_init()

View File

@@ -1,5 +1,3 @@
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
@@ -9,7 +7,7 @@ from .._model_utils.guid import GUID
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_ingredient_ref_link"
instruction_id = Column(Integer, ForeignKey("recipe_instructions.id"))
instruction_id = Column(GUID, ForeignKey("recipe_instructions.id"))
reference_id = Column(GUID)
@auto_init()
@@ -19,7 +17,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=GUID.generate)
parent_id = Column(Integer, ForeignKey("recipes.id"))
position = Column(Integer)
type = Column(String, default="")

View File

@@ -49,7 +49,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
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_id = sa.Column(GUID, sa.ForeignKey("users.id"))
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan")

View File

@@ -1,12 +1,13 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from sqlalchemy import Column, ForeignKey, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID
class PasswordResetModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "password_reset_tokens"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(GUID, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="password_reset_tokens", uselist=False)
token = Column(String(64), unique=True, nullable=False)

View File

@@ -1,10 +1,11 @@
from sqlalchemy import Column, ForeignKey, Integer, Table
from .._model_base import SqlAlchemyBase
from .._model_utils import GUID
users_to_favorites = Table(
"users_to_favorites",
SqlAlchemyBase.metadata,
Column("user_id", Integer, ForeignKey("users.id")),
Column("user_id", GUID, ForeignKey("users.id")),
Column("recipe_id", Integer, ForeignKey("recipes.id")),
)

View File

@@ -11,19 +11,21 @@ from .user_to_favorite import users_to_favorites
class LongLiveToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "long_live_tokens"
parent_id = Column(Integer, ForeignKey("users.id"))
name = Column(String, nullable=False)
token = Column(String, nullable=False)
user_id = Column(GUID, ForeignKey("users.id"))
user = orm.relationship("User")
def __init__(self, session, name, token, parent_id) -> None:
def __init__(self, name, token, user_id, **_) -> None:
self.name = name
self.token = token
self.user = User.get_ref(session, parent_id)
self.user_id = user_id
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id = Column(GUID, primary_key=True, default=GUID.generate)
full_name = Column(String, index=True)
username = Column(String, index=True, unique=True)
email = Column(String, unique=True, index=True)
@@ -34,6 +36,8 @@ class User(SqlAlchemyBase, BaseMixins):
group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
cache_key = Column(String, default="1234")
# Group Permissions
can_manage = Column(Boolean, default=False)
can_invite = Column(Boolean, default=False)

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter
from . import recipe
from . import media_recipe, media_user
media_router = APIRouter(prefix="/api/media", tags=["Recipe: Images and Assets"])
media_router.include_router(recipe.router)
media_router.include_router(media_recipe.router)
media_router.include_router(media_user.router)

View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, HTTPException, status
from pydantic import UUID4
from starlette.responses import FileResponse
from mealie.schema.user import PrivateUser
"""
These routes are for development only! These assets are served by Caddy when not
in development mode. If you make changes, be sure to test the production container.
"""
router = APIRouter(prefix="/users")
@router.get("/{user_id}/{file_name}", response_class=FileResponse)
async def get_user_image(user_id: UUID4, file_name: str):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = PrivateUser.get_directory(user_id) / file_name
if recipe_image.exists():
return FileResponse(recipe_image, media_type="image/webp")
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View File

@@ -22,7 +22,7 @@ async def create_api_token(
):
"""Create api_token in the Database"""
token_data = {"long_token": True, "id": current_user.id}
token_data = {"long_token": True, "id": str(current_user.id)}
five_years = timedelta(1825)
token = create_access_token(token_data, five_years)
@@ -30,7 +30,7 @@ async def create_api_token(
token_model = CreateToken(
name=token_name.name,
token=token,
parent_id=current_user.id,
user_id=current_user.id,
)
db = get_database(session)

View File

@@ -1,4 +1,5 @@
from fastapi import BackgroundTasks, Depends, HTTPException, status
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie.core import security
@@ -39,15 +40,15 @@ async def create_user(
@admin_router.get("/{id}", response_model=UserOut)
async def get_user(id: int, session: Session = Depends(generate_session)):
async def get_user(id: UUID4, session: Session = Depends(generate_session)):
db = get_database(session)
return db.users.get(id)
@admin_router.delete("/{id}")
def delete_user(
id: UUID4,
background_tasks: BackgroundTasks,
id: int,
session: Session = Depends(generate_session),
current_user: PrivateUser = Depends(get_current_user),
):
@@ -55,7 +56,7 @@ def delete_user(
assert_user_change_allowed(id, current_user)
if id == 1:
if id == 1: # TODO: identify super_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER")
try:
@@ -75,7 +76,7 @@ async def get_logged_in_user(
@user_router.put("/{id}")
async def update_user(
id: int,
id: UUID4,
new_data: UserBase,
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),

View File

@@ -1,51 +1,49 @@
import shutil
from pathlib import Path
from fastapi import Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from fastapi.routing import APIRouter
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie import utils
from mealie.core.dependencies import get_current_user
from mealie.core.dependencies.dependencies import temporary_dir
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.user import PrivateUser
from mealie.services.image import minify
public_router = APIRouter(prefix="", tags=["Users: Images"])
user_router = UserAPIRouter(prefix="", tags=["Users: Images"])
@public_router.get("/{id}/image")
async def get_user_image(id: str):
"""Returns a users profile picture"""
user_dir = app_dirs.USER_DIR.joinpath(id)
for recipe_image in user_dir.glob("profile_image.*"):
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@user_router.post("/{id}/image")
def update_user_image(
id: str,
profile_image: UploadFile = File(...),
id: UUID4,
profile: UploadFile = File(...),
temp_dir: Path = Depends(temporary_dir),
current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
):
"""Updates a User Image"""
assert_user_change_allowed(id, current_user)
extension = profile_image.filename.split(".")[-1]
temp_img = temp_dir.joinpath(profile.filename)
app_dirs.USER_DIR.joinpath(id).mkdir(parents=True, exist_ok=True)
with temp_img.open("wb") as buffer:
shutil.copyfileobj(profile.file, buffer)
[x.unlink() for x in app_dirs.USER_DIR.joinpath(id).glob("profile_image.*")]
image = minify.to_webp(temp_img)
dest = PrivateUser.get_directory(id) / "profile.webp"
dest = app_dirs.USER_DIR.joinpath(id, f"profile_image.{extension}")
shutil.copyfile(image, dest)
with dest.open("wb") as buffer:
shutil.copyfileobj(profile_image.file, buffer)
db = get_database(session)
db.users.patch(id, {"cache_key": utils.new_cache_key()})
if not dest.is_file:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -1,8 +1,9 @@
from fastapi_camelcase import CamelModel
from pydantic import UUID4
class SetPermissions(CamelModel):
user_id: int
user_id: UUID4
can_manage: bool = False
can_invite: bool = False
can_organize: bool = False

View File

@@ -1,10 +1,10 @@
import datetime
from pathlib import Path
from typing import Any, Optional
from uuid import UUID, uuid4
from uuid import uuid4
from fastapi_camelcase import CamelModel
from pydantic import BaseModel, Field, validator
from pydantic import UUID4, BaseModel, Field, validator
from pydantic.utils import GetterDict
from slugify import slugify
@@ -63,8 +63,8 @@ class CreateRecipe(CamelModel):
class RecipeSummary(CamelModel):
id: Optional[int]
user_id: int = 0
group_id: UUID = Field(default_factory=uuid4)
user_id: UUID4 = Field(default_factory=uuid4)
group_id: UUID4 = Field(default_factory=uuid4)
name: Optional[str]
slug: str = ""
@@ -109,6 +109,12 @@ class RecipeSummary(CamelModel):
return uuid4()
return group_id
@validator("user_id", always=True, pre=True)
def validate_user_id(user_id: list[Any]):
if isinstance(user_id, int):
return uuid4()
return user_id
class Recipe(RecipeSummary):
recipe_ingredient: Optional[list[RecipeIngredient]] = []

View File

@@ -3,6 +3,7 @@ from typing import Optional
from uuid import UUID
from fastapi_camelcase import CamelModel
from pydantic import UUID4
class UserBase(CamelModel):
@@ -20,7 +21,7 @@ class RecipeCommentCreate(CamelModel):
class RecipeCommentSave(RecipeCommentCreate):
user_id: int
user_id: UUID4
class RecipeCommentUpdate(CamelModel):
@@ -33,7 +34,7 @@ class RecipeCommentOut(RecipeCommentCreate):
recipe_id: int
created_at: datetime
update_at: datetime
user_id: int
user_id: UUID4
user: UserBase
class Config:

View File

@@ -32,7 +32,7 @@ class LongLiveTokenOut(LoingLiveTokenIn):
class CreateToken(LoingLiveTokenIn):
parent_id: int
user_id: UUID4
token: str
class Config:
@@ -88,10 +88,11 @@ class UserIn(UserBase):
class UserOut(UserBase):
id: int
id: UUID4
group: str
group_id: UUID4
tokens: Optional[list[LongLiveTokenOut]]
cache_key: str
favorite_recipes: Optional[list[str]] = []
class Config:
@@ -127,6 +128,15 @@ class PrivateUser(UserOut):
class Config:
orm_mode = True
@staticmethod
def get_directory(user_id: UUID4) -> Path:
user_dir = get_app_dirs().USER_DIR / str(user_id)
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir
def directory(self) -> Path:
return PrivateUser.get_directory(self.id)
class UpdateGroup(GroupBase):
id: UUID4

View File

@@ -1,4 +1,5 @@
from fastapi_camelcase import CamelModel
from pydantic import UUID4
from .user import PrivateUser
@@ -18,7 +19,7 @@ class ResetPassword(ValidateResetToken):
class SavePasswordResetToken(CamelModel):
user_id: int
user_id: UUID4
token: str

View File

@@ -23,6 +23,22 @@ def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes:
return ImageSizes(org=sizeof_fmt(org_img), min=sizeof_fmt(min_img), tiny=sizeof_fmt(tiny_img))
def to_webp(image_file: Path, quality: int = 100) -> Path:
"""
Converts an image to the webp format in-place. The original image is not
removed By default, the quality is set to 100.
"""
if image_file.suffix == ".webp":
return image_file
img = Image.open(image_file)
dest = image_file.with_suffix(".webp")
img.save(dest, "WEBP", quality=quality)
return dest
def minify_image(image_file: Path, force=False) -> ImageSizes:
"""Minifies an image in it's original file format. Quality is lost

View File

@@ -2,6 +2,8 @@ from pathlib import Path
from typing import Tuple
from uuid import UUID
from pydantic import UUID4
from mealie.core import root_logger
from mealie.db.database import Database
from mealie.schema.recipe import Recipe
@@ -27,7 +29,7 @@ class BaseMigrator(BaseService):
report_id: int
report: ReportOut
def __init__(self, archive: Path, db: Database, session, user_id: int, group_id: UUID, add_migration_tag: bool):
def __init__(self, archive: Path, db: Database, session, user_id: UUID4, group_id: UUID, add_migration_tag: bool):
self.archive = archive
self.db = db
self.session = session

View File

@@ -51,6 +51,9 @@ class MealieAlphaMigrator(BaseMigrator):
recipe["comments"] = []
# Reset ID on migration
recipe["id"] = None
return Recipe(**recipe)
def _migrate(self) -> None:

View File

@@ -1,6 +1,6 @@
from typing import TypeVar
from pydantic import BaseModel
from pydantic import UUID4, BaseModel
from slugify import slugify
from sqlalchemy.orm import Session
@@ -13,7 +13,7 @@ T = TypeVar("T", bound=BaseModel)
class DatabaseMigrationHelpers:
def __init__(self, db: Database, session: Session, group_id: int, user_id: int) -> None:
def __init__(self, db: Database, session: Session, group_id: int, user_id: UUID4) -> None:
self.group_id = group_id
self.user_id = user_id
self.session = session

View File

@@ -60,10 +60,10 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
raise HTTPException(status.HTTP_403_FORBIDDEN)
def can_update(self) -> bool:
if self.user.id == self.item.user_id:
return True
if self.item.settings.locked and self.user.id != self.item.user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
raise HTTPException(status.HTTP_403_FORBIDDEN)
return True
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)

View File

@@ -1,3 +1,5 @@
from pathlib import Path
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
@@ -43,3 +45,6 @@ class UserService(UserHttpService[int, str]):
self.target_user.password = hash_password(password_change.new_password)
return self.db.users.update_password(self.target_user.id, self.target_user.password)
def set_profile_picture(self, file_path: Path) -> PrivateUser:
pass

View File

@@ -0,0 +1 @@
from .cache_key import new_cache_key

View File

@@ -0,0 +1,9 @@
import random
import string
def new_cache_key(length=4) -> str:
"""returns a 4 character string to be used as a cache key for frontend data"""
options = string.ascii_letters + string.digits
return "".join(random.choices(options, k=length))