feat(backend): start multi-tenant support (WIP) (#680)

* fix ts types

* feat(code-generation): ♻️ update code-generation formats

* new scope

* add step button

* fix linter error

* update code-generation tags

* feat(backend):  start multi-tenant support

* feat(backend):  group invitation token generation and signup

* refactor(backend): ♻️ move group admin actions to admin router

* set url base to include `/admin`

* feat(frontend):  generate user sign-up links

* test(backend):  refactor test-suite to further decouple tests (WIP)

* feat(backend): 🐛 assign owner on backup import for recipes

* fix(backend): 🐛 assign recipe owner on migration from other service

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-09-09 08:51:29 -08:00 committed by GitHub
parent 3c504e7048
commit bdaf758712
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
90 changed files with 1793 additions and 949 deletions

2
.gitignore vendored
View file

@ -151,3 +151,5 @@ dev/data/recipes/*
dev/scripts/output/app_routes.py
dev/scripts/output/javascriptAPI/*
mealie/services/scraper/ingredient_nlp/model.crfmodel
dev/code-generation/generated/openapi.json
dev/code-generation/generated/test_routes.py

View file

@ -1,5 +1,10 @@
{
"conventionalCommits.scopes": ["frontend", "docs", "backend"],
"conventionalCommits.scopes": [
"frontend",
"docs",
"backend",
"code-generation"
],
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
"cSpell.words": ["compression", "hkotel", "performant", "postgres", "webp"],
"editor.codeActionsOnSave": {

View file

@ -0,0 +1,95 @@
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Tuple
from black import FileMode, format_str
from jinja2 import Template
def render_python_template(template_file: Path, dest: Path, data: dict) -> str:
""" Render and Format a Jinja2 Template for Python Code"""
tplt = Template(template_file.read_text())
text = tplt.render(data)
text = format_str(text, mode=FileMode())
dest.write_text(text)
@dataclass
class CodeSlicer:
start: int
end: int
indentation: str
text: list[str]
_next_line = None
def purge_lines(self) -> None:
start = self.start + 1
end = self.end
del self.text[start:end]
def push_line(self, string: str) -> None:
self._next_line = self._next_line or self.start + 1
print(self.indentation)
self.text.insert(self._next_line, self.indentation + string + "\n")
self._next_line += 1
def get_indentation_of_string(line: str, comment_char: str = "//") -> str:
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
def find_start_end(file_text: list[str], gen_id: str) -> Tuple[int, int]:
start = None
end = None
indentation = None
for i, line in enumerate(file_text):
if "CODE_GEN_ID:" in line and gen_id in line:
start = i
indentation = get_indentation_of_string(line)
if f"END: {gen_id}" in line:
end = i
if start is None or end is None:
raise Exception("Could not find start and end of code generation block")
if start > end:
raise Exception(f"Start ({start=}) of code generation block is after end ({end=})")
return start, end, indentation
def inject_inline(file_path: Path, key: str, code: list[str]) -> None:
"""Injects a list of strings into the file where the key is found in the format defined
by the code-generation. Strings are properly indented and a '\n' is added to the end of
each string.
Start -> 'CODE_GEN_ID: <key>'
End -> 'END: <key>'
If no 'CODE_GEN_ID: <key>' is found, and exception is raised
Args:
file_path (Path): Write to file
key (str): CODE_GEN_ID: <key>
code (list[str]): List of strings to inject.
"""
with open(file_path, "r") as f:
file_text = f.readlines()
start, end, indentation = find_start_end(file_text, key)
slicer = CodeSlicer(start, end, indentation, file_text)
slicer.purge_lines()
for line in code:
slicer.push_line(line)
with open(file_path, "w") as file:
file.writelines(slicer.text)

View file

@ -0,0 +1,131 @@
import json
import re
from pathlib import Path
from typing import Any
from _static import Directories
from fastapi import FastAPI
from humps import camelize
from slugify import slugify
def get_openapi_spec_by_ref(app, type_reference: str) -> dict:
if not type_reference:
return None
schemas = app["components"]["schemas"]
type_text = type_reference.split("/")[-1]
return schemas.get(type_text, type_reference)
def recursive_dict_search(data: dict[str, Any], key: str) -> Any:
"""
Walks a dictionary searching for a key and returns all the keys
matching the provided key"""
if key in data:
return data[key]
for _, v in data.items():
if isinstance(v, dict):
result = recursive_dict_search(v, key)
if result:
return result
return None
class APIFunction:
def __init__(self, app, route: str, verb: str, data: dict):
self.name_camel = camelize(data.get("summary"))
self.name_snake = slugify(data.get("summary"), separator="_")
self.http_verb = verb
self.path_vars = re.findall(r"\{(.*?)\}", route)
self.path_is_func = "{" in route
self.js_route = route.replace("{", "${")
self.py_route = route
self.body_schema = get_openapi_spec_by_ref(app, recursive_dict_search(data, "$ref"))
def path_args(self) -> str:
return ", ".join(x + ": string | number" for x in self.path_vars)
# body: Optional[list[str]] = []
# path_params: Optional[list[str]] = []
# query_params: Optional[list[str]] = []
# class APIModule(BaseModel):
# name: str
# functions: list[APIFunction]
class OpenAPIParser:
def __init__(self, app: FastAPI) -> None:
self.app = app
self.spec = app.openapi()
self.modules = {}
def dump(self, out_path: Path) -> Path:
""" Writes the Open API as JSON to a json file"""
OPEN_API_FILE = out_path or Directories.out_dir / "openapi.json"
with open(OPEN_API_FILE, "w") as f:
f.write(json.dumps(self.spec, indent=4))
def _group_by_module_tag(self):
"""
Itterates over all routes and groups them by module. Modules are determined
by the suffix text before : in the first tag for the router. These are used
to generate the typescript class interface for interacting with the API
"""
modules = {}
all_paths = self.spec["paths"]
for path, http_verbs in all_paths.items():
for _, value in http_verbs.items():
if "tags" in value:
tag: str = value["tags"][0]
if ":" in tag:
tag = tag.removeprefix('"').split(":")[0].replace(" ", "")
if modules.get(tag):
modules[tag][path] = http_verbs
else:
modules[tag] = {path: http_verbs}
return modules
def _get_openapi_spec(self, type_reference: str) -> dict:
schemas = self.app["components"]["schemas"]
type_text = type_reference.split("/")[-1]
return schemas.get(type_text, type_reference)
def _fill_schema_references(self, raw_modules: dict) -> dict:
for _, routes in raw_modules.items():
for _, verbs in routes.items():
for _, value in verbs.items():
if "requestBody" in value:
try:
schema_ref = value["requestBody"]["content"]["application/json"]["schema"]["$ref"]
schema = self._get_openapi_spec(schema_ref)
value["requestBody"]["content"]["application/json"]["schema"] = schema
except Exception:
continue
return raw_modules
def get_by_module(self) -> dict:
"""Returns paths where tags are split by : and left right is considered the module"""
raw_modules = self._group_by_module_tag()
modules = {}
for module_name, routes in raw_modules.items():
for route, verbs in routes.items():
for verb, value in verbs.items():
function = APIFunction(self.spec, route, verb, value)
if modules.get(module_name):
modules[module_name].append(function)
else:
modules[module_name] = [function]
return modules

View file

@ -0,0 +1,86 @@
import re
from enum import Enum
from typing import Optional
from humps import camelize
from pydantic import BaseModel, Field
from slugify import slugify
class RouteObject:
def __init__(self, route_string) -> None:
self.prefix = "/" + route_string.split("/")[1]
self.route = "/" + route_string.split("/", 2)[2]
self.js_route = self.route.replace("{", "${")
self.parts = route_string.split("/")[1:]
self.var = re.findall(r"\{(.*?)\}", route_string)
self.is_function = "{" in self.route
self.router_slug = slugify("_".join(self.parts[1:]), separator="_")
self.router_camel = camelize(self.router_slug)
class RequestType(str, Enum):
get = "get"
put = "put"
post = "post"
patch = "patch"
delete = "delete"
class ParameterIn(str, Enum):
query = "query"
path = "path"
class RouterParameter(BaseModel):
required: bool = False
name: str
location: ParameterIn = Field(..., alias="in")
class RequestBody(BaseModel):
required: bool = False
class HTTPRequest(BaseModel):
request_type: RequestType
description: str = ""
summary: str
requestBody: Optional[RequestBody]
parameters: list[RouterParameter] = []
tags: list[str]
def list_as_js_object_string(self, parameters, braces=True):
if len(parameters) == 0:
return ""
if braces:
return "{" + ", ".join(parameters) + "}"
else:
return ", ".join(parameters)
def payload(self):
return "payload" if self.requestBody else ""
def function_args(self):
all_params = [p.name for p in self.parameters]
if self.requestBody:
all_params.append("payload")
return self.list_as_js_object_string(all_params)
def query_params(self):
params = [param.name for param in self.parameters if param.location == ParameterIn.query]
return self.list_as_js_object_string(params)
def path_params(self):
params = [param.name for param in self.parameters if param.location == ParameterIn.path]
return self.list_as_js_object_string(parameters=params, braces=False)
@property
def summary_camel(self):
return camelize(slugify(self.summary))
@property
def js_docs(self):
return self.description.replace("\n", " \n * ")

View file

@ -0,0 +1,24 @@
from pathlib import Path
CWD = Path(__file__).parent
class Directories:
out_dir = CWD / "generated"
class CodeTemplates:
interface = CWD / "templates" / "interface.js"
pytest_routes = CWD / "templates" / "test_routes.py.j2"
class CodeDest:
interface = CWD / "generated" / "interface.js"
pytest_routes = CWD / "generated" / "test_routes.py"
class CodeKeys:
""" Hard coded comment IDs that are used to generate code"""
nuxt_local_messages = "MESSAGE_LOCALES"
nuxt_local_dates = "DATE_LOCALES"

View file

@ -0,0 +1,39 @@
from pathlib import Path
from _gen_utils import inject_inline
from _static import CodeKeys
PROJECT_DIR = Path(__file__).parent.parent.parent
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js"
"""
This snippet walks the message and dat locales directories and generates the import information
for the nuxt.config.js file and automatically injects it into the nuxt.config.js file. Note that
the code generation ID is hardcoded into the script and required in the nuxt config.
"""
def main(): # sourcery skip: list-comprehension
print("Starting...")
all_date_locales = []
for match in datetime_dir.glob("*.json"):
all_date_locales.append(f'"{match.stem}": require("./lang/dateTimeFormats/{match.name}"),')
all_langs = []
for match in locales_dir.glob("*.json"):
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},'
all_langs.append(lang_string)
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales)
print("Finished...")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,53 @@
import json
from typing import Any
from _gen_utils import render_python_template
from _open_api_parser import OpenAPIParser
from _static import CodeDest, CodeTemplates
from rich.console import Console
from mealie.app import app
"""
This code is used for generating route objects for each route in the OpenAPI Specification.
Currently, they are NOT automatically injected into the test suite. As such, you'll need to copy
the relavent contents of the generated file into the test suite where applicable. I am slowly
migrating the test suite to use this new generated file and this process will be "automated" in the
future.
"""
console = Console()
def write_dict_to_file(file_name: str, data: dict[str, Any]):
with open(file_name, "w") as f:
f.write(json.dumps(data, indent=4))
def main():
print("Starting...")
open_api = OpenAPIParser(app)
modules = open_api.get_by_module()
mods = []
for mod, value in modules.items():
routes = []
existings = set()
# Reduce routes by unique py_route attribute
for route in value:
if route.py_route not in existings:
existings.add(route.py_route)
routes.append(route)
module = {"name": mod, "routes": routes}
mods.append(module)
render_python_template(CodeTemplates.pytest_routes, CodeDest.pytest_routes, {"mods": mods})
print("Finished...")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,9 @@
{% for mod in mods %}
class {{mod.name}}Routes:{% for route in mod.routes %}{% if not route.path_is_func %}
{{route.name_snake}} = "{{ route.py_route }}"{% endif %}{% endfor %}{% for route in mod.routes %}
{% if route.path_is_func %}
@staticmethod
def {{route.name_snake}}({{ route.path_vars|join(", ") }}):
return f"{{route.py_route}}"
{% endif %}{% endfor %}
{% endfor %}

View file

@ -0,0 +1,60 @@
import json
import random
import string
import time
import requests
def random_string(length: int) -> str:
return "".join(random.choice(string.ascii_lowercase) for _ in range(length))
def payload_factory() -> dict:
return {"name": random_string(15)}
def login(username="changeme@email.com", password="MyPassword"):
payload = {"username": username, "password": password}
r = requests.post("http://localhost:9000/api/auth/token", payload)
# Bearer
token = json.loads(r.text).get("access_token")
return {"Authorization": f"Bearer {token}"}
def populate_data(token):
for _ in range(300):
payload = payload_factory()
r = requests.post("http://localhost:9000/api/recipes", json=payload, headers=token)
if r.status_code != 201:
print(f"Error: {r.status_code}")
print(r.text)
exit()
else:
print(f"Created recipe: {payload}")
def time_request(url, headers):
start = time.time()
_ = requests.get(url, headers=headers)
end = time.time()
print(end - start)
def main():
print("Starting...")
token = login()
# populate_data(token)
for _ in range(10):
time_request("http://localhost:9000/api/recipes", token)
print("Finished...")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,58 @@
from pathlib import Path
from jinja2 import Template
template = """// This Code is auto generated by gen_global_componenets.py
{% for name in global %} import {{ name }} from "@/components/global/{{ name }}.vue";
{% endfor %}
{% for name in layout %} import {{ name }} from "@/components/layout/{{ name }}.vue";
{% endfor %}
declare module "vue" {
export interface GlobalComponents {
// Global Components
{% for name in global %} {{ name }}: typeof {{ name }};
{% endfor %} // Layout Components
{% for name in layout %} {{ name }}: typeof {{ name }};
{% endfor %}
}
}
export {};
"""
project_dir = Path(__file__).parent.parent.parent
destination_file = project_dir / "frontend" / "types" / "components.d.ts"
component_paths = {
"global": project_dir / "frontend" / "components" / "global",
"layout": project_dir / "frontend" / "components" / "Layout",
}
def render_template(template: str, data: dict) -> None:
template = Template(template)
return template.render(**data)
def build_data(component_paths: dict) -> dict:
data = {}
for name, path in component_paths.items():
components = []
for component in path.glob("*.vue"):
components.append(component.stem)
data[name] = components
return data
def write_template(text: str) -> None:
destination_file.write_text(text)
if __name__ == "__main__":
data = build_data(component_paths)
text = render_template(template, build_data(component_paths))
write_template(text)

View file

@ -1,38 +0,0 @@
from pathlib import Path
from pprint import pprint
PROJECT_DIR = Path(__file__).parent.parent.parent
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
locales_dir = PROJECT_DIR / "datetime" / "lang" / "messages"
"""
{
code: "en-US",
file: "en-US.json",
}
"en-US": require("./lang/dateTimeFormats/en-US.json"),
"""
def main():
print("Starting...")
all_langs = []
for match in datetime_dir.glob("*.json"):
print(f'"{match.stem}": require("./lang/dateTimeFormats/{match.name}"),')
all_langs.append({"code": match.stem, "file": match.name})
print("\n\n\n--------- All Languages -----------")
pprint(all_langs)
print("Finished...")
if __name__ == "__main__":
main()

View file

@ -4,13 +4,15 @@ import { GroupInDB } from "~/types/api-types/user";
const prefix = "/api";
const routes = {
groups: `${prefix}/groups`,
groups: `${prefix}/admin/groups`,
groupsSelf: `${prefix}/groups/self`,
categories: `${prefix}/groups/categories`,
preferences: `${prefix}/groups/preferences`,
groupsId: (id: string | number) => `${prefix}/groups/${id}`,
invitation: `${prefix}/groups/invitations`,
groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`,
};
interface Category {
@ -44,6 +46,16 @@ export interface Group extends CreateGroup {
preferences: Preferences;
}
export interface CreateInvitation {
uses: number;
}
export interface Invitation {
group_id: number;
token: string;
uses_left: number;
}
export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
baseRoute = routes.groups;
itemRoute = routes.groupsId;
@ -68,4 +80,8 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
async setPreferences(payload: UpdatePreferences) {
return await this.requests.put<Preferences>(routes.preferences, payload);
}
async createInvitation(payload: CreateInvitation) {
return await this.requests.post<Invitation>(routes.invitation, payload);
}
}

View file

@ -1,7 +1,6 @@
<template>
<div>
<section>
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
<div>
<draggable
:disabled="!edit"
:value="value"
@ -75,8 +74,7 @@
</v-hover>
</div>
</draggable>
</div>
</div>
</section>
</template>
<script>

View file

@ -0,0 +1,18 @@
import { useRoute, WritableComputedRef, computed } from "@nuxtjs/composition-api";
export function useRouterQuery(query: string) {
const router = useRoute();
// TODO FUTURE: Remove when migrating to Vue 3
const param: WritableComputedRef<string> = computed({
get(): string {
// @ts-ignore
return router.value?.query[query] || "";
},
set(v: string): void {
router.value.query[query] = v;
},
});
return param;
}

View file

@ -452,6 +452,7 @@
"error-cannot-delete-super-user": "Error! Cannot Delete Super User",
"existing-password-does-not-match": "Existing password does not match",
"full-name": "Full Name",
"invite-only": "Invite Only",
"link-id": "Link ID",
"link-name": "Link Name",
"login": "Login",
@ -459,30 +460,31 @@
"manage-users": "Manage Users",
"new-password": "New Password",
"new-user": "New User",
"password": "Password",
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
"password-must-match": "Password must match",
"password-reset-failed": "Password reset failed",
"password-updated": "Password updated",
"password": "Password",
"register": "Register",
"reset-password": "Reset Password",
"sign-in": "Sign in",
"total-mealplans": "Total MealPlans",
"total-users": "Total Users",
"upload-photo": "Upload Photo",
"use-8-characters-or-more-for-your-password": "Use 8 characters or more for your password",
"user": "User",
"user-created": "User created",
"user-creation-failed": "User creation failed",
"user-deleted": "User deleted",
"user-id": "User ID",
"user-id-with-value": "User ID: {id}",
"user-id": "User ID",
"user-password": "User Password",
"user-successfully-logged-in": "User Successfully Logged In",
"user-update-failed": "User update failed",
"user-updated": "User updated",
"user": "User",
"username": "Username",
"users": "Users",
"users-header": "USERS",
"users": "Users",
"webhook-time": "Webhook Time",
"webhooks-enabled": "Webhooks Enabled",
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user",

View file

@ -40,6 +40,7 @@ export default {
// https://go.nuxtjs.dev/typescript
"@nuxt/typescript-build",
// https://go.nuxtjs.dev/vuetify
// https://go.nuxtjs.dev/vuetify
"@nuxtjs/vuetify",
// https://composition-api.nuxtjs.org/getting-started/setup
"@nuxtjs/composition-api/module",
@ -115,7 +116,7 @@ export default {
i18n: {
locales: [
// Auto Generated from "generate_nuxt_locales.py"
// CODE_GEN_ID: MESSAGE_LOCALES
{ code: "el-GR", file: "el-GR.json" },
{ code: "it-IT", file: "it-IT.json" },
{ code: "ko-KR", file: "ko-KR.json" },
@ -147,13 +148,14 @@ export default {
{ code: "en-GB", file: "en-GB.json" },
{ code: "fi-FI", file: "fi-FI.json" },
{ code: "vi-VN", file: "vi-VN.json" },
// END: MESSAGE_LOCALES
],
lazy: true,
langDir: "lang/messages",
defaultLocale: "en-US",
vueI18n: {
dateTimeFormats: {
// Auto Generated from "generate_nuxt_locales.py"
// CODE_GEN_ID: DATE_LOCALES
"el-GR": require("./lang/dateTimeFormats/el-GR.json"),
"it-IT": require("./lang/dateTimeFormats/it-IT.json"),
"ko-KR": require("./lang/dateTimeFormats/ko-KR.json"),
@ -185,6 +187,7 @@ export default {
"en-GB": require("./lang/dateTimeFormats/en-GB.json"),
"fi-FI": require("./lang/dateTimeFormats/fi-FI.json"),
"vi-VN": require("./lang/dateTimeFormats/vi-VN.json"),
// END: DATE_LOCALES
},
},
fallbackLocale: "es",

View file

@ -37,6 +37,7 @@
"@nuxtjs/eslint-config-typescript": "^6.0.1",
"@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/vuetify": "^1.12.1",
"@vue/runtime-dom": "^3.2.9",
"eslint": "^7.29.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-nuxt": "^2.0.0",

View file

@ -174,50 +174,44 @@
type="password"
/>
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
Login
{{ $t("user.login") }}
</v-btn>
</v-form>
</v-card-text>
<v-btn v-if="$config.ALLOW_SIGNUP" class="mx-auto" text to="/register"> Register </v-btn>
<v-btn v-else class="mx-auto" text disabled> Invite Only </v-btn>
<v-btn v-if="allowSignup" class="mx-auto" text to="/register"> {{ $t("user.register") }} </v-btn>
<v-btn v-else class="mx-auto" text disabled> {{ $t("user.invite-only") }} </v-btn>
</v-card>
</v-container>
</template>
<script lang="ts" setup>
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { computed, reactive } from "@vue/reactivity";
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
const { $auth } = useContext();
export default defineComponent({
layout: "basic",
setup() {
return {};
},
data() {
return {
loggingIn: false,
form: {
const form = reactive({
email: "changeme@email.com",
password: "MyPassword",
},
};
},
computed: {
allowSignup(): boolean {
// @ts-ignore
return process.env.ALLOW_SIGNUP;
},
},
methods: {
async authenticate() {
this.loggingIn = true;
const formData = new FormData();
formData.append("username", this.form.email);
formData.append("password", this.form.password);
});
await this.$auth.loginWith("local", { data: formData });
this.loggingIn = false;
},
},
const loggingIn = ref(false);
const allowSignup = computed(() => process.env.ALLOW_SIGNUP);
async function authenticate() {
loggingIn.value = true;
const formData = new FormData();
formData.append("username", form.email);
formData.append("password", form.password);
await $auth.loginWith("local", { data: formData });
loggingIn.value = false;
}
</script>
<script lang="ts">
export default defineComponent({
layout: "basic",
});
</script>

View file

@ -222,6 +222,9 @@
<v-col cols="12" sm="12" md="8" lg="8">
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
<div class="d-flex">
<BaseButton v-if="form" class="ml-auto my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div>
<RecipeNotes v-model="recipe.notes" :edit="form" />
</v-col>
</v-row>
@ -346,6 +349,22 @@ export default defineComponent({
list.splice(index, 1);
}
function addStep(steps: Array<string> | null = null) {
if (!recipe.value?.recipeInstructions) {
return;
}
if (steps) {
const cleanedSteps = steps.map((step) => {
return { text: step, title: "" };
});
recipe.value.recipeInstructions.push(...cleanedSteps);
} else {
recipe.value.recipeInstructions.push({ text: "", title: "" });
}
}
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
@ -386,6 +405,7 @@ export default defineComponent({
api,
form,
loading,
addStep,
deleteRecipe,
updateRecipe,
uploadImage,

View file

@ -4,18 +4,14 @@
<v-card-title class="headline"> User Registration </v-card-title>
<v-card-text>
<v-form ref="domRegisterForm" @submit.prevent="register()">
<ToggleState>
<template #activator="{ toggle }">
<div class="d-flex justify-center my-2">
<v-btn-toggle tile mandatory group color="primary">
<v-btn small @click="toggle(false)"> Create a Group </v-btn>
<v-btn small @click="toggle(true)"> Join a Group </v-btn>
<v-btn-toggle v-model="joinGroup" mandatory tile group color="primary">
<v-btn :value="false" small @click="joinGroup = false"> Create a Group </v-btn>
<v-btn :value="true" small @click="joinGroup = true"> Join a Group </v-btn>
</v-btn-toggle>
</div>
</template>
<template #default="{ state }">
<v-text-field
v-if="!state"
v-if="!joinGroup"
v-model="form.group"
filled
rounded
@ -37,8 +33,6 @@
:prepend-icon="$globals.icons.group"
label="Group Token"
/>
</template>
</ToggleState>
<v-text-field
v-model="form.email"
filled
@ -105,29 +99,42 @@
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { computed, defineComponent, reactive, toRefs, ref, useRouter, watch } from "@nuxtjs/composition-api";
import { validators } from "@/composables/use-validators";
import { useApiSingleton } from "~/composables/use-api";
import { alert } from "~/composables/use-toast";
import { useRouterQuery } from "@/composables/use-router";
export default defineComponent({
layout: "basic",
setup() {
const api = useApiSingleton();
const state = reactive({
joinGroup: false,
loggingIn: false,
success: false,
});
const allowSignup = computed(() => process.env.AllOW_SIGNUP);
const router = useRouter();
const token = useRouterQuery("token");
watch(token, (newToken) => {
if (newToken) {
console.log(token);
form.groupToken = newToken;
}
});
if (token) {
state.joinGroup = true;
}
// @ts-ignore
const domRegisterForm = ref<VForm>(null);
const form = reactive({
group: "",
groupToken: "",
groupToken: token,
email: "",
username: "",
password: "",
@ -139,23 +146,24 @@ export default defineComponent({
const passwordMatch = () => form.password === form.passwordConfirm || "Passwords do not match";
const tokenOrGroup = () => form.group !== "" || form.groupToken !== "" || "Group name or token must be given";
const router = useRouter();
async function register() {
if (!domRegisterForm.value?.validate()) {
return;
}
const { data, response } = await api.register.register(form);
const { response } = await api.register.register(form);
if (response?.status === 201) {
state.success = true;
alert.success("Registration Success");
router.push("/user/login");
}
console.log(data, response);
}
return {
token,
domRegisterForm,
validators,
allowSignup,

View file

@ -5,8 +5,10 @@
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
</template>
<template #title> Your Profile Settings </template>
Some text here...
</BasePageTitle>
<section>
<ToggleState tag="article">
<template #activator="{ toggle, state }">
@ -19,9 +21,8 @@
{{ $t("settings.profile") }}
</v-btn>
</template>
<template #default="{ state }">
<v-slide-x-transition group mode="in" hide-on-leave>
<v-slide-x-transition>
<div v-if="!state" key="personal-info">
<BaseCardSectionTitle class="mt-10" title="Personal Information"> </BaseCardSectionTitle>
<v-card tag="article" outlined>
@ -90,8 +91,14 @@
label="Show advanced features (API Keys, Webhooks, and Data Management)"
@change="updateUser"
></v-checkbox>
<div class="d-flex justify-center mt-5">
<v-btn outlined class="rounded-xl" to="/user/group"> Looking for Privacy Settings? </v-btn>
<div class="d-flex flex-wrap justify-center mt-5">
<v-btn outlined class="rounded-xl my-1 mx-1" to="/user/profile" nuxt exact>
<v-icon left>
{{ $globals.icons.backArrow }}
</v-icon>
Back to Profile
</v-btn>
<v-btn outlined class="rounded-xl my-1 mx-1" to="/user/group"> Looking for Privacy Settings? </v-btn>
</div>
</section>
</v-container>

View file

@ -9,6 +9,23 @@
Manage your profile, recipes, and group settings.
<a href="https://hay-kot.github.io/mealie/" target="_blank"> Learn More </a>
</p>
<v-card flat width="100%" max-width="600px">
<v-card-actions class="d-flex justify-center">
<v-btn outlined rounded @click="getSignupLink()">
<v-icon left>
{{ $globals.icons.createAlt }}
</v-icon>
Get Invite Link
</v-btn>
</v-card-actions>
<v-card-text v-if="generatedLink !== ''" class="d-flex">
<v-text-field v-model="generatedLink" solo readonly>
<template #append>
<AppButtonCopy :copy-text="generatedLink" />
</template>
</v-text-field>
</v-card-text>
</v-card>
</section>
<section>
<div>
@ -21,7 +38,7 @@
:link="{ text: 'Manage User Profile', to: '/user/profile/edit' }"
:image="require('~/static/svgs/manage-profile.svg')"
>
<template #title> User Profile </template>
<template #title> User Settings </template>
Manage your preferences, change your password, and update your email
</UserProfileLinkCard>
</v-col>
@ -78,8 +95,9 @@
</template>
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { computed, defineComponent, useContext, ref } from "@nuxtjs/composition-api";
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
import { useApiSingleton } from "~/composables/use-api";
export default defineComponent({
components: {
@ -88,7 +106,23 @@ export default defineComponent({
setup() {
const user = computed(() => useContext().$auth.user);
return { user };
const generatedLink = ref("");
const api = useApiSingleton();
async function getSignupLink() {
const { data } = await api.groups.createInvitation({ uses: 1 });
if (data) {
generatedLink.value = constructLink(data.token);
}
}
function constructLink(token: string) {
return `${window.location.origin}/register?token=${token}`;
}
return { user, constructLink, generatedLink, getSignupLink };
},
});
</script>

View file

@ -94,6 +94,7 @@ import {
mdiFolderZipOutline,
mdiFoodApple,
mdiBeakerOutline,
mdiArrowLeftBoldOutline,
} from "@mdi/js";
const icons = {
@ -184,6 +185,7 @@ const icons = {
zip: mdiFolderZipOutline,
// Crud
backArrow: mdiArrowLeftBoldOutline,
createAlt: mdiPlus,
create: mdiPlusCircle,
delete: mdiDelete,

View file

@ -18,5 +18,8 @@
},
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next"]
},
"exclude": ["node_modules", ".nuxt", "dist"]
"exclude": ["node_modules", ".nuxt", "dist"],
"vueCompilerOptions": {
"experimentalCompatMode": 2
}
}

View file

@ -1,9 +1,9 @@
import { TranslateResult } from "vue-i18n";
export interface SideBarLink {
icon: string
to: string
title: TranslateResult
icon: string;
to: string;
title: TranslateResult;
}
export type SidebarLinks = Array<SideBarLink>
export type SidebarLinks = Array<SideBarLink>;

49
frontend/types/components.d.ts vendored Normal file
View file

@ -0,0 +1,49 @@
// This Code is auto generated by gen_global_componenets.py
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import BaseColorPicker from "@/components/global/BaseColorPicker.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import BaseAutoForm from "@/components/global/BaseAutoForm.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppFloatingButton from "@/components/layout/AppFloatingButton.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
declare module "vue" {
export interface GlobalComponents {
// Global Components
BaseCardSectionTitle: typeof BaseCardSectionTitle;
AppLoader: typeof AppLoader;
BaseButton: typeof BaseButton;
BaseDialog: typeof BaseDialog;
BaseStatCard: typeof BaseStatCard;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
BaseColorPicker: typeof BaseColorPicker;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
BasePageTitle: typeof BasePageTitle;
BaseAutoForm: typeof BaseAutoForm;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppFloatingButton: typeof AppFloatingButton;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
}
}
export {};

7
frontend/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
import Auth from "@nuxtjs/auth-next/dist/core/auth";
declare module "vue/types/vue" {
interface Vue {
$auth: Auth;
}
}

View file

@ -2097,6 +2097,13 @@
dependencies:
tslib "^2.3.0"
"@vue/reactivity@3.2.9":
version "3.2.9"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.9.tgz#f4ec61519f4779224d98a23ac07b481d95687cae"
integrity sha512-V0me78KlETt/9u3S9BoViEZNCFr/fDWodLq/KqYbFj+YySnCDD0clmjgBSQvIM63D+z3iUXftJyv08vAjlWrvw==
dependencies:
"@vue/shared" "3.2.9"
"@vue/ref-transform@^3.2.6":
version "3.2.8"
resolved "https://registry.yarnpkg.com/@vue/ref-transform/-/ref-transform-3.2.8.tgz#a527047bab43ce50ef3d400ce71312ab30f825dc"
@ -2108,11 +2115,33 @@
estree-walker "^2.0.2"
magic-string "^0.25.7"
"@vue/runtime-core@3.2.9":
version "3.2.9"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.9.tgz#32854c9d9853aa2075fcecfc762b5f033a6bae1e"
integrity sha512-CaSjy/kBrSFtSwyW2sY7RTN5YGmcDg8xLzKmFmIrkI9AXv/YjViQjSKUNHTAhnGq0K739vhFO4r3meBNEWqiOw==
dependencies:
"@vue/reactivity" "3.2.9"
"@vue/shared" "3.2.9"
"@vue/runtime-dom@^3.2.9":
version "3.2.9"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.9.tgz#397572a142db2772fb4b7f0a2bc06b5486e5db81"
integrity sha512-Vi8eOaP7/8NYSWIl8/klPtkiI+IQq/gPAI77U7PVoJ22tTcK/+9IIrMEN2TD+jUkHTRRIymMECEv+hWQT1Mo1g==
dependencies:
"@vue/runtime-core" "3.2.9"
"@vue/shared" "3.2.9"
csstype "^2.6.8"
"@vue/shared@3.2.8", "@vue/shared@^3.2.4", "@vue/shared@^3.2.6":
version "3.2.8"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.8.tgz#2f918e330aeb3f56ab1031ca60a5b30672512457"
integrity sha512-E2DQQnG7Qr4GwTs3GlfPPlHliGVADoufTnhpwfoViw7JlyLMmYtjfnTwM6nXAwvSJWiF7D+7AxpnWBBT3VWo6Q==
"@vue/shared@3.2.9":
version "3.2.9"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.9.tgz#44e44dbd82819997f192fb7dbdb90af5715dbf52"
integrity sha512-+CifxkLVhjKT14g/LMZil8//SdCzkMkS8VfRX0cqNJiFKK4AWvxj0KV1dhbr8czikY0DZUGQew3tRMRRChMGtA==
"@vueuse/core@^5.2.0":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-5.3.0.tgz#d8c6e939e18089afa224fab6e443fae2bdb57a51"
@ -4174,6 +4203,11 @@ csso@^4.0.2:
dependencies:
css-tree "^1.1.2"
csstype@^2.6.8:
version "2.6.17"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.17.tgz#4cf30eb87e1d1a005d8b6510f95292413f6a1c0e"
integrity sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==
cuint@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"

View file

@ -10,7 +10,6 @@ from mealie.routes.mealplans import meal_plan_router
from mealie.routes.media import media_router
from mealie.routes.site_settings import settings_router
from mealie.services.events import create_general_event
from mealie.services.recipe.all_recipe_service import subscripte_to_recipe_events
logger = get_logger()
@ -71,7 +70,6 @@ def system_startup():
)
)
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
subscripte_to_recipe_events()
def main():

View file

@ -6,6 +6,18 @@ from mealie.schema.user.user import PrivateUser
from .dependencies import generate_session, get_admin_user, get_current_user, is_logged_in
class RequestContext:
def __init__(
self,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
user=Depends(get_current_user),
):
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: bool = user
class PublicDeps:
"""
PublicDeps contains the common dependencies for all read operations through the API.

View file

@ -52,6 +52,21 @@ class BaseAccessModel(Generic[T, D]):
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()]
def multi_query(
self,
session: Session,
query_by: dict[str, str],
start=0,
limit: int = None,
override_schema=None,
) -> list[T]:
eff_schema = override_schema or self.schema
return [
eff_schema.from_orm(x)
for x in session.query(self.sql_model).filter_by(**query_by).offset(start).limit(limit).all()
]
def get_all_limit_columns(self, session: Session, fields: list[str], limit: int = None) -> list[D]:
"""Queries the database for the selected model. Restricts return responses to the
keys specified under "fields"
@ -105,11 +120,6 @@ class BaseAccessModel(Generic[T, D]):
eff_schema = override_schema or self.schema
return eff_schema.from_orm(result)
def get_many(
self, session: Session, value: str, key: str = None, limit=1, any_case=False, override_schema=None
) -> list[T]:
pass
def get(
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
) -> T | list[T]:

View file

@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.group.webhooks import GroupWebhooksModel
@ -22,6 +23,7 @@ from mealie.schema.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
from mealie.schema.recipe import (
@ -87,6 +89,7 @@ class DatabaseAccessLayer:
# Group Data
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
self.group_tokens = BaseAccessModel("token", GroupInviteToken, ReadInviteToken)
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)

View file

@ -1,3 +1,6 @@
from .cookbook import *
from .group import *
from .invite_tokens import *
from .preferences import *
from .shopping_list import *
from .webhooks import *

View file

@ -3,6 +3,7 @@ import sqlalchemy.orm as orm
from sqlalchemy.orm.session import Session
from mealie.core.config import settings
from mealie.db.models.group.invite_tokens import GroupInviteToken
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
@ -14,11 +15,13 @@ from .preferences import GroupPreferencesModel
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
invite_tokens = orm.relationship(
GroupInviteToken, back_populates="group", cascade="all, delete-orphan", uselist=True
)
preferences = orm.relationship(
GroupPreferencesModel,
back_populates="group",
@ -27,13 +30,16 @@ class Group(SqlAlchemyBase, BaseMixins):
cascade="all, delete-orphan",
)
# Recipes
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True)
# CRUD From Others
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences"})
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens"})
def __init__(self, **_) -> None:
pass

View file

@ -0,0 +1,17 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "invite_tokens"
token = Column(String, index=True, nullable=False, unique=True)
uses_left = Column(Integer, nullable=False, default=1)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="invite_tokens")
@auto_init()
def __init__(self, **_):
pass

View file

@ -8,6 +8,7 @@ from sqlalchemy.orm import validates
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from ..users import users_to_favorites
from .api_extras import ApiExtras
from .assets import RecipeAsset
from .category import recipes2categories
@ -22,6 +23,19 @@ from .tool import Tool
class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
slug = sa.Column(sa.String, index=True)
# ID Relationships
group_id = sa.Column(sa.Integer, 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 = orm.relationship("User", uselist=False, foreign_keys=[user_id])
favorited_by: list = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
# General Recipe Properties
name = sa.Column(sa.String, nullable=False)
description = sa.Column(sa.String)
@ -57,7 +71,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
# Mealie Specific
slug = sa.Column(sa.String, index=True, unique=True)
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
@ -69,10 +82,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
date_added = sa.Column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime)
# Favorited By
favorited_by_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
favorited_by = orm.relationship("User", back_populates="favorite_recipes")
class Config:
get_attr = "slug"

View file

@ -1 +1,2 @@
from .user_to_favorite import *
from .users import *

View file

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

View file

@ -1,9 +1,10 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.core.config import settings
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.group import Group
from mealie.db.models.recipe.recipe import RecipeModel
from .._model_base import BaseMixins, SqlAlchemyBase
from ..group import Group
from .user_to_favorite import users_to_favorites
class LongLiveToken(SqlAlchemyBase, BaseMixins):
@ -33,6 +34,8 @@ class User(SqlAlchemyBase, BaseMixins):
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
# Recipes
tokens: list[LongLiveToken] = orm.relationship(
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
@ -41,7 +44,10 @@ class User(SqlAlchemyBase, BaseMixins):
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
def __init__(
self,
@ -65,9 +71,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.password = password
self.advanced = advanced
self.favorite_recipes = [
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
]
self.favorite_recipes = []
if self.username is None:
self.username = full_name
@ -99,10 +103,6 @@ class User(SqlAlchemyBase, BaseMixins):
if password:
self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
]
def update_password(self, password):
self.password = password

View file

@ -1,8 +1,9 @@
from fastapi import APIRouter
from . import admin_about, admin_log
from . import admin_about, admin_group, admin_log
router = APIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"])
router.include_router(admin_group.router, tags=["Admin: Group"])

View file

@ -4,24 +4,21 @@ from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup
from mealie.services.events import create_group_event
admin_router = AdminAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
router = AdminAPIRouter(prefix="/groups")
@admin_router.get("", response_model=list[GroupInDB])
async def get_all_groups(
session: Session = Depends(generate_session),
):
@router.get("", response_model=list[GroupInDB])
async def get_all_groups(session: Session = Depends(generate_session)):
""" Returns a list of all groups in the database """
return db.groups.get_all(session)
@admin_router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
@router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
async def create_group(
background_tasks: BackgroundTasks,
group_data: GroupBase,
@ -37,17 +34,13 @@ async def create_group(
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@admin_router.put("/{id}")
async def update_group_data(
id: int,
group_data: UpdateGroup,
session: Session = Depends(generate_session),
):
@router.put("/{id}")
async def update_group_data(id: int, group_data: UpdateGroup, session: Session = Depends(generate_session)):
""" Updates a User Group """
db.groups.update(session, id, group_data.dict())
@admin_router.delete("/{id}")
@router.delete("/{id}")
async def delete_user_group(
background_tasks: BackgroundTasks,
id: int,

View file

@ -6,11 +6,13 @@ from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, s
from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.core.dependencies import get_current_user
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.admin import BackupJob, ImportJob, Imports, LocalBackup
from mealie.schema.user.user import PrivateUser
from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
@ -82,10 +84,12 @@ def import_database(
file_name: str,
import_data: ImportJob,
session: Session = Depends(generate_session),
user: PrivateUser = Depends(get_current_user),
):
""" Import a database backup file generated from Mealie. """
db_import = imports.import_database(
user=user,
session=session,
archive=import_data.name,
import_recipes=import_data.recipes,

View file

@ -3,7 +3,7 @@ from fastapi import APIRouter
from mealie.services._base_http_service import RouterFactory
from mealie.services.group_services import CookbookService, WebhookService
from . import categories, crud, self_service
from . import categories, invitations, preferences, self_service
router = APIRouter()
@ -13,5 +13,5 @@ router.include_router(self_service.user_router)
router.include_router(cookbook_router)
router.include_router(categories.user_router)
router.include_router(webhook_router)
router.include_router(crud.user_router)
router.include_router(crud.admin_router)
router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"])
router.include_router(preferences.router, prefix="/groups/preferences", tags=["Group: Preferences"])

View file

@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends, status
from mealie.schema.group.invite_token import CreateInviteToken, ReadInviteToken
from mealie.services.group_services.group_service import GroupSelfService
router = APIRouter()
@router.get("", response_model=list[ReadInviteToken])
def get_invite_tokens(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.get_invite_tokens()
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
def create_invite_token(
uses: CreateInviteToken, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
return g_service.create_invite_token(uses.uses)

View file

@ -0,0 +1,19 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.services.group_services.group_service import GroupSelfService
router = UserAPIRouter()
@router.put("", response_model=ReadGroupPreferences)
def update_group_preferences(
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
return g_service.update_preferences(new_pref).preferences
@router.get("", response_model=ReadGroupPreferences)
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.item.preferences

View file

@ -1,7 +1,6 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.user.user import GroupInDB
from mealie.services.group_services.group_service import GroupSelfService
@ -13,15 +12,3 @@ async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSe
""" Returns the Group Data for the Current User """
return g_service.item
@user_router.put("/preferences", response_model=ReadGroupPreferences)
def update_group_preferences(
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
return g_service.update_preferences(new_pref).preferences
@user_router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.item.preferences

View file

@ -25,7 +25,7 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
and should not hit the API in production"""
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
if recipe_image:
if recipe_image.exists():
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View file

@ -8,7 +8,9 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.routes.users.crud import get_logged_in_user
from mealie.schema.admin import MigrationFile, Migrations
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import migration
router = AdminAPIRouter(prefix="/api/migrations", tags=["Migration"])
@ -36,10 +38,15 @@ def get_all_migration_options():
@router.post("/{import_type}/{file_name}/import")
def import_migration(import_type: migration.Migration, file_name: str, session: Session = Depends(generate_session)):
def import_migration(
import_type: migration.Migration,
file_name: str,
session: Session = Depends(generate_session),
user: PrivateUser = Depends(get_logged_in_user),
):
""" Imports all the recipes in a given directory """
file_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
return migration.migrate(import_type, file_path, session)
return migration.migrate(user, import_type, file_path, session)
@router.delete("/{import_type}/{file_name}/delete", status_code=status.HTTP_200_OK)

View file

@ -8,7 +8,6 @@ router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(recipe_crud_routes.public_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"])

View file

@ -4,28 +4,10 @@ from sqlalchemy.orm.session import Session
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.schema.recipe import RecipeSummary
from mealie.services.recipe.all_recipe_service import AllRecipesService
router = APIRouter()
@router.get("")
def get_recipe_summary(all_recipes_service: AllRecipesService.query = Depends()):
"""
Returns key the recipe summary data for recipes in the database. You can perform
slice operations to set the skip/end amounts for recipes. All recipes are sorted by the added date.
**Query Parameters**
- skip: The database entry to start at. (0 Indexed)
- end: The number of entries to return.
skip=2, end=10 will return entries
"""
return all_recipes_service.get_recipes()
@router.get("/summary/untagged", response_model=list[RecipeSummary])
async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)):
return db.recipes.count_untagged(session, count=count, override_schema=RecipeSummary)

View file

@ -2,7 +2,7 @@ import json
import shutil
from zipfile import ZipFile
from fastapi import APIRouter, Depends, File
from fastapi import Depends, File
from fastapi.datastructures import UploadFile
from scrape_schema_recipe import scrape_url
from sqlalchemy.orm.session import Session
@ -14,26 +14,24 @@ from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes
from mealie.schema.recipe.recipe import CreateRecipe
from mealie.schema.recipe.recipe import CreateRecipe, RecipeSummary
from mealie.services.image.image import write_image
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.scraper.scraper import create_from_url
user_router = UserAPIRouter()
public_router = APIRouter()
logger = get_logger()
@public_router.get("/{slug}", response_model=Recipe)
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
""" Takes in a recipe slug, returns all data for a recipe """
return recipe_service.item
@user_router.get("", response_model=list[RecipeSummary])
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)):
return service.get_all(start, limit)
@user_router.post("", status_code=201, response_model=str)
def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.private)) -> str:
""" Takes in a JSON string and loads data into the database as a new entry"""
return recipe_service.create_recipe(data).slug
return recipe_service.create_one(data).slug
@user_router.post("/create-url", status_code=201, response_model=str)
@ -41,7 +39,7 @@ def parse_recipe_url(url: CreateRecipeByURL, recipe_service: RecipeService = Dep
""" Takes in a URL and attempts to scrape data and load it into the database """
recipe = create_from_url(url.url)
return recipe_service.create_recipe(recipe).slug
return recipe_service.create_one(recipe).slug
@user_router.post("/test-scrape-url")
@ -80,7 +78,13 @@ async def create_recipe_from_zip(
return recipe
@public_router.get("/{slug}/zip")
@user_router.get("/{slug}", response_model=Recipe)
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
""" Takes in a recipe slug, returns all data for a recipe """
return recipe_service.item
@user_router.get("/{slug}/zip")
async def get_recipe_as_zip(
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
):
@ -102,17 +106,17 @@ async def get_recipe_as_zip(
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Updates a recipe by existing slug and data. """
return recipe_service.update_recipe(data)
return recipe_service.update_one(data)
@user_router.patch("/{slug}")
def patch_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Updates a recipe by existing slug and data. """
return recipe_service.patch_recipe(data)
return recipe_service.patch_one(data)
@user_router.delete("/{slug}")
def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Deletes a recipe by slug """
return recipe_service.delete_recipe()
return recipe_service.delete_one()

View file

@ -18,27 +18,27 @@ async def get_all(
@router.post("", response_model=IngredientFood, status_code=status.HTTP_201_CREATED)
async def create_unit(unit: CreateIngredientFood, session: Session = Depends(generate_session)):
async def create_food(unit: CreateIngredientFood, session: Session = Depends(generate_session)):
""" Create unit in the Database """
return db.ingredient_foods.create(session, unit)
@router.get("/{id}")
async def get_unit(id: str, session: Session = Depends(generate_session)):
async def get_food(id: str, session: Session = Depends(generate_session)):
""" Get unit from the Database """
return db.ingredient_foods.get(session, id)
@router.put("/{id}")
async def update_unit(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)):
async def update_food(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)):
""" Update unit in the Database """
return db.ingredient_foods.update(session, id, unit)
@router.delete("/{id}")
async def delete_unit(id: str, session: Session = Depends(generate_session)):
async def delete_food(id: str, session: Session = Depends(generate_session)):
""" Delete unit from the Database """
return db.ingredient_foods.delete(session, id)

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter
from . import api_tokens, crud, favorites, images, passwords, registration, sign_up
from . import api_tokens, crud, favorites, images, passwords, registration
# Must be used because of the way FastAPI works with nested routes
user_prefix = "/users"
@ -9,9 +9,6 @@ router = APIRouter()
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
router.include_router(sign_up.admin_router, prefix=user_prefix, tags=["Users: Sign-Up"])
router.include_router(sign_up.public_router, prefix=user_prefix, tags=["Users: Sign-Up"])
router.include_router(crud.user_router, prefix=user_prefix, tags=["Users: CRUD"])
router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"])

View file

@ -8,7 +8,7 @@ router = APIRouter(prefix="/register")
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def reset_user_password(
def register_new_user(
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
):
return registration_service.register_user(data)

View file

@ -1,72 +0,0 @@
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_admin_user
from mealie.core.security import hash_password
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.user import PrivateUser, SignUpIn, SignUpOut, SignUpToken, UserIn
from mealie.services.events import create_user_event
public_router = APIRouter(prefix="/sign-ups")
admin_router = AdminAPIRouter(prefix="/sign-ups")
@admin_router.get("", response_model=list[SignUpOut])
async def get_all_open_sign_ups(session: Session = Depends(generate_session)):
""" Returns a list of open sign up links """
return db.sign_ups.get_all(session)
@admin_router.post("", response_model=SignUpToken)
async def create_user_sign_up_key(
background_tasks: BackgroundTasks,
key_data: SignUpIn,
current_user: PrivateUser = Depends(get_admin_user),
session: Session = Depends(generate_session),
):
""" Generates a Random Token that a new user can sign up with """
sign_up = {
"token": str(uuid.uuid1().hex),
"name": key_data.name,
"admin": key_data.admin,
}
background_tasks.add_task(
create_user_event, "Sign-up Token Created", f"Created by {current_user.full_name}", session=session
)
return db.sign_ups.create(session, sign_up)
@public_router.post("/{token}")
async def create_user_with_token(
background_tasks: BackgroundTasks, token: str, new_user: UserIn, session: Session = Depends(generate_session)
):
""" Creates a user with a valid sign up token """
# Validate Token
db_entry: SignUpOut = db.sign_ups.get(session, token, limit=1)
if not db_entry:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
# Create User
new_user.admin = db_entry.admin
new_user.password = hash_password(new_user.password)
db.users.create(session, new_user.dict())
# DeleteToken
background_tasks.add_task(
create_user_event, "Sign-up Token Used", f"New User {new_user.full_name}", session=session
)
db.sign_ups.delete(session, token)
@admin_router.delete("/{token}")
async def delete_token(token: str, session: Session = Depends(generate_session)):
""" Removed a token from the database """
db.sign_ups.delete(session, token)

View file

@ -0,0 +1,20 @@
from fastapi_camelcase import CamelModel
class CreateInviteToken(CamelModel):
uses: int
class SaveInviteToken(CamelModel):
uses_left: int
group_id: int
token: str
class ReadInviteToken(CamelModel):
token: str
uses_left: int
group_id: int
class Config:
orm_mode = True

View file

@ -32,11 +32,15 @@ class CreateRecipe(CamelModel):
class RecipeSummary(CamelModel):
id: Optional[int]
user_id: int = 0
group_id: int = 0
name: Optional[str]
slug: str = ""
image: Optional[Any]
description: Optional[str]
description: Optional[str] = ""
recipe_category: Optional[list[str]] = []
tags: Optional[list[str]] = []
rating: Optional[int]
@ -112,32 +116,6 @@ class Recipe(RecipeSummary):
"extras": {x.key_name: x.value for x in name_orm.extras},
}
schema_extra = {
"example": {
"name": "Chicken and Rice With Leeks and Salsa Verde",
"description": "This one-skillet dinner gets deep oniony flavor from lots of leeks cooked down to jammy tenderness.",
"image": "chicken-and-rice-with-leeks-and-salsa-verde.jpg",
"recipe_yield": "4 Servings",
"recipe_ingredient": [
"1 1/2 lb. skinless, boneless chicken thighs (4-8 depending on size)",
"Kosher salt, freshly ground pepper",
"3 Tbsp. unsalted butter, divided",
],
"recipe_instructions": [
{
"text": "Season chicken with salt and pepper.",
},
],
"slug": "chicken-and-rice-with-leeks-and-salsa-verde",
"tags": ["favorite", "yummy!"],
"recipe_category": ["Dinner", "Pasta"],
"notes": [{"title": "Watch Out!", "text": "Prep the day before!"}],
"org_url": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde",
"rating": 3,
"extras": {"message": "Don't forget to defrost the chicken!"},
}
}
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values):
if not values.get("name"):

View file

@ -81,6 +81,7 @@ class UserIn(UserBase):
class UserOut(UserBase):
id: int
group: str
group_id: int
tokens: Optional[list[LongLiveTokenOut]]
favorite_recipes: Optional[list[str]] = []
@ -112,6 +113,7 @@ class UserFavorites(UserBase):
class PrivateUser(UserOut):
password: str
group_id: int
class Config:
orm_mode = True

View file

@ -27,6 +27,7 @@ from mealie.services.image import minify
class ImportDatabase:
def __init__(
self,
user: PrivateUser,
session: Session,
zip_archive: str,
force_import: bool = False,
@ -41,6 +42,7 @@ class ImportDatabase:
Raises:
Exception: If the zip file does not exists an exception raise.
"""
self.user = user
self.session = session
self.archive = app_dirs.BACKUP_DIR.joinpath(zip_archive)
self.force_imports = force_import
@ -66,6 +68,9 @@ class ImportDatabase:
for recipe in recipes:
recipe: Recipe
recipe.group_id = self.user.group_id
recipe.user_id = self.user.id
import_status = self.import_model(
db_table=db.recipes,
model=recipe,
@ -308,6 +313,7 @@ class ImportDatabase:
def import_database(
session: Session,
user: PrivateUser,
archive,
import_recipes=True,
import_settings=True,
@ -317,7 +323,7 @@ def import_database(
force_import: bool = False,
rebase: bool = False,
):
import_session = ImportDatabase(session, archive, force_import)
import_session = ImportDatabase(user, session, archive, force_import)
recipe_report = []
if import_recipes:

View file

@ -1,10 +1,13 @@
from __future__ import annotations
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken, SaveInviteToken
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
from mealie.services._base_http_service.http_services import UserHttpService
@ -50,3 +53,10 @@ class GroupSelfService(UserHttpService[int, str]):
self.db.group_preferences.update(self.session, self.group_id, new_preferences)
return self.populate_item()
def create_invite_token(self, uses: int = 1) -> None:
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex)
return self.db.group_tokens.create(self.session, token)
def get_invite_tokens(self) -> list[ReadInviteToken]:
return self.db.group_tokens.multi_query(self.session, {"group_id": self.group_id})

View file

@ -10,6 +10,7 @@ from mealie.core import root_logger
from mealie.db.database import db
from mealie.schema.admin import MigrationImport
from mealie.schema.recipe import Recipe
from mealie.schema.user.user import PrivateUser
from mealie.services.image import image
from mealie.services.scraper import cleaner
from mealie.utils.unzip import unpack_zip
@ -34,6 +35,8 @@ class MigrationBase(BaseModel):
session: Optional[Any]
key_aliases: Optional[list[MigrationAlias]]
user: PrivateUser
@property
def temp_dir(self) -> TemporaryDirectory:
"""unpacks the migration_file into a temporary directory
@ -162,6 +165,10 @@ class MigrationBase(BaseModel):
"""
for recipe in validated_recipes:
recipe.user_id = self.user.id
recipe.group_id = self.user.group_id
exception = ""
status = False
try:

View file

@ -5,6 +5,7 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import app_dirs
from mealie.schema.admin import MigrationImport
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import helpers
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
@ -18,8 +19,8 @@ class ChowdownMigration(MigrationBase):
]
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
cd_migration = ChowdownMigration(migration_file=zip_path, session=session)
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
cd_migration = ChowdownMigration(user=user, migration_file=zip_path, session=session)
with cd_migration.temp_dir as dir:
chow_dir = next(Path(dir).iterdir())

View file

@ -19,7 +19,7 @@ class Migration(str, Enum):
chowdown = "chowdown"
def migrate(migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
def migrate(user, migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
"""The new entry point for accessing migrations within the 'migrations' service.
Using the 'Migrations' enum class as a selector for migration_type to direct which function
to call. All migrations will return a MigrationImport object that is built for displaying
@ -37,10 +37,10 @@ def migrate(migration_type: str, file_path: Path, session: Session) -> list[Migr
logger.info(f"Starting Migration from {migration_type}")
if migration_type == Migration.nextcloud.value:
migration_imports = nextcloud.migrate(session, file_path)
migration_imports = nextcloud.migrate(user, session, file_path)
elif migration_type == Migration.chowdown.value:
migration_imports = chowdown.migrate(session, file_path)
migration_imports = chowdown.migrate(user, session, file_path)
else:
return []

View file

@ -6,6 +6,7 @@ from slugify import slugify
from sqlalchemy.orm.session import Session
from mealie.schema.admin import MigrationImport
from mealie.schema.user.user import PrivateUser
from mealie.services.migrations import helpers
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
@ -42,9 +43,9 @@ class NextcloudMigration(MigrationBase):
]
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
nc_migration = NextcloudMigration(migration_file=zip_path, session=session)
nc_migration = NextcloudMigration(user=user, migration_file=zip_path, session=session)
with nc_migration.temp_dir as dir:
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)

View file

@ -1,71 +0,0 @@
import json
from functools import lru_cache
from fastapi import Depends, Response
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import is_logged_in
from mealie.core.root_logger import get_logger
from mealie.db.database import db
from mealie.db.db_setup import SessionLocal, generate_session
from mealie.schema.recipe import RecipeSummary
logger = get_logger()
class AllRecipesService:
def __init__(self, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)):
self.start = 0
self.limit = 9999
self.session = session or SessionLocal()
self.is_user = is_user
@classmethod
def query(
cls, start=0, limit=9999, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)
):
set_query = cls(session, is_user)
set_query.start = start
set_query.limit = limit
return set_query
def get_recipes(self):
if self.is_user:
return get_all_recipes_user(self.limit, self.start)
else:
return get_all_recipes_public(self.limit, self.start)
@lru_cache(maxsize=1)
def get_all_recipes_user(limit, start):
with SessionLocal() as session:
all_recipes: list[RecipeSummary] = db.recipes.get_all(
session, limit=limit, start=start, order_by="date_updated", override_schema=RecipeSummary
)
all_recipes_json = [recipe.dict() for recipe in all_recipes]
return Response(content=json.dumps(jsonable_encoder(all_recipes_json)), media_type="application/json")
@lru_cache(maxsize=1)
def get_all_recipes_public(limit, start):
with SessionLocal() as session:
all_recipes: list[RecipeSummary] = db.recipes.get_all_public(
session, limit=limit, start=start, order_by="date_updated", override_schema=RecipeSummary
)
all_recipes_json = [recipe.dict() for recipe in all_recipes]
return Response(content=json.dumps(jsonable_encoder(all_recipes_json)), media_type="application/json")
def clear_all_cache():
get_all_recipes_user.cache_clear()
get_all_recipes_public.cache_clear()
logger.info("All Recipes Cache Cleared")
def subscripte_to_recipe_events():
db.recipes.subscribe(clear_all_cache)
logger.info("All Recipes Subscribed to Database Events")

View file

@ -0,0 +1,16 @@
from mealie.schema.recipe import Recipe
from mealie.schema.user.user import PrivateUser
def recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
"""
The main creation point for recipes. The factor method returns an instance of the
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
else-where to avoid conflicts.
"""
additional_attrs = additional_attrs or {}
additional_attrs["name"] = name
additional_attrs["user_id"] = user.id
additional_attrs["group_id"] = user.group_id
return Recipe(**additional_attrs)

View file

@ -7,14 +7,15 @@ from sqlalchemy.exc import IntegrityError
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.services.recipe.mixins import recipe_creation_factory
logger = get_logger(module=__name__)
class RecipeService(PublicHttpService[str, Recipe]):
class RecipeService(UserHttpService[str, Recipe]):
"""
Class Methods:
`read_existing`: Reads an existing recipe from the database.
@ -46,9 +47,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
return self.item
# CRUD METHODS
def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
if isinstance(create_data, CreateRecipe):
create_data = Recipe(name=create_data.name)
def get_all(self, start=0, limit=None):
return self.db.recipes.multi_query(
self.session, {"group_id": self.user.group_id}, start=start, limit=limit, override_schema=RecipeSummary
)
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
try:
self.item = self.db.recipes.create(self.session, create_data)
@ -56,13 +61,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
self._create_event(
"Recipe Created (URL)",
"Recipe Created",
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
)
return self.item
def update_recipe(self, update_data: Recipe) -> Recipe:
def update_one(self, update_data: Recipe) -> Recipe:
original_slug = self.item.slug
try:
@ -74,7 +79,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
return self.item
def patch_recipe(self, patch_data: Recipe) -> Recipe:
def patch_one(self, patch_data: Recipe) -> Recipe:
original_slug = self.item.slug
try:
@ -88,16 +93,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
return self.item
def delete_recipe(self) -> Recipe:
"""removes a recipe from the database and purges the existing files from the filesystem.
Raises:
HTTPException: 400 Bad Request
Returns:
Recipe: The deleted recipe
"""
def delete_one(self) -> Recipe:
try:
recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
self._delete_assets()

View file

@ -1,3 +1,5 @@
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password
from mealie.schema.group.group_preferences import CreateGroupPreferences
@ -20,13 +22,37 @@ class RegistrationService(PublicHttpService[int, str]):
self.registration = registration
logger.info(f"Registering user {registration.username}")
token_entry = None
if registration.group:
group = self._create_new_group()
else:
group = self._existing_group_ref()
group = self._register_new_group()
return self._create_new_user(group)
elif registration.group_token and registration.group_token != "":
token_entry = self.db.group_tokens.get(self.session, registration.group_token)
print("Token Entry", token_entry)
if not token_entry:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"})
group = self.db.groups.get(self.session, token_entry.group_id)
else:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"})
user = self._create_new_user(group)
if token_entry and user:
token_entry.uses_left = token_entry.uses_left - 1
if token_entry.uses_left == 0:
self.db.group_tokens.delete(self.session, token_entry.token)
else:
self.db.group_tokens.update(self.session, token_entry.token, token_entry)
return user
def _create_new_user(self, group: GroupInDB) -> PrivateUser:
new_user = UserIn(
@ -40,7 +66,7 @@ class RegistrationService(PublicHttpService[int, str]):
return self.db.users.create(self.session, new_user)
def _create_new_group(self) -> GroupInDB:
def _register_new_group(self) -> GroupInDB:
group_data = GroupBase(name=self.registration.group)
group_preferences = CreateGroupPreferences(
@ -56,6 +82,3 @@ class RegistrationService(PublicHttpService[int, str]):
)
return create_new_group(self.session, group_data, group_preferences)
def _existing_group_ref(self) -> GroupInDB:
pass

View file

@ -11,6 +11,8 @@ from mealie.db.db_setup import SessionLocal, generate_session
from mealie.db.init_db import main
from tests.app_routes import AppRoutes
from tests.test_config import TEST_DATA
from tests.utils.factories import random_email, random_string, user_registration_factory
from tests.utils.fixture_schemas import TestUser
from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases
main()
@ -62,12 +64,46 @@ def admin_token(api_client: requests, api_routes: AppRoutes):
return login(form_data, api_client, api_routes)
@fixture(scope="session")
def g2_user(admin_token, api_client: requests, api_routes: AppRoutes):
# Create the user
create_data = {
"fullName": random_string(),
"username": random_string(),
"email": random_email(),
"password": "useruser",
"group": "New Group",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.groups, json={"name": "New Group"}, headers=admin_token)
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
# Log in as this user
form_data = {"username": create_data["email"], "password": "useruser"}
token = login(form_data, api_client, api_routes)
self_response = api_client.get(api_routes.users_self, headers=token)
assert self_response.status_code == 200
user_id = json.loads(self_response.text).get("id")
group_id = json.loads(self_response.text).get("groupId")
return TestUser(user_id=user_id, group_id=group_id, token=token)
@fixture(scope="session")
def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
# Create the user
create_data = {
"fullName": "User",
"email": "user@email.com",
"fullName": random_string(),
"username": random_string(),
"email": random_email(),
"password": "useruser",
"group": "Home",
"admin": False,
@ -79,7 +115,7 @@ def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
assert response.status_code == 201
# Log in as this user
form_data = {"username": "user@email.com", "password": "useruser"}
form_data = {"username": create_data["email"], "password": "useruser"}
return login(form_data, api_client, api_routes)
@ -96,3 +132,45 @@ def raw_recipe_no_image():
@fixture(scope="session")
def recipe_store():
return get_recipe_test_cases()
@fixture(scope="module")
def unique_user(api_client: TestClient, api_routes: AppRoutes):
registration = user_registration_factory()
response = api_client.post("/api/users/register", json=registration.dict(by_alias=True))
assert response.status_code == 201
form_data = {"username": registration.username, "password": registration.password}
token = login(form_data, api_client, api_routes)
user_data = api_client.get(api_routes.users_self, headers=token).json()
assert token is not None
try:
yield TestUser(group_id=user_data.get("groupId"), user_id=user_data.get("id"), token=token)
finally:
# TODO: Delete User after test
pass
@fixture(scope="session")
def admin_user(api_client: TestClient, api_routes: AppRoutes):
form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD}
token = login(form_data, api_client, api_routes)
user_data = api_client.get(api_routes.users_self, headers=token).json()
assert token is not None
assert user_data.get("admin") is True
assert user_data.get("groupId") is not None
assert user_data.get("id") is not None
try:
yield TestUser(group_id=user_data.get("groupId"), user_id=user_data.get("id"), token=token)
finally:
# TODO: Delete User after test
pass

View file

@ -0,0 +1,91 @@
import json
from fastapi.testclient import TestClient
from tests.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)
assert response.status_code == 200
admin_data = response.json()
assert admin_data["id"] == admin_user.user_id
assert admin_data["groupId"] == admin_user.group_id
assert admin_data["fullName"] == "Change Me"
assert admin_data["email"] == "changeme@email.com"
def test_create_user(api_client: TestClient, api_routes: AppRoutes, admin_token):
create_data = {
"fullName": "My New User",
"email": "newuser@email.com",
"password": "MyStrongPassword",
"group": "Home",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
user_data = response.json()
assert user_data["fullName"] == create_data["fullName"]
assert user_data["email"] == create_data["email"]
assert user_data["group"] == create_data["group"]
assert user_data["admin"] == create_data["admin"]
def test_create_user_as_non_admin(api_client: TestClient, api_routes: AppRoutes, user_token):
create_data = {
"fullName": "My New User",
"email": "newuser@email.com",
"password": "MyStrongPassword",
"group": "Home",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.users, json=create_data, headers=user_token)
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)
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)
assert response.status_code == 403
def test_self_demote_admin(api_client: TestClient, api_routes: AppRoutes, admin_token):
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)
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)
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)
assert response.status_code == 200

View file

@ -0,0 +1,46 @@
import json
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/admin/groups"
def item(id: str) -> str:
return f"{Routes.base}/{id}"
def test_create_group(api_client: TestClient, admin_token):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_token)
assert response.status_code == 201
def test_user_cant_create_group(api_client: TestClient, unique_user: TestUser):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=unique_user.token)
assert response.status_code == 403
def test_home_group_not_deletable(api_client: TestClient, admin_token):
response = api_client.delete(Routes.item(1), headers=admin_token)
assert response.status_code == 400
def test_delete_group(api_client: TestClient, admin_token):
response = api_client.post(Routes.base, json={"name": random_string()}, headers=admin_token)
assert response.status_code == 201
group_id = json.loads(response.text)["id"]
response = api_client.delete(Routes.item(group_id), headers=admin_token)
assert response.status_code == 200
# Ensure Group is Deleted
response = api_client.get(Routes.base, headers=admin_token)
for g in response.json():
assert g["id"] != group_id

View file

@ -1,60 +0,0 @@
import json
import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
from tests.utils.assertion_helpers import assert_ignore_keys
@pytest.fixture
def group_data():
return {"name": "Test Group"}
def test_create_group(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.post(api_routes.groups, json={"name": "Test Group"}, headers=admin_token)
assert response.status_code == 201
def test_get_self_group(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.get(api_routes.groups, headers=admin_token)
assert response.status_code == 200
assert len(json.loads(response.text)) >= 2
def test_update_group(api_client: TestClient, api_routes: AppRoutes, admin_token):
new_data = {
"name": "New Group Name",
"id": 2,
"categories": [],
"webhooks": [],
"users": [],
"mealplans": [],
"shoppingLists": [],
}
# Test Update
response = api_client.put(api_routes.groups_id(2), json=new_data, headers=admin_token)
assert response.status_code == 200
# Validate Changes
response = api_client.get(api_routes.groups, headers=admin_token)
all_groups = json.loads(response.text)
id_2 = filter(lambda x: x["id"] == 2, all_groups)
assert_ignore_keys(new_data, next(id_2), ["preferences"])
def test_home_group_not_deletable(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.groups_id(1), headers=admin_token)
assert response.status_code == 400
def test_delete_group(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.groups_id(2), headers=admin_token)
assert response.status_code == 200

View file

@ -14,7 +14,7 @@ def backup_data():
"recipes": True,
"settings": False, # ! Broken
"groups": False, # ! Also Broken
"users": True,
"users": False,
}

View file

@ -1,103 +0,0 @@
import json
import pytest
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
from tests.utils.recipe_data import RecipeSiteTestCase
def get_meal_plan_template(first=None, second=None):
return {
"group": "Home",
"startDate": "2021-01-18",
"endDate": "2021-01-19",
"planDays": [
{
"date": "2021-1-18",
"meals": [{"slug": first, "name": "", "description": ""}],
},
{
"date": "2021-1-19",
"meals": [{"slug": second, "name": "", "description": ""}],
},
],
}
@pytest.fixture(scope="session")
def slug_1(api_client: TestClient, api_routes: AppRoutes, admin_token, recipe_store: list[RecipeSiteTestCase]):
slug_1 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[0].url}, headers=admin_token)
slug_1 = json.loads(slug_1.content)
yield slug_1
api_client.delete(api_routes.recipes_recipe_slug(slug_1))
@pytest.fixture(scope="session")
def slug_2(api_client: TestClient, api_routes: AppRoutes, admin_token, recipe_store: list[RecipeSiteTestCase]):
slug_2 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[1].url}, headers=admin_token)
slug_2 = json.loads(slug_2.content)
yield slug_2
api_client.delete(api_routes.recipes_recipe_slug(slug_2))
def test_create_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, admin_token):
meal_plan = get_meal_plan_template(slug_1, slug_2)
response = api_client.post(api_routes.meal_plans_create, json=meal_plan, headers=admin_token)
assert response.status_code == 201
def test_read_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, admin_token):
response = api_client.get(api_routes.meal_plans_all, headers=admin_token)
assert response.status_code == 200
meal_plan_template = get_meal_plan_template(slug_1, slug_2)
created_meal_plan = json.loads(response.text)
meals = created_meal_plan[0]["planDays"]
assert meals[0]["meals"][0]["slug"] == meal_plan_template["planDays"][0]["meals"][0]["slug"]
assert meals[1]["meals"][0]["slug"] == meal_plan_template["planDays"][1]["meals"][0]["slug"]
def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, admin_token):
response = api_client.get(api_routes.meal_plans_all, headers=admin_token)
existing_mealplan = json.loads(response.text)
existing_mealplan = existing_mealplan[0]
# Swap
plan_uid = existing_mealplan.get("id")
existing_mealplan["planDays"][0]["meals"][0]["slug"] = slug_2
existing_mealplan["planDays"][1]["meals"][0]["slug"] = slug_1
response = api_client.put(api_routes.meal_plans_plan_id(plan_uid), json=existing_mealplan, headers=admin_token)
assert response.status_code == 200
response = api_client.get(api_routes.meal_plans_all, headers=admin_token)
existing_mealplan = json.loads(response.text)
existing_mealplan = existing_mealplan[0]
assert existing_mealplan["planDays"][0]["meals"][0]["slug"] == slug_2
assert existing_mealplan["planDays"][1]["meals"][0]["slug"] == slug_1
def test_delete_mealplan(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.get(api_routes.meal_plans_all, headers=admin_token)
assert response.status_code == 200
existing_mealplan = json.loads(response.text)
existing_mealplan = existing_mealplan[0]
plan_uid = existing_mealplan.get("id")
response = api_client.delete(api_routes.meal_plans_plan_id(plan_uid), headers=admin_token)
assert response.status_code == 200

View file

@ -1,58 +0,0 @@
import json
import pytest
from fastapi.testclient import TestClient
from mealie.schema.user import SignUpToken
from tests.app_routes import AppRoutes
@pytest.fixture()
def active_link(api_client: TestClient, api_routes: AppRoutes, admin_token):
data = {"name": "Fixture Token", "admin": True}
response = api_client.post(api_routes.users_sign_ups, json=data, headers=admin_token)
return SignUpToken(**json.loads(response.text))
@pytest.fixture()
def sign_up_user():
return {
"fullName": "Test User",
"email": "test_user@email.com",
"admin": True,
"group": "string",
"password": "MySecretPassword",
}
def test_create_sign_up_link(api_client: TestClient, api_routes: AppRoutes, admin_token):
data = {"name": "Test Token", "admin": False}
response = api_client.post(api_routes.users_sign_ups, json=data, headers=admin_token)
assert response.status_code == 200
def test_new_user_signup(api_client: TestClient, api_routes: AppRoutes, active_link: SignUpToken, sign_up_user):
# Creation
response = api_client.post(api_routes.users_sign_ups_token(active_link.token), json=sign_up_user)
assert response.status_code == 200
# Login
form_data = {"username": "test_user@email.com", "password": "MySecretPassword"}
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 200
def test_delete_sign_up_link(
api_client: TestClient, api_routes: AppRoutes, admin_token, active_link: SignUpToken, sign_up_user
):
response = api_client.delete(api_routes.users_sign_ups_token(active_link.token), headers=admin_token)
assert response.status_code == 200
# Validate admin_token is Gone
response = api_client.get(api_routes.users_sign_ups, headers=admin_token)
assert sign_up_user not in json.loads(response.content)

View file

@ -1,177 +0,0 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
from pytest import fixture
from mealie.core.config import app_dirs
from mealie.schema.user import UserOut
from tests.app_routes import AppRoutes
@fixture(scope="session")
def admin_user():
return UserOut(
id=1,
fullName="Change Me",
username="Change Me",
email="changeme@email.com",
group="Home",
admin=True,
tokens=[],
)
@fixture(scope="session")
def new_user():
return UserOut(
id=3,
fullName="My New User",
username="My New User",
email="newuser@email.com",
group="Home",
admin=False,
tokens=[],
)
def test_failed_login(api_client: TestClient, api_routes: AppRoutes):
form_data = {"username": "changeme@email.com", "password": "WRONG_PASSWORD"}
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 401
def test_superuser_login(api_client: TestClient, api_routes: AppRoutes, admin_token):
form_data = {"username": "changeme@email.com", "password": "MyPassword"}
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 200
new_token = json.loads(response.text).get("access_token")
response = api_client.get(api_routes.users_self, headers=admin_token)
assert response.status_code == 200
return {"Authorization": f"Bearer {new_token}"}
def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, admin_token, admin_user: UserOut):
response = api_client.get(api_routes.users_id(1), headers=admin_token)
assert response.status_code == 200
assert json.loads(response.text) == admin_user.dict(by_alias=True)
def test_create_user(api_client: TestClient, api_routes: AppRoutes, admin_token, new_user):
create_data = {
"fullName": "My New User",
"email": "newuser@email.com",
"password": "MyStrongPassword",
"group": "Home",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
assert response.status_code == 201
assert json.loads(response.text) == new_user.dict(by_alias=True)
def test_create_user_as_non_admin(api_client: TestClient, api_routes: AppRoutes, user_token):
create_data = {
"fullName": "My New User",
"email": "newuser@email.com",
"password": "MyStrongPassword",
"group": "Home",
"admin": False,
"tokens": [],
}
response = api_client.post(api_routes.users, json=create_data, headers=user_token)
assert response.status_code == 403
def test_get_all_users(api_client: TestClient, api_routes: AppRoutes, admin_token, new_user, admin_user):
response = api_client.get(api_routes.users, headers=admin_token)
assert response.status_code == 200
all_users = json.loads(response.text)
assert admin_user.dict(by_alias=True) in all_users
assert new_user.dict(by_alias=True) in all_users
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)
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, user_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=user_token, json=update_data)
assert response.status_code == 403
def test_update_self_as_not_admin(api_client: TestClient, api_routes: AppRoutes, user_token):
update_data = {"fullName": "User fullname", "email": "user@email.com", "group": "Home", "admin": False}
response = api_client.put(api_routes.users_id(4), headers=user_token, json=update_data)
assert response.status_code == 200
def test_self_demote_admin(api_client: TestClient, api_routes: AppRoutes, admin_token):
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)
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)
assert response.status_code == 403
def test_reset_user_password(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.put(api_routes.users_id_reset_password(3), headers=admin_token)
assert response.status_code == 200
form_data = {"username": "newuser@email.com", "password": "MyPassword"}
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 200
def test_delete_user(api_client: TestClient, api_routes: AppRoutes, admin_token):
response = api_client.delete(api_routes.users_id(2), headers=admin_token)
assert response.status_code == 200
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
)
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
)
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()])

View file

@ -0,0 +1,85 @@
import pytest
from fastapi.testclient import TestClient
from tests.utils.factories import user_registration_factory
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/groups/invitations"
auth_token = "/api/auth/token"
self = "/api/users/self"
register = "/api/users/register"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
@pytest.fixture(scope="function")
def invite(api_client: TestClient, unique_user: TestUser) -> None:
# Test Creation
r = api_client.post(Routes.base, json={"uses": 2}, headers=unique_user.token)
assert r.status_code == 201
invitation = r.json()
return invitation["token"]
def test_get_all_invitation(api_client: TestClient, unique_user: TestUser, invite: str) -> None:
# Get All Invites
r = api_client.get(Routes.base, headers=unique_user.token)
assert r.status_code == 200
items = r.json()
assert len(items) == 1
for item in items:
assert item["groupId"] == unique_user.group_id
assert item["token"] == invite
def register_user(api_client, invite):
# Test User can Join Group
registration = user_registration_factory()
registration.group = ""
registration.group_token = invite
response = api_client.post(Routes.register, json=registration.dict(by_alias=True))
print(response.json())
return registration, response
def test_group_invitation_link(api_client: TestClient, unique_user: TestUser, invite: str):
registration, r = register_user(api_client, invite)
assert r.status_code == 201
# Login as new User
form_data = {"username": registration.email, "password": registration.password}
r = api_client.post(Routes.auth_token, form_data)
assert r.status_code == 200
token = r.json().get("access_token")
assert token is not None
# Check user Group is Same
r = api_client.get(Routes.self, headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json()["groupId"] == unique_user.group_id
def test_group_invitation_delete_after_uses(api_client: TestClient, invite: str) -> None:
# Register First User
_, r = register_user(api_client, invite)
assert r.status_code == 201
# Register Second User
_, r = register_user(api_client, invite)
assert r.status_code == 201
# Check Group Invitation is Deleted
_, r = register_user(api_client, invite)
assert r.status_code == 400

View file

@ -2,6 +2,7 @@ from fastapi.testclient import TestClient
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from tests.utils.assertion_helpers import assert_ignore_keys
from tests.utils.fixture_schemas import TestUser
class Routes:
@ -9,8 +10,8 @@ class Routes:
preferences = "/api/groups/preferences"
def test_get_preferences(api_client: TestClient, admin_token) -> None:
response = api_client.get(Routes.preferences, headers=admin_token)
def test_get_preferences(api_client: TestClient, unique_user: TestUser) -> None:
response = api_client.get(Routes.preferences, headers=unique_user.token)
assert response.status_code == 200
@ -21,8 +22,8 @@ def test_get_preferences(api_client: TestClient, admin_token) -> None:
assert preferences["recipeShowNutrition"] is False
def test_preferences_in_group(api_client: TestClient, admin_token) -> None:
response = api_client.get(Routes.base, headers=admin_token)
def test_preferences_in_group(api_client: TestClient, unique_user: TestUser) -> None:
response = api_client.get(Routes.base, headers=unique_user.token)
assert response.status_code == 200
@ -35,10 +36,10 @@ def test_preferences_in_group(api_client: TestClient, admin_token) -> None:
assert group["preferences"]["recipeShowNutrition"] is False
def test_update_preferences(api_client: TestClient, admin_token) -> None:
def test_update_preferences(api_client: TestClient, unique_user: TestUser) -> None:
new_data = UpdateGroupPreferences(recipe_public=False, recipe_show_nutrition=True)
response = api_client.put(Routes.preferences, json=new_data.dict(), headers=admin_token)
response = api_client.put(Routes.preferences, json=new_data.dict(), headers=unique_user.token)
assert response.status_code == 200

View file

@ -1,6 +1,6 @@
from fastapi.testclient import TestClient
from mealie.schema.user.registration import CreateUserRegistration
from tests.utils.factories import user_registration_factory
class Routes:
@ -9,21 +9,13 @@ class Routes:
def test_user_registration_new_group(api_client: TestClient):
registration = CreateUserRegistration(
group="New Group Name",
email="email@email.com",
username="fake-user-name",
password="fake-password",
password_confirm="fake-password",
advanced=False,
private=False,
)
registration = user_registration_factory()
response = api_client.post(Routes.base, json=registration.dict(by_alias=True))
assert response.status_code == 201
# Login
form_data = {"username": "email@email.com", "password": "fake-password"}
form_data = {"username": registration.email, "password": registration.password}
response = api_client.post(Routes.auth_token, form_data)
assert response.status_code == 200

View file

@ -0,0 +1,63 @@
import pytest
from fastapi.testclient import TestClient
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/groups/webhooks"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
@pytest.fixture()
def webhook_data():
return {"enabled": True, "name": "Test-Name", "url": "https://my-fake-url.com", "time": "00:00"}
def test_create_webhook(api_client: TestClient, unique_user: TestUser, webhook_data):
response = api_client.post(Routes.base, json=webhook_data, headers=unique_user.token)
assert response.status_code == 200
def test_read_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
response = api_client.get(Routes.item(1), headers=unique_user.token)
webhook = response.json()
assert webhook["id"] == 1
assert webhook["name"] == webhook_data["name"]
assert webhook["url"] == webhook_data["url"]
assert webhook["time"] == webhook_data["time"]
assert webhook["enabled"] == webhook_data["enabled"]
def test_update_webhook(api_client: TestClient, webhook_data, unique_user: TestUser):
webhook_data["id"] = 1
webhook_data["name"] = "My New Name"
webhook_data["url"] = "https://my-new-fake-url.com"
webhook_data["time"] = "01:00"
webhook_data["enabled"] = False
response = api_client.put(Routes.item(1), json=webhook_data, headers=unique_user.token)
assert response.status_code == 200
updated_webhook = response.json()
assert updated_webhook["name"] == webhook_data["name"]
assert updated_webhook["url"] == webhook_data["url"]
assert updated_webhook["time"] == webhook_data["time"]
assert updated_webhook["enabled"] == webhook_data["enabled"]
assert response.status_code == 200
def test_delete_webhook(api_client: TestClient, unique_user: TestUser):
response = api_client.delete(Routes.item(1), headers=unique_user.token)
assert response.status_code == 200
response = api_client.get(Routes.item(1), headers=unique_user.token)
assert response.status_code == 404

View file

@ -5,25 +5,30 @@ from fastapi.testclient import TestClient
from slugify import slugify
from tests.app_routes import AppRoutes
from tests.utils.fixture_schemas import TestUser
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
recipe_test_data = get_recipe_test_cases()
@pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_create_by_url(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=user_token)
def test_create_by_url(
api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser
):
api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=unique_user.token)
response = api_client.post(api_routes.recipes_create_url, json={"url": recipe_data.url}, headers=user_token)
response = api_client.post(api_routes.recipes_create_url, json={"url": recipe_data.url}, headers=unique_user.token)
assert response.status_code == 201
assert json.loads(response.text) == recipe_data.expected_slug
@pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
def test_read_update(
api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser
):
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
response = api_client.get(recipe_url, headers=user_token)
response = api_client.get(recipe_url, headers=unique_user.token)
assert response.status_code == 200
recipe = json.loads(response.text)
@ -39,12 +44,12 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data:
test_categories = ["one", "two", "three"]
recipe["recipeCategory"] = test_categories
response = api_client.put(recipe_url, json=recipe, headers=user_token)
response = api_client.put(recipe_url, json=recipe, headers=unique_user.token)
assert response.status_code == 200
assert json.loads(response.text).get("slug") == recipe_data.expected_slug
response = api_client.get(recipe_url, headers=user_token)
response = api_client.get(recipe_url, headers=unique_user.token)
assert response.status_code == 200
recipe = json.loads(response.text)
@ -53,9 +58,9 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data:
@pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
response = api_client.get(recipe_url, headers=user_token)
response = api_client.get(recipe_url, headers=unique_user.token)
assert response.status_code == 200
recipe = json.loads(response.text)
@ -63,7 +68,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci
new_slug = slugify(new_name)
recipe["name"] = new_name
response = api_client.put(recipe_url, json=recipe, headers=user_token)
response = api_client.put(recipe_url, json=recipe, headers=unique_user.token)
assert response.status_code == 200
assert json.loads(response.text).get("slug") == new_slug
@ -72,7 +77,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci
@pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
response = api_client.delete(recipe_url, headers=user_token)
response = api_client.delete(recipe_url, headers=unique_user.token)
assert response.status_code == 200

View file

@ -0,0 +1,76 @@
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/recipes"
user = "/api/users/self"
GROUP_ID = 1
ADMIN_ID = 1
USER_ID = 2
def test_ownership_on_new_with_admin(api_client: TestClient, admin_token):
recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=admin_token)
assert response.status_code == 201
recipe = api_client.get(Routes.base + f"/{recipe_name}", headers=admin_token).json()
assert recipe["userId"] == ADMIN_ID
assert recipe["groupId"] == GROUP_ID
def test_ownership_on_new_with_user(api_client: TestClient, g2_user: TestUser):
recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=g2_user.token)
assert response.status_code == 201
response = api_client.get(Routes.base + f"/{recipe_name}", headers=g2_user.token)
assert response.status_code == 200
recipe = response.json()
assert recipe["userId"] == g2_user.user_id
assert recipe["groupId"] == g2_user.group_id
def test_get_all_only_includes_group_recipes(api_client: TestClient, unique_user: TestUser):
for _ in range(5):
recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=unique_user.token)
response = api_client.get(Routes.base, headers=unique_user.token)
assert response.status_code == 200
recipes = response.json()
assert len(recipes) == 5
for recipe in recipes:
assert recipe["groupId"] == unique_user.group_id
assert recipe["userId"] == unique_user.user_id
def test_unique_slug_by_group(api_client: TestClient, unique_user: TestUser, g2_user: TestUser) -> None:
create_data = {"name": random_string()}
response = api_client.post(Routes.base, json=create_data, headers=unique_user.token)
assert response.status_code == 201
response = api_client.post(Routes.base, json=create_data, headers=g2_user.token)
assert response.status_code == 201
# Try to create a recipe again with the same name
response = api_client.post(Routes.base, json=create_data, headers=g2_user.token)
assert response.status_code == 400

View file

@ -0,0 +1,28 @@
from pathlib import Path
from fastapi.testclient import TestClient
from mealie.core.config import app_dirs
from tests.app_routes import AppRoutes
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
)
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
)
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()])

View file

@ -0,0 +1,25 @@
import json
from fastapi.testclient import TestClient
from tests.app_routes import AppRoutes
def test_failed_login(api_client: TestClient, api_routes: AppRoutes):
form_data = {"username": "changeme@email.com", "password": "WRONG_PASSWORD"}
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 401
def test_superuser_login(api_client: TestClient, api_routes: AppRoutes, admin_token):
form_data = {"username": "changeme@email.com", "password": "MyPassword"}
response = api_client.post(api_routes.auth_token, form_data)
assert response.status_code == 200
new_token = json.loads(response.text).get("access_token")
response = api_client.get(api_routes.users_self, headers=admin_token)
assert response.status_code == 200
return {"Authorization": f"Bearer {new_token}"}

View file

@ -6,9 +6,11 @@ from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
test_cases = get_recipe_test_cases()
"""
These tests are skipped by default and only really used when troubleshooting the parser
directly. If you are working on improve the parser you can add test cases to the `get_recipe_test_cases` function
and then use this test case by removing the `@pytest.mark.skip` and than testing your results.
"""

24
tests/utils/factories.py Normal file
View file

@ -0,0 +1,24 @@
import random
import string
from mealie.schema.user.registration import CreateUserRegistration
def random_string(length=10) -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length)).strip()
def random_email(length=10) -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length)) + "@fake.com"
def user_registration_factory() -> CreateUserRegistration:
return CreateUserRegistration(
group=random_string(),
email=random_email(),
username=random_string(),
password="fake-password",
password_confirm="fake-password",
advanced=False,
private=False,
)

View file

@ -0,0 +1,9 @@
from dataclasses import dataclass
from typing import Any
@dataclass
class TestUser:
user_id: int
group_id: int
token: Any