1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 07:39:41 +02:00

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 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 { handle @proxied {
uri strip_suffix / uri strip_suffix /
reverse_proxy http://127.0.0.1:9000 reverse_proxy http://127.0.0.1:9000

View file

@ -9,9 +9,8 @@
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<div class="d-flex mt-3" style="gap: 10px"> <div class="d-flex mt-3" style="gap: 10px">
<v-avatar size="40"> <UserAvatar size="40" :user-id="$auth.user.id" />
<img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
</v-avatar>
<v-textarea <v-textarea
v-model="comment" v-model="comment"
hide-details="" hide-details=""
@ -32,9 +31,7 @@
</div> </div>
</div> </div>
<div v-for="comment in comments" :key="comment.id" class="d-flex my-2" style="gap: 10px"> <div v-for="comment in comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
<v-avatar size="40"> <UserAvatar size="40" :user-id="comment.userId" />
<img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
</v-avatar>
<v-card outlined class="flex-grow-1"> <v-card outlined class="flex-grow-1">
<v-card-text class="pa-3 pb-0"> <v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.username }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p> <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 { defineComponent, ref, toRefs, onMounted, reactive } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { RecipeComment } from "~/api/class-interfaces/recipes/types"; import { RecipeComment } from "~/api/class-interfaces/recipes/types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({ export default defineComponent({
components: {
UserAvatar,
},
props: { props: {
slug: { slug: {
type: String, 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 --> <!-- User Profile -->
<template v-if="$auth.user"> <template v-if="$auth.user">
<v-list-item two-line to="/user/profile" exact> <v-list-item two-line to="/user/profile" exact>
<v-list-item-avatar color="accent" class="white--text"> <UserAvatar list :user-id="$auth.user.id" />
<v-img :src="require(`~/static/account.png`)" />
</v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title> {{ $auth.user.fullName }}</v-list-item-title> <v-list-item-title> {{ $auth.user.fullName }}</v-list-item-title>
@ -130,8 +128,12 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
import { SidebarLinks } from "~/types/application-types"; import { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({ export default defineComponent({
components: {
UserAvatar,
},
props: { props: {
value: { value: {
type: Boolean, type: Boolean,

View file

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

View file

@ -2,12 +2,21 @@
<v-container class="narrow-container"> <v-container class="narrow-container">
<BasePageTitle divider> <BasePageTitle divider>
<template #header> <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>
<template #title> Your Profile Settings </template> <template #title> Your Profile Settings </template>
</BasePageTitle> </BasePageTitle>
<section> <section class="mt-5">
<ToggleState tag="article"> <ToggleState tag="article">
<template #activator="{ toggle, state }"> <template #activator="{ toggle, state }">
<v-btn v-if="!state" color="info" class="mt-2 mb-n3" @click="toggle"> <v-btn v-if="!state" color="info" class="mt-2 mb-n3" @click="toggle">
@ -105,8 +114,12 @@
<script lang="ts"> <script lang="ts">
import { ref, reactive, defineComponent, computed, useContext, watch } from "@nuxtjs/composition-api"; import { ref, reactive, defineComponent, computed, useContext, watch } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({ export default defineComponent({
components: {
UserAvatar,
},
setup() { setup() {
const nuxtContext = useContext(); const nuxtContext = useContext();
const user = computed(() => nuxtContext.$auth.user); const user = computed(() => nuxtContext.$auth.user);

View file

@ -1,9 +1,8 @@
<template> <template>
<v-container v-if="user"> <v-container v-if="user">
<section class="d-flex flex-column align-center"> <section class="d-flex flex-column align-center">
<v-avatar color="primary" size="75" class="mb-2"> <UserAvatar size="84" :user-id="$auth.user.id" />
<v-img :src="require(`~/static/account.png`)" />
</v-avatar>
<h2 class="headline">👋 Welcome, {{ user.fullName }}</h2> <h2 class="headline">👋 Welcome, {{ user.fullName }}</h2>
<p class="subtitle-1 mb-0"> <p class="subtitle-1 mb-0">
Manage your profile, recipes, and group settings. 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 { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
export default defineComponent({ export default defineComponent({
name: "UserProfile",
components: { components: {
UserProfileLinkCard, UserProfileLinkCard,
UserAvatar,
}, },
scrollToTop: true, scrollToTop: true,
setup() { 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 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 import AppDirectories, AppSettings
from .settings.static import APP_VERSION, DB_VERSION from .settings.static import APP_VERSION, DB_VERSION
@ -18,11 +18,15 @@ ENV = BASE_DIR.joinpath(".env")
dotenv.load_dotenv(ENV) dotenv.load_dotenv(ENV)
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"] PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
TESTING = os.getenv("TESTING", "True").lower() in ["true", "1"]
def determine_data_dir() -> Path: def determine_data_dir() -> Path:
global PRODUCTION global PRODUCTION, TESTING, BASE_DIR
global BASE_DIR
if TESTING:
return BASE_DIR.joinpath("tests/.temp")
if PRODUCTION: if PRODUCTION:
return Path("/app/data") return Path("/app/data")

View file

@ -1,4 +1,5 @@
import shutil import shutil
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from uuid import uuid4 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: def validate_long_live_token(session: Session, client_token: str, id: int) -> PrivateUser:
db = get_database(session) 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: for token in tokens:
token: LongLiveTokenInDB token: LongLiveTokenInDB
@ -150,3 +151,21 @@ async def temporary_dir() -> Path:
yield temp_path yield temp_path
finally: finally:
shutil.rmtree(temp_path) 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 expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire}) to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM) 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: def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_encoding="utf-8") -> AbstractDBProvider:
if provider_name == "sqlite": if provider_name == "postgres":
return SQLiteProvider(data_dir=data_dir)
elif provider_name == "postgres":
return PostgresProvider(_env_file=env_file, _env_file_encoding=env_encoding) return PostgresProvider(_env_file=env_file, _env_file_encoding=env_encoding)
elif provider_name == "sqlite":
return SQLiteProvider(data_dir=data_dir)
else: else:
return return

View file

@ -1,5 +1,8 @@
import shutil
from pathlib import Path from pathlib import Path
from mealie.assets import templates
class AppDirectories: class AppDirectories:
def __init__(self, data_dir: Path) -> None: def __init__(self, data_dir: Path) -> None:
@ -35,3 +38,9 @@ class AppDirectories:
for dir in required_dirs: for dir in required_dirs:
dir.mkdir(parents=True, exist_ok=True) 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: with open(secrets_file, "r") as f:
return f.read() return f.read()
else: else:
data_dir.mkdir(parents=True, exist_ok=True)
with open(secrets_file, "w") as f: with open(secrets_file, "w") as f:
new_secret = secrets.token_hex(32) new_secret = secrets.token_hex(32)
f.write(new_secret) f.write(new_secret)

View file

@ -1,4 +1,3 @@
import os
from pathlib import Path from pathlib import Path
APP_VERSION = "v1.0.0b" APP_VERSION = "v1.0.0b"
@ -6,5 +5,3 @@ DB_VERSION = "v1.0.0b"
CWD = Path(__file__).parent CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent.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 typing import Any, Callable, Generic, TypeVar, Union
from uuid import UUID from uuid import UUID
from pydantic import UUID4
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import load_only from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -39,7 +40,7 @@ class AccessModel(Generic[T, D]):
def subscribe(self, func: Callable) -> None: def subscribe(self, func: Callable) -> None:
self.observers.append(func) 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.limit_by_user = True
self.user_id = user_id self.user_id = user_id
return self return self

View file

@ -1,5 +1,8 @@
from mealie.db.models.users import User import random
from mealie.schema.user.user import PrivateUser import shutil
from mealie.assets import users as users_assets
from mealie.schema.user.user import PrivateUser, User
from ._access_model import AccessModel from ._access_model import AccessModel
@ -11,3 +14,23 @@ class UserDataAccessModel(AccessModel[PrivateUser, User]):
self.session.commit() self.session.commit()
return self.schema.from_orm(entry) 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) db = get_database(session)
try: 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: except Exception:
init_db(db) init_db(db)
return return

View file

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

View file

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

View file

@ -1,5 +1,4 @@
from datetime import datetime from datetime import datetime
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, orm from sqlalchemy import Column, ForeignKey, orm
from sqlalchemy.sql.sqltypes import Boolean, DateTime, String from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
@ -12,7 +11,7 @@ from .._model_utils.guid import GUID
class ReportEntryModel(SqlAlchemyBase, BaseMixins): class ReportEntryModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "report_entries" __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) success = Column(Boolean, default=False)
message = Column(String, nullable=True) message = Column(String, nullable=True)
@ -29,7 +28,7 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins):
class ReportModel(SqlAlchemyBase, BaseMixins): class ReportModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_reports" __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) name = Column(String, nullable=False)
status = 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 sqlalchemy import Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase 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): class RecipeComment(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_comments" __tablename__ = "recipe_comments"
id = Column(GUID, primary_key=True, default=uuid4) id = Column(GUID, primary_key=True, default=GUID.generate)
text = Column(String) text = Column(String)
# Recipe Link # Recipe Link
@ -17,7 +15,7 @@ class RecipeComment(SqlAlchemyBase, BaseMixins):
recipe = orm.relationship("RecipeModel", back_populates="comments") recipe = orm.relationship("RecipeModel", back_populates="comments")
# User Link # 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]) user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id])
@auto_init() @auto_init()

View file

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

View file

@ -49,7 +49,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
group_id = sa.Column(GUID, sa.ForeignKey("groups.id")) group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_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]) user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan") 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_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID
class PasswordResetModel(SqlAlchemyBase, BaseMixins): class PasswordResetModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "password_reset_tokens" __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) user = orm.relationship("User", back_populates="password_reset_tokens", uselist=False)
token = Column(String(64), unique=True, nullable=False) token = Column(String(64), unique=True, nullable=False)

View file

@ -1,10 +1,11 @@
from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy import Column, ForeignKey, Integer, Table
from .._model_base import SqlAlchemyBase from .._model_base import SqlAlchemyBase
from .._model_utils import GUID
users_to_favorites = Table( users_to_favorites = Table(
"users_to_favorites", "users_to_favorites",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
Column("user_id", Integer, ForeignKey("users.id")), Column("user_id", GUID, ForeignKey("users.id")),
Column("recipe_id", Integer, ForeignKey("recipes.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): class LongLiveToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "long_live_tokens" __tablename__ = "long_live_tokens"
parent_id = Column(Integer, ForeignKey("users.id"))
name = Column(String, nullable=False) name = Column(String, nullable=False)
token = Column(String, nullable=False) token = Column(String, nullable=False)
user_id = Column(GUID, ForeignKey("users.id"))
user = orm.relationship("User") 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.name = name
self.token = token self.token = token
self.user = User.get_ref(session, parent_id) self.user_id = user_id
class User(SqlAlchemyBase, BaseMixins): class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users" __tablename__ = "users"
id = Column(GUID, primary_key=True, default=GUID.generate)
full_name = Column(String, index=True) full_name = Column(String, index=True)
username = Column(String, index=True, unique=True) username = Column(String, index=True, unique=True)
email = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True)
@ -34,6 +36,8 @@ class User(SqlAlchemyBase, BaseMixins):
group_id = Column(GUID, ForeignKey("groups.id")) group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users") group = orm.relationship("Group", back_populates="users")
cache_key = Column(String, default="1234")
# Group Permissions # Group Permissions
can_manage = Column(Boolean, default=False) can_manage = Column(Boolean, default=False)
can_invite = Column(Boolean, default=False) can_invite = Column(Boolean, default=False)

View file

@ -1,7 +1,8 @@
from fastapi import APIRouter 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 = 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""" """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) five_years = timedelta(1825)
token = create_access_token(token_data, five_years) token = create_access_token(token_data, five_years)
@ -30,7 +30,7 @@ async def create_api_token(
token_model = CreateToken( token_model = CreateToken(
name=token_name.name, name=token_name.name,
token=token, token=token,
parent_id=current_user.id, user_id=current_user.id,
) )
db = get_database(session) db = get_database(session)

View file

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

View file

@ -1,51 +1,49 @@
import shutil import shutil
from pathlib import Path
from fastapi import Depends, File, HTTPException, UploadFile, status from fastapi import Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs from mealie import utils
app_dirs = get_app_dirs()
from mealie.core.dependencies import get_current_user 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.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.user import PrivateUser from mealie.schema.user import PrivateUser
from mealie.services.image import minify
public_router = APIRouter(prefix="", tags=["Users: Images"]) public_router = APIRouter(prefix="", tags=["Users: Images"])
user_router = UserAPIRouter(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") @user_router.post("/{id}/image")
def update_user_image( def update_user_image(
id: str, id: UUID4,
profile_image: UploadFile = File(...), profile: UploadFile = File(...),
temp_dir: Path = Depends(temporary_dir),
current_user: PrivateUser = Depends(get_current_user), current_user: PrivateUser = Depends(get_current_user),
session: Session = Depends(generate_session),
): ):
"""Updates a User Image""" """Updates a User Image"""
assert_user_change_allowed(id, current_user) 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: db = get_database(session)
shutil.copyfileobj(profile_image.file, buffer)
db.users.patch(id, {"cache_key": utils.new_cache_key()})
if not dest.is_file: if not dest.is_file:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

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

View file

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

View file

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

View file

@ -32,7 +32,7 @@ class LongLiveTokenOut(LoingLiveTokenIn):
class CreateToken(LoingLiveTokenIn): class CreateToken(LoingLiveTokenIn):
parent_id: int user_id: UUID4
token: str token: str
class Config: class Config:
@ -88,10 +88,11 @@ class UserIn(UserBase):
class UserOut(UserBase): class UserOut(UserBase):
id: int id: UUID4
group: str group: str
group_id: UUID4 group_id: UUID4
tokens: Optional[list[LongLiveTokenOut]] tokens: Optional[list[LongLiveTokenOut]]
cache_key: str
favorite_recipes: Optional[list[str]] = [] favorite_recipes: Optional[list[str]] = []
class Config: class Config:
@ -127,6 +128,15 @@ class PrivateUser(UserOut):
class Config: class Config:
orm_mode = True 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): class UpdateGroup(GroupBase):
id: UUID4 id: UUID4

View file

@ -1,4 +1,5 @@
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import UUID4
from .user import PrivateUser from .user import PrivateUser
@ -18,7 +19,7 @@ class ResetPassword(ValidateResetToken):
class SavePasswordResetToken(CamelModel): class SavePasswordResetToken(CamelModel):
user_id: int user_id: UUID4
token: str 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)) 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: def minify_image(image_file: Path, force=False) -> ImageSizes:
"""Minifies an image in it's original file format. Quality is lost """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 typing import Tuple
from uuid import UUID from uuid import UUID
from pydantic import UUID4
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.database import Database from mealie.db.database import Database
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
@ -27,7 +29,7 @@ class BaseMigrator(BaseService):
report_id: int report_id: int
report: ReportOut 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.archive = archive
self.db = db self.db = db
self.session = session self.session = session

View file

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

View file

@ -1,6 +1,6 @@
from typing import TypeVar from typing import TypeVar
from pydantic import BaseModel from pydantic import UUID4, BaseModel
from slugify import slugify from slugify import slugify
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -13,7 +13,7 @@ T = TypeVar("T", bound=BaseModel)
class DatabaseMigrationHelpers: 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.group_id = group_id
self.user_id = user_id self.user_id = user_id
self.session = session self.session = session

View file

@ -60,10 +60,10 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
raise HTTPException(status.HTTP_403_FORBIDDEN) raise HTTPException(status.HTTP_403_FORBIDDEN)
def can_update(self) -> bool: def can_update(self) -> bool:
if self.user.id == self.item.user_id: if self.item.settings.locked and self.user.id != self.item.user_id:
return True 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]: 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) 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 fastapi import HTTPException, status
from mealie.core.root_logger import get_logger 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) self.target_user.password = hash_password(password_change.new_password)
return self.db.users.update_password(self.target_user.id, self.target_user.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 fastapi.testclient import TestClient
from pytest import fixture from pytest import fixture
from mealie.app import app from mealie.app import app
from mealie.core import config
from mealie.db.db_setup import SessionLocal, generate_session from mealie.db.db_setup import SessionLocal, generate_session
from mealie.db.init_db import main from mealie.db.init_db import main
from tests import data as test_data from tests import data as test_data
@ -28,6 +34,7 @@ def api_client():
yield TestClient(app) yield TestClient(app)
try: try:
settings = config.get_app_settings()
settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider settings.DB_PROVIDER.db_path.unlink() # Handle SQLite Provider
except Exception: except Exception:
pass pass
@ -41,3 +48,19 @@ def test_image_jpg():
@fixture(scope="session") @fixture(scope="session")
def test_image_png(): def test_image_png():
return test_data.images_test_image_2 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_admin import *
from .fixture_database import *
from .fixture_recipe import * from .fixture_recipe import *
from .fixture_routes import * from .fixture_routes import *
from .fixture_users 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 from tests.utils.fixture_schemas import TestUser
def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, admin_token, admin_user: TestUser): def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, admin_user: TestUser):
response = api_client.get(api_routes.users_id(1), headers=admin_token) response = api_client.get(api_routes.users_id(admin_user.user_id), headers=admin_user.token)
assert response.status_code == 200 assert response.status_code == 200
admin_data = response.json() 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 assert response.status_code == 403
def test_update_user(api_client: TestClient, api_routes: AppRoutes, admin_token): def test_update_user(api_client: TestClient, api_routes: AppRoutes, admin_user: TestUser):
update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": True} update_data = {
response = api_client.put(api_routes.users_id(1), headers=admin_token, json=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 response.status_code == 200
assert json.loads(response.text).get("access_token") 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): def test_update_other_user_as_not_admin(
update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": True} api_client: TestClient, api_routes: AppRoutes, unique_user: TestUser, g2_user: TestUser
response = api_client.put(api_routes.users_id(1), headers=unique_user.token, json=update_data) ):
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 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} 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 assert response.status_code == 403
def test_self_promote_admin(api_client: TestClient, api_routes: AppRoutes, user_token): def test_self_promote_admin(api_client: TestClient, api_routes: AppRoutes, unique_user: TestUser):
update_data = {"id": 3, "fullName": "Updated Name", "email": "user@email.com", "group": "Home", "admin": True} update_data = {
response = api_client.put(api_routes.users_id(2), headers=user_token, json=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 assert response.status_code == 403
def test_delete_user(api_client: TestClient, api_routes: AppRoutes, 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(2), headers=admin_token) response = api_client.delete(api_routes.users_id(unique_user.user_id), headers=admin_token)
assert response.status_code == 200 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 fastapi.testclient import TestClient
from mealie.core.config import get_app_dirs from tests import data as test_data
from tests.utils.fixture_schemas import TestUser
app_dirs = get_app_dirs()
from tests.utils.app_routes import AppRoutes
def test_update_user_image( class Routes:
api_client: TestClient, api_routes: AppRoutes, test_image_jpg: Path, test_image_png: Path, admin_token def get_user_image(user_id: str, file_name: str = "profile.webp") -> str:
): return f"/api/media/users/{user_id}/{file_name}"
response = api_client.post(
api_routes.users_id_image(2), files={"profile_image": test_image_jpg.open("rb")}, headers=admin_token
)
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 assert response.status_code == 200
response = api_client.post( # Ensure that the returned value is a valid image
api_routes.users_id_image(2), files={"profile_image": test_image_png.open("rb")}, headers=admin_token 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 assert response.status_code == 200
directory = app_dirs.USER_DIR.joinpath("2") # Request the image again
assert directory.joinpath("profile_image.png").is_file() response = api_client.get(Routes.get_user_image(str(unique_user.user_id)))
assert response.status_code == 200
# Old profile images are removed
assert 1 == len([file for file in directory.glob("profile_image.*") if file.is_file()])

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") monkeypatch.setenv("DB_ENGINE", "sqlite")
get_app_settings.cache_clear() get_app_settings.cache_clear()
app_settings = get_app_settings() 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): def test_pg_connection_args(monkeypatch):

View file

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