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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 433 additions and 181 deletions

View File

@ -20,6 +20,13 @@
file_server
}
# Handles User Images
handle_path /api/media/users/* {
header @static Cache-Control max-age=31536000
root * /app/data/users/
file_server
}
handle @proxied {
uri strip_suffix /
reverse_proxy http://127.0.0.1:9000

View File

@ -9,9 +9,8 @@
<v-divider class="mx-2"></v-divider>
<div class="d-flex flex-column">
<div class="d-flex mt-3" style="gap: 10px">
<v-avatar size="40">
<img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
</v-avatar>
<UserAvatar size="40" :user-id="$auth.user.id" />
<v-textarea
v-model="comment"
hide-details=""
@ -32,9 +31,7 @@
</div>
</div>
<div v-for="comment in comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
<v-avatar size="40">
<img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
</v-avatar>
<UserAvatar size="40" :user-id="comment.userId" />
<v-card outlined class="flex-grow-1">
<v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.username }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
@ -60,8 +57,12 @@
import { defineComponent, ref, toRefs, onMounted, reactive } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { RecipeComment } from "~/api/class-interfaces/recipes/types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: {
UserAvatar,
},
props: {
slug: {
type: String,

View File

@ -0,0 +1,46 @@
<template>
<v-list-item-avatar v-if="list && userId">
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img>
</v-list-item-avatar>
<v-avatar v-else-if="userId" :size="size">
<v-img :src="imageURL" :alt="userId" @load="error = false" @error="error = true"> </v-img>
</v-avatar>
</template>
<script lang="ts">
import { defineComponent, toRefs, reactive, useContext, computed } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
userId: {
type: String,
required: true,
},
list: {
type: Boolean,
default: false,
},
size: {
type: String,
default: "42",
},
},
setup(props) {
const state = reactive({
error: false,
});
const { $auth } = useContext();
const imageURL = computed(() => {
const key = $auth?.user?.cacheKey || "";
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
});
return {
imageURL,
...toRefs(state),
};
},
});
</script>

View File

@ -3,9 +3,7 @@
<!-- User Profile -->
<template v-if="$auth.user">
<v-list-item two-line to="/user/profile" exact>
<v-list-item-avatar color="accent" class="white--text">
<v-img :src="require(`~/static/account.png`)" />
</v-list-item-avatar>
<UserAvatar list :user-id="$auth.user.id" />
<v-list-item-content>
<v-list-item-title> {{ $auth.user.fullName }}</v-list-item-title>
@ -130,8 +128,12 @@
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: {
UserAvatar,
},
props: {
value: {
type: Boolean,

View File

@ -17,10 +17,8 @@
hide-default-footer
disable-pagination
>
<template #item.avatar="">
<v-avatar>
<img src="https://i.pravatar.cc/300" alt="John" />
</v-avatar>
<template #item.avatar="{ item }">
<UserAvatar :user-id="item.id" />
</template>
<template #item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
@ -66,8 +64,12 @@
import { defineComponent, ref, onMounted, useContext } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { UserOut } from "~/types/api-types/user";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: {
UserAvatar,
},
setup() {
const api = useUserApi();

View File

@ -2,12 +2,21 @@
<v-container class="narrow-container">
<BasePageTitle divider>
<template #header>
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
<div class="d-flex flex-column align-center justify-center">
<UserAvatar size="96" :user-id="$auth.user.id" />
<AppButtonUpload
class="my-1"
file-name="profile"
accept="image/*"
:url="`/api/users/${$auth.user.id}/image`"
@uploaded="$auth.fetchUser()"
/>
</div>
</template>
<template #title> Your Profile Settings </template>
</BasePageTitle>
<section>
<section class="mt-5">
<ToggleState tag="article">
<template #activator="{ toggle, state }">
<v-btn v-if="!state" color="info" class="mt-2 mb-n3" @click="toggle">
@ -105,8 +114,12 @@
<script lang="ts">
import { ref, reactive, defineComponent, computed, useContext, watch } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: {
UserAvatar,
},
setup() {
const nuxtContext = useContext();
const user = computed(() => nuxtContext.$auth.user);

View File

@ -1,9 +1,8 @@
<template>
<v-container v-if="user">
<section class="d-flex flex-column align-center">
<v-avatar color="primary" size="75" class="mb-2">
<v-img :src="require(`~/static/account.png`)" />
</v-avatar>
<UserAvatar size="84" :user-id="$auth.user.id" />
<h2 class="headline">👋 Welcome, {{ user.fullName }}</h2>
<p class="subtitle-1 mb-0">
Manage your profile, recipes, and group settings.
@ -137,10 +136,13 @@ import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vu
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { alert } from "~/composables/use-toast";
import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
export default defineComponent({
name: "UserProfile",
components: {
UserProfileLinkCard,
UserAvatar,
},
scrollToTop: true,
setup() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

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))

View File

@ -1,9 +1,15 @@
from tests.pre_test import settings # isort:skip
import os
os.environ["PRODUCTION"] = "True"
os.environ["TESTING"] = "True"
from pathlib import Path
from fastapi.testclient import TestClient
from pytest import fixture
from mealie.app import app
from mealie.core import config
from mealie.db.db_setup import SessionLocal, generate_session
from mealie.db.init_db import main
from tests import data as test_data
@ -28,6 +34,7 @@ def api_client():
yield TestClient(app)
try:
settings = config.get_app_settings()
settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider
except Exception:
pass
@ -41,3 +48,19 @@ def test_image_jpg():
@fixture(scope="session")
def test_image_png():
return test_data.images_test_image_2
@fixture(scope="session", autouse=True)
def global_cleanup() -> None:
"""Purges the .temp directory used for testing"""
yield None
try:
temp_dir = Path(__file__).parent / ".temp"
if temp_dir.exists():
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception:
pass

View File

@ -1,4 +1,5 @@
from .fixture_admin import *
from .fixture_database import *
from .fixture_recipe import *
from .fixture_routes import *
from .fixture_users import *

14
tests/fixtures/fixture_database.py vendored Normal file
View File

@ -0,0 +1,14 @@
import pytest
from mealie.db.database import Database, get_database
from mealie.db.db_setup import SessionLocal
@pytest.fixture()
def database() -> Database:
try:
db = SessionLocal()
yield get_database(db)
finally:
db.close()

View File

@ -6,8 +6,8 @@ from tests.utils.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser
def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, admin_token, admin_user: TestUser):
response = api_client.get(api_routes.users_id(1), headers=admin_token)
def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, admin_user: TestUser):
response = api_client.get(api_routes.users_id(admin_user.user_id), headers=admin_user.token)
assert response.status_code == 200
admin_data = response.json()
@ -56,36 +56,56 @@ def test_create_user_as_non_admin(api_client: TestClient, api_routes: AppRoutes,
assert response.status_code == 403
def test_update_user(api_client: TestClient, api_routes: AppRoutes, admin_token):
update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": True}
response = api_client.put(api_routes.users_id(1), headers=admin_token, json=update_data)
def test_update_user(api_client: TestClient, api_routes: AppRoutes, admin_user: TestUser):
update_data = {
"id": admin_user.user_id,
"fullName": "Updated Name",
"email": "changeme@email.com",
"group": "Home",
"admin": True,
}
response = api_client.put(api_routes.users_id(admin_user.user_id), headers=admin_user.token, json=update_data)
assert response.status_code == 200
assert json.loads(response.text).get("access_token")
def test_update_other_user_as_not_admin(api_client: TestClient, api_routes: AppRoutes, unique_user: TestUser):
update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": True}
response = api_client.put(api_routes.users_id(1), headers=unique_user.token, json=update_data)
def test_update_other_user_as_not_admin(
api_client: TestClient, api_routes: AppRoutes, unique_user: TestUser, g2_user: TestUser
):
update_data = {
"id": unique_user.user_id,
"fullName": "Updated Name",
"email": "changeme@email.com",
"group": "Home",
"admin": True,
}
response = api_client.put(api_routes.users_id(g2_user.user_id), headers=unique_user.token, json=update_data)
assert response.status_code == 403
def test_self_demote_admin(api_client: TestClient, api_routes: AppRoutes, admin_token):
def test_self_demote_admin(api_client: TestClient, api_routes: AppRoutes, admin_user: TestUser):
update_data = {"fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": False}
response = api_client.put(api_routes.users_id(1), headers=admin_token, json=update_data)
response = api_client.put(api_routes.users_id(admin_user.user_id), headers=admin_user.token, json=update_data)
assert response.status_code == 403
def test_self_promote_admin(api_client: TestClient, api_routes: AppRoutes, user_token):
update_data = {"id": 3, "fullName": "Updated Name", "email": "user@email.com", "group": "Home", "admin": True}
response = api_client.put(api_routes.users_id(2), headers=user_token, json=update_data)
def test_self_promote_admin(api_client: TestClient, api_routes: AppRoutes, unique_user: TestUser):
update_data = {
"id": unique_user.user_id,
"fullName": "Updated Name",
"email": "user@email.com",
"group": "Home",
"admin": True,
}
response = api_client.put(api_routes.users_id(unique_user.user_id), headers=unique_user.token, json=update_data)
assert response.status_code == 403
def test_delete_user(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.users_id(2), headers=admin_token)
def test_delete_user(api_client: TestClient, api_routes: AppRoutes, admin_token, unique_user: TestUser):
response = api_client.delete(api_routes.users_id(unique_user.user_id), headers=admin_token)
assert response.status_code == 200

View File

@ -1,27 +0,0 @@
import json
import pytest
from fastapi.testclient import TestClient
from tests.utils.app_routes import AppRoutes
@pytest.fixture
def backup_data():
return {
"name": "test_backup_2021-Apr-27.zip",
"force": True,
"recipes": True,
"settings": False, # ! Broken
"groups": False, # ! Also Broken
"users": False,
}
def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, admin_token):
import_route = api_routes.backups_file_name_import("test_backup_2021-Apr-27.zip")
response = api_client.post(import_route, json=backup_data, headers=admin_token)
assert response.status_code == 200
for _, value in json.loads(response.content).items():
for v in value:
assert v["status"] is True

View File

@ -1,30 +1,33 @@
from pathlib import Path
from fastapi.testclient import TestClient
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from tests.utils.app_routes import AppRoutes
from tests import data as test_data
from tests.utils.fixture_schemas import TestUser
def test_update_user_image(
api_client: TestClient, api_routes: AppRoutes, test_image_jpg: Path, test_image_png: Path, admin_token
):
response = api_client.post(
api_routes.users_id_image(2), files={"profile_image": test_image_jpg.open("rb")}, headers=admin_token
)
class Routes:
def get_user_image(user_id: str, file_name: str = "profile.webp") -> str:
return f"/api/media/users/{user_id}/{file_name}"
def user_image(user_id: str) -> str:
return f"/api/users/{user_id}/image"
def test_user_get_image(api_client: TestClient, unique_user: TestUser):
# Get the user's image
response = api_client.get(Routes.get_user_image(str(unique_user.user_id)))
assert response.status_code == 200
response = api_client.post(
api_routes.users_id_image(2), files={"profile_image": test_image_png.open("rb")}, headers=admin_token
)
# Ensure that the returned value is a valid image
assert response.headers["Content-Type"] == "image/webp"
def test_user_update_image(api_client: TestClient, unique_user: TestUser):
image = {"profile": test_data.images_test_image_1.read_bytes()}
# Update the user's image
response = api_client.post(Routes.user_image(str(unique_user.user_id)), files=image, headers=unique_user.token)
assert response.status_code == 200
directory = app_dirs.USER_DIR.joinpath("2")
assert directory.joinpath("profile_image.png").is_file()
# Old profile images are removed
assert 1 == len([file for file in directory.glob("profile_image.*") if file.is_file()])
# Request the image again
response = api_client.get(Routes.get_user_image(str(unique_user.user_id)))
assert response.status_code == 200

View File

@ -1,15 +0,0 @@
import os
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.settings.db_providers import SQLiteProvider
os.environ["PRODUCTION"] = "True"
os.environ["TESTING"] = "True"
settings = get_app_settings()
app_dirs = get_app_dirs()
settings.DB_PROVIDER = SQLiteProvider(data_dir=app_dirs.DATA_DIR, prefix="test_")
if settings.DB_ENGINE != "postgres":
# Monkeypatch Database Testing
settings.DB_PROVIDER = SQLiteProvider(data_dir=app_dirs.DATA_DIR, prefix="test_")

View File

@ -0,0 +1,10 @@
from mealie.db.database import Database
from mealie.schema.user import PrivateUser
from tests.utils.fixture_schemas import TestUser
def test_user_directory_deleted_on_delete(database: Database, unique_user: TestUser) -> None:
user_dir = PrivateUser.get_directory(unique_user.user_id)
assert user_dir.exists()
database.users.delete(unique_user.user_id)
assert not user_dir.exists()

View File

@ -48,7 +48,7 @@ def test_default_connection_args(monkeypatch):
monkeypatch.setenv("DB_ENGINE", "sqlite")
get_app_settings.cache_clear()
app_settings = get_app_settings()
assert re.match(r"sqlite:////.*mealie/dev/data/*mealie*.db", app_settings.DB_URL)
assert re.match(r"sqlite:////.*mealie*.db", app_settings.DB_URL)
def test_pg_connection_args(monkeypatch):

View File

@ -6,7 +6,7 @@ from uuid import UUID
@dataclass
class TestUser:
email: str
user_id: int
user_id: UUID
_group_id: UUID
token: Any