mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04: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:
parent
a2f8f27193
commit
ea7c4771ee
@ -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
|
||||
|
@ -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,
|
||||
|
46
frontend/components/Domain/User/UserAvatar.vue
Normal file
46
frontend/components/Domain/User/UserAvatar.vue
Normal 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>
|
@ -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,
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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() {
|
||||
|
BIN
frontend/static/fallback-profile.webp
Normal file
BIN
frontend/static/fallback-profile.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
5
mealie/assets/templates/__init__.py
Normal file
5
mealie/assets/templates/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
recipes_markdown = CWD / "recipes.md"
|
7
mealie/assets/users/__init__.py
Normal file
7
mealie/assets/users/__init__.py
Normal 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"
|
BIN
mealie/assets/users/random_1.webp
Normal file
BIN
mealie/assets/users/random_1.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
BIN
mealie/assets/users/random_2.webp
Normal file
BIN
mealie/assets/users/random_2.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
mealie/assets/users/random_3.webp
Normal file
BIN
mealie/assets/users/random_3.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@ -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")
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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="")
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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")),
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
24
mealie/routes/media/media_user.py
Normal file
24
mealie/routes/media/media_user.py
Normal 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)
|
@ -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)
|
||||
|
@ -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),
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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]] = []
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -51,6 +51,9 @@ class MealieAlphaMigrator(BaseMigrator):
|
||||
|
||||
recipe["comments"] = []
|
||||
|
||||
# Reset ID on migration
|
||||
recipe["id"] = None
|
||||
|
||||
return Recipe(**recipe)
|
||||
|
||||
def _migrate(self) -> None:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -0,0 +1 @@
|
||||
from .cache_key import new_cache_key
|
9
mealie/utils/cache_key.py
Normal file
9
mealie/utils/cache_key.py
Normal 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))
|
@ -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
|
||||
|
1
tests/fixtures/__init__.py
vendored
1
tests/fixtures/__init__.py
vendored
@ -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
14
tests/fixtures/fixture_database.py
vendored
Normal 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()
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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_")
|
10
tests/unit_tests/repository_tests/test_user_repository.py
Normal file
10
tests/unit_tests/repository_tests/test_user_repository.py
Normal 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()
|
@ -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):
|
||||
|
@ -6,7 +6,7 @@ from uuid import UUID
|
||||
@dataclass
|
||||
class TestUser:
|
||||
email: str
|
||||
user_id: int
|
||||
user_id: UUID
|
||||
_group_id: UUID
|
||||
token: Any
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user