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:
parent
a2f8f27193
commit
ea7c4771ee
64 changed files with 433 additions and 181 deletions
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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 -->
|
<!-- 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,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
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
|
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")
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"]
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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="")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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")),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
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"""
|
"""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)
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]] = []
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
1
tests/fixtures/__init__.py
vendored
1
tests/fixtures/__init__.py
vendored
|
@ -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
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
|
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
|
||||||
|
|
|
@ -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 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()])
|
|
||||||
|
|
|
@ -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")
|
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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue