diff --git a/.gitignore b/.gitignore index 8818df6e..8be40997 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index df64ce89..43cbef30 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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": { diff --git a/dev/code-generation/_gen_utils.py b/dev/code-generation/_gen_utils.py new file mode 100644 index 00000000..736894e9 --- /dev/null +++ b/dev/code-generation/_gen_utils.py @@ -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: ' + End -> 'END: ' + + If no 'CODE_GEN_ID: ' is found, and exception is raised + + Args: + file_path (Path): Write to file + key (str): CODE_GEN_ID: + 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) diff --git a/dev/code-generation/_open_api_parser.py b/dev/code-generation/_open_api_parser.py new file mode 100644 index 00000000..533178c9 --- /dev/null +++ b/dev/code-generation/_open_api_parser.py @@ -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 diff --git a/dev/code-generation/_router.py b/dev/code-generation/_router.py new file mode 100644 index 00000000..f89a084d --- /dev/null +++ b/dev/code-generation/_router.py @@ -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 * ") diff --git a/dev/code-generation/_static.py b/dev/code-generation/_static.py new file mode 100644 index 00000000..e24f574b --- /dev/null +++ b/dev/code-generation/_static.py @@ -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" diff --git a/dev/code-generation/gen_nuxt_locales.py b/dev/code-generation/gen_nuxt_locales.py new file mode 100644 index 00000000..71d0ff46 --- /dev/null +++ b/dev/code-generation/gen_nuxt_locales.py @@ -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() diff --git a/dev/code-generation/gen_pytest_routes.py b/dev/code-generation/gen_pytest_routes.py new file mode 100644 index 00000000..50c97659 --- /dev/null +++ b/dev/code-generation/gen_pytest_routes.py @@ -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() diff --git a/dev/code-generation/templates/test_routes.py.j2 b/dev/code-generation/templates/test_routes.py.j2 new file mode 100644 index 00000000..621d492e --- /dev/null +++ b/dev/code-generation/templates/test_routes.py.j2 @@ -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 %} \ No newline at end of file diff --git a/dev/scripts/all_recipes_stress_test.py b/dev/scripts/all_recipes_stress_test.py new file mode 100644 index 00000000..4b1fb0c0 --- /dev/null +++ b/dev/scripts/all_recipes_stress_test.py @@ -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() diff --git a/dev/scripts/gen_global_componenets.py b/dev/scripts/gen_global_componenets.py new file mode 100644 index 00000000..f1b6b49c --- /dev/null +++ b/dev/scripts/gen_global_componenets.py @@ -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) diff --git a/dev/scripts/generate_nuxt_locales.py b/dev/scripts/generate_nuxt_locales.py deleted file mode 100644 index 7dae549a..00000000 --- a/dev/scripts/generate_nuxt_locales.py +++ /dev/null @@ -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() diff --git a/frontend/api/class-interfaces/groups.ts b/frontend/api/class-interfaces/groups.ts index a5d6b966..9d7e3f93 100644 --- a/frontend/api/class-interfaces/groups.ts +++ b/frontend/api/class-interfaces/groups.ts @@ -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 { baseRoute = routes.groups; itemRoute = routes.groupsId; @@ -68,4 +80,8 @@ export class GroupAPI extends BaseCRUDAPI { async setPreferences(payload: UpdatePreferences) { return await this.requests.put(routes.preferences, payload); } + + async createInvitation(payload: CreateInvitation) { + return await this.requests.post(routes.invitation, payload); + } } diff --git a/frontend/components/Domain/Recipe/RecipeInstructions.vue b/frontend/components/Domain/Recipe/RecipeInstructions.vue index 34ef3af1..11f43b51 100644 --- a/frontend/components/Domain/Recipe/RecipeInstructions.vue +++ b/frontend/components/Domain/Recipe/RecipeInstructions.vue @@ -1,82 +1,80 @@ + + \ No newline at end of file + diff --git a/frontend/pages/recipe/_slug.vue b/frontend/pages/recipe/_slug.vue index d667a393..f92a3589 100644 --- a/frontend/pages/recipe/_slug.vue +++ b/frontend/pages/recipe/_slug.vue @@ -222,6 +222,9 @@ +
+ {{ $t("general.new") }} +
@@ -346,6 +349,22 @@ export default defineComponent({ list.splice(index, 1); } + function addStep(steps: Array | 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 | null = null) { if (ingredients?.length) { const newIngredients = ingredients.map((x) => { @@ -386,6 +405,7 @@ export default defineComponent({ api, form, loading, + addStep, deleteRecipe, updateRecipe, uploadImage, diff --git a/frontend/pages/register.vue b/frontend/pages/register.vue index c1aa7c15..c8b01794 100644 --- a/frontend/pages/register.vue +++ b/frontend/pages/register.vue @@ -4,41 +4,35 @@ User Registration - - - - +
+ + Create a Group + Join a Group + +
+ + diff --git a/frontend/plugins/globals.ts b/frontend/plugins/globals.ts index b27f0e5b..b4185f4c 100644 --- a/frontend/plugins/globals.ts +++ b/frontend/plugins/globals.ts @@ -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, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index cfc21acc..2b549a15 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -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 + } } diff --git a/frontend/types/application-types.ts b/frontend/types/application-types.ts index 6a1d8ff9..39b4f537 100644 --- a/frontend/types/application-types.ts +++ b/frontend/types/application-types.ts @@ -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 \ No newline at end of file +export type SidebarLinks = Array; diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts new file mode 100644 index 00000000..8c22f6a1 --- /dev/null +++ b/frontend/types/components.d.ts @@ -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 {}; \ No newline at end of file diff --git a/frontend/types/index.d.ts b/frontend/types/index.d.ts new file mode 100644 index 00000000..9d074d12 --- /dev/null +++ b/frontend/types/index.d.ts @@ -0,0 +1,7 @@ +import Auth from "@nuxtjs/auth-next/dist/core/auth"; + +declare module "vue/types/vue" { + interface Vue { + $auth: Auth; + } +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e9b45026..5335e495 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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" diff --git a/mealie/app.py b/mealie/app.py index 4d040d3b..56c159fe 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -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(): diff --git a/mealie/core/dependencies/grouped.py b/mealie/core/dependencies/grouped.py index 63a52fa9..cbdaacae 100644 --- a/mealie/core/dependencies/grouped.py +++ b/mealie/core/dependencies/grouped.py @@ -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. diff --git a/mealie/db/data_access_layer/_base_access_model.py b/mealie/db/data_access_layer/_base_access_model.py index 4099a991..9ca32fb7 100644 --- a/mealie/db/data_access_layer/_base_access_model.py +++ b/mealie/db/data_access_layer/_base_access_model.py @@ -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]: diff --git a/mealie/db/data_access_layer/db_access.py b/mealie/db/data_access_layer/db_access.py index 839fc6ef..b52ff348 100644 --- a/mealie/db/data_access_layer/db_access.py +++ b/mealie/db/data_access_layer/db_access.py @@ -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) diff --git a/mealie/db/models/group/__init__.py b/mealie/db/models/group/__init__.py index e5b29626..bd579ce1 100644 --- a/mealie/db/models/group/__init__.py +++ b/mealie/db/models/group/__init__.py @@ -1,3 +1,6 @@ +from .cookbook import * from .group import * +from .invite_tokens import * +from .preferences import * from .shopping_list import * from .webhooks import * diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index 442eb3dd..8ac65688 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -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 diff --git a/mealie/db/models/group/invite_tokens.py b/mealie/db/models/group/invite_tokens.py new file mode 100644 index 00000000..4ad6f79e --- /dev/null +++ b/mealie/db/models/group/invite_tokens.py @@ -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 diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 71026034..8dc18de7 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -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" diff --git a/mealie/db/models/users/__init__.py b/mealie/db/models/users/__init__.py index 9917a30a..1b4636eb 100644 --- a/mealie/db/models/users/__init__.py +++ b/mealie/db/models/users/__init__.py @@ -1 +1,2 @@ +from .user_to_favorite import * from .users import * diff --git a/mealie/db/models/users/user_to_favorite.py b/mealie/db/models/users/user_to_favorite.py new file mode 100644 index 00000000..5d7d6741 --- /dev/null +++ b/mealie/db/models/users/user_to_favorite.py @@ -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")), +) diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 9fc8a464..4ceaaa2b 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -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 diff --git a/mealie/routes/admin/__init__.py b/mealie/routes/admin/__init__.py index 7c3c6ace..f49dbbec 100644 --- a/mealie/routes/admin/__init__.py +++ b/mealie/routes/admin/__init__.py @@ -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"]) diff --git a/mealie/routes/groups/crud.py b/mealie/routes/admin/admin_group.py similarity index 75% rename from mealie/routes/groups/crud.py rename to mealie/routes/admin/admin_group.py index cd26f132..aa64dd5d 100644 --- a/mealie/routes/groups/crud.py +++ b/mealie/routes/admin/admin_group.py @@ -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, diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index dbdc3a35..291a389d 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -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, diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index c6d53622..e5be08d8 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -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"]) diff --git a/mealie/routes/groups/invitations.py b/mealie/routes/groups/invitations.py new file mode 100644 index 00000000..a4f63546 --- /dev/null +++ b/mealie/routes/groups/invitations.py @@ -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) diff --git a/mealie/routes/groups/preferences.py b/mealie/routes/groups/preferences.py new file mode 100644 index 00000000..5020bb01 --- /dev/null +++ b/mealie/routes/groups/preferences.py @@ -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 diff --git a/mealie/routes/groups/self_service.py b/mealie/routes/groups/self_service.py index a7c19daa..9977d85d 100644 --- a/mealie/routes/groups/self_service.py +++ b/mealie/routes/groups/self_service.py @@ -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 diff --git a/mealie/routes/media/recipe.py b/mealie/routes/media/recipe.py index adec2890..b3dfb5ed 100644 --- a/mealie/routes/media/recipe.py +++ b/mealie/routes/media/recipe.py @@ -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) diff --git a/mealie/routes/migration_routes.py b/mealie/routes/migration_routes.py index 65cdd6b5..156e3404 100644 --- a/mealie/routes/migration_routes.py +++ b/mealie/routes/migration_routes.py @@ -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) diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index d0b1fba0..fc9023f6 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -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"]) diff --git a/mealie/routes/recipe/all_recipe_routes.py b/mealie/routes/recipe/all_recipe_routes.py index 783c5d7e..707d98b6 100644 --- a/mealie/routes/recipe/all_recipe_routes.py +++ b/mealie/routes/recipe/all_recipe_routes.py @@ -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) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index de6767b1..e386ff48 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -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() diff --git a/mealie/routes/unit_and_foods/food_routes.py b/mealie/routes/unit_and_foods/food_routes.py index 10e8e253..cb516449 100644 --- a/mealie/routes/unit_and_foods/food_routes.py +++ b/mealie/routes/unit_and_foods/food_routes.py @@ -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) diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py index 3e4015c8..32ce31e3 100644 --- a/mealie/routes/users/__init__.py +++ b/mealie/routes/users/__init__.py @@ -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"]) diff --git a/mealie/routes/users/registration.py b/mealie/routes/users/registration.py index 6c4688ef..e194aeb1 100644 --- a/mealie/routes/users/registration.py +++ b/mealie/routes/users/registration.py @@ -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) diff --git a/mealie/routes/users/sign_up.py b/mealie/routes/users/sign_up.py deleted file mode 100644 index 7ce9a191..00000000 --- a/mealie/routes/users/sign_up.py +++ /dev/null @@ -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) diff --git a/mealie/schema/group/invite_token.py b/mealie/schema/group/invite_token.py new file mode 100644 index 00000000..31bc53c4 --- /dev/null +++ b/mealie/schema/group/invite_token.py @@ -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 diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index cdd2640d..6a734096 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -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"): diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index 8738a68d..093ab176 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -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 diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index daf670c8..1a0aac9a 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -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: diff --git a/mealie/services/group_services/group_service.py b/mealie/services/group_services/group_service.py index 658ceead..df98a1e4 100644 --- a/mealie/services/group_services/group_service.py +++ b/mealie/services/group_services/group_service.py @@ -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}) diff --git a/mealie/services/migrations/_migration_base.py b/mealie/services/migrations/_migration_base.py index e15a4899..ca67e57f 100644 --- a/mealie/services/migrations/_migration_base.py +++ b/mealie/services/migrations/_migration_base.py @@ -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: diff --git a/mealie/services/migrations/chowdown.py b/mealie/services/migrations/chowdown.py index c1ca6abc..c54e0370 100644 --- a/mealie/services/migrations/chowdown.py +++ b/mealie/services/migrations/chowdown.py @@ -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()) diff --git a/mealie/services/migrations/migration.py b/mealie/services/migrations/migration.py index a169d5c2..fcead17c 100644 --- a/mealie/services/migrations/migration.py +++ b/mealie/services/migrations/migration.py @@ -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 [] diff --git a/mealie/services/migrations/nextcloud.py b/mealie/services/migrations/nextcloud.py index e93c2ba8..dd74b283 100644 --- a/mealie/services/migrations/nextcloud.py +++ b/mealie/services/migrations/nextcloud.py @@ -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) diff --git a/mealie/services/recipe/all_recipe_service.py b/mealie/services/recipe/all_recipe_service.py deleted file mode 100644 index 69143028..00000000 --- a/mealie/services/recipe/all_recipe_service.py +++ /dev/null @@ -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") diff --git a/mealie/services/recipe/mixins.py b/mealie/services/recipe/mixins.py new file mode 100644 index 00000000..7677a34c --- /dev/null +++ b/mealie/services/recipe/mixins.py @@ -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) diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 2634cfba..670ee5f8 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -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() diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index 45b8a13f..abb83836 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 25107a59..47f9aec0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/integration_tests/admin_tests/test_admin_user_actions.py b/tests/integration_tests/admin_tests/test_admin_user_actions.py new file mode 100644 index 00000000..729abefe --- /dev/null +++ b/tests/integration_tests/admin_tests/test_admin_user_actions.py @@ -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 diff --git a/tests/integration_tests/admin_tests/test_group_admin_actions.py b/tests/integration_tests/admin_tests/test_group_admin_actions.py new file mode 100644 index 00000000..1142b74f --- /dev/null +++ b/tests/integration_tests/admin_tests/test_group_admin_actions.py @@ -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 diff --git a/tests/integration_tests/recipe_tests/test_recipe_assets.py b/tests/integration_tests/recipe_tests/test_recipe_assets.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/integration_tests/test_group_routes.py b/tests/integration_tests/test_group_routes.py deleted file mode 100644 index e2ce5f48..00000000 --- a/tests/integration_tests/test_group_routes.py +++ /dev/null @@ -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 diff --git a/tests/integration_tests/test_import_routes.py b/tests/integration_tests/test_import_routes.py index cdbadae2..cc9bbec6 100644 --- a/tests/integration_tests/test_import_routes.py +++ b/tests/integration_tests/test_import_routes.py @@ -14,7 +14,7 @@ def backup_data(): "recipes": True, "settings": False, # ! Broken "groups": False, # ! Also Broken - "users": True, + "users": False, } diff --git a/tests/integration_tests/test_meal_routes.py b/tests/integration_tests/test_meal_routes.py deleted file mode 100644 index c1044ad3..00000000 --- a/tests/integration_tests/test_meal_routes.py +++ /dev/null @@ -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 diff --git a/tests/integration_tests/test_signup_routes.py b/tests/integration_tests/test_signup_routes.py deleted file mode 100644 index 7dc905ee..00000000 --- a/tests/integration_tests/test_signup_routes.py +++ /dev/null @@ -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) diff --git a/tests/integration_tests/test_user_routes.py b/tests/integration_tests/test_user_routes.py deleted file mode 100644 index c5709188..00000000 --- a/tests/integration_tests/test_user_routes.py +++ /dev/null @@ -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()]) diff --git a/tests/integration_tests/user_group_tests/test_group_invitation.py b/tests/integration_tests/user_group_tests/test_group_invitation.py new file mode 100644 index 00000000..371275f7 --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_invitation.py @@ -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 diff --git a/tests/integration_tests/user_group_tests/test_group_self_service.py b/tests/integration_tests/user_group_tests/test_group_preferences.py similarity index 70% rename from tests/integration_tests/user_group_tests/test_group_self_service.py rename to tests/integration_tests/user_group_tests/test_group_preferences.py index 13cf1a22..b803758f 100644 --- a/tests/integration_tests/user_group_tests/test_group_self_service.py +++ b/tests/integration_tests/user_group_tests/test_group_preferences.py @@ -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 diff --git a/tests/integration_tests/user_group_tests/test_group_registration.py b/tests/integration_tests/user_group_tests/test_group_registration.py index e30f9669..8117efdf 100644 --- a/tests/integration_tests/user_group_tests/test_group_registration.py +++ b/tests/integration_tests/user_group_tests/test_group_registration.py @@ -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 diff --git a/tests/integration_tests/user_group_tests/test_group_webhooks.py b/tests/integration_tests/user_group_tests/test_group_webhooks.py new file mode 100644 index 00000000..be56658f --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_webhooks.py @@ -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 diff --git a/tests/integration_tests/recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py similarity index 73% rename from tests/integration_tests/recipe_tests/test_recipe_crud.py rename to tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 6a6b3cea..d33ce9ec 100644 --- a/tests/integration_tests/recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -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 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py new file mode 100644 index 00000000..676f176a --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -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 diff --git a/tests/integration_tests/test_long_live_tokens.py b/tests/integration_tests/user_tests/test_user_api_token.py similarity index 100% rename from tests/integration_tests/test_long_live_tokens.py rename to tests/integration_tests/user_tests/test_user_api_token.py diff --git a/tests/integration_tests/user_tests/test_user_images.py b/tests/integration_tests/user_tests/test_user_images.py new file mode 100644 index 00000000..1f9d6c50 --- /dev/null +++ b/tests/integration_tests/user_tests/test_user_images.py @@ -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()]) diff --git a/tests/integration_tests/user_tests/test_user_login.py b/tests/integration_tests/user_tests/test_user_login.py new file mode 100644 index 00000000..a1c409d1 --- /dev/null +++ b/tests/integration_tests/user_tests/test_user_login.py @@ -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}"} diff --git a/tests/unit_tests/test_recipe_parser.py b/tests/unit_tests/test_recipe_parser.py index 5296a95b..b84a6734 100644 --- a/tests/unit_tests/test_recipe_parser.py +++ b/tests/unit_tests/test_recipe_parser.py @@ -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. + """ diff --git a/tests/utils/factories.py b/tests/utils/factories.py new file mode 100644 index 00000000..46993a5f --- /dev/null +++ b/tests/utils/factories.py @@ -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, + ) diff --git a/tests/utils/fixture_schemas.py b/tests/utils/fixture_schemas.py new file mode 100644 index 00000000..85687601 --- /dev/null +++ b/tests/utils/fixture_schemas.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class TestUser: + user_id: int + group_id: int + token: Any