feat(backend): ✨ start multi-tenant support (WIP) (#680)
* fix ts types * feat(code-generation): ♻️ update code-generation formats * new scope * add step button * fix linter error * update code-generation tags * feat(backend): ✨ start multi-tenant support * feat(backend): ✨ group invitation token generation and signup * refactor(backend): ♻️ move group admin actions to admin router * set url base to include `/admin` * feat(frontend): ✨ generate user sign-up links * test(backend): ✅ refactor test-suite to further decouple tests (WIP) * feat(backend): 🐛 assign owner on backup import for recipes * fix(backend): 🐛 assign recipe owner on migration from other service Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
parent
3c504e7048
commit
bdaf758712
90 changed files with 1793 additions and 949 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -151,3 +151,5 @@ dev/data/recipes/*
|
||||||
dev/scripts/output/app_routes.py
|
dev/scripts/output/app_routes.py
|
||||||
dev/scripts/output/javascriptAPI/*
|
dev/scripts/output/javascriptAPI/*
|
||||||
mealie/services/scraper/ingredient_nlp/model.crfmodel
|
mealie/services/scraper/ingredient_nlp/model.crfmodel
|
||||||
|
dev/code-generation/generated/openapi.json
|
||||||
|
dev/code-generation/generated/test_routes.py
|
||||||
|
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
@ -1,5 +1,10 @@
|
||||||
{
|
{
|
||||||
"conventionalCommits.scopes": ["frontend", "docs", "backend"],
|
"conventionalCommits.scopes": [
|
||||||
|
"frontend",
|
||||||
|
"docs",
|
||||||
|
"backend",
|
||||||
|
"code-generation"
|
||||||
|
],
|
||||||
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
|
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
|
||||||
"cSpell.words": ["compression", "hkotel", "performant", "postgres", "webp"],
|
"cSpell.words": ["compression", "hkotel", "performant", "postgres", "webp"],
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
|
|
95
dev/code-generation/_gen_utils.py
Normal file
95
dev/code-generation/_gen_utils.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from black import FileMode, format_str
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
|
||||||
|
def render_python_template(template_file: Path, dest: Path, data: dict) -> str:
|
||||||
|
""" Render and Format a Jinja2 Template for Python Code"""
|
||||||
|
tplt = Template(template_file.read_text())
|
||||||
|
text = tplt.render(data)
|
||||||
|
text = format_str(text, mode=FileMode())
|
||||||
|
dest.write_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodeSlicer:
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
|
||||||
|
indentation: str
|
||||||
|
text: list[str]
|
||||||
|
|
||||||
|
_next_line = None
|
||||||
|
|
||||||
|
def purge_lines(self) -> None:
|
||||||
|
start = self.start + 1
|
||||||
|
end = self.end
|
||||||
|
del self.text[start:end]
|
||||||
|
|
||||||
|
def push_line(self, string: str) -> None:
|
||||||
|
self._next_line = self._next_line or self.start + 1
|
||||||
|
print(self.indentation)
|
||||||
|
self.text.insert(self._next_line, self.indentation + string + "\n")
|
||||||
|
self._next_line += 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_indentation_of_string(line: str, comment_char: str = "//") -> str:
|
||||||
|
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def find_start_end(file_text: list[str], gen_id: str) -> Tuple[int, int]:
|
||||||
|
start = None
|
||||||
|
end = None
|
||||||
|
indentation = None
|
||||||
|
|
||||||
|
for i, line in enumerate(file_text):
|
||||||
|
if "CODE_GEN_ID:" in line and gen_id in line:
|
||||||
|
start = i
|
||||||
|
indentation = get_indentation_of_string(line)
|
||||||
|
if f"END: {gen_id}" in line:
|
||||||
|
end = i
|
||||||
|
|
||||||
|
if start is None or end is None:
|
||||||
|
raise Exception("Could not find start and end of code generation block")
|
||||||
|
|
||||||
|
if start > end:
|
||||||
|
raise Exception(f"Start ({start=}) of code generation block is after end ({end=})")
|
||||||
|
|
||||||
|
return start, end, indentation
|
||||||
|
|
||||||
|
|
||||||
|
def inject_inline(file_path: Path, key: str, code: list[str]) -> None:
|
||||||
|
"""Injects a list of strings into the file where the key is found in the format defined
|
||||||
|
by the code-generation. Strings are properly indented and a '\n' is added to the end of
|
||||||
|
each string.
|
||||||
|
|
||||||
|
Start -> 'CODE_GEN_ID: <key>'
|
||||||
|
End -> 'END: <key>'
|
||||||
|
|
||||||
|
If no 'CODE_GEN_ID: <key>' is found, and exception is raised
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (Path): Write to file
|
||||||
|
key (str): CODE_GEN_ID: <key>
|
||||||
|
code (list[str]): List of strings to inject.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open(file_path, "r") as f:
|
||||||
|
file_text = f.readlines()
|
||||||
|
|
||||||
|
start, end, indentation = find_start_end(file_text, key)
|
||||||
|
|
||||||
|
slicer = CodeSlicer(start, end, indentation, file_text)
|
||||||
|
|
||||||
|
slicer.purge_lines()
|
||||||
|
|
||||||
|
for line in code:
|
||||||
|
slicer.push_line(line)
|
||||||
|
|
||||||
|
with open(file_path, "w") as file:
|
||||||
|
file.writelines(slicer.text)
|
131
dev/code-generation/_open_api_parser.py
Normal file
131
dev/code-generation/_open_api_parser.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from _static import Directories
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from humps import camelize
|
||||||
|
from slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
|
def get_openapi_spec_by_ref(app, type_reference: str) -> dict:
|
||||||
|
if not type_reference:
|
||||||
|
return None
|
||||||
|
|
||||||
|
schemas = app["components"]["schemas"]
|
||||||
|
type_text = type_reference.split("/")[-1]
|
||||||
|
return schemas.get(type_text, type_reference)
|
||||||
|
|
||||||
|
|
||||||
|
def recursive_dict_search(data: dict[str, Any], key: str) -> Any:
|
||||||
|
"""
|
||||||
|
Walks a dictionary searching for a key and returns all the keys
|
||||||
|
matching the provided key"""
|
||||||
|
if key in data:
|
||||||
|
return data[key]
|
||||||
|
for _, v in data.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
result = recursive_dict_search(v, key)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class APIFunction:
|
||||||
|
def __init__(self, app, route: str, verb: str, data: dict):
|
||||||
|
self.name_camel = camelize(data.get("summary"))
|
||||||
|
self.name_snake = slugify(data.get("summary"), separator="_")
|
||||||
|
|
||||||
|
self.http_verb = verb
|
||||||
|
self.path_vars = re.findall(r"\{(.*?)\}", route)
|
||||||
|
self.path_is_func = "{" in route
|
||||||
|
self.js_route = route.replace("{", "${")
|
||||||
|
self.py_route = route
|
||||||
|
|
||||||
|
self.body_schema = get_openapi_spec_by_ref(app, recursive_dict_search(data, "$ref"))
|
||||||
|
|
||||||
|
def path_args(self) -> str:
|
||||||
|
return ", ".join(x + ": string | number" for x in self.path_vars)
|
||||||
|
|
||||||
|
# body: Optional[list[str]] = []
|
||||||
|
# path_params: Optional[list[str]] = []
|
||||||
|
# query_params: Optional[list[str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
# class APIModule(BaseModel):
|
||||||
|
# name: str
|
||||||
|
# functions: list[APIFunction]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIParser:
|
||||||
|
def __init__(self, app: FastAPI) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.spec = app.openapi()
|
||||||
|
|
||||||
|
self.modules = {}
|
||||||
|
|
||||||
|
def dump(self, out_path: Path) -> Path:
|
||||||
|
""" Writes the Open API as JSON to a json file"""
|
||||||
|
OPEN_API_FILE = out_path or Directories.out_dir / "openapi.json"
|
||||||
|
|
||||||
|
with open(OPEN_API_FILE, "w") as f:
|
||||||
|
f.write(json.dumps(self.spec, indent=4))
|
||||||
|
|
||||||
|
def _group_by_module_tag(self):
|
||||||
|
"""
|
||||||
|
Itterates over all routes and groups them by module. Modules are determined
|
||||||
|
by the suffix text before : in the first tag for the router. These are used
|
||||||
|
to generate the typescript class interface for interacting with the API
|
||||||
|
"""
|
||||||
|
modules = {}
|
||||||
|
|
||||||
|
all_paths = self.spec["paths"]
|
||||||
|
for path, http_verbs in all_paths.items():
|
||||||
|
for _, value in http_verbs.items():
|
||||||
|
if "tags" in value:
|
||||||
|
tag: str = value["tags"][0]
|
||||||
|
if ":" in tag:
|
||||||
|
tag = tag.removeprefix('"').split(":")[0].replace(" ", "")
|
||||||
|
if modules.get(tag):
|
||||||
|
modules[tag][path] = http_verbs
|
||||||
|
else:
|
||||||
|
modules[tag] = {path: http_verbs}
|
||||||
|
|
||||||
|
return modules
|
||||||
|
|
||||||
|
def _get_openapi_spec(self, type_reference: str) -> dict:
|
||||||
|
schemas = self.app["components"]["schemas"]
|
||||||
|
type_text = type_reference.split("/")[-1]
|
||||||
|
return schemas.get(type_text, type_reference)
|
||||||
|
|
||||||
|
def _fill_schema_references(self, raw_modules: dict) -> dict:
|
||||||
|
for _, routes in raw_modules.items():
|
||||||
|
for _, verbs in routes.items():
|
||||||
|
for _, value in verbs.items():
|
||||||
|
if "requestBody" in value:
|
||||||
|
try:
|
||||||
|
schema_ref = value["requestBody"]["content"]["application/json"]["schema"]["$ref"]
|
||||||
|
schema = self._get_openapi_spec(schema_ref)
|
||||||
|
value["requestBody"]["content"]["application/json"]["schema"] = schema
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return raw_modules
|
||||||
|
|
||||||
|
def get_by_module(self) -> dict:
|
||||||
|
"""Returns paths where tags are split by : and left right is considered the module"""
|
||||||
|
raw_modules = self._group_by_module_tag()
|
||||||
|
|
||||||
|
modules = {}
|
||||||
|
for module_name, routes in raw_modules.items():
|
||||||
|
for route, verbs in routes.items():
|
||||||
|
for verb, value in verbs.items():
|
||||||
|
function = APIFunction(self.spec, route, verb, value)
|
||||||
|
|
||||||
|
if modules.get(module_name):
|
||||||
|
modules[module_name].append(function)
|
||||||
|
else:
|
||||||
|
modules[module_name] = [function]
|
||||||
|
|
||||||
|
return modules
|
86
dev/code-generation/_router.py
Normal file
86
dev/code-generation/_router.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from humps import camelize
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
|
class RouteObject:
|
||||||
|
def __init__(self, route_string) -> None:
|
||||||
|
self.prefix = "/" + route_string.split("/")[1]
|
||||||
|
self.route = "/" + route_string.split("/", 2)[2]
|
||||||
|
self.js_route = self.route.replace("{", "${")
|
||||||
|
self.parts = route_string.split("/")[1:]
|
||||||
|
self.var = re.findall(r"\{(.*?)\}", route_string)
|
||||||
|
self.is_function = "{" in self.route
|
||||||
|
self.router_slug = slugify("_".join(self.parts[1:]), separator="_")
|
||||||
|
self.router_camel = camelize(self.router_slug)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestType(str, Enum):
|
||||||
|
get = "get"
|
||||||
|
put = "put"
|
||||||
|
post = "post"
|
||||||
|
patch = "patch"
|
||||||
|
delete = "delete"
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterIn(str, Enum):
|
||||||
|
query = "query"
|
||||||
|
path = "path"
|
||||||
|
|
||||||
|
|
||||||
|
class RouterParameter(BaseModel):
|
||||||
|
required: bool = False
|
||||||
|
name: str
|
||||||
|
location: ParameterIn = Field(..., alias="in")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestBody(BaseModel):
|
||||||
|
required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPRequest(BaseModel):
|
||||||
|
request_type: RequestType
|
||||||
|
description: str = ""
|
||||||
|
summary: str
|
||||||
|
requestBody: Optional[RequestBody]
|
||||||
|
|
||||||
|
parameters: list[RouterParameter] = []
|
||||||
|
tags: list[str]
|
||||||
|
|
||||||
|
def list_as_js_object_string(self, parameters, braces=True):
|
||||||
|
if len(parameters) == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if braces:
|
||||||
|
return "{" + ", ".join(parameters) + "}"
|
||||||
|
else:
|
||||||
|
return ", ".join(parameters)
|
||||||
|
|
||||||
|
def payload(self):
|
||||||
|
return "payload" if self.requestBody else ""
|
||||||
|
|
||||||
|
def function_args(self):
|
||||||
|
all_params = [p.name for p in self.parameters]
|
||||||
|
if self.requestBody:
|
||||||
|
all_params.append("payload")
|
||||||
|
return self.list_as_js_object_string(all_params)
|
||||||
|
|
||||||
|
def query_params(self):
|
||||||
|
params = [param.name for param in self.parameters if param.location == ParameterIn.query]
|
||||||
|
return self.list_as_js_object_string(params)
|
||||||
|
|
||||||
|
def path_params(self):
|
||||||
|
params = [param.name for param in self.parameters if param.location == ParameterIn.path]
|
||||||
|
return self.list_as_js_object_string(parameters=params, braces=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def summary_camel(self):
|
||||||
|
return camelize(slugify(self.summary))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def js_docs(self):
|
||||||
|
return self.description.replace("\n", " \n * ")
|
24
dev/code-generation/_static.py
Normal file
24
dev/code-generation/_static.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CWD = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
class Directories:
|
||||||
|
out_dir = CWD / "generated"
|
||||||
|
|
||||||
|
|
||||||
|
class CodeTemplates:
|
||||||
|
interface = CWD / "templates" / "interface.js"
|
||||||
|
pytest_routes = CWD / "templates" / "test_routes.py.j2"
|
||||||
|
|
||||||
|
|
||||||
|
class CodeDest:
|
||||||
|
interface = CWD / "generated" / "interface.js"
|
||||||
|
pytest_routes = CWD / "generated" / "test_routes.py"
|
||||||
|
|
||||||
|
|
||||||
|
class CodeKeys:
|
||||||
|
""" Hard coded comment IDs that are used to generate code"""
|
||||||
|
|
||||||
|
nuxt_local_messages = "MESSAGE_LOCALES"
|
||||||
|
nuxt_local_dates = "DATE_LOCALES"
|
39
dev/code-generation/gen_nuxt_locales.py
Normal file
39
dev/code-generation/gen_nuxt_locales.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from _gen_utils import inject_inline
|
||||||
|
from _static import CodeKeys
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
||||||
|
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
||||||
|
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js"
|
||||||
|
|
||||||
|
"""
|
||||||
|
This snippet walks the message and dat locales directories and generates the import information
|
||||||
|
for the nuxt.config.js file and automatically injects it into the nuxt.config.js file. Note that
|
||||||
|
the code generation ID is hardcoded into the script and required in the nuxt config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main(): # sourcery skip: list-comprehension
|
||||||
|
print("Starting...")
|
||||||
|
|
||||||
|
all_date_locales = []
|
||||||
|
for match in datetime_dir.glob("*.json"):
|
||||||
|
all_date_locales.append(f'"{match.stem}": require("./lang/dateTimeFormats/{match.name}"),')
|
||||||
|
|
||||||
|
all_langs = []
|
||||||
|
for match in locales_dir.glob("*.json"):
|
||||||
|
lang_string = f'{{ code: "{match.stem}", file: "{match.name}" }},'
|
||||||
|
all_langs.append(lang_string)
|
||||||
|
|
||||||
|
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
||||||
|
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||||
|
|
||||||
|
print("Finished...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
53
dev/code-generation/gen_pytest_routes.py
Normal file
53
dev/code-generation/gen_pytest_routes.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from _gen_utils import render_python_template
|
||||||
|
from _open_api_parser import OpenAPIParser
|
||||||
|
from _static import CodeDest, CodeTemplates
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from mealie.app import app
|
||||||
|
|
||||||
|
"""
|
||||||
|
This code is used for generating route objects for each route in the OpenAPI Specification.
|
||||||
|
Currently, they are NOT automatically injected into the test suite. As such, you'll need to copy
|
||||||
|
the relavent contents of the generated file into the test suite where applicable. I am slowly
|
||||||
|
migrating the test suite to use this new generated file and this process will be "automated" in the
|
||||||
|
future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def write_dict_to_file(file_name: str, data: dict[str, Any]):
|
||||||
|
with open(file_name, "w") as f:
|
||||||
|
f.write(json.dumps(data, indent=4))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Starting...")
|
||||||
|
open_api = OpenAPIParser(app)
|
||||||
|
modules = open_api.get_by_module()
|
||||||
|
|
||||||
|
mods = []
|
||||||
|
|
||||||
|
for mod, value in modules.items():
|
||||||
|
|
||||||
|
routes = []
|
||||||
|
existings = set()
|
||||||
|
# Reduce routes by unique py_route attribute
|
||||||
|
for route in value:
|
||||||
|
if route.py_route not in existings:
|
||||||
|
existings.add(route.py_route)
|
||||||
|
routes.append(route)
|
||||||
|
|
||||||
|
module = {"name": mod, "routes": routes}
|
||||||
|
mods.append(module)
|
||||||
|
|
||||||
|
render_python_template(CodeTemplates.pytest_routes, CodeDest.pytest_routes, {"mods": mods})
|
||||||
|
|
||||||
|
print("Finished...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
9
dev/code-generation/templates/test_routes.py.j2
Normal file
9
dev/code-generation/templates/test_routes.py.j2
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% for mod in mods %}
|
||||||
|
class {{mod.name}}Routes:{% for route in mod.routes %}{% if not route.path_is_func %}
|
||||||
|
{{route.name_snake}} = "{{ route.py_route }}"{% endif %}{% endfor %}{% for route in mod.routes %}
|
||||||
|
{% if route.path_is_func %}
|
||||||
|
@staticmethod
|
||||||
|
def {{route.name_snake}}({{ route.path_vars|join(", ") }}):
|
||||||
|
return f"{{route.py_route}}"
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
{% endfor %}
|
60
dev/scripts/all_recipes_stress_test.py
Normal file
60
dev/scripts/all_recipes_stress_test.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def random_string(length: int) -> str:
|
||||||
|
return "".join(random.choice(string.ascii_lowercase) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def payload_factory() -> dict:
|
||||||
|
return {"name": random_string(15)}
|
||||||
|
|
||||||
|
|
||||||
|
def login(username="changeme@email.com", password="MyPassword"):
|
||||||
|
|
||||||
|
payload = {"username": username, "password": password}
|
||||||
|
r = requests.post("http://localhost:9000/api/auth/token", payload)
|
||||||
|
|
||||||
|
# Bearer
|
||||||
|
token = json.loads(r.text).get("access_token")
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def populate_data(token):
|
||||||
|
for _ in range(300):
|
||||||
|
payload = payload_factory()
|
||||||
|
r = requests.post("http://localhost:9000/api/recipes", json=payload, headers=token)
|
||||||
|
|
||||||
|
if r.status_code != 201:
|
||||||
|
print(f"Error: {r.status_code}")
|
||||||
|
print(r.text)
|
||||||
|
exit()
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"Created recipe: {payload}")
|
||||||
|
|
||||||
|
|
||||||
|
def time_request(url, headers):
|
||||||
|
start = time.time()
|
||||||
|
_ = requests.get(url, headers=headers)
|
||||||
|
end = time.time()
|
||||||
|
print(end - start)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Starting...")
|
||||||
|
token = login()
|
||||||
|
# populate_data(token)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
time_request("http://localhost:9000/api/recipes", token)
|
||||||
|
|
||||||
|
print("Finished...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
58
dev/scripts/gen_global_componenets.py
Normal file
58
dev/scripts/gen_global_componenets.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
template = """// This Code is auto generated by gen_global_componenets.py
|
||||||
|
{% for name in global %} import {{ name }} from "@/components/global/{{ name }}.vue";
|
||||||
|
{% endfor %}
|
||||||
|
{% for name in layout %} import {{ name }} from "@/components/layout/{{ name }}.vue";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
declare module "vue" {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
// Global Components
|
||||||
|
{% for name in global %} {{ name }}: typeof {{ name }};
|
||||||
|
{% endfor %} // Layout Components
|
||||||
|
{% for name in layout %} {{ name }}: typeof {{ name }};
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
"""
|
||||||
|
|
||||||
|
project_dir = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
destination_file = project_dir / "frontend" / "types" / "components.d.ts"
|
||||||
|
|
||||||
|
component_paths = {
|
||||||
|
"global": project_dir / "frontend" / "components" / "global",
|
||||||
|
"layout": project_dir / "frontend" / "components" / "Layout",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template: str, data: dict) -> None:
|
||||||
|
template = Template(template)
|
||||||
|
|
||||||
|
return template.render(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_data(component_paths: dict) -> dict:
|
||||||
|
data = {}
|
||||||
|
for name, path in component_paths.items():
|
||||||
|
components = []
|
||||||
|
for component in path.glob("*.vue"):
|
||||||
|
components.append(component.stem)
|
||||||
|
data[name] = components
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def write_template(text: str) -> None:
|
||||||
|
destination_file.write_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
data = build_data(component_paths)
|
||||||
|
text = render_template(template, build_data(component_paths))
|
||||||
|
write_template(text)
|
|
@ -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()
|
|
|
@ -4,13 +4,15 @@ import { GroupInDB } from "~/types/api-types/user";
|
||||||
const prefix = "/api";
|
const prefix = "/api";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
groups: `${prefix}/groups`,
|
groups: `${prefix}/admin/groups`,
|
||||||
groupsSelf: `${prefix}/groups/self`,
|
groupsSelf: `${prefix}/groups/self`,
|
||||||
categories: `${prefix}/groups/categories`,
|
categories: `${prefix}/groups/categories`,
|
||||||
|
|
||||||
preferences: `${prefix}/groups/preferences`,
|
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 {
|
interface Category {
|
||||||
|
@ -44,6 +46,16 @@ export interface Group extends CreateGroup {
|
||||||
preferences: Preferences;
|
preferences: Preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateInvitation {
|
||||||
|
uses: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invitation {
|
||||||
|
group_id: number;
|
||||||
|
token: string;
|
||||||
|
uses_left: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
|
export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
|
||||||
baseRoute = routes.groups;
|
baseRoute = routes.groups;
|
||||||
itemRoute = routes.groupsId;
|
itemRoute = routes.groupsId;
|
||||||
|
@ -68,4 +80,8 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
|
||||||
async setPreferences(payload: UpdatePreferences) {
|
async setPreferences(payload: UpdatePreferences) {
|
||||||
return await this.requests.put<Preferences>(routes.preferences, payload);
|
return await this.requests.put<Preferences>(routes.preferences, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createInvitation(payload: CreateInvitation) {
|
||||||
|
return await this.requests.post<Invitation>(routes.invitation, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,82 +1,80 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<section>
|
||||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||||
<div>
|
<draggable
|
||||||
<draggable
|
:disabled="!edit"
|
||||||
:disabled="!edit"
|
:value="value"
|
||||||
:value="value"
|
handle=".handle"
|
||||||
handle=".handle"
|
@input="updateIndex"
|
||||||
@input="updateIndex"
|
@start="drag = true"
|
||||||
@start="drag = true"
|
@end="drag = false"
|
||||||
@end="drag = false"
|
>
|
||||||
>
|
<div v-for="(step, index) in value" :key="index">
|
||||||
<div v-for="(step, index) in value" :key="index">
|
<v-app-bar v-if="showTitleEditor[index]" class="primary mx-1 mt-6" dark dense rounded>
|
||||||
<v-app-bar v-if="showTitleEditor[index]" class="primary mx-1 mt-6" dark dense rounded>
|
<v-toolbar-title v-if="!edit" class="headline">
|
||||||
<v-toolbar-title v-if="!edit" class="headline">
|
<v-app-bar-title v-text="step.title"> </v-app-bar-title>
|
||||||
<v-app-bar-title v-text="step.title"> </v-app-bar-title>
|
</v-toolbar-title>
|
||||||
</v-toolbar-title>
|
<v-text-field
|
||||||
<v-text-field
|
v-if="edit"
|
||||||
v-if="edit"
|
v-model="step.title"
|
||||||
v-model="step.title"
|
class="headline pa-0 mt-5"
|
||||||
class="headline pa-0 mt-5"
|
dense
|
||||||
dense
|
solo
|
||||||
solo
|
flat
|
||||||
flat
|
:placeholder="$t('recipe.section-title')"
|
||||||
:placeholder="$t('recipe.section-title')"
|
background-color="primary"
|
||||||
background-color="primary"
|
>
|
||||||
>
|
</v-text-field>
|
||||||
</v-text-field>
|
</v-app-bar>
|
||||||
</v-app-bar>
|
<v-hover v-slot="{ hover }">
|
||||||
<v-hover v-slot="{ hover }">
|
<v-card
|
||||||
<v-card
|
class="ma-1"
|
||||||
class="ma-1"
|
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
||||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
:elevation="hover ? 12 : 2"
|
||||||
:elevation="hover ? 12 : 2"
|
:ripple="false"
|
||||||
:ripple="false"
|
@click="toggleDisabled(index)"
|
||||||
@click="toggleDisabled(index)"
|
>
|
||||||
>
|
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
<v-btn
|
||||||
<v-btn
|
v-if="edit"
|
||||||
v-if="edit"
|
fab
|
||||||
fab
|
x-small
|
||||||
x-small
|
color="white"
|
||||||
color="white"
|
class="mr-2"
|
||||||
class="mr-2"
|
elevation="0"
|
||||||
elevation="0"
|
@click="removeByIndex(value, index)"
|
||||||
@click="removeByIndex(value, index)"
|
>
|
||||||
>
|
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
||||||
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
</v-btn>
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
||||||
|
|
||||||
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
|
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
|
||||||
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||||
<v-fade-transition>
|
<v-fade-transition>
|
||||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||||
{{ $globals.icons.checkboxMarkedCircle }}
|
{{ $globals.icons.checkboxMarkedCircle }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-fade-transition>
|
</v-fade-transition>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text v-if="edit">
|
<v-card-text v-if="edit">
|
||||||
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
|
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
|
||||||
</v-textarea>
|
</v-textarea>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
</div>
|
</div>
|
||||||
</draggable>
|
</draggable>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
18
frontend/composables/use-router.ts
Normal file
18
frontend/composables/use-router.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { useRoute, WritableComputedRef, computed } from "@nuxtjs/composition-api";
|
||||||
|
|
||||||
|
export function useRouterQuery(query: string) {
|
||||||
|
const router = useRoute();
|
||||||
|
// TODO FUTURE: Remove when migrating to Vue 3
|
||||||
|
|
||||||
|
const param: WritableComputedRef<string> = computed({
|
||||||
|
get(): string {
|
||||||
|
// @ts-ignore
|
||||||
|
return router.value?.query[query] || "";
|
||||||
|
},
|
||||||
|
set(v: string): void {
|
||||||
|
router.value.query[query] = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return param;
|
||||||
|
}
|
|
@ -452,6 +452,7 @@
|
||||||
"error-cannot-delete-super-user": "Error! Cannot Delete Super User",
|
"error-cannot-delete-super-user": "Error! Cannot Delete Super User",
|
||||||
"existing-password-does-not-match": "Existing password does not match",
|
"existing-password-does-not-match": "Existing password does not match",
|
||||||
"full-name": "Full Name",
|
"full-name": "Full Name",
|
||||||
|
"invite-only": "Invite Only",
|
||||||
"link-id": "Link ID",
|
"link-id": "Link ID",
|
||||||
"link-name": "Link Name",
|
"link-name": "Link Name",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
|
@ -459,30 +460,31 @@
|
||||||
"manage-users": "Manage Users",
|
"manage-users": "Manage Users",
|
||||||
"new-password": "New Password",
|
"new-password": "New Password",
|
||||||
"new-user": "New User",
|
"new-user": "New User",
|
||||||
"password": "Password",
|
|
||||||
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
||||||
"password-must-match": "Password must match",
|
"password-must-match": "Password must match",
|
||||||
"password-reset-failed": "Password reset failed",
|
"password-reset-failed": "Password reset failed",
|
||||||
"password-updated": "Password updated",
|
"password-updated": "Password updated",
|
||||||
|
"password": "Password",
|
||||||
|
"register": "Register",
|
||||||
"reset-password": "Reset Password",
|
"reset-password": "Reset Password",
|
||||||
"sign-in": "Sign in",
|
"sign-in": "Sign in",
|
||||||
"total-mealplans": "Total MealPlans",
|
"total-mealplans": "Total MealPlans",
|
||||||
"total-users": "Total Users",
|
"total-users": "Total Users",
|
||||||
"upload-photo": "Upload Photo",
|
"upload-photo": "Upload Photo",
|
||||||
"use-8-characters-or-more-for-your-password": "Use 8 characters or more for your password",
|
"use-8-characters-or-more-for-your-password": "Use 8 characters or more for your password",
|
||||||
"user": "User",
|
|
||||||
"user-created": "User created",
|
"user-created": "User created",
|
||||||
"user-creation-failed": "User creation failed",
|
"user-creation-failed": "User creation failed",
|
||||||
"user-deleted": "User deleted",
|
"user-deleted": "User deleted",
|
||||||
"user-id": "User ID",
|
|
||||||
"user-id-with-value": "User ID: {id}",
|
"user-id-with-value": "User ID: {id}",
|
||||||
|
"user-id": "User ID",
|
||||||
"user-password": "User Password",
|
"user-password": "User Password",
|
||||||
"user-successfully-logged-in": "User Successfully Logged In",
|
"user-successfully-logged-in": "User Successfully Logged In",
|
||||||
"user-update-failed": "User update failed",
|
"user-update-failed": "User update failed",
|
||||||
"user-updated": "User updated",
|
"user-updated": "User updated",
|
||||||
|
"user": "User",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"users": "Users",
|
|
||||||
"users-header": "USERS",
|
"users-header": "USERS",
|
||||||
|
"users": "Users",
|
||||||
"webhook-time": "Webhook Time",
|
"webhook-time": "Webhook Time",
|
||||||
"webhooks-enabled": "Webhooks Enabled",
|
"webhooks-enabled": "Webhooks Enabled",
|
||||||
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user",
|
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user",
|
||||||
|
|
|
@ -40,6 +40,7 @@ export default {
|
||||||
// https://go.nuxtjs.dev/typescript
|
// https://go.nuxtjs.dev/typescript
|
||||||
"@nuxt/typescript-build",
|
"@nuxt/typescript-build",
|
||||||
// https://go.nuxtjs.dev/vuetify
|
// https://go.nuxtjs.dev/vuetify
|
||||||
|
// https://go.nuxtjs.dev/vuetify
|
||||||
"@nuxtjs/vuetify",
|
"@nuxtjs/vuetify",
|
||||||
// https://composition-api.nuxtjs.org/getting-started/setup
|
// https://composition-api.nuxtjs.org/getting-started/setup
|
||||||
"@nuxtjs/composition-api/module",
|
"@nuxtjs/composition-api/module",
|
||||||
|
@ -115,7 +116,7 @@ export default {
|
||||||
|
|
||||||
i18n: {
|
i18n: {
|
||||||
locales: [
|
locales: [
|
||||||
// Auto Generated from "generate_nuxt_locales.py"
|
// CODE_GEN_ID: MESSAGE_LOCALES
|
||||||
{ code: "el-GR", file: "el-GR.json" },
|
{ code: "el-GR", file: "el-GR.json" },
|
||||||
{ code: "it-IT", file: "it-IT.json" },
|
{ code: "it-IT", file: "it-IT.json" },
|
||||||
{ code: "ko-KR", file: "ko-KR.json" },
|
{ code: "ko-KR", file: "ko-KR.json" },
|
||||||
|
@ -147,13 +148,14 @@ export default {
|
||||||
{ code: "en-GB", file: "en-GB.json" },
|
{ code: "en-GB", file: "en-GB.json" },
|
||||||
{ code: "fi-FI", file: "fi-FI.json" },
|
{ code: "fi-FI", file: "fi-FI.json" },
|
||||||
{ code: "vi-VN", file: "vi-VN.json" },
|
{ code: "vi-VN", file: "vi-VN.json" },
|
||||||
|
// END: MESSAGE_LOCALES
|
||||||
],
|
],
|
||||||
lazy: true,
|
lazy: true,
|
||||||
langDir: "lang/messages",
|
langDir: "lang/messages",
|
||||||
defaultLocale: "en-US",
|
defaultLocale: "en-US",
|
||||||
vueI18n: {
|
vueI18n: {
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
// Auto Generated from "generate_nuxt_locales.py"
|
// CODE_GEN_ID: DATE_LOCALES
|
||||||
"el-GR": require("./lang/dateTimeFormats/el-GR.json"),
|
"el-GR": require("./lang/dateTimeFormats/el-GR.json"),
|
||||||
"it-IT": require("./lang/dateTimeFormats/it-IT.json"),
|
"it-IT": require("./lang/dateTimeFormats/it-IT.json"),
|
||||||
"ko-KR": require("./lang/dateTimeFormats/ko-KR.json"),
|
"ko-KR": require("./lang/dateTimeFormats/ko-KR.json"),
|
||||||
|
@ -185,6 +187,7 @@ export default {
|
||||||
"en-GB": require("./lang/dateTimeFormats/en-GB.json"),
|
"en-GB": require("./lang/dateTimeFormats/en-GB.json"),
|
||||||
"fi-FI": require("./lang/dateTimeFormats/fi-FI.json"),
|
"fi-FI": require("./lang/dateTimeFormats/fi-FI.json"),
|
||||||
"vi-VN": require("./lang/dateTimeFormats/vi-VN.json"),
|
"vi-VN": require("./lang/dateTimeFormats/vi-VN.json"),
|
||||||
|
// END: DATE_LOCALES
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fallbackLocale: "es",
|
fallbackLocale: "es",
|
||||||
|
|
|
@ -37,6 +37,7 @@
|
||||||
"@nuxtjs/eslint-config-typescript": "^6.0.1",
|
"@nuxtjs/eslint-config-typescript": "^6.0.1",
|
||||||
"@nuxtjs/eslint-module": "^3.0.2",
|
"@nuxtjs/eslint-module": "^3.0.2",
|
||||||
"@nuxtjs/vuetify": "^1.12.1",
|
"@nuxtjs/vuetify": "^1.12.1",
|
||||||
|
"@vue/runtime-dom": "^3.2.9",
|
||||||
"eslint": "^7.29.0",
|
"eslint": "^7.29.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"eslint-plugin-nuxt": "^2.0.0",
|
"eslint-plugin-nuxt": "^2.0.0",
|
||||||
|
|
|
@ -174,50 +174,44 @@
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
|
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
|
||||||
Login
|
{{ $t("user.login") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-btn v-if="$config.ALLOW_SIGNUP" class="mx-auto" text to="/register"> Register </v-btn>
|
<v-btn v-if="allowSignup" class="mx-auto" text to="/register"> {{ $t("user.register") }} </v-btn>
|
||||||
<v-btn v-else class="mx-auto" text disabled> Invite Only </v-btn>
|
<v-btn v-else class="mx-auto" text disabled> {{ $t("user.invite-only") }} </v-btn>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
<script lang="ts">
|
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import { computed, reactive } from "@vue/reactivity";
|
||||||
|
|
||||||
|
const { $auth } = useContext();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
email: "changeme@email.com",
|
||||||
|
password: "MyPassword",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loggingIn = ref(false);
|
||||||
|
|
||||||
|
const allowSignup = computed(() => process.env.ALLOW_SIGNUP);
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
loggingIn.value = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("username", form.email);
|
||||||
|
formData.append("password", form.password);
|
||||||
|
|
||||||
|
await $auth.loginWith("local", { data: formData });
|
||||||
|
loggingIn.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
layout: "basic",
|
layout: "basic",
|
||||||
setup() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loggingIn: false,
|
|
||||||
form: {
|
|
||||||
email: "changeme@email.com",
|
|
||||||
password: "MyPassword",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
allowSignup(): boolean {
|
|
||||||
// @ts-ignore
|
|
||||||
return process.env.ALLOW_SIGNUP;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async authenticate() {
|
|
||||||
this.loggingIn = true;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("username", this.form.email);
|
|
||||||
formData.append("password", this.form.password);
|
|
||||||
|
|
||||||
await this.$auth.loginWith("local", { data: formData });
|
|
||||||
this.loggingIn = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -222,6 +222,9 @@
|
||||||
|
|
||||||
<v-col cols="12" sm="12" md="8" lg="8">
|
<v-col cols="12" sm="12" md="8" lg="8">
|
||||||
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
|
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
|
||||||
|
<div class="d-flex">
|
||||||
|
<BaseButton v-if="form" class="ml-auto my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||||
|
</div>
|
||||||
<RecipeNotes v-model="recipe.notes" :edit="form" />
|
<RecipeNotes v-model="recipe.notes" :edit="form" />
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
@ -346,6 +349,22 @@ export default defineComponent({
|
||||||
list.splice(index, 1);
|
list.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addStep(steps: Array<string> | null = null) {
|
||||||
|
if (!recipe.value?.recipeInstructions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps) {
|
||||||
|
const cleanedSteps = steps.map((step) => {
|
||||||
|
return { text: step, title: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||||
|
} else {
|
||||||
|
recipe.value.recipeInstructions.push({ text: "", title: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addIngredient(ingredients: Array<string> | null = null) {
|
function addIngredient(ingredients: Array<string> | null = null) {
|
||||||
if (ingredients?.length) {
|
if (ingredients?.length) {
|
||||||
const newIngredients = ingredients.map((x) => {
|
const newIngredients = ingredients.map((x) => {
|
||||||
|
@ -386,6 +405,7 @@ export default defineComponent({
|
||||||
api,
|
api,
|
||||||
form,
|
form,
|
||||||
loading,
|
loading,
|
||||||
|
addStep,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
|
|
@ -4,41 +4,35 @@
|
||||||
<v-card-title class="headline"> User Registration </v-card-title>
|
<v-card-title class="headline"> User Registration </v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-form ref="domRegisterForm" @submit.prevent="register()">
|
<v-form ref="domRegisterForm" @submit.prevent="register()">
|
||||||
<ToggleState>
|
<div class="d-flex justify-center my-2">
|
||||||
<template #activator="{ toggle }">
|
<v-btn-toggle v-model="joinGroup" mandatory tile group color="primary">
|
||||||
<div class="d-flex justify-center my-2">
|
<v-btn :value="false" small @click="joinGroup = false"> Create a Group </v-btn>
|
||||||
<v-btn-toggle tile mandatory group color="primary">
|
<v-btn :value="true" small @click="joinGroup = true"> Join a Group </v-btn>
|
||||||
<v-btn small @click="toggle(false)"> Create a Group </v-btn>
|
</v-btn-toggle>
|
||||||
<v-btn small @click="toggle(true)"> Join a Group </v-btn>
|
</div>
|
||||||
</v-btn-toggle>
|
<v-text-field
|
||||||
</div>
|
v-if="!joinGroup"
|
||||||
</template>
|
v-model="form.group"
|
||||||
<template #default="{ state }">
|
filled
|
||||||
<v-text-field
|
rounded
|
||||||
v-if="!state"
|
autofocus
|
||||||
v-model="form.group"
|
validate-on-blur
|
||||||
filled
|
class="rounded-lg"
|
||||||
rounded
|
:prepend-icon="$globals.icons.group"
|
||||||
autofocus
|
:rules="[tokenOrGroup]"
|
||||||
validate-on-blur
|
label="New Group Name"
|
||||||
class="rounded-lg"
|
/>
|
||||||
:prepend-icon="$globals.icons.group"
|
<v-text-field
|
||||||
:rules="[tokenOrGroup]"
|
v-else
|
||||||
label="New Group Name"
|
v-model="form.groupToken"
|
||||||
/>
|
filled
|
||||||
<v-text-field
|
rounded
|
||||||
v-else
|
validate-on-blur
|
||||||
v-model="form.groupToken"
|
:rules="[tokenOrGroup]"
|
||||||
filled
|
class="rounded-lg"
|
||||||
rounded
|
:prepend-icon="$globals.icons.group"
|
||||||
validate-on-blur
|
label="Group Token"
|
||||||
:rules="[tokenOrGroup]"
|
/>
|
||||||
class="rounded-lg"
|
|
||||||
:prepend-icon="$globals.icons.group"
|
|
||||||
label="Group Token"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ToggleState>
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
filled
|
filled
|
||||||
|
@ -105,29 +99,42 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, reactive, toRefs, ref, useRouter, watch } from "@nuxtjs/composition-api";
|
||||||
import { validators } from "@/composables/use-validators";
|
import { validators } from "@/composables/use-validators";
|
||||||
import { useApiSingleton } from "~/composables/use-api";
|
import { useApiSingleton } from "~/composables/use-api";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useRouterQuery } from "@/composables/use-router";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
layout: "basic",
|
layout: "basic",
|
||||||
setup() {
|
setup() {
|
||||||
const api = useApiSingleton();
|
const api = useApiSingleton();
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
|
joinGroup: false,
|
||||||
loggingIn: false,
|
loggingIn: false,
|
||||||
success: false,
|
success: false,
|
||||||
});
|
});
|
||||||
const allowSignup = computed(() => process.env.AllOW_SIGNUP);
|
const allowSignup = computed(() => process.env.AllOW_SIGNUP);
|
||||||
|
|
||||||
const router = useRouter();
|
const token = useRouterQuery("token");
|
||||||
|
|
||||||
|
watch(token, (newToken) => {
|
||||||
|
if (newToken) {
|
||||||
|
console.log(token);
|
||||||
|
form.groupToken = newToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
state.joinGroup = true;
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const domRegisterForm = ref<VForm>(null);
|
const domRegisterForm = ref<VForm>(null);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
group: "",
|
group: "",
|
||||||
groupToken: "",
|
groupToken: token,
|
||||||
email: "",
|
email: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
@ -139,23 +146,24 @@ export default defineComponent({
|
||||||
const passwordMatch = () => form.password === form.passwordConfirm || "Passwords do not match";
|
const passwordMatch = () => form.password === form.passwordConfirm || "Passwords do not match";
|
||||||
const tokenOrGroup = () => form.group !== "" || form.groupToken !== "" || "Group name or token must be given";
|
const tokenOrGroup = () => form.group !== "" || form.groupToken !== "" || "Group name or token must be given";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
async function register() {
|
async function register() {
|
||||||
if (!domRegisterForm.value?.validate()) {
|
if (!domRegisterForm.value?.validate()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, response } = await api.register.register(form);
|
const { response } = await api.register.register(form);
|
||||||
|
|
||||||
if (response?.status === 201) {
|
if (response?.status === 201) {
|
||||||
state.success = true;
|
state.success = true;
|
||||||
alert.success("Registration Success");
|
alert.success("Registration Success");
|
||||||
router.push("/user/login");
|
router.push("/user/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(data, response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
token,
|
||||||
domRegisterForm,
|
domRegisterForm,
|
||||||
validators,
|
validators,
|
||||||
allowSignup,
|
allowSignup,
|
||||||
|
|
|
@ -5,8 +5,10 @@
|
||||||
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
|
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
|
||||||
</template>
|
</template>
|
||||||
<template #title> Your Profile Settings </template>
|
<template #title> Your Profile Settings </template>
|
||||||
Some text here...
|
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<ToggleState tag="article">
|
<ToggleState tag="article">
|
||||||
<template #activator="{ toggle, state }">
|
<template #activator="{ toggle, state }">
|
||||||
|
@ -19,9 +21,8 @@
|
||||||
{{ $t("settings.profile") }}
|
{{ $t("settings.profile") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ state }">
|
<template #default="{ state }">
|
||||||
<v-slide-x-transition group mode="in" hide-on-leave>
|
<v-slide-x-transition>
|
||||||
<div v-if="!state" key="personal-info">
|
<div v-if="!state" key="personal-info">
|
||||||
<BaseCardSectionTitle class="mt-10" title="Personal Information"> </BaseCardSectionTitle>
|
<BaseCardSectionTitle class="mt-10" title="Personal Information"> </BaseCardSectionTitle>
|
||||||
<v-card tag="article" outlined>
|
<v-card tag="article" outlined>
|
||||||
|
@ -90,8 +91,14 @@
|
||||||
label="Show advanced features (API Keys, Webhooks, and Data Management)"
|
label="Show advanced features (API Keys, Webhooks, and Data Management)"
|
||||||
@change="updateUser"
|
@change="updateUser"
|
||||||
></v-checkbox>
|
></v-checkbox>
|
||||||
<div class="d-flex justify-center mt-5">
|
<div class="d-flex flex-wrap justify-center mt-5">
|
||||||
<v-btn outlined class="rounded-xl" to="/user/group"> Looking for Privacy Settings? </v-btn>
|
<v-btn outlined class="rounded-xl my-1 mx-1" to="/user/profile" nuxt exact>
|
||||||
|
<v-icon left>
|
||||||
|
{{ $globals.icons.backArrow }}
|
||||||
|
</v-icon>
|
||||||
|
Back to Profile
|
||||||
|
</v-btn>
|
||||||
|
<v-btn outlined class="rounded-xl my-1 mx-1" to="/user/group"> Looking for Privacy Settings? </v-btn>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
|
@ -9,6 +9,23 @@
|
||||||
Manage your profile, recipes, and group settings.
|
Manage your profile, recipes, and group settings.
|
||||||
<a href="https://hay-kot.github.io/mealie/" target="_blank"> Learn More </a>
|
<a href="https://hay-kot.github.io/mealie/" target="_blank"> Learn More </a>
|
||||||
</p>
|
</p>
|
||||||
|
<v-card flat width="100%" max-width="600px">
|
||||||
|
<v-card-actions class="d-flex justify-center">
|
||||||
|
<v-btn outlined rounded @click="getSignupLink()">
|
||||||
|
<v-icon left>
|
||||||
|
{{ $globals.icons.createAlt }}
|
||||||
|
</v-icon>
|
||||||
|
Get Invite Link
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
<v-card-text v-if="generatedLink !== ''" class="d-flex">
|
||||||
|
<v-text-field v-model="generatedLink" solo readonly>
|
||||||
|
<template #append>
|
||||||
|
<AppButtonCopy :copy-text="generatedLink" />
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div>
|
<div>
|
||||||
|
@ -21,7 +38,7 @@
|
||||||
:link="{ text: 'Manage User Profile', to: '/user/profile/edit' }"
|
:link="{ text: 'Manage User Profile', to: '/user/profile/edit' }"
|
||||||
:image="require('~/static/svgs/manage-profile.svg')"
|
:image="require('~/static/svgs/manage-profile.svg')"
|
||||||
>
|
>
|
||||||
<template #title> User Profile </template>
|
<template #title> User Settings </template>
|
||||||
Manage your preferences, change your password, and update your email
|
Manage your preferences, change your password, and update your email
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
@ -78,8 +95,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext, ref } from "@nuxtjs/composition-api";
|
||||||
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
||||||
|
import { useApiSingleton } from "~/composables/use-api";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
@ -88,7 +106,23 @@ export default defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const user = computed(() => useContext().$auth.user);
|
const user = computed(() => useContext().$auth.user);
|
||||||
|
|
||||||
return { user };
|
const generatedLink = ref("");
|
||||||
|
|
||||||
|
const api = useApiSingleton();
|
||||||
|
|
||||||
|
async function getSignupLink() {
|
||||||
|
const { data } = await api.groups.createInvitation({ uses: 1 });
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
generatedLink.value = constructLink(data.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructLink(token: string) {
|
||||||
|
return `${window.location.origin}/register?token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, constructLink, generatedLink, getSignupLink };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -94,6 +94,7 @@ import {
|
||||||
mdiFolderZipOutline,
|
mdiFolderZipOutline,
|
||||||
mdiFoodApple,
|
mdiFoodApple,
|
||||||
mdiBeakerOutline,
|
mdiBeakerOutline,
|
||||||
|
mdiArrowLeftBoldOutline,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
|
@ -184,6 +185,7 @@ const icons = {
|
||||||
zip: mdiFolderZipOutline,
|
zip: mdiFolderZipOutline,
|
||||||
|
|
||||||
// Crud
|
// Crud
|
||||||
|
backArrow: mdiArrowLeftBoldOutline,
|
||||||
createAlt: mdiPlus,
|
createAlt: mdiPlus,
|
||||||
create: mdiPlusCircle,
|
create: mdiPlusCircle,
|
||||||
delete: mdiDelete,
|
delete: mdiDelete,
|
||||||
|
|
|
@ -18,5 +18,8 @@
|
||||||
},
|
},
|
||||||
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next"]
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { TranslateResult } from "vue-i18n";
|
import { TranslateResult } from "vue-i18n";
|
||||||
|
|
||||||
export interface SideBarLink {
|
export interface SideBarLink {
|
||||||
icon: string
|
icon: string;
|
||||||
to: string
|
to: string;
|
||||||
title: TranslateResult
|
title: TranslateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SidebarLinks = Array<SideBarLink>
|
export type SidebarLinks = Array<SideBarLink>;
|
||||||
|
|
49
frontend/types/components.d.ts
vendored
Normal file
49
frontend/types/components.d.ts
vendored
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
// This Code is auto generated by gen_global_componenets.py
|
||||||
|
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
|
||||||
|
import AppLoader from "@/components/global/AppLoader.vue";
|
||||||
|
import BaseButton from "@/components/global/BaseButton.vue";
|
||||||
|
import BaseDialog from "@/components/global/BaseDialog.vue";
|
||||||
|
import BaseStatCard from "@/components/global/BaseStatCard.vue";
|
||||||
|
import ToggleState from "@/components/global/ToggleState.vue";
|
||||||
|
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
|
||||||
|
import BaseColorPicker from "@/components/global/BaseColorPicker.vue";
|
||||||
|
import BaseDivider from "@/components/global/BaseDivider.vue";
|
||||||
|
import AutoForm from "@/components/global/AutoForm.vue";
|
||||||
|
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
|
||||||
|
import BasePageTitle from "@/components/global/BasePageTitle.vue";
|
||||||
|
import BaseAutoForm from "@/components/global/BaseAutoForm.vue";
|
||||||
|
|
||||||
|
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
|
||||||
|
import AppFloatingButton from "@/components/layout/AppFloatingButton.vue";
|
||||||
|
import AppHeader from "@/components/layout/AppHeader.vue";
|
||||||
|
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||||
|
import AppFooter from "@/components/layout/AppFooter.vue";
|
||||||
|
|
||||||
|
|
||||||
|
declare module "vue" {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
// Global Components
|
||||||
|
BaseCardSectionTitle: typeof BaseCardSectionTitle;
|
||||||
|
AppLoader: typeof AppLoader;
|
||||||
|
BaseButton: typeof BaseButton;
|
||||||
|
BaseDialog: typeof BaseDialog;
|
||||||
|
BaseStatCard: typeof BaseStatCard;
|
||||||
|
ToggleState: typeof ToggleState;
|
||||||
|
AppButtonCopy: typeof AppButtonCopy;
|
||||||
|
BaseColorPicker: typeof BaseColorPicker;
|
||||||
|
BaseDivider: typeof BaseDivider;
|
||||||
|
AutoForm: typeof AutoForm;
|
||||||
|
AppButtonUpload: typeof AppButtonUpload;
|
||||||
|
BasePageTitle: typeof BasePageTitle;
|
||||||
|
BaseAutoForm: typeof BaseAutoForm;
|
||||||
|
// Layout Components
|
||||||
|
TheSnackbar: typeof TheSnackbar;
|
||||||
|
AppFloatingButton: typeof AppFloatingButton;
|
||||||
|
AppHeader: typeof AppHeader;
|
||||||
|
AppSidebar: typeof AppSidebar;
|
||||||
|
AppFooter: typeof AppFooter;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
7
frontend/types/index.d.ts
vendored
Normal file
7
frontend/types/index.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Auth from "@nuxtjs/auth-next/dist/core/auth";
|
||||||
|
|
||||||
|
declare module "vue/types/vue" {
|
||||||
|
interface Vue {
|
||||||
|
$auth: Auth;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2097,6 +2097,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.3.0"
|
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":
|
"@vue/ref-transform@^3.2.6":
|
||||||
version "3.2.8"
|
version "3.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@vue/ref-transform/-/ref-transform-3.2.8.tgz#a527047bab43ce50ef3d400ce71312ab30f825dc"
|
resolved "https://registry.yarnpkg.com/@vue/ref-transform/-/ref-transform-3.2.8.tgz#a527047bab43ce50ef3d400ce71312ab30f825dc"
|
||||||
|
@ -2108,11 +2115,33 @@
|
||||||
estree-walker "^2.0.2"
|
estree-walker "^2.0.2"
|
||||||
magic-string "^0.25.7"
|
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":
|
"@vue/shared@3.2.8", "@vue/shared@^3.2.4", "@vue/shared@^3.2.6":
|
||||||
version "3.2.8"
|
version "3.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.8.tgz#2f918e330aeb3f56ab1031ca60a5b30672512457"
|
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.8.tgz#2f918e330aeb3f56ab1031ca60a5b30672512457"
|
||||||
integrity sha512-E2DQQnG7Qr4GwTs3GlfPPlHliGVADoufTnhpwfoViw7JlyLMmYtjfnTwM6nXAwvSJWiF7D+7AxpnWBBT3VWo6Q==
|
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":
|
"@vueuse/core@^5.2.0":
|
||||||
version "5.3.0"
|
version "5.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-5.3.0.tgz#d8c6e939e18089afa224fab6e443fae2bdb57a51"
|
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-5.3.0.tgz#d8c6e939e18089afa224fab6e443fae2bdb57a51"
|
||||||
|
@ -4174,6 +4203,11 @@ csso@^4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
css-tree "^1.1.2"
|
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:
|
cuint@^0.2.2:
|
||||||
version "0.2.2"
|
version "0.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
|
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
|
||||||
|
|
|
@ -10,7 +10,6 @@ from mealie.routes.mealplans import meal_plan_router
|
||||||
from mealie.routes.media import media_router
|
from mealie.routes.media import media_router
|
||||||
from mealie.routes.site_settings import settings_router
|
from mealie.routes.site_settings import settings_router
|
||||||
from mealie.services.events import create_general_event
|
from mealie.services.events import create_general_event
|
||||||
from mealie.services.recipe.all_recipe_service import subscripte_to_recipe_events
|
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
@ -71,7 +70,6 @@ def system_startup():
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
|
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
|
||||||
subscripte_to_recipe_events()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
@ -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
|
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:
|
class PublicDeps:
|
||||||
"""
|
"""
|
||||||
PublicDeps contains the common dependencies for all read operations through the API.
|
PublicDeps contains the common dependencies for all read operations through the API.
|
||||||
|
|
|
@ -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()]
|
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]:
|
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
|
"""Queries the database for the selected model. Restricts return responses to the
|
||||||
keys specified under "fields"
|
keys specified under "fields"
|
||||||
|
@ -105,11 +120,6 @@ class BaseAccessModel(Generic[T, D]):
|
||||||
eff_schema = override_schema or self.schema
|
eff_schema = override_schema or self.schema
|
||||||
return eff_schema.from_orm(result)
|
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(
|
def get(
|
||||||
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
|
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
|
||||||
) -> T | list[T]:
|
) -> T | list[T]:
|
||||||
|
|
|
@ -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.event import Event, EventNotification
|
||||||
from mealie.db.models.group import Group
|
from mealie.db.models.group import Group
|
||||||
from mealie.db.models.group.cookbook import CookBook
|
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.preferences import GroupPreferencesModel
|
||||||
from mealie.db.models.group.shopping_list import ShoppingList
|
from mealie.db.models.group.shopping_list import ShoppingList
|
||||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
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 Event as EventSchema
|
||||||
from mealie.schema.events import EventNotificationIn
|
from mealie.schema.events import EventNotificationIn
|
||||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
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.group.webhook import ReadWebhook
|
||||||
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
|
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
|
||||||
from mealie.schema.recipe import (
|
from mealie.schema.recipe import (
|
||||||
|
@ -87,6 +89,7 @@ class DatabaseAccessLayer:
|
||||||
|
|
||||||
# Group Data
|
# Group Data
|
||||||
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
|
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
|
||||||
|
self.group_tokens = BaseAccessModel("token", GroupInviteToken, ReadInviteToken)
|
||||||
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
|
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
|
||||||
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
|
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
|
||||||
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
|
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
from .cookbook import *
|
||||||
from .group import *
|
from .group import *
|
||||||
|
from .invite_tokens import *
|
||||||
|
from .preferences import *
|
||||||
from .shopping_list import *
|
from .shopping_list import *
|
||||||
from .webhooks import *
|
from .webhooks import *
|
||||||
|
|
|
@ -3,6 +3,7 @@ import sqlalchemy.orm as orm
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from mealie.core.config import settings
|
from mealie.core.config import settings
|
||||||
|
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||||
|
|
||||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
from .._model_utils import auto_init
|
from .._model_utils import auto_init
|
||||||
|
@ -14,11 +15,13 @@ from .preferences import GroupPreferencesModel
|
||||||
|
|
||||||
class Group(SqlAlchemyBase, BaseMixins):
|
class Group(SqlAlchemyBase, BaseMixins):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
id = sa.Column(sa.Integer, primary_key=True)
|
|
||||||
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
||||||
users = orm.relationship("User", back_populates="group")
|
users = orm.relationship("User", back_populates="group")
|
||||||
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
|
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(
|
preferences = orm.relationship(
|
||||||
GroupPreferencesModel,
|
GroupPreferencesModel,
|
||||||
back_populates="group",
|
back_populates="group",
|
||||||
|
@ -27,13 +30,16 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Recipes
|
||||||
|
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True)
|
||||||
|
|
||||||
# CRUD From Others
|
# CRUD From Others
|
||||||
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
|
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")
|
webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
|
||||||
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
|
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
|
||||||
shopping_lists = orm.relationship("ShoppingList", 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:
|
def __init__(self, **_) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
17
mealie/db/models/group/invite_tokens.py
Normal file
17
mealie/db/models/group/invite_tokens.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||||
|
|
||||||
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
|
from .._model_utils import auto_init
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
||||||
|
__tablename__ = "invite_tokens"
|
||||||
|
token = Column(String, index=True, nullable=False, unique=True)
|
||||||
|
uses_left = Column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||||
|
group = orm.relationship("Group", back_populates="invite_tokens")
|
||||||
|
|
||||||
|
@auto_init()
|
||||||
|
def __init__(self, **_):
|
||||||
|
pass
|
|
@ -8,6 +8,7 @@ from sqlalchemy.orm import validates
|
||||||
|
|
||||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
from .._model_utils import auto_init
|
from .._model_utils import auto_init
|
||||||
|
from ..users import users_to_favorites
|
||||||
from .api_extras import ApiExtras
|
from .api_extras import ApiExtras
|
||||||
from .assets import RecipeAsset
|
from .assets import RecipeAsset
|
||||||
from .category import recipes2categories
|
from .category import recipes2categories
|
||||||
|
@ -22,6 +23,19 @@ from .tool import Tool
|
||||||
|
|
||||||
class RecipeModel(SqlAlchemyBase, BaseMixins):
|
class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
__tablename__ = "recipes"
|
__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
|
# General Recipe Properties
|
||||||
name = sa.Column(sa.String, nullable=False)
|
name = sa.Column(sa.String, nullable=False)
|
||||||
description = sa.Column(sa.String)
|
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")
|
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
|
||||||
|
|
||||||
# Mealie Specific
|
# Mealie Specific
|
||||||
slug = sa.Column(sa.String, index=True, unique=True)
|
|
||||||
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
|
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
|
||||||
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
|
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
|
||||||
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
|
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_added = sa.Column(sa.Date, default=date.today)
|
||||||
date_updated = sa.Column(sa.DateTime)
|
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:
|
class Config:
|
||||||
get_attr = "slug"
|
get_attr = "slug"
|
||||||
|
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
|
from .user_to_favorite import *
|
||||||
from .users import *
|
from .users import *
|
||||||
|
|
10
mealie/db/models/users/user_to_favorite.py
Normal file
10
mealie/db/models/users/user_to_favorite.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, Table
|
||||||
|
|
||||||
|
from .._model_base import SqlAlchemyBase
|
||||||
|
|
||||||
|
users_to_favorites = Table(
|
||||||
|
"users_to_favorites",
|
||||||
|
SqlAlchemyBase.metadata,
|
||||||
|
Column("user_id", Integer, ForeignKey("users.id")),
|
||||||
|
Column("recipe_id", Integer, ForeignKey("recipes.id")),
|
||||||
|
)
|
|
@ -1,9 +1,10 @@
|
||||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||||
|
|
||||||
from mealie.core.config import settings
|
from mealie.core.config import settings
|
||||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
|
||||||
from mealie.db.models.group import Group
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
from mealie.db.models.recipe.recipe import RecipeModel
|
from ..group import Group
|
||||||
|
from .user_to_favorite import users_to_favorites
|
||||||
|
|
||||||
|
|
||||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||||
|
@ -33,6 +34,8 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||||
group = orm.relationship("Group", back_populates="users")
|
group = orm.relationship("Group", back_populates="users")
|
||||||
|
|
||||||
|
# Recipes
|
||||||
|
|
||||||
tokens: list[LongLiveToken] = orm.relationship(
|
tokens: list[LongLiveToken] = orm.relationship(
|
||||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
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
|
"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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -65,9 +71,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||||
self.password = password
|
self.password = password
|
||||||
self.advanced = advanced
|
self.advanced = advanced
|
||||||
|
|
||||||
self.favorite_recipes = [
|
self.favorite_recipes = []
|
||||||
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
|
|
||||||
]
|
|
||||||
|
|
||||||
if self.username is None:
|
if self.username is None:
|
||||||
self.username = full_name
|
self.username = full_name
|
||||||
|
@ -99,10 +103,6 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||||
if password:
|
if password:
|
||||||
self.password = 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):
|
def update_password(self, password):
|
||||||
self.password = password
|
self.password = password
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from . import admin_about, admin_log
|
from . import admin_about, admin_group, admin_log
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin")
|
router = APIRouter(prefix="/admin")
|
||||||
|
|
||||||
router.include_router(admin_about.router, tags=["Admin: About"])
|
router.include_router(admin_about.router, tags=["Admin: About"])
|
||||||
router.include_router(admin_log.router, tags=["Admin: Log"])
|
router.include_router(admin_log.router, tags=["Admin: Log"])
|
||||||
|
router.include_router(admin_group.router, tags=["Admin: Group"])
|
||||||
|
|
|
@ -4,24 +4,21 @@ from sqlalchemy.orm.session import Session
|
||||||
from mealie.core.dependencies import get_current_user
|
from mealie.core.dependencies import get_current_user
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.db.db_setup import generate_session
|
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.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup
|
||||||
from mealie.services.events import create_group_event
|
from mealie.services.events import create_group_event
|
||||||
|
|
||||||
admin_router = AdminAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
|
router = AdminAPIRouter(prefix="/groups")
|
||||||
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("", response_model=list[GroupInDB])
|
@router.get("", response_model=list[GroupInDB])
|
||||||
async def get_all_groups(
|
async def get_all_groups(session: Session = Depends(generate_session)):
|
||||||
session: Session = Depends(generate_session),
|
|
||||||
):
|
|
||||||
""" Returns a list of all groups in the database """
|
""" Returns a list of all groups in the database """
|
||||||
|
|
||||||
return db.groups.get_all(session)
|
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(
|
async def create_group(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
group_data: GroupBase,
|
group_data: GroupBase,
|
||||||
|
@ -37,17 +34,13 @@ async def create_group(
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.put("/{id}")
|
@router.put("/{id}")
|
||||||
async def update_group_data(
|
async def update_group_data(id: int, group_data: UpdateGroup, session: Session = Depends(generate_session)):
|
||||||
id: int,
|
|
||||||
group_data: UpdateGroup,
|
|
||||||
session: Session = Depends(generate_session),
|
|
||||||
):
|
|
||||||
""" Updates a User Group """
|
""" Updates a User Group """
|
||||||
db.groups.update(session, id, group_data.dict())
|
db.groups.update(session, id, group_data.dict())
|
||||||
|
|
||||||
|
|
||||||
@admin_router.delete("/{id}")
|
@router.delete("/{id}")
|
||||||
async def delete_user_group(
|
async def delete_user_group(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
id: int,
|
id: int,
|
|
@ -6,11 +6,13 @@ from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, s
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from mealie.core.config import app_dirs
|
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.root_logger import get_logger
|
||||||
from mealie.core.security import create_file_token
|
from mealie.core.security import create_file_token
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
from mealie.routes.routers import AdminAPIRouter
|
from mealie.routes.routers import AdminAPIRouter
|
||||||
from mealie.schema.admin import BackupJob, ImportJob, Imports, LocalBackup
|
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 import imports
|
||||||
from mealie.services.backups.exports import backup_all
|
from mealie.services.backups.exports import backup_all
|
||||||
from mealie.services.events import create_backup_event
|
from mealie.services.events import create_backup_event
|
||||||
|
@ -82,10 +84,12 @@ def import_database(
|
||||||
file_name: str,
|
file_name: str,
|
||||||
import_data: ImportJob,
|
import_data: ImportJob,
|
||||||
session: Session = Depends(generate_session),
|
session: Session = Depends(generate_session),
|
||||||
|
user: PrivateUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
""" Import a database backup file generated from Mealie. """
|
""" Import a database backup file generated from Mealie. """
|
||||||
|
|
||||||
db_import = imports.import_database(
|
db_import = imports.import_database(
|
||||||
|
user=user,
|
||||||
session=session,
|
session=session,
|
||||||
archive=import_data.name,
|
archive=import_data.name,
|
||||||
import_recipes=import_data.recipes,
|
import_recipes=import_data.recipes,
|
||||||
|
|
|
@ -3,7 +3,7 @@ from fastapi import APIRouter
|
||||||
from mealie.services._base_http_service import RouterFactory
|
from mealie.services._base_http_service import RouterFactory
|
||||||
from mealie.services.group_services import CookbookService, WebhookService
|
from mealie.services.group_services import CookbookService, WebhookService
|
||||||
|
|
||||||
from . import categories, crud, self_service
|
from . import categories, invitations, preferences, self_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
@ -13,5 +13,5 @@ router.include_router(self_service.user_router)
|
||||||
router.include_router(cookbook_router)
|
router.include_router(cookbook_router)
|
||||||
router.include_router(categories.user_router)
|
router.include_router(categories.user_router)
|
||||||
router.include_router(webhook_router)
|
router.include_router(webhook_router)
|
||||||
router.include_router(crud.user_router)
|
router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"])
|
||||||
router.include_router(crud.admin_router)
|
router.include_router(preferences.router, prefix="/groups/preferences", tags=["Group: Preferences"])
|
||||||
|
|
18
mealie/routes/groups/invitations.py
Normal file
18
mealie/routes/groups/invitations.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
|
||||||
|
from mealie.schema.group.invite_token import CreateInviteToken, ReadInviteToken
|
||||||
|
from mealie.services.group_services.group_service import GroupSelfService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ReadInviteToken])
|
||||||
|
def get_invite_tokens(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
|
||||||
|
return g_service.get_invite_tokens()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_invite_token(
|
||||||
|
uses: CreateInviteToken, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
|
||||||
|
):
|
||||||
|
return g_service.create_invite_token(uses.uses)
|
19
mealie/routes/groups/preferences.py
Normal file
19
mealie/routes/groups/preferences.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from mealie.routes.routers import UserAPIRouter
|
||||||
|
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
|
||||||
|
from mealie.services.group_services.group_service import GroupSelfService
|
||||||
|
|
||||||
|
router = UserAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=ReadGroupPreferences)
|
||||||
|
def update_group_preferences(
|
||||||
|
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
|
||||||
|
):
|
||||||
|
return g_service.update_preferences(new_pref).preferences
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ReadGroupPreferences)
|
||||||
|
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
|
||||||
|
return g_service.item.preferences
|
|
@ -1,7 +1,6 @@
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from mealie.routes.routers import UserAPIRouter
|
from mealie.routes.routers import UserAPIRouter
|
||||||
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
|
|
||||||
from mealie.schema.user.user import GroupInDB
|
from mealie.schema.user.user import GroupInDB
|
||||||
from mealie.services.group_services.group_service import GroupSelfService
|
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 """
|
""" Returns the Group Data for the Current User """
|
||||||
|
|
||||||
return g_service.item
|
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
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
|
||||||
and should not hit the API in production"""
|
and should not hit the API in production"""
|
||||||
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
|
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
|
||||||
|
|
||||||
if recipe_image:
|
if recipe_image.exists():
|
||||||
return FileResponse(recipe_image)
|
return FileResponse(recipe_image)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||||
|
|
|
@ -8,7 +8,9 @@ from sqlalchemy.orm.session import Session
|
||||||
from mealie.core.config import app_dirs
|
from mealie.core.config import app_dirs
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
from mealie.routes.routers import AdminAPIRouter
|
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.admin import MigrationFile, Migrations
|
||||||
|
from mealie.schema.user.user import PrivateUser
|
||||||
from mealie.services.migrations import migration
|
from mealie.services.migrations import migration
|
||||||
|
|
||||||
router = AdminAPIRouter(prefix="/api/migrations", tags=["Migration"])
|
router = AdminAPIRouter(prefix="/api/migrations", tags=["Migration"])
|
||||||
|
@ -36,10 +38,15 @@ def get_all_migration_options():
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{import_type}/{file_name}/import")
|
@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 """
|
""" Imports all the recipes in a given directory """
|
||||||
file_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
|
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)
|
@router.delete("/{import_type}/{file_name}/delete", status_code=status.HTTP_200_OK)
|
||||||
|
|
|
@ -8,7 +8,6 @@ router = APIRouter()
|
||||||
|
|
||||||
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
|
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.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(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(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||||
router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"])
|
router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"])
|
||||||
|
|
|
@ -4,28 +4,10 @@ from sqlalchemy.orm.session import Session
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
from mealie.schema.recipe import RecipeSummary
|
from mealie.schema.recipe import RecipeSummary
|
||||||
from mealie.services.recipe.all_recipe_service import AllRecipesService
|
|
||||||
|
|
||||||
router = APIRouter()
|
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])
|
@router.get("/summary/untagged", response_model=list[RecipeSummary])
|
||||||
async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)):
|
async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)):
|
||||||
return db.recipes.count_untagged(session, count=count, override_schema=RecipeSummary)
|
return db.recipes.count_untagged(session, count=count, override_schema=RecipeSummary)
|
||||||
|
|
|
@ -2,7 +2,7 @@ import json
|
||||||
import shutil
|
import shutil
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File
|
from fastapi import Depends, File
|
||||||
from fastapi.datastructures import UploadFile
|
from fastapi.datastructures import UploadFile
|
||||||
from scrape_schema_recipe import scrape_url
|
from scrape_schema_recipe import scrape_url
|
||||||
from sqlalchemy.orm.session import Session
|
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.db.db_setup import generate_session
|
||||||
from mealie.routes.routers import UserAPIRouter
|
from mealie.routes.routers import UserAPIRouter
|
||||||
from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes
|
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.image.image import write_image
|
||||||
from mealie.services.recipe.recipe_service import RecipeService
|
from mealie.services.recipe.recipe_service import RecipeService
|
||||||
from mealie.services.scraper.scraper import create_from_url
|
from mealie.services.scraper.scraper import create_from_url
|
||||||
|
|
||||||
user_router = UserAPIRouter()
|
user_router = UserAPIRouter()
|
||||||
public_router = APIRouter()
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@public_router.get("/{slug}", response_model=Recipe)
|
@user_router.get("", response_model=list[RecipeSummary])
|
||||||
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
|
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)):
|
||||||
""" Takes in a recipe slug, returns all data for a recipe """
|
return service.get_all(start, limit)
|
||||||
return recipe_service.item
|
|
||||||
|
|
||||||
|
|
||||||
@user_router.post("", status_code=201, response_model=str)
|
@user_router.post("", status_code=201, response_model=str)
|
||||||
def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.private)) -> 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"""
|
""" 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)
|
@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 """
|
""" Takes in a URL and attempts to scrape data and load it into the database """
|
||||||
|
|
||||||
recipe = create_from_url(url.url)
|
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")
|
@user_router.post("/test-scrape-url")
|
||||||
|
@ -80,7 +78,13 @@ async def create_recipe_from_zip(
|
||||||
return recipe
|
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(
|
async def get_recipe_as_zip(
|
||||||
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
|
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)):
|
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||||
""" Updates a recipe by existing slug and data. """
|
""" Updates a recipe by existing slug and data. """
|
||||||
|
|
||||||
return recipe_service.update_recipe(data)
|
return recipe_service.update_one(data)
|
||||||
|
|
||||||
|
|
||||||
@user_router.patch("/{slug}")
|
@user_router.patch("/{slug}")
|
||||||
def patch_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
def patch_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||||
""" Updates a recipe by existing slug and data. """
|
""" Updates a recipe by existing slug and data. """
|
||||||
|
|
||||||
return recipe_service.patch_recipe(data)
|
return recipe_service.patch_one(data)
|
||||||
|
|
||||||
|
|
||||||
@user_router.delete("/{slug}")
|
@user_router.delete("/{slug}")
|
||||||
def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||||
""" Deletes a recipe by slug """
|
""" Deletes a recipe by slug """
|
||||||
return recipe_service.delete_recipe()
|
return recipe_service.delete_one()
|
||||||
|
|
|
@ -18,27 +18,27 @@ async def get_all(
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=IngredientFood, status_code=status.HTTP_201_CREATED)
|
@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 """
|
""" Create unit in the Database """
|
||||||
|
|
||||||
return db.ingredient_foods.create(session, unit)
|
return db.ingredient_foods.create(session, unit)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}")
|
@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 """
|
""" Get unit from the Database """
|
||||||
|
|
||||||
return db.ingredient_foods.get(session, id)
|
return db.ingredient_foods.get(session, id)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{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 """
|
""" Update unit in the Database """
|
||||||
|
|
||||||
return db.ingredient_foods.update(session, id, unit)
|
return db.ingredient_foods.update(session, id, unit)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}")
|
@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 """
|
""" Delete unit from the Database """
|
||||||
return db.ingredient_foods.delete(session, id)
|
return db.ingredient_foods.delete(session, id)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import APIRouter
|
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
|
# Must be used because of the way FastAPI works with nested routes
|
||||||
user_prefix = "/users"
|
user_prefix = "/users"
|
||||||
|
@ -9,9 +9,6 @@ router = APIRouter()
|
||||||
|
|
||||||
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
|
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.user_router, prefix=user_prefix, tags=["Users: CRUD"])
|
||||||
router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"])
|
router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"])
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ router = APIRouter(prefix="/register")
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
@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)
|
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
|
||||||
):
|
):
|
||||||
return registration_service.register_user(data)
|
return registration_service.register_user(data)
|
||||||
|
|
|
@ -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)
|
|
20
mealie/schema/group/invite_token.py
Normal file
20
mealie/schema/group/invite_token.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from fastapi_camelcase import CamelModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInviteToken(CamelModel):
|
||||||
|
uses: int
|
||||||
|
|
||||||
|
|
||||||
|
class SaveInviteToken(CamelModel):
|
||||||
|
uses_left: int
|
||||||
|
group_id: int
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReadInviteToken(CamelModel):
|
||||||
|
token: str
|
||||||
|
uses_left: int
|
||||||
|
group_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
|
@ -32,11 +32,15 @@ class CreateRecipe(CamelModel):
|
||||||
|
|
||||||
class RecipeSummary(CamelModel):
|
class RecipeSummary(CamelModel):
|
||||||
id: Optional[int]
|
id: Optional[int]
|
||||||
|
|
||||||
|
user_id: int = 0
|
||||||
|
group_id: int = 0
|
||||||
|
|
||||||
name: Optional[str]
|
name: Optional[str]
|
||||||
slug: str = ""
|
slug: str = ""
|
||||||
image: Optional[Any]
|
image: Optional[Any]
|
||||||
|
|
||||||
description: Optional[str]
|
description: Optional[str] = ""
|
||||||
recipe_category: Optional[list[str]] = []
|
recipe_category: Optional[list[str]] = []
|
||||||
tags: Optional[list[str]] = []
|
tags: Optional[list[str]] = []
|
||||||
rating: Optional[int]
|
rating: Optional[int]
|
||||||
|
@ -112,32 +116,6 @@ class Recipe(RecipeSummary):
|
||||||
"extras": {x.key_name: x.value for x in name_orm.extras},
|
"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)
|
@validator("slug", always=True, pre=True)
|
||||||
def validate_slug(slug: str, values):
|
def validate_slug(slug: str, values):
|
||||||
if not values.get("name"):
|
if not values.get("name"):
|
||||||
|
|
|
@ -81,6 +81,7 @@ class UserIn(UserBase):
|
||||||
class UserOut(UserBase):
|
class UserOut(UserBase):
|
||||||
id: int
|
id: int
|
||||||
group: str
|
group: str
|
||||||
|
group_id: int
|
||||||
tokens: Optional[list[LongLiveTokenOut]]
|
tokens: Optional[list[LongLiveTokenOut]]
|
||||||
favorite_recipes: Optional[list[str]] = []
|
favorite_recipes: Optional[list[str]] = []
|
||||||
|
|
||||||
|
@ -112,6 +113,7 @@ class UserFavorites(UserBase):
|
||||||
|
|
||||||
class PrivateUser(UserOut):
|
class PrivateUser(UserOut):
|
||||||
password: str
|
password: str
|
||||||
|
group_id: int
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
|
@ -27,6 +27,7 @@ from mealie.services.image import minify
|
||||||
class ImportDatabase:
|
class ImportDatabase:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
user: PrivateUser,
|
||||||
session: Session,
|
session: Session,
|
||||||
zip_archive: str,
|
zip_archive: str,
|
||||||
force_import: bool = False,
|
force_import: bool = False,
|
||||||
|
@ -41,6 +42,7 @@ class ImportDatabase:
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If the zip file does not exists an exception raise.
|
Exception: If the zip file does not exists an exception raise.
|
||||||
"""
|
"""
|
||||||
|
self.user = user
|
||||||
self.session = session
|
self.session = session
|
||||||
self.archive = app_dirs.BACKUP_DIR.joinpath(zip_archive)
|
self.archive = app_dirs.BACKUP_DIR.joinpath(zip_archive)
|
||||||
self.force_imports = force_import
|
self.force_imports = force_import
|
||||||
|
@ -66,6 +68,9 @@ class ImportDatabase:
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
recipe: Recipe
|
recipe: Recipe
|
||||||
|
|
||||||
|
recipe.group_id = self.user.group_id
|
||||||
|
recipe.user_id = self.user.id
|
||||||
|
|
||||||
import_status = self.import_model(
|
import_status = self.import_model(
|
||||||
db_table=db.recipes,
|
db_table=db.recipes,
|
||||||
model=recipe,
|
model=recipe,
|
||||||
|
@ -308,6 +313,7 @@ class ImportDatabase:
|
||||||
|
|
||||||
def import_database(
|
def import_database(
|
||||||
session: Session,
|
session: Session,
|
||||||
|
user: PrivateUser,
|
||||||
archive,
|
archive,
|
||||||
import_recipes=True,
|
import_recipes=True,
|
||||||
import_settings=True,
|
import_settings=True,
|
||||||
|
@ -317,7 +323,7 @@ def import_database(
|
||||||
force_import: bool = False,
|
force_import: bool = False,
|
||||||
rebase: bool = False,
|
rebase: bool = False,
|
||||||
):
|
):
|
||||||
import_session = ImportDatabase(session, archive, force_import)
|
import_session = ImportDatabase(user, session, archive, force_import)
|
||||||
|
|
||||||
recipe_report = []
|
recipe_report = []
|
||||||
if import_recipes:
|
if import_recipes:
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
|
|
||||||
from mealie.core.dependencies.grouped import UserDeps
|
from mealie.core.dependencies.grouped import UserDeps
|
||||||
from mealie.core.root_logger import get_logger
|
from mealie.core.root_logger import get_logger
|
||||||
from mealie.schema.group.group_preferences import UpdateGroupPreferences
|
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.recipe.recipe_category import CategoryBase
|
||||||
from mealie.schema.user.user import GroupInDB
|
from mealie.schema.user.user import GroupInDB
|
||||||
from mealie.services._base_http_service.http_services import UserHttpService
|
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)
|
self.db.group_preferences.update(self.session, self.group_id, new_preferences)
|
||||||
|
|
||||||
return self.populate_item()
|
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})
|
||||||
|
|
|
@ -10,6 +10,7 @@ from mealie.core import root_logger
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.schema.admin import MigrationImport
|
from mealie.schema.admin import MigrationImport
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
|
from mealie.schema.user.user import PrivateUser
|
||||||
from mealie.services.image import image
|
from mealie.services.image import image
|
||||||
from mealie.services.scraper import cleaner
|
from mealie.services.scraper import cleaner
|
||||||
from mealie.utils.unzip import unpack_zip
|
from mealie.utils.unzip import unpack_zip
|
||||||
|
@ -34,6 +35,8 @@ class MigrationBase(BaseModel):
|
||||||
session: Optional[Any]
|
session: Optional[Any]
|
||||||
key_aliases: Optional[list[MigrationAlias]]
|
key_aliases: Optional[list[MigrationAlias]]
|
||||||
|
|
||||||
|
user: PrivateUser
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def temp_dir(self) -> TemporaryDirectory:
|
def temp_dir(self) -> TemporaryDirectory:
|
||||||
"""unpacks the migration_file into a temporary directory
|
"""unpacks the migration_file into a temporary directory
|
||||||
|
@ -162,6 +165,10 @@ class MigrationBase(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for recipe in validated_recipes:
|
for recipe in validated_recipes:
|
||||||
|
|
||||||
|
recipe.user_id = self.user.id
|
||||||
|
recipe.group_id = self.user.group_id
|
||||||
|
|
||||||
exception = ""
|
exception = ""
|
||||||
status = False
|
status = False
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -5,6 +5,7 @@ from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from mealie.core.config import app_dirs
|
from mealie.core.config import app_dirs
|
||||||
from mealie.schema.admin import MigrationImport
|
from mealie.schema.admin import MigrationImport
|
||||||
|
from mealie.schema.user.user import PrivateUser
|
||||||
from mealie.services.migrations import helpers
|
from mealie.services.migrations import helpers
|
||||||
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
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]:
|
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||||
cd_migration = ChowdownMigration(migration_file=zip_path, session=session)
|
cd_migration = ChowdownMigration(user=user, migration_file=zip_path, session=session)
|
||||||
|
|
||||||
with cd_migration.temp_dir as dir:
|
with cd_migration.temp_dir as dir:
|
||||||
chow_dir = next(Path(dir).iterdir())
|
chow_dir = next(Path(dir).iterdir())
|
||||||
|
|
|
@ -19,7 +19,7 @@ class Migration(str, Enum):
|
||||||
chowdown = "chowdown"
|
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.
|
"""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
|
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
|
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}")
|
logger.info(f"Starting Migration from {migration_type}")
|
||||||
|
|
||||||
if migration_type == Migration.nextcloud.value:
|
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:
|
elif migration_type == Migration.chowdown.value:
|
||||||
migration_imports = chowdown.migrate(session, file_path)
|
migration_imports = chowdown.migrate(user, session, file_path)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
|
@ -6,6 +6,7 @@ from slugify import slugify
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from mealie.schema.admin import MigrationImport
|
from mealie.schema.admin import MigrationImport
|
||||||
|
from mealie.schema.user.user import PrivateUser
|
||||||
from mealie.services.migrations import helpers
|
from mealie.services.migrations import helpers
|
||||||
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
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:
|
with nc_migration.temp_dir as dir:
|
||||||
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)
|
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)
|
||||||
|
|
|
@ -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")
|
|
16
mealie/services/recipe/mixins.py
Normal file
16
mealie/services/recipe/mixins.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from mealie.schema.recipe import Recipe
|
||||||
|
from mealie.schema.user.user import PrivateUser
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
|
||||||
|
"""
|
||||||
|
The main creation point for recipes. The factor method returns an instance of the
|
||||||
|
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
|
||||||
|
else-where to avoid conflicts.
|
||||||
|
"""
|
||||||
|
additional_attrs = additional_attrs or {}
|
||||||
|
additional_attrs["name"] = name
|
||||||
|
additional_attrs["user_id"] = user.id
|
||||||
|
additional_attrs["group_id"] = user.group_id
|
||||||
|
|
||||||
|
return Recipe(**additional_attrs)
|
|
@ -7,14 +7,15 @@ from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
|
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
|
||||||
from mealie.core.root_logger import get_logger
|
from mealie.core.root_logger import get_logger
|
||||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
|
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
|
||||||
from mealie.services._base_http_service.http_services import PublicHttpService
|
from mealie.services._base_http_service.http_services import UserHttpService
|
||||||
from mealie.services.events import create_recipe_event
|
from mealie.services.events import create_recipe_event
|
||||||
|
from mealie.services.recipe.mixins import recipe_creation_factory
|
||||||
|
|
||||||
logger = get_logger(module=__name__)
|
logger = get_logger(module=__name__)
|
||||||
|
|
||||||
|
|
||||||
class RecipeService(PublicHttpService[str, Recipe]):
|
class RecipeService(UserHttpService[str, Recipe]):
|
||||||
"""
|
"""
|
||||||
Class Methods:
|
Class Methods:
|
||||||
`read_existing`: Reads an existing recipe from the database.
|
`read_existing`: Reads an existing recipe from the database.
|
||||||
|
@ -46,9 +47,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
||||||
return self.item
|
return self.item
|
||||||
|
|
||||||
# CRUD METHODS
|
# CRUD METHODS
|
||||||
def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
def get_all(self, start=0, limit=None):
|
||||||
if isinstance(create_data, CreateRecipe):
|
return self.db.recipes.multi_query(
|
||||||
create_data = Recipe(name=create_data.name)
|
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:
|
try:
|
||||||
self.item = self.db.recipes.create(self.session, create_data)
|
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"})
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
|
||||||
|
|
||||||
self._create_event(
|
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}",
|
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.item
|
return self.item
|
||||||
|
|
||||||
def update_recipe(self, update_data: Recipe) -> Recipe:
|
def update_one(self, update_data: Recipe) -> Recipe:
|
||||||
original_slug = self.item.slug
|
original_slug = self.item.slug
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -74,7 +79,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
||||||
|
|
||||||
return self.item
|
return self.item
|
||||||
|
|
||||||
def patch_recipe(self, patch_data: Recipe) -> Recipe:
|
def patch_one(self, patch_data: Recipe) -> Recipe:
|
||||||
original_slug = self.item.slug
|
original_slug = self.item.slug
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -88,16 +93,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
||||||
|
|
||||||
return self.item
|
return self.item
|
||||||
|
|
||||||
def delete_recipe(self) -> Recipe:
|
def delete_one(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
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
|
recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
|
||||||
self._delete_assets()
|
self._delete_assets()
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from mealie.core.root_logger import get_logger
|
from mealie.core.root_logger import get_logger
|
||||||
from mealie.core.security import hash_password
|
from mealie.core.security import hash_password
|
||||||
from mealie.schema.group.group_preferences import CreateGroupPreferences
|
from mealie.schema.group.group_preferences import CreateGroupPreferences
|
||||||
|
@ -20,13 +22,37 @@ class RegistrationService(PublicHttpService[int, str]):
|
||||||
self.registration = registration
|
self.registration = registration
|
||||||
|
|
||||||
logger.info(f"Registering user {registration.username}")
|
logger.info(f"Registering user {registration.username}")
|
||||||
|
token_entry = None
|
||||||
|
|
||||||
if registration.group:
|
if registration.group:
|
||||||
group = self._create_new_group()
|
group = self._register_new_group()
|
||||||
else:
|
|
||||||
group = self._existing_group_ref()
|
|
||||||
|
|
||||||
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:
|
def _create_new_user(self, group: GroupInDB) -> PrivateUser:
|
||||||
new_user = UserIn(
|
new_user = UserIn(
|
||||||
|
@ -40,7 +66,7 @@ class RegistrationService(PublicHttpService[int, str]):
|
||||||
|
|
||||||
return self.db.users.create(self.session, new_user)
|
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_data = GroupBase(name=self.registration.group)
|
||||||
|
|
||||||
group_preferences = CreateGroupPreferences(
|
group_preferences = CreateGroupPreferences(
|
||||||
|
@ -56,6 +82,3 @@ class RegistrationService(PublicHttpService[int, str]):
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_new_group(self.session, group_data, group_preferences)
|
return create_new_group(self.session, group_data, group_preferences)
|
||||||
|
|
||||||
def _existing_group_ref(self) -> GroupInDB:
|
|
||||||
pass
|
|
||||||
|
|
|
@ -11,6 +11,8 @@ from mealie.db.db_setup import SessionLocal, generate_session
|
||||||
from mealie.db.init_db import main
|
from mealie.db.init_db import main
|
||||||
from tests.app_routes import AppRoutes
|
from tests.app_routes import AppRoutes
|
||||||
from tests.test_config import TEST_DATA
|
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
|
from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
@ -62,12 +64,46 @@ def admin_token(api_client: requests, api_routes: AppRoutes):
|
||||||
return login(form_data, api_client, api_routes)
|
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")
|
@fixture(scope="session")
|
||||||
def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
|
def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
|
||||||
# Create the user
|
# Create the user
|
||||||
create_data = {
|
create_data = {
|
||||||
"fullName": "User",
|
"fullName": random_string(),
|
||||||
"email": "user@email.com",
|
"username": random_string(),
|
||||||
|
"email": random_email(),
|
||||||
"password": "useruser",
|
"password": "useruser",
|
||||||
"group": "Home",
|
"group": "Home",
|
||||||
"admin": False,
|
"admin": False,
|
||||||
|
@ -79,7 +115,7 @@ def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
# Log in as this user
|
# 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)
|
return login(form_data, api_client, api_routes)
|
||||||
|
|
||||||
|
|
||||||
|
@ -96,3 +132,45 @@ def raw_recipe_no_image():
|
||||||
@fixture(scope="session")
|
@fixture(scope="session")
|
||||||
def recipe_store():
|
def recipe_store():
|
||||||
return get_recipe_test_cases()
|
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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
|
@ -14,7 +14,7 @@ def backup_data():
|
||||||
"recipes": True,
|
"recipes": True,
|
||||||
"settings": False, # ! Broken
|
"settings": False, # ! Broken
|
||||||
"groups": False, # ! Also Broken
|
"groups": False, # ! Also Broken
|
||||||
"users": True,
|
"users": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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)
|
|
|
@ -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()])
|
|
|
@ -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
|
|
@ -2,6 +2,7 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from mealie.schema.group.group_preferences import UpdateGroupPreferences
|
from mealie.schema.group.group_preferences import UpdateGroupPreferences
|
||||||
from tests.utils.assertion_helpers import assert_ignore_keys
|
from tests.utils.assertion_helpers import assert_ignore_keys
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
|
@ -9,8 +10,8 @@ class Routes:
|
||||||
preferences = "/api/groups/preferences"
|
preferences = "/api/groups/preferences"
|
||||||
|
|
||||||
|
|
||||||
def test_get_preferences(api_client: TestClient, admin_token) -> None:
|
def test_get_preferences(api_client: TestClient, unique_user: TestUser) -> None:
|
||||||
response = api_client.get(Routes.preferences, headers=admin_token)
|
response = api_client.get(Routes.preferences, headers=unique_user.token)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@ -21,8 +22,8 @@ def test_get_preferences(api_client: TestClient, admin_token) -> None:
|
||||||
assert preferences["recipeShowNutrition"] is False
|
assert preferences["recipeShowNutrition"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_preferences_in_group(api_client: TestClient, admin_token) -> None:
|
def test_preferences_in_group(api_client: TestClient, unique_user: TestUser) -> None:
|
||||||
response = api_client.get(Routes.base, headers=admin_token)
|
response = api_client.get(Routes.base, headers=unique_user.token)
|
||||||
|
|
||||||
assert response.status_code == 200
|
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
|
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)
|
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
|
assert response.status_code == 200
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from mealie.schema.user.registration import CreateUserRegistration
|
from tests.utils.factories import user_registration_factory
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
|
@ -9,21 +9,13 @@ class Routes:
|
||||||
|
|
||||||
|
|
||||||
def test_user_registration_new_group(api_client: TestClient):
|
def test_user_registration_new_group(api_client: TestClient):
|
||||||
registration = CreateUserRegistration(
|
registration = user_registration_factory()
|
||||||
group="New Group Name",
|
|
||||||
email="email@email.com",
|
|
||||||
username="fake-user-name",
|
|
||||||
password="fake-password",
|
|
||||||
password_confirm="fake-password",
|
|
||||||
advanced=False,
|
|
||||||
private=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = api_client.post(Routes.base, json=registration.dict(by_alias=True))
|
response = api_client.post(Routes.base, json=registration.dict(by_alias=True))
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
# Login
|
# 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)
|
response = api_client.post(Routes.auth_token, form_data)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
|
@ -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
|
|
@ -5,25 +5,30 @@ from fastapi.testclient import TestClient
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
|
|
||||||
from tests.app_routes import AppRoutes
|
from tests.app_routes import AppRoutes
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
|
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
|
||||||
|
|
||||||
recipe_test_data = get_recipe_test_cases()
|
recipe_test_data = get_recipe_test_cases()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||||
def test_create_by_url(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
|
def test_create_by_url(
|
||||||
api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=user_token)
|
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 response.status_code == 201
|
||||||
assert json.loads(response.text) == recipe_data.expected_slug
|
assert json.loads(response.text) == recipe_data.expected_slug
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
@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)
|
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
|
assert response.status_code == 200
|
||||||
|
|
||||||
recipe = json.loads(response.text)
|
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"]
|
test_categories = ["one", "two", "three"]
|
||||||
recipe["recipeCategory"] = test_categories
|
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 response.status_code == 200
|
||||||
assert json.loads(response.text).get("slug") == recipe_data.expected_slug
|
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
|
assert response.status_code == 200
|
||||||
recipe = json.loads(response.text)
|
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)
|
@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)
|
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
|
assert response.status_code == 200
|
||||||
|
|
||||||
recipe = json.loads(response.text)
|
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)
|
new_slug = slugify(new_name)
|
||||||
recipe["name"] = 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 response.status_code == 200
|
||||||
assert json.loads(response.text).get("slug") == new_slug
|
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)
|
@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)
|
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
|
assert response.status_code == 200
|
|
@ -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
|
28
tests/integration_tests/user_tests/test_user_images.py
Normal file
28
tests/integration_tests/user_tests/test_user_images.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from mealie.core.config import app_dirs
|
||||||
|
from tests.app_routes import AppRoutes
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_image(
|
||||||
|
api_client: TestClient, api_routes: AppRoutes, test_image_jpg: Path, test_image_png: Path, admin_token
|
||||||
|
):
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.users_id_image(2), files={"profile_image": test_image_jpg.open("rb")}, headers=admin_token
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.users_id_image(2), files={"profile_image": test_image_png.open("rb")}, headers=admin_token
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
directory = app_dirs.USER_DIR.joinpath("2")
|
||||||
|
assert directory.joinpath("profile_image.png").is_file()
|
||||||
|
|
||||||
|
# Old profile images are removed
|
||||||
|
assert 1 == len([file for file in directory.glob("profile_image.*") if file.is_file()])
|
25
tests/integration_tests/user_tests/test_user_login.py
Normal file
25
tests/integration_tests/user_tests/test_user_login.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.app_routes import AppRoutes
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_login(api_client: TestClient, api_routes: AppRoutes):
|
||||||
|
form_data = {"username": "changeme@email.com", "password": "WRONG_PASSWORD"}
|
||||||
|
response = api_client.post(api_routes.auth_token, form_data)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_superuser_login(api_client: TestClient, api_routes: AppRoutes, admin_token):
|
||||||
|
form_data = {"username": "changeme@email.com", "password": "MyPassword"}
|
||||||
|
response = api_client.post(api_routes.auth_token, form_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
new_token = json.loads(response.text).get("access_token")
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.users_self, headers=admin_token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
return {"Authorization": f"Bearer {new_token}"}
|
|
@ -6,9 +6,11 @@ from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
|
||||||
test_cases = get_recipe_test_cases()
|
test_cases = get_recipe_test_cases()
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
These tests are skipped by default and only really used when troubleshooting the parser
|
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
|
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.
|
and then use this test case by removing the `@pytest.mark.skip` and than testing your results.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
24
tests/utils/factories.py
Normal file
24
tests/utils/factories.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
from mealie.schema.user.registration import CreateUserRegistration
|
||||||
|
|
||||||
|
|
||||||
|
def random_string(length=10) -> str:
|
||||||
|
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def random_email(length=10) -> str:
|
||||||
|
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length)) + "@fake.com"
|
||||||
|
|
||||||
|
|
||||||
|
def user_registration_factory() -> CreateUserRegistration:
|
||||||
|
return CreateUserRegistration(
|
||||||
|
group=random_string(),
|
||||||
|
email=random_email(),
|
||||||
|
username=random_string(),
|
||||||
|
password="fake-password",
|
||||||
|
password_confirm="fake-password",
|
||||||
|
advanced=False,
|
||||||
|
private=False,
|
||||||
|
)
|
9
tests/utils/fixture_schemas.py
Normal file
9
tests/utils/fixture_schemas.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestUser:
|
||||||
|
user_id: int
|
||||||
|
group_id: int
|
||||||
|
token: Any
|
Loading…
Reference in a new issue