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/javascriptAPI/*
|
||||
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.words": ["compression", "hkotel", "performant", "postgres", "webp"],
|
||||
"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 routes = {
|
||||
groups: `${prefix}/groups`,
|
||||
groups: `${prefix}/admin/groups`,
|
||||
groupsSelf: `${prefix}/groups/self`,
|
||||
categories: `${prefix}/groups/categories`,
|
||||
|
||||
preferences: `${prefix}/groups/preferences`,
|
||||
|
||||
groupsId: (id: string | number) => `${prefix}/groups/${id}`,
|
||||
invitation: `${prefix}/groups/invitations`,
|
||||
|
||||
groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`,
|
||||
};
|
||||
|
||||
interface Category {
|
||||
|
@ -44,6 +46,16 @@ export interface Group extends CreateGroup {
|
|||
preferences: Preferences;
|
||||
}
|
||||
|
||||
export interface CreateInvitation {
|
||||
uses: number;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
group_id: number;
|
||||
token: string;
|
||||
uses_left: number;
|
||||
}
|
||||
|
||||
export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
|
||||
baseRoute = routes.groups;
|
||||
itemRoute = routes.groupsId;
|
||||
|
@ -68,4 +80,8 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
|
|||
async setPreferences(payload: UpdatePreferences) {
|
||||
return await this.requests.put<Preferences>(routes.preferences, payload);
|
||||
}
|
||||
|
||||
async createInvitation(payload: CreateInvitation) {
|
||||
return await this.requests.post<Invitation>(routes.invitation, payload);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,82 +1,80 @@
|
|||
<template>
|
||||
<div>
|
||||
<section>
|
||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||
<div>
|
||||
<draggable
|
||||
:disabled="!edit"
|
||||
:value="value"
|
||||
handle=".handle"
|
||||
@input="updateIndex"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<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-toolbar-title v-if="!edit" class="headline">
|
||||
<v-app-bar-title v-text="step.title"> </v-app-bar-title>
|
||||
</v-toolbar-title>
|
||||
<v-text-field
|
||||
v-if="edit"
|
||||
v-model="step.title"
|
||||
class="headline pa-0 mt-5"
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
background-color="primary"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-app-bar>
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-card
|
||||
class="ma-1"
|
||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:ripple="false"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeByIndex(value, index)"
|
||||
>
|
||||
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<draggable
|
||||
:disabled="!edit"
|
||||
:value="value"
|
||||
handle=".handle"
|
||||
@input="updateIndex"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<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-toolbar-title v-if="!edit" class="headline">
|
||||
<v-app-bar-title v-text="step.title"> </v-app-bar-title>
|
||||
</v-toolbar-title>
|
||||
<v-text-field
|
||||
v-if="edit"
|
||||
v-model="step.title"
|
||||
class="headline pa-0 mt-5"
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
background-color="primary"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-app-bar>
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-card
|
||||
class="ma-1"
|
||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:ripple="false"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeByIndex(value, index)"
|
||||
>
|
||||
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</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)">
|
||||
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
||||
</v-btn>
|
||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<v-fade-transition>
|
||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||
{{ $globals.icons.checkboxMarkedCircle }}
|
||||
</v-icon>
|
||||
</v-fade-transition>
|
||||
</v-card-title>
|
||||
<v-card-text v-if="edit">
|
||||
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
|
||||
</v-textarea>
|
||||
</v-card-text>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
|
||||
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
||||
</v-btn>
|
||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<v-fade-transition>
|
||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||
{{ $globals.icons.checkboxMarkedCircle }}
|
||||
</v-icon>
|
||||
</v-fade-transition>
|
||||
</v-card-title>
|
||||
<v-card-text v-if="edit">
|
||||
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
|
||||
</v-textarea>
|
||||
</v-card-text>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
</draggable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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",
|
||||
"existing-password-does-not-match": "Existing password does not match",
|
||||
"full-name": "Full Name",
|
||||
"invite-only": "Invite Only",
|
||||
"link-id": "Link ID",
|
||||
"link-name": "Link Name",
|
||||
"login": "Login",
|
||||
|
@ -459,30 +460,31 @@
|
|||
"manage-users": "Manage Users",
|
||||
"new-password": "New Password",
|
||||
"new-user": "New User",
|
||||
"password": "Password",
|
||||
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
||||
"password-must-match": "Password must match",
|
||||
"password-reset-failed": "Password reset failed",
|
||||
"password-updated": "Password updated",
|
||||
"password": "Password",
|
||||
"register": "Register",
|
||||
"reset-password": "Reset Password",
|
||||
"sign-in": "Sign in",
|
||||
"total-mealplans": "Total MealPlans",
|
||||
"total-users": "Total Users",
|
||||
"upload-photo": "Upload Photo",
|
||||
"use-8-characters-or-more-for-your-password": "Use 8 characters or more for your password",
|
||||
"user": "User",
|
||||
"user-created": "User created",
|
||||
"user-creation-failed": "User creation failed",
|
||||
"user-deleted": "User deleted",
|
||||
"user-id": "User ID",
|
||||
"user-id-with-value": "User ID: {id}",
|
||||
"user-id": "User ID",
|
||||
"user-password": "User Password",
|
||||
"user-successfully-logged-in": "User Successfully Logged In",
|
||||
"user-update-failed": "User update failed",
|
||||
"user-updated": "User updated",
|
||||
"user": "User",
|
||||
"username": "Username",
|
||||
"users": "Users",
|
||||
"users-header": "USERS",
|
||||
"users": "Users",
|
||||
"webhook-time": "Webhook Time",
|
||||
"webhooks-enabled": "Webhooks Enabled",
|
||||
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user",
|
||||
|
|
|
@ -40,6 +40,7 @@ export default {
|
|||
// https://go.nuxtjs.dev/typescript
|
||||
"@nuxt/typescript-build",
|
||||
// https://go.nuxtjs.dev/vuetify
|
||||
// https://go.nuxtjs.dev/vuetify
|
||||
"@nuxtjs/vuetify",
|
||||
// https://composition-api.nuxtjs.org/getting-started/setup
|
||||
"@nuxtjs/composition-api/module",
|
||||
|
@ -115,7 +116,7 @@ export default {
|
|||
|
||||
i18n: {
|
||||
locales: [
|
||||
// Auto Generated from "generate_nuxt_locales.py"
|
||||
// CODE_GEN_ID: MESSAGE_LOCALES
|
||||
{ code: "el-GR", file: "el-GR.json" },
|
||||
{ code: "it-IT", file: "it-IT.json" },
|
||||
{ code: "ko-KR", file: "ko-KR.json" },
|
||||
|
@ -147,13 +148,14 @@ export default {
|
|||
{ code: "en-GB", file: "en-GB.json" },
|
||||
{ code: "fi-FI", file: "fi-FI.json" },
|
||||
{ code: "vi-VN", file: "vi-VN.json" },
|
||||
// END: MESSAGE_LOCALES
|
||||
],
|
||||
lazy: true,
|
||||
langDir: "lang/messages",
|
||||
defaultLocale: "en-US",
|
||||
vueI18n: {
|
||||
dateTimeFormats: {
|
||||
// Auto Generated from "generate_nuxt_locales.py"
|
||||
// CODE_GEN_ID: DATE_LOCALES
|
||||
"el-GR": require("./lang/dateTimeFormats/el-GR.json"),
|
||||
"it-IT": require("./lang/dateTimeFormats/it-IT.json"),
|
||||
"ko-KR": require("./lang/dateTimeFormats/ko-KR.json"),
|
||||
|
@ -185,6 +187,7 @@ export default {
|
|||
"en-GB": require("./lang/dateTimeFormats/en-GB.json"),
|
||||
"fi-FI": require("./lang/dateTimeFormats/fi-FI.json"),
|
||||
"vi-VN": require("./lang/dateTimeFormats/vi-VN.json"),
|
||||
// END: DATE_LOCALES
|
||||
},
|
||||
},
|
||||
fallbackLocale: "es",
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
"@nuxtjs/eslint-config-typescript": "^6.0.1",
|
||||
"@nuxtjs/eslint-module": "^3.0.2",
|
||||
"@nuxtjs/vuetify": "^1.12.1",
|
||||
"@vue/runtime-dom": "^3.2.9",
|
||||
"eslint": "^7.29.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-nuxt": "^2.0.0",
|
||||
|
|
|
@ -174,50 +174,44 @@
|
|||
type="password"
|
||||
/>
|
||||
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
|
||||
Login
|
||||
{{ $t("user.login") }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-btn v-if="$config.ALLOW_SIGNUP" class="mx-auto" text to="/register"> Register </v-btn>
|
||||
<v-btn v-else class="mx-auto" text disabled> Invite Only </v-btn>
|
||||
<v-btn v-if="allowSignup" class="mx-auto" text to="/register"> {{ $t("user.register") }} </v-btn>
|
||||
<v-btn v-else class="mx-auto" text disabled> {{ $t("user.invite-only") }} </v-btn>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
<script lang="ts" setup>
|
||||
import { defineComponent, ref, useContext } 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({
|
||||
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">
|
||||
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
|
||||
<div class="d-flex">
|
||||
<BaseButton v-if="form" class="ml-auto my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||
</div>
|
||||
<RecipeNotes v-model="recipe.notes" :edit="form" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
@ -346,6 +349,22 @@ export default defineComponent({
|
|||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
function addStep(steps: Array<string> | null = null) {
|
||||
if (!recipe.value?.recipeInstructions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { text: step, title: "" };
|
||||
});
|
||||
|
||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||
} else {
|
||||
recipe.value.recipeInstructions.push({ text: "", title: "" });
|
||||
}
|
||||
}
|
||||
|
||||
function addIngredient(ingredients: Array<string> | null = null) {
|
||||
if (ingredients?.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
|
@ -386,6 +405,7 @@ export default defineComponent({
|
|||
api,
|
||||
form,
|
||||
loading,
|
||||
addStep,
|
||||
deleteRecipe,
|
||||
updateRecipe,
|
||||
uploadImage,
|
||||
|
|
|
@ -4,41 +4,35 @@
|
|||
<v-card-title class="headline"> User Registration </v-card-title>
|
||||
<v-card-text>
|
||||
<v-form ref="domRegisterForm" @submit.prevent="register()">
|
||||
<ToggleState>
|
||||
<template #activator="{ toggle }">
|
||||
<div class="d-flex justify-center my-2">
|
||||
<v-btn-toggle tile mandatory group color="primary">
|
||||
<v-btn small @click="toggle(false)"> Create a Group </v-btn>
|
||||
<v-btn small @click="toggle(true)"> Join a Group </v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ state }">
|
||||
<v-text-field
|
||||
v-if="!state"
|
||||
v-model="form.group"
|
||||
filled
|
||||
rounded
|
||||
autofocus
|
||||
validate-on-blur
|
||||
class="rounded-lg"
|
||||
:prepend-icon="$globals.icons.group"
|
||||
:rules="[tokenOrGroup]"
|
||||
label="New Group Name"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="form.groupToken"
|
||||
filled
|
||||
rounded
|
||||
validate-on-blur
|
||||
:rules="[tokenOrGroup]"
|
||||
class="rounded-lg"
|
||||
:prepend-icon="$globals.icons.group"
|
||||
label="Group Token"
|
||||
/>
|
||||
</template>
|
||||
</ToggleState>
|
||||
<div class="d-flex justify-center my-2">
|
||||
<v-btn-toggle v-model="joinGroup" mandatory tile group color="primary">
|
||||
<v-btn :value="false" small @click="joinGroup = false"> Create a Group </v-btn>
|
||||
<v-btn :value="true" small @click="joinGroup = true"> Join a Group </v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-if="!joinGroup"
|
||||
v-model="form.group"
|
||||
filled
|
||||
rounded
|
||||
autofocus
|
||||
validate-on-blur
|
||||
class="rounded-lg"
|
||||
:prepend-icon="$globals.icons.group"
|
||||
:rules="[tokenOrGroup]"
|
||||
label="New Group Name"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="form.groupToken"
|
||||
filled
|
||||
rounded
|
||||
validate-on-blur
|
||||
:rules="[tokenOrGroup]"
|
||||
class="rounded-lg"
|
||||
:prepend-icon="$globals.icons.group"
|
||||
label="Group Token"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
filled
|
||||
|
@ -105,29 +99,42 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, reactive, toRefs, ref, useRouter, watch } from "@nuxtjs/composition-api";
|
||||
import { validators } from "@/composables/use-validators";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useRouterQuery } from "@/composables/use-router";
|
||||
|
||||
export default defineComponent({
|
||||
layout: "basic",
|
||||
setup() {
|
||||
const api = useApiSingleton();
|
||||
const state = reactive({
|
||||
joinGroup: false,
|
||||
loggingIn: false,
|
||||
success: false,
|
||||
});
|
||||
const allowSignup = computed(() => process.env.AllOW_SIGNUP);
|
||||
|
||||
const router = useRouter();
|
||||
const token = useRouterQuery("token");
|
||||
|
||||
watch(token, (newToken) => {
|
||||
if (newToken) {
|
||||
console.log(token);
|
||||
form.groupToken = newToken;
|
||||
}
|
||||
});
|
||||
|
||||
if (token) {
|
||||
state.joinGroup = true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const domRegisterForm = ref<VForm>(null);
|
||||
|
||||
const form = reactive({
|
||||
group: "",
|
||||
groupToken: "",
|
||||
groupToken: token,
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
|
@ -139,23 +146,24 @@ export default defineComponent({
|
|||
const passwordMatch = () => form.password === form.passwordConfirm || "Passwords do not match";
|
||||
const tokenOrGroup = () => form.group !== "" || form.groupToken !== "" || "Group name or token must be given";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function register() {
|
||||
if (!domRegisterForm.value?.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, response } = await api.register.register(form);
|
||||
const { response } = await api.register.register(form);
|
||||
|
||||
if (response?.status === 201) {
|
||||
state.success = true;
|
||||
alert.success("Registration Success");
|
||||
router.push("/user/login");
|
||||
}
|
||||
|
||||
console.log(data, response);
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
domRegisterForm,
|
||||
validators,
|
||||
allowSignup,
|
||||
|
|
|
@ -5,8 +5,10 @@
|
|||
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
|
||||
</template>
|
||||
<template #title> Your Profile Settings </template>
|
||||
Some text here...
|
||||
</BasePageTitle>
|
||||
|
||||
|
||||
|
||||
<section>
|
||||
<ToggleState tag="article">
|
||||
<template #activator="{ toggle, state }">
|
||||
|
@ -19,9 +21,8 @@
|
|||
{{ $t("settings.profile") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template #default="{ state }">
|
||||
<v-slide-x-transition group mode="in" hide-on-leave>
|
||||
<v-slide-x-transition>
|
||||
<div v-if="!state" key="personal-info">
|
||||
<BaseCardSectionTitle class="mt-10" title="Personal Information"> </BaseCardSectionTitle>
|
||||
<v-card tag="article" outlined>
|
||||
|
@ -90,8 +91,14 @@
|
|||
label="Show advanced features (API Keys, Webhooks, and Data Management)"
|
||||
@change="updateUser"
|
||||
></v-checkbox>
|
||||
<div class="d-flex justify-center mt-5">
|
||||
<v-btn outlined class="rounded-xl" to="/user/group"> Looking for Privacy Settings? </v-btn>
|
||||
<div class="d-flex flex-wrap justify-center mt-5">
|
||||
<v-btn outlined class="rounded-xl my-1 mx-1" to="/user/profile" nuxt exact>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.backArrow }}
|
||||
</v-icon>
|
||||
Back to Profile
|
||||
</v-btn>
|
||||
<v-btn outlined class="rounded-xl my-1 mx-1" to="/user/group"> Looking for Privacy Settings? </v-btn>
|
||||
</div>
|
||||
</section>
|
||||
</v-container>
|
||||
|
|
|
@ -9,6 +9,23 @@
|
|||
Manage your profile, recipes, and group settings.
|
||||
<a href="https://hay-kot.github.io/mealie/" target="_blank"> Learn More </a>
|
||||
</p>
|
||||
<v-card flat width="100%" max-width="600px">
|
||||
<v-card-actions class="d-flex justify-center">
|
||||
<v-btn outlined rounded @click="getSignupLink()">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
Get Invite Link
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<v-card-text v-if="generatedLink !== ''" class="d-flex">
|
||||
<v-text-field v-model="generatedLink" solo readonly>
|
||||
<template #append>
|
||||
<AppButtonCopy :copy-text="generatedLink" />
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</section>
|
||||
<section>
|
||||
<div>
|
||||
|
@ -21,7 +38,7 @@
|
|||
:link="{ text: 'Manage User Profile', to: '/user/profile/edit' }"
|
||||
:image="require('~/static/svgs/manage-profile.svg')"
|
||||
>
|
||||
<template #title> User Profile </template>
|
||||
<template #title> User Settings </template>
|
||||
Manage your preferences, change your password, and update your email
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
|
@ -78,8 +95,9 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, useContext, ref } from "@nuxtjs/composition-api";
|
||||
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
|
@ -88,7 +106,23 @@ export default defineComponent({
|
|||
setup() {
|
||||
const user = computed(() => useContext().$auth.user);
|
||||
|
||||
return { user };
|
||||
const generatedLink = ref("");
|
||||
|
||||
const api = useApiSingleton();
|
||||
|
||||
async function getSignupLink() {
|
||||
const { data } = await api.groups.createInvitation({ uses: 1 });
|
||||
|
||||
if (data) {
|
||||
generatedLink.value = constructLink(data.token);
|
||||
}
|
||||
}
|
||||
|
||||
function constructLink(token: string) {
|
||||
return `${window.location.origin}/register?token=${token}`;
|
||||
}
|
||||
|
||||
return { user, constructLink, generatedLink, getSignupLink };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -94,6 +94,7 @@ import {
|
|||
mdiFolderZipOutline,
|
||||
mdiFoodApple,
|
||||
mdiBeakerOutline,
|
||||
mdiArrowLeftBoldOutline,
|
||||
} from "@mdi/js";
|
||||
|
||||
const icons = {
|
||||
|
@ -184,6 +185,7 @@ const icons = {
|
|||
zip: mdiFolderZipOutline,
|
||||
|
||||
// Crud
|
||||
backArrow: mdiArrowLeftBoldOutline,
|
||||
createAlt: mdiPlus,
|
||||
create: mdiPlusCircle,
|
||||
delete: mdiDelete,
|
||||
|
|
|
@ -18,5 +18,8 @@
|
|||
},
|
||||
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next"]
|
||||
},
|
||||
"exclude": ["node_modules", ".nuxt", "dist"]
|
||||
"exclude": ["node_modules", ".nuxt", "dist"],
|
||||
"vueCompilerOptions": {
|
||||
"experimentalCompatMode": 2
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { TranslateResult } from "vue-i18n";
|
||||
|
||||
export interface SideBarLink {
|
||||
icon: string
|
||||
to: string
|
||||
title: TranslateResult
|
||||
icon: string;
|
||||
to: string;
|
||||
title: TranslateResult;
|
||||
}
|
||||
|
||||
export type SidebarLinks = Array<SideBarLink>
|
||||
export type SidebarLinks = Array<SideBarLink>;
|
||||
|
|
49
frontend/types/components.d.ts
vendored
Normal file
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:
|
||||
tslib "^2.3.0"
|
||||
|
||||
"@vue/reactivity@3.2.9":
|
||||
version "3.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.9.tgz#f4ec61519f4779224d98a23ac07b481d95687cae"
|
||||
integrity sha512-V0me78KlETt/9u3S9BoViEZNCFr/fDWodLq/KqYbFj+YySnCDD0clmjgBSQvIM63D+z3iUXftJyv08vAjlWrvw==
|
||||
dependencies:
|
||||
"@vue/shared" "3.2.9"
|
||||
|
||||
"@vue/ref-transform@^3.2.6":
|
||||
version "3.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@vue/ref-transform/-/ref-transform-3.2.8.tgz#a527047bab43ce50ef3d400ce71312ab30f825dc"
|
||||
|
@ -2108,11 +2115,33 @@
|
|||
estree-walker "^2.0.2"
|
||||
magic-string "^0.25.7"
|
||||
|
||||
"@vue/runtime-core@3.2.9":
|
||||
version "3.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.9.tgz#32854c9d9853aa2075fcecfc762b5f033a6bae1e"
|
||||
integrity sha512-CaSjy/kBrSFtSwyW2sY7RTN5YGmcDg8xLzKmFmIrkI9AXv/YjViQjSKUNHTAhnGq0K739vhFO4r3meBNEWqiOw==
|
||||
dependencies:
|
||||
"@vue/reactivity" "3.2.9"
|
||||
"@vue/shared" "3.2.9"
|
||||
|
||||
"@vue/runtime-dom@^3.2.9":
|
||||
version "3.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.9.tgz#397572a142db2772fb4b7f0a2bc06b5486e5db81"
|
||||
integrity sha512-Vi8eOaP7/8NYSWIl8/klPtkiI+IQq/gPAI77U7PVoJ22tTcK/+9IIrMEN2TD+jUkHTRRIymMECEv+hWQT1Mo1g==
|
||||
dependencies:
|
||||
"@vue/runtime-core" "3.2.9"
|
||||
"@vue/shared" "3.2.9"
|
||||
csstype "^2.6.8"
|
||||
|
||||
"@vue/shared@3.2.8", "@vue/shared@^3.2.4", "@vue/shared@^3.2.6":
|
||||
version "3.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.8.tgz#2f918e330aeb3f56ab1031ca60a5b30672512457"
|
||||
integrity sha512-E2DQQnG7Qr4GwTs3GlfPPlHliGVADoufTnhpwfoViw7JlyLMmYtjfnTwM6nXAwvSJWiF7D+7AxpnWBBT3VWo6Q==
|
||||
|
||||
"@vue/shared@3.2.9":
|
||||
version "3.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.9.tgz#44e44dbd82819997f192fb7dbdb90af5715dbf52"
|
||||
integrity sha512-+CifxkLVhjKT14g/LMZil8//SdCzkMkS8VfRX0cqNJiFKK4AWvxj0KV1dhbr8czikY0DZUGQew3tRMRRChMGtA==
|
||||
|
||||
"@vueuse/core@^5.2.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-5.3.0.tgz#d8c6e939e18089afa224fab6e443fae2bdb57a51"
|
||||
|
@ -4174,6 +4203,11 @@ csso@^4.0.2:
|
|||
dependencies:
|
||||
css-tree "^1.1.2"
|
||||
|
||||
csstype@^2.6.8:
|
||||
version "2.6.17"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.17.tgz#4cf30eb87e1d1a005d8b6510f95292413f6a1c0e"
|
||||
integrity sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==
|
||||
|
||||
cuint@^0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
|
||||
|
|
|
@ -10,7 +10,6 @@ from mealie.routes.mealplans import meal_plan_router
|
|||
from mealie.routes.media import media_router
|
||||
from mealie.routes.site_settings import settings_router
|
||||
from mealie.services.events import create_general_event
|
||||
from mealie.services.recipe.all_recipe_service import subscripte_to_recipe_events
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
@ -71,7 +70,6 @@ def system_startup():
|
|||
)
|
||||
)
|
||||
create_general_event("Application Startup", f"Mealie API started on port {settings.API_PORT}")
|
||||
subscripte_to_recipe_events()
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
@ -6,6 +6,18 @@ from mealie.schema.user.user import PrivateUser
|
|||
from .dependencies import generate_session, get_admin_user, get_current_user, is_logged_in
|
||||
|
||||
|
||||
class RequestContext:
|
||||
def __init__(
|
||||
self,
|
||||
background_tasks: BackgroundTasks,
|
||||
session: Session = Depends(generate_session),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
self.session: Session = session
|
||||
self.bg_task: BackgroundTasks = background_tasks
|
||||
self.user: bool = user
|
||||
|
||||
|
||||
class PublicDeps:
|
||||
"""
|
||||
PublicDeps contains the common dependencies for all read operations through the API.
|
||||
|
|
|
@ -52,6 +52,21 @@ class BaseAccessModel(Generic[T, D]):
|
|||
|
||||
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).offset(start).limit(limit).all()]
|
||||
|
||||
def multi_query(
|
||||
self,
|
||||
session: Session,
|
||||
query_by: dict[str, str],
|
||||
start=0,
|
||||
limit: int = None,
|
||||
override_schema=None,
|
||||
) -> list[T]:
|
||||
eff_schema = override_schema or self.schema
|
||||
|
||||
return [
|
||||
eff_schema.from_orm(x)
|
||||
for x in session.query(self.sql_model).filter_by(**query_by).offset(start).limit(limit).all()
|
||||
]
|
||||
|
||||
def get_all_limit_columns(self, session: Session, fields: list[str], limit: int = None) -> list[D]:
|
||||
"""Queries the database for the selected model. Restricts return responses to the
|
||||
keys specified under "fields"
|
||||
|
@ -105,11 +120,6 @@ class BaseAccessModel(Generic[T, D]):
|
|||
eff_schema = override_schema or self.schema
|
||||
return eff_schema.from_orm(result)
|
||||
|
||||
def get_many(
|
||||
self, session: Session, value: str, key: str = None, limit=1, any_case=False, override_schema=None
|
||||
) -> list[T]:
|
||||
pass
|
||||
|
||||
def get(
|
||||
self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None
|
||||
) -> T | list[T]:
|
||||
|
|
|
@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
|
|||
from mealie.db.models.event import Event, EventNotification
|
||||
from mealie.db.models.group import Group
|
||||
from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.shopping_list import ShoppingList
|
||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
||||
|
@ -22,6 +23,7 @@ from mealie.schema.cookbook import ReadCookBook
|
|||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.invite_token import ReadInviteToken
|
||||
from mealie.schema.group.webhook import ReadWebhook
|
||||
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
|
||||
from mealie.schema.recipe import (
|
||||
|
@ -87,6 +89,7 @@ class DatabaseAccessLayer:
|
|||
|
||||
# Group Data
|
||||
self.groups = GroupDataAccessModel(pk_id, Group, GroupInDB)
|
||||
self.group_tokens = BaseAccessModel("token", GroupInviteToken, ReadInviteToken)
|
||||
self.meals = BaseAccessModel(pk_id, MealPlan, MealPlanOut)
|
||||
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
|
||||
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
from .cookbook import *
|
||||
from .group import *
|
||||
from .invite_tokens import *
|
||||
from .preferences import *
|
||||
from .shopping_list import *
|
||||
from .webhooks import *
|
||||
|
|
|
@ -3,6 +3,7 @@ import sqlalchemy.orm as orm
|
|||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import settings
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
|
@ -14,11 +15,13 @@ from .preferences import GroupPreferencesModel
|
|||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
|
||||
users = orm.relationship("User", back_populates="group")
|
||||
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
|
||||
|
||||
invite_tokens = orm.relationship(
|
||||
GroupInviteToken, back_populates="group", cascade="all, delete-orphan", uselist=True
|
||||
)
|
||||
preferences = orm.relationship(
|
||||
GroupPreferencesModel,
|
||||
back_populates="group",
|
||||
|
@ -27,13 +30,16 @@ class Group(SqlAlchemyBase, BaseMixins):
|
|||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Recipes
|
||||
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True)
|
||||
|
||||
# CRUD From Others
|
||||
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
|
||||
webhooks = orm.relationship(GroupWebhooksModel, uselist=True, cascade="all, delete-orphan")
|
||||
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
|
||||
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
|
||||
|
||||
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences"})
|
||||
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens"})
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
|
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_utils import auto_init
|
||||
from ..users import users_to_favorites
|
||||
from .api_extras import ApiExtras
|
||||
from .assets import RecipeAsset
|
||||
from .category import recipes2categories
|
||||
|
@ -22,6 +23,19 @@ from .tool import Tool
|
|||
|
||||
class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipes"
|
||||
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
|
||||
|
||||
slug = sa.Column(sa.String, index=True)
|
||||
|
||||
# ID Relationships
|
||||
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
|
||||
|
||||
user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
|
||||
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
|
||||
|
||||
favorited_by: list = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes")
|
||||
|
||||
# General Recipe Properties
|
||||
name = sa.Column(sa.String, nullable=False)
|
||||
description = sa.Column(sa.String)
|
||||
|
@ -57,7 +71,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
|
||||
|
||||
# Mealie Specific
|
||||
slug = sa.Column(sa.String, index=True, unique=True)
|
||||
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
|
||||
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
|
||||
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
|
||||
|
@ -69,10 +82,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
date_added = sa.Column(sa.Date, default=date.today)
|
||||
date_updated = sa.Column(sa.DateTime)
|
||||
|
||||
# Favorited By
|
||||
favorited_by_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"))
|
||||
favorited_by = orm.relationship("User", back_populates="favorite_recipes")
|
||||
|
||||
class Config:
|
||||
get_attr = "slug"
|
||||
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
from .user_to_favorite 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 mealie.core.config import settings
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models.group import Group
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from ..group import Group
|
||||
from .user_to_favorite import users_to_favorites
|
||||
|
||||
|
||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
|
@ -33,6 +34,8 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="users")
|
||||
|
||||
# Recipes
|
||||
|
||||
tokens: list[LongLiveToken] = orm.relationship(
|
||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
@ -41,7 +44,10 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
||||
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
|
||||
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
|
||||
|
||||
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -65,9 +71,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
self.password = password
|
||||
self.advanced = advanced
|
||||
|
||||
self.favorite_recipes = [
|
||||
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
|
||||
]
|
||||
self.favorite_recipes = []
|
||||
|
||||
if self.username is None:
|
||||
self.username = full_name
|
||||
|
@ -99,10 +103,6 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
if password:
|
||||
self.password = password
|
||||
|
||||
self.favorite_recipes = [
|
||||
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
|
||||
]
|
||||
|
||||
def update_password(self, password):
|
||||
self.password = password
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from . import admin_about, admin_log
|
||||
from . import admin_about, admin_group, admin_log
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
|
||||
router.include_router(admin_about.router, tags=["Admin: About"])
|
||||
router.include_router(admin_log.router, tags=["Admin: Log"])
|
||||
router.include_router(admin_group.router, tags=["Admin: Group"])
|
||||
|
|
|
@ -4,24 +4,21 @@ from sqlalchemy.orm.session import Session
|
|||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
|
||||
from mealie.routes.routers import AdminAPIRouter
|
||||
from mealie.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup
|
||||
from mealie.services.events import create_group_event
|
||||
|
||||
admin_router = AdminAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
|
||||
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: CRUD"])
|
||||
router = AdminAPIRouter(prefix="/groups")
|
||||
|
||||
|
||||
@admin_router.get("", response_model=list[GroupInDB])
|
||||
async def get_all_groups(
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
@router.get("", response_model=list[GroupInDB])
|
||||
async def get_all_groups(session: Session = Depends(generate_session)):
|
||||
""" Returns a list of all groups in the database """
|
||||
|
||||
return db.groups.get_all(session)
|
||||
|
||||
|
||||
@admin_router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
|
||||
async def create_group(
|
||||
background_tasks: BackgroundTasks,
|
||||
group_data: GroupBase,
|
||||
|
@ -37,17 +34,13 @@ async def create_group(
|
|||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@admin_router.put("/{id}")
|
||||
async def update_group_data(
|
||||
id: int,
|
||||
group_data: UpdateGroup,
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
@router.put("/{id}")
|
||||
async def update_group_data(id: int, group_data: UpdateGroup, session: Session = Depends(generate_session)):
|
||||
""" Updates a User Group """
|
||||
db.groups.update(session, id, group_data.dict())
|
||||
|
||||
|
||||
@admin_router.delete("/{id}")
|
||||
@router.delete("/{id}")
|
||||
async def delete_user_group(
|
||||
background_tasks: BackgroundTasks,
|
||||
id: int,
|
|
@ -6,11 +6,13 @@ from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, s
|
|||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import app_dirs
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.core.security import create_file_token
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.routers import AdminAPIRouter
|
||||
from mealie.schema.admin import BackupJob, ImportJob, Imports, LocalBackup
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.services.backups import imports
|
||||
from mealie.services.backups.exports import backup_all
|
||||
from mealie.services.events import create_backup_event
|
||||
|
@ -82,10 +84,12 @@ def import_database(
|
|||
file_name: str,
|
||||
import_data: ImportJob,
|
||||
session: Session = Depends(generate_session),
|
||||
user: PrivateUser = Depends(get_current_user),
|
||||
):
|
||||
""" Import a database backup file generated from Mealie. """
|
||||
|
||||
db_import = imports.import_database(
|
||||
user=user,
|
||||
session=session,
|
||||
archive=import_data.name,
|
||||
import_recipes=import_data.recipes,
|
||||
|
|
|
@ -3,7 +3,7 @@ from fastapi import APIRouter
|
|||
from mealie.services._base_http_service import RouterFactory
|
||||
from mealie.services.group_services import CookbookService, WebhookService
|
||||
|
||||
from . import categories, crud, self_service
|
||||
from . import categories, invitations, preferences, self_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
@ -13,5 +13,5 @@ router.include_router(self_service.user_router)
|
|||
router.include_router(cookbook_router)
|
||||
router.include_router(categories.user_router)
|
||||
router.include_router(webhook_router)
|
||||
router.include_router(crud.user_router)
|
||||
router.include_router(crud.admin_router)
|
||||
router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"])
|
||||
router.include_router(preferences.router, prefix="/groups/preferences", tags=["Group: Preferences"])
|
||||
|
|
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 mealie.routes.routers import UserAPIRouter
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
|
||||
from mealie.schema.user.user import GroupInDB
|
||||
from mealie.services.group_services.group_service import GroupSelfService
|
||||
|
||||
|
@ -13,15 +12,3 @@ async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSe
|
|||
""" Returns the Group Data for the Current User """
|
||||
|
||||
return g_service.item
|
||||
|
||||
|
||||
@user_router.put("/preferences", response_model=ReadGroupPreferences)
|
||||
def update_group_preferences(
|
||||
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
|
||||
):
|
||||
return g_service.update_preferences(new_pref).preferences
|
||||
|
||||
|
||||
@user_router.get("/preferences", response_model=ReadGroupPreferences)
|
||||
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
|
||||
return g_service.item.preferences
|
||||
|
|
|
@ -25,7 +25,7 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
|
|||
and should not hit the API in production"""
|
||||
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
|
||||
|
||||
if recipe_image:
|
||||
if recipe_image.exists():
|
||||
return FileResponse(recipe_image)
|
||||
else:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
|
|
@ -8,7 +8,9 @@ from sqlalchemy.orm.session import Session
|
|||
from mealie.core.config import app_dirs
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.routers import AdminAPIRouter
|
||||
from mealie.routes.users.crud import get_logged_in_user
|
||||
from mealie.schema.admin import MigrationFile, Migrations
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.services.migrations import migration
|
||||
|
||||
router = AdminAPIRouter(prefix="/api/migrations", tags=["Migration"])
|
||||
|
@ -36,10 +38,15 @@ def get_all_migration_options():
|
|||
|
||||
|
||||
@router.post("/{import_type}/{file_name}/import")
|
||||
def import_migration(import_type: migration.Migration, file_name: str, session: Session = Depends(generate_session)):
|
||||
def import_migration(
|
||||
import_type: migration.Migration,
|
||||
file_name: str,
|
||||
session: Session = Depends(generate_session),
|
||||
user: PrivateUser = Depends(get_logged_in_user),
|
||||
):
|
||||
""" Imports all the recipes in a given directory """
|
||||
file_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
|
||||
return migration.migrate(import_type, file_path, session)
|
||||
return migration.migrate(user, import_type, file_path, session)
|
||||
|
||||
|
||||
@router.delete("/{import_type}/{file_name}/delete", status_code=status.HTTP_200_OK)
|
||||
|
|
|
@ -8,7 +8,6 @@ router = APIRouter()
|
|||
|
||||
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
|
||||
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
|
||||
router.include_router(recipe_crud_routes.public_router, prefix=prefix, tags=["Recipe: CRUD"])
|
||||
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
|
||||
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||
router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"])
|
||||
|
|
|
@ -4,28 +4,10 @@ from sqlalchemy.orm.session import Session
|
|||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
from mealie.services.recipe.all_recipe_service import AllRecipesService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_recipe_summary(all_recipes_service: AllRecipesService.query = Depends()):
|
||||
"""
|
||||
Returns key the recipe summary data for recipes in the database. You can perform
|
||||
slice operations to set the skip/end amounts for recipes. All recipes are sorted by the added date.
|
||||
|
||||
**Query Parameters**
|
||||
- skip: The database entry to start at. (0 Indexed)
|
||||
- end: The number of entries to return.
|
||||
|
||||
skip=2, end=10 will return entries
|
||||
|
||||
"""
|
||||
|
||||
return all_recipes_service.get_recipes()
|
||||
|
||||
|
||||
@router.get("/summary/untagged", response_model=list[RecipeSummary])
|
||||
async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)):
|
||||
return db.recipes.count_untagged(session, count=count, override_schema=RecipeSummary)
|
||||
|
|
|
@ -2,7 +2,7 @@ import json
|
|||
import shutil
|
||||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import APIRouter, Depends, File
|
||||
from fastapi import Depends, File
|
||||
from fastapi.datastructures import UploadFile
|
||||
from scrape_schema_recipe import scrape_url
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
@ -14,26 +14,24 @@ from mealie.db.database import db
|
|||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.routers import UserAPIRouter
|
||||
from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes
|
||||
from mealie.schema.recipe.recipe import CreateRecipe
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, RecipeSummary
|
||||
from mealie.services.image.image import write_image
|
||||
from mealie.services.recipe.recipe_service import RecipeService
|
||||
from mealie.services.scraper.scraper import create_from_url
|
||||
|
||||
user_router = UserAPIRouter()
|
||||
public_router = APIRouter()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@public_router.get("/{slug}", response_model=Recipe)
|
||||
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
|
||||
""" Takes in a recipe slug, returns all data for a recipe """
|
||||
return recipe_service.item
|
||||
@user_router.get("", response_model=list[RecipeSummary])
|
||||
async def get_all(start=0, limit=None, service: RecipeService = Depends(RecipeService.private)):
|
||||
return service.get_all(start, limit)
|
||||
|
||||
|
||||
@user_router.post("", status_code=201, response_model=str)
|
||||
def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.private)) -> str:
|
||||
""" Takes in a JSON string and loads data into the database as a new entry"""
|
||||
return recipe_service.create_recipe(data).slug
|
||||
return recipe_service.create_one(data).slug
|
||||
|
||||
|
||||
@user_router.post("/create-url", status_code=201, response_model=str)
|
||||
|
@ -41,7 +39,7 @@ def parse_recipe_url(url: CreateRecipeByURL, recipe_service: RecipeService = Dep
|
|||
""" Takes in a URL and attempts to scrape data and load it into the database """
|
||||
|
||||
recipe = create_from_url(url.url)
|
||||
return recipe_service.create_recipe(recipe).slug
|
||||
return recipe_service.create_one(recipe).slug
|
||||
|
||||
|
||||
@user_router.post("/test-scrape-url")
|
||||
|
@ -80,7 +78,13 @@ async def create_recipe_from_zip(
|
|||
return recipe
|
||||
|
||||
|
||||
@public_router.get("/{slug}/zip")
|
||||
@user_router.get("/{slug}", response_model=Recipe)
|
||||
def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)):
|
||||
""" Takes in a recipe slug, returns all data for a recipe """
|
||||
return recipe_service.item
|
||||
|
||||
|
||||
@user_router.get("/{slug}/zip")
|
||||
async def get_recipe_as_zip(
|
||||
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
|
||||
):
|
||||
|
@ -102,17 +106,17 @@ async def get_recipe_as_zip(
|
|||
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||
""" Updates a recipe by existing slug and data. """
|
||||
|
||||
return recipe_service.update_recipe(data)
|
||||
return recipe_service.update_one(data)
|
||||
|
||||
|
||||
@user_router.patch("/{slug}")
|
||||
def patch_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||
""" Updates a recipe by existing slug and data. """
|
||||
|
||||
return recipe_service.patch_recipe(data)
|
||||
return recipe_service.patch_one(data)
|
||||
|
||||
|
||||
@user_router.delete("/{slug}")
|
||||
def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||
""" Deletes a recipe by slug """
|
||||
return recipe_service.delete_recipe()
|
||||
return recipe_service.delete_one()
|
||||
|
|
|
@ -18,27 +18,27 @@ async def get_all(
|
|||
|
||||
|
||||
@router.post("", response_model=IngredientFood, status_code=status.HTTP_201_CREATED)
|
||||
async def create_unit(unit: CreateIngredientFood, session: Session = Depends(generate_session)):
|
||||
async def create_food(unit: CreateIngredientFood, session: Session = Depends(generate_session)):
|
||||
""" Create unit in the Database """
|
||||
|
||||
return db.ingredient_foods.create(session, unit)
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_unit(id: str, session: Session = Depends(generate_session)):
|
||||
async def get_food(id: str, session: Session = Depends(generate_session)):
|
||||
""" Get unit from the Database """
|
||||
|
||||
return db.ingredient_foods.get(session, id)
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_unit(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)):
|
||||
async def update_food(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)):
|
||||
""" Update unit in the Database """
|
||||
|
||||
return db.ingredient_foods.update(session, id, unit)
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_unit(id: str, session: Session = Depends(generate_session)):
|
||||
async def delete_food(id: str, session: Session = Depends(generate_session)):
|
||||
""" Delete unit from the Database """
|
||||
return db.ingredient_foods.delete(session, id)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from . import api_tokens, crud, favorites, images, passwords, registration, sign_up
|
||||
from . import api_tokens, crud, favorites, images, passwords, registration
|
||||
|
||||
# Must be used because of the way FastAPI works with nested routes
|
||||
user_prefix = "/users"
|
||||
|
@ -9,9 +9,6 @@ router = APIRouter()
|
|||
|
||||
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
|
||||
|
||||
router.include_router(sign_up.admin_router, prefix=user_prefix, tags=["Users: Sign-Up"])
|
||||
router.include_router(sign_up.public_router, prefix=user_prefix, tags=["Users: Sign-Up"])
|
||||
|
||||
router.include_router(crud.user_router, prefix=user_prefix, tags=["Users: CRUD"])
|
||||
router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"])
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ router = APIRouter(prefix="/register")
|
|||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def reset_user_password(
|
||||
def register_new_user(
|
||||
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
|
||||
):
|
||||
return registration_service.register_user(data)
|
||||
|
|
|
@ -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):
|
||||
id: Optional[int]
|
||||
|
||||
user_id: int = 0
|
||||
group_id: int = 0
|
||||
|
||||
name: Optional[str]
|
||||
slug: str = ""
|
||||
image: Optional[Any]
|
||||
|
||||
description: Optional[str]
|
||||
description: Optional[str] = ""
|
||||
recipe_category: Optional[list[str]] = []
|
||||
tags: Optional[list[str]] = []
|
||||
rating: Optional[int]
|
||||
|
@ -112,32 +116,6 @@ class Recipe(RecipeSummary):
|
|||
"extras": {x.key_name: x.value for x in name_orm.extras},
|
||||
}
|
||||
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"name": "Chicken and Rice With Leeks and Salsa Verde",
|
||||
"description": "This one-skillet dinner gets deep oniony flavor from lots of leeks cooked down to jammy tenderness.",
|
||||
"image": "chicken-and-rice-with-leeks-and-salsa-verde.jpg",
|
||||
"recipe_yield": "4 Servings",
|
||||
"recipe_ingredient": [
|
||||
"1 1/2 lb. skinless, boneless chicken thighs (4-8 depending on size)",
|
||||
"Kosher salt, freshly ground pepper",
|
||||
"3 Tbsp. unsalted butter, divided",
|
||||
],
|
||||
"recipe_instructions": [
|
||||
{
|
||||
"text": "Season chicken with salt and pepper.",
|
||||
},
|
||||
],
|
||||
"slug": "chicken-and-rice-with-leeks-and-salsa-verde",
|
||||
"tags": ["favorite", "yummy!"],
|
||||
"recipe_category": ["Dinner", "Pasta"],
|
||||
"notes": [{"title": "Watch Out!", "text": "Prep the day before!"}],
|
||||
"org_url": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde",
|
||||
"rating": 3,
|
||||
"extras": {"message": "Don't forget to defrost the chicken!"},
|
||||
}
|
||||
}
|
||||
|
||||
@validator("slug", always=True, pre=True)
|
||||
def validate_slug(slug: str, values):
|
||||
if not values.get("name"):
|
||||
|
|
|
@ -81,6 +81,7 @@ class UserIn(UserBase):
|
|||
class UserOut(UserBase):
|
||||
id: int
|
||||
group: str
|
||||
group_id: int
|
||||
tokens: Optional[list[LongLiveTokenOut]]
|
||||
favorite_recipes: Optional[list[str]] = []
|
||||
|
||||
|
@ -112,6 +113,7 @@ class UserFavorites(UserBase):
|
|||
|
||||
class PrivateUser(UserOut):
|
||||
password: str
|
||||
group_id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
|
@ -27,6 +27,7 @@ from mealie.services.image import minify
|
|||
class ImportDatabase:
|
||||
def __init__(
|
||||
self,
|
||||
user: PrivateUser,
|
||||
session: Session,
|
||||
zip_archive: str,
|
||||
force_import: bool = False,
|
||||
|
@ -41,6 +42,7 @@ class ImportDatabase:
|
|||
Raises:
|
||||
Exception: If the zip file does not exists an exception raise.
|
||||
"""
|
||||
self.user = user
|
||||
self.session = session
|
||||
self.archive = app_dirs.BACKUP_DIR.joinpath(zip_archive)
|
||||
self.force_imports = force_import
|
||||
|
@ -66,6 +68,9 @@ class ImportDatabase:
|
|||
for recipe in recipes:
|
||||
recipe: Recipe
|
||||
|
||||
recipe.group_id = self.user.group_id
|
||||
recipe.user_id = self.user.id
|
||||
|
||||
import_status = self.import_model(
|
||||
db_table=db.recipes,
|
||||
model=recipe,
|
||||
|
@ -308,6 +313,7 @@ class ImportDatabase:
|
|||
|
||||
def import_database(
|
||||
session: Session,
|
||||
user: PrivateUser,
|
||||
archive,
|
||||
import_recipes=True,
|
||||
import_settings=True,
|
||||
|
@ -317,7 +323,7 @@ def import_database(
|
|||
force_import: bool = False,
|
||||
rebase: bool = False,
|
||||
):
|
||||
import_session = ImportDatabase(session, archive, force_import)
|
||||
import_session = ImportDatabase(user, session, archive, force_import)
|
||||
|
||||
recipe_report = []
|
||||
if import_recipes:
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from mealie.core.dependencies.grouped import UserDeps
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.schema.group.group_preferences import UpdateGroupPreferences
|
||||
from mealie.schema.group.invite_token import ReadInviteToken, SaveInviteToken
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase
|
||||
from mealie.schema.user.user import GroupInDB
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
|
@ -50,3 +53,10 @@ class GroupSelfService(UserHttpService[int, str]):
|
|||
self.db.group_preferences.update(self.session, self.group_id, new_preferences)
|
||||
|
||||
return self.populate_item()
|
||||
|
||||
def create_invite_token(self, uses: int = 1) -> None:
|
||||
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex)
|
||||
return self.db.group_tokens.create(self.session, token)
|
||||
|
||||
def get_invite_tokens(self) -> list[ReadInviteToken]:
|
||||
return self.db.group_tokens.multi_query(self.session, {"group_id": self.group_id})
|
||||
|
|
|
@ -10,6 +10,7 @@ from mealie.core import root_logger
|
|||
from mealie.db.database import db
|
||||
from mealie.schema.admin import MigrationImport
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.services.image import image
|
||||
from mealie.services.scraper import cleaner
|
||||
from mealie.utils.unzip import unpack_zip
|
||||
|
@ -34,6 +35,8 @@ class MigrationBase(BaseModel):
|
|||
session: Optional[Any]
|
||||
key_aliases: Optional[list[MigrationAlias]]
|
||||
|
||||
user: PrivateUser
|
||||
|
||||
@property
|
||||
def temp_dir(self) -> TemporaryDirectory:
|
||||
"""unpacks the migration_file into a temporary directory
|
||||
|
@ -162,6 +165,10 @@ class MigrationBase(BaseModel):
|
|||
"""
|
||||
|
||||
for recipe in validated_recipes:
|
||||
|
||||
recipe.user_id = self.user.id
|
||||
recipe.group_id = self.user.group_id
|
||||
|
||||
exception = ""
|
||||
status = False
|
||||
try:
|
||||
|
|
|
@ -5,6 +5,7 @@ from sqlalchemy.orm.session import Session
|
|||
|
||||
from mealie.core.config import app_dirs
|
||||
from mealie.schema.admin import MigrationImport
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.services.migrations import helpers
|
||||
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
||||
|
||||
|
@ -18,8 +19,8 @@ class ChowdownMigration(MigrationBase):
|
|||
]
|
||||
|
||||
|
||||
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||
cd_migration = ChowdownMigration(migration_file=zip_path, session=session)
|
||||
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||
cd_migration = ChowdownMigration(user=user, migration_file=zip_path, session=session)
|
||||
|
||||
with cd_migration.temp_dir as dir:
|
||||
chow_dir = next(Path(dir).iterdir())
|
||||
|
|
|
@ -19,7 +19,7 @@ class Migration(str, Enum):
|
|||
chowdown = "chowdown"
|
||||
|
||||
|
||||
def migrate(migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
|
||||
def migrate(user, migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
|
||||
"""The new entry point for accessing migrations within the 'migrations' service.
|
||||
Using the 'Migrations' enum class as a selector for migration_type to direct which function
|
||||
to call. All migrations will return a MigrationImport object that is built for displaying
|
||||
|
@ -37,10 +37,10 @@ def migrate(migration_type: str, file_path: Path, session: Session) -> list[Migr
|
|||
logger.info(f"Starting Migration from {migration_type}")
|
||||
|
||||
if migration_type == Migration.nextcloud.value:
|
||||
migration_imports = nextcloud.migrate(session, file_path)
|
||||
migration_imports = nextcloud.migrate(user, session, file_path)
|
||||
|
||||
elif migration_type == Migration.chowdown.value:
|
||||
migration_imports = chowdown.migrate(session, file_path)
|
||||
migration_imports = chowdown.migrate(user, session, file_path)
|
||||
|
||||
else:
|
||||
return []
|
||||
|
|
|
@ -6,6 +6,7 @@ from slugify import slugify
|
|||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.schema.admin import MigrationImport
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.services.migrations import helpers
|
||||
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
||||
|
||||
|
@ -42,9 +43,9 @@ class NextcloudMigration(MigrationBase):
|
|||
]
|
||||
|
||||
|
||||
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||
def migrate(user: PrivateUser, session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||
|
||||
nc_migration = NextcloudMigration(migration_file=zip_path, session=session)
|
||||
nc_migration = NextcloudMigration(user=user, migration_file=zip_path, session=session)
|
||||
|
||||
with nc_migration.temp_dir as dir:
|
||||
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)
|
||||
|
|
|
@ -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.root_logger import get_logger
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
|
||||
from mealie.services._base_http_service.http_services import PublicHttpService
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
from mealie.services.events import create_recipe_event
|
||||
from mealie.services.recipe.mixins import recipe_creation_factory
|
||||
|
||||
logger = get_logger(module=__name__)
|
||||
|
||||
|
||||
class RecipeService(PublicHttpService[str, Recipe]):
|
||||
class RecipeService(UserHttpService[str, Recipe]):
|
||||
"""
|
||||
Class Methods:
|
||||
`read_existing`: Reads an existing recipe from the database.
|
||||
|
@ -46,9 +47,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
|||
return self.item
|
||||
|
||||
# CRUD METHODS
|
||||
def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
||||
if isinstance(create_data, CreateRecipe):
|
||||
create_data = Recipe(name=create_data.name)
|
||||
def get_all(self, start=0, limit=None):
|
||||
return self.db.recipes.multi_query(
|
||||
self.session, {"group_id": self.user.group_id}, start=start, limit=limit, override_schema=RecipeSummary
|
||||
)
|
||||
|
||||
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
||||
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
|
||||
|
||||
try:
|
||||
self.item = self.db.recipes.create(self.session, create_data)
|
||||
|
@ -56,13 +61,13 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
|||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
|
||||
|
||||
self._create_event(
|
||||
"Recipe Created (URL)",
|
||||
"Recipe Created",
|
||||
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
|
||||
)
|
||||
|
||||
return self.item
|
||||
|
||||
def update_recipe(self, update_data: Recipe) -> Recipe:
|
||||
def update_one(self, update_data: Recipe) -> Recipe:
|
||||
original_slug = self.item.slug
|
||||
|
||||
try:
|
||||
|
@ -74,7 +79,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
|||
|
||||
return self.item
|
||||
|
||||
def patch_recipe(self, patch_data: Recipe) -> Recipe:
|
||||
def patch_one(self, patch_data: Recipe) -> Recipe:
|
||||
original_slug = self.item.slug
|
||||
|
||||
try:
|
||||
|
@ -88,16 +93,7 @@ class RecipeService(PublicHttpService[str, Recipe]):
|
|||
|
||||
return self.item
|
||||
|
||||
def delete_recipe(self) -> Recipe:
|
||||
"""removes a recipe from the database and purges the existing files from the filesystem.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 Bad Request
|
||||
|
||||
Returns:
|
||||
Recipe: The deleted recipe
|
||||
"""
|
||||
|
||||
def delete_one(self) -> Recipe:
|
||||
try:
|
||||
recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
|
||||
self._delete_assets()
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from fastapi import HTTPException, status
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.core.security import hash_password
|
||||
from mealie.schema.group.group_preferences import CreateGroupPreferences
|
||||
|
@ -20,13 +22,37 @@ class RegistrationService(PublicHttpService[int, str]):
|
|||
self.registration = registration
|
||||
|
||||
logger.info(f"Registering user {registration.username}")
|
||||
token_entry = None
|
||||
|
||||
if registration.group:
|
||||
group = self._create_new_group()
|
||||
else:
|
||||
group = self._existing_group_ref()
|
||||
group = self._register_new_group()
|
||||
|
||||
return self._create_new_user(group)
|
||||
elif registration.group_token and registration.group_token != "":
|
||||
|
||||
token_entry = self.db.group_tokens.get(self.session, registration.group_token)
|
||||
|
||||
print("Token Entry", token_entry)
|
||||
|
||||
if not token_entry:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"})
|
||||
|
||||
group = self.db.groups.get(self.session, token_entry.group_id)
|
||||
|
||||
else:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"})
|
||||
|
||||
user = self._create_new_user(group)
|
||||
|
||||
if token_entry and user:
|
||||
token_entry.uses_left = token_entry.uses_left - 1
|
||||
|
||||
if token_entry.uses_left == 0:
|
||||
self.db.group_tokens.delete(self.session, token_entry.token)
|
||||
|
||||
else:
|
||||
self.db.group_tokens.update(self.session, token_entry.token, token_entry)
|
||||
|
||||
return user
|
||||
|
||||
def _create_new_user(self, group: GroupInDB) -> PrivateUser:
|
||||
new_user = UserIn(
|
||||
|
@ -40,7 +66,7 @@ class RegistrationService(PublicHttpService[int, str]):
|
|||
|
||||
return self.db.users.create(self.session, new_user)
|
||||
|
||||
def _create_new_group(self) -> GroupInDB:
|
||||
def _register_new_group(self) -> GroupInDB:
|
||||
group_data = GroupBase(name=self.registration.group)
|
||||
|
||||
group_preferences = CreateGroupPreferences(
|
||||
|
@ -56,6 +82,3 @@ class RegistrationService(PublicHttpService[int, str]):
|
|||
)
|
||||
|
||||
return create_new_group(self.session, group_data, group_preferences)
|
||||
|
||||
def _existing_group_ref(self) -> GroupInDB:
|
||||
pass
|
||||
|
|
|
@ -11,6 +11,8 @@ from mealie.db.db_setup import SessionLocal, generate_session
|
|||
from mealie.db.init_db import main
|
||||
from tests.app_routes import AppRoutes
|
||||
from tests.test_config import TEST_DATA
|
||||
from tests.utils.factories import random_email, random_string, user_registration_factory
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases
|
||||
|
||||
main()
|
||||
|
@ -62,12 +64,46 @@ def admin_token(api_client: requests, api_routes: AppRoutes):
|
|||
return login(form_data, api_client, api_routes)
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
def g2_user(admin_token, api_client: requests, api_routes: AppRoutes):
|
||||
# Create the user
|
||||
create_data = {
|
||||
"fullName": random_string(),
|
||||
"username": random_string(),
|
||||
"email": random_email(),
|
||||
"password": "useruser",
|
||||
"group": "New Group",
|
||||
"admin": False,
|
||||
"tokens": [],
|
||||
}
|
||||
|
||||
response = api_client.post(api_routes.groups, json={"name": "New Group"}, headers=admin_token)
|
||||
response = api_client.post(api_routes.users, json=create_data, headers=admin_token)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
# Log in as this user
|
||||
form_data = {"username": create_data["email"], "password": "useruser"}
|
||||
|
||||
token = login(form_data, api_client, api_routes)
|
||||
|
||||
self_response = api_client.get(api_routes.users_self, headers=token)
|
||||
|
||||
assert self_response.status_code == 200
|
||||
|
||||
user_id = json.loads(self_response.text).get("id")
|
||||
group_id = json.loads(self_response.text).get("groupId")
|
||||
|
||||
return TestUser(user_id=user_id, group_id=group_id, token=token)
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
|
||||
# Create the user
|
||||
create_data = {
|
||||
"fullName": "User",
|
||||
"email": "user@email.com",
|
||||
"fullName": random_string(),
|
||||
"username": random_string(),
|
||||
"email": random_email(),
|
||||
"password": "useruser",
|
||||
"group": "Home",
|
||||
"admin": False,
|
||||
|
@ -79,7 +115,7 @@ def user_token(admin_token, api_client: requests, api_routes: AppRoutes):
|
|||
assert response.status_code == 201
|
||||
|
||||
# Log in as this user
|
||||
form_data = {"username": "user@email.com", "password": "useruser"}
|
||||
form_data = {"username": create_data["email"], "password": "useruser"}
|
||||
return login(form_data, api_client, api_routes)
|
||||
|
||||
|
||||
|
@ -96,3 +132,45 @@ def raw_recipe_no_image():
|
|||
@fixture(scope="session")
|
||||
def recipe_store():
|
||||
return get_recipe_test_cases()
|
||||
|
||||
|
||||
@fixture(scope="module")
|
||||
def unique_user(api_client: TestClient, api_routes: AppRoutes):
|
||||
registration = user_registration_factory()
|
||||
|
||||
response = api_client.post("/api/users/register", json=registration.dict(by_alias=True))
|
||||
assert response.status_code == 201
|
||||
|
||||
form_data = {"username": registration.username, "password": registration.password}
|
||||
|
||||
token = login(form_data, api_client, api_routes)
|
||||
|
||||
user_data = api_client.get(api_routes.users_self, headers=token).json()
|
||||
assert token is not None
|
||||
|
||||
try:
|
||||
yield TestUser(group_id=user_data.get("groupId"), user_id=user_data.get("id"), token=token)
|
||||
finally:
|
||||
# TODO: Delete User after test
|
||||
pass
|
||||
|
||||
|
||||
@fixture(scope="session")
|
||||
def admin_user(api_client: TestClient, api_routes: AppRoutes):
|
||||
|
||||
form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD}
|
||||
|
||||
token = login(form_data, api_client, api_routes)
|
||||
|
||||
user_data = api_client.get(api_routes.users_self, headers=token).json()
|
||||
assert token is not None
|
||||
|
||||
assert user_data.get("admin") is True
|
||||
assert user_data.get("groupId") is not None
|
||||
assert user_data.get("id") is not None
|
||||
|
||||
try:
|
||||
yield TestUser(group_id=user_data.get("groupId"), user_id=user_data.get("id"), token=token)
|
||||
finally:
|
||||
# TODO: Delete User after test
|
||||
pass
|
||||
|
|
|
@ -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,
|
||||
"settings": False, # ! 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 tests.utils.assertion_helpers import assert_ignore_keys
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
class Routes:
|
||||
|
@ -9,8 +10,8 @@ class Routes:
|
|||
preferences = "/api/groups/preferences"
|
||||
|
||||
|
||||
def test_get_preferences(api_client: TestClient, admin_token) -> None:
|
||||
response = api_client.get(Routes.preferences, headers=admin_token)
|
||||
def test_get_preferences(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
response = api_client.get(Routes.preferences, headers=unique_user.token)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
@ -21,8 +22,8 @@ def test_get_preferences(api_client: TestClient, admin_token) -> None:
|
|||
assert preferences["recipeShowNutrition"] is False
|
||||
|
||||
|
||||
def test_preferences_in_group(api_client: TestClient, admin_token) -> None:
|
||||
response = api_client.get(Routes.base, headers=admin_token)
|
||||
def test_preferences_in_group(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
response = api_client.get(Routes.base, headers=unique_user.token)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
@ -35,10 +36,10 @@ def test_preferences_in_group(api_client: TestClient, admin_token) -> None:
|
|||
assert group["preferences"]["recipeShowNutrition"] is False
|
||||
|
||||
|
||||
def test_update_preferences(api_client: TestClient, admin_token) -> None:
|
||||
def test_update_preferences(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
new_data = UpdateGroupPreferences(recipe_public=False, recipe_show_nutrition=True)
|
||||
|
||||
response = api_client.put(Routes.preferences, json=new_data.dict(), headers=admin_token)
|
||||
response = api_client.put(Routes.preferences, json=new_data.dict(), headers=unique_user.token)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.schema.user.registration import CreateUserRegistration
|
||||
from tests.utils.factories import user_registration_factory
|
||||
|
||||
|
||||
class Routes:
|
||||
|
@ -9,21 +9,13 @@ class Routes:
|
|||
|
||||
|
||||
def test_user_registration_new_group(api_client: TestClient):
|
||||
registration = CreateUserRegistration(
|
||||
group="New Group Name",
|
||||
email="email@email.com",
|
||||
username="fake-user-name",
|
||||
password="fake-password",
|
||||
password_confirm="fake-password",
|
||||
advanced=False,
|
||||
private=False,
|
||||
)
|
||||
registration = user_registration_factory()
|
||||
|
||||
response = api_client.post(Routes.base, json=registration.dict(by_alias=True))
|
||||
assert response.status_code == 201
|
||||
|
||||
# Login
|
||||
form_data = {"username": "email@email.com", "password": "fake-password"}
|
||||
form_data = {"username": registration.email, "password": registration.password}
|
||||
|
||||
response = api_client.post(Routes.auth_token, form_data)
|
||||
assert response.status_code == 200
|
||||
|
|
|
@ -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 tests.app_routes import AppRoutes
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
|
||||
|
||||
recipe_test_data = get_recipe_test_cases()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_create_by_url(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
|
||||
api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=user_token)
|
||||
def test_create_by_url(
|
||||
api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser
|
||||
):
|
||||
api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=unique_user.token)
|
||||
|
||||
response = api_client.post(api_routes.recipes_create_url, json={"url": recipe_data.url}, headers=user_token)
|
||||
response = api_client.post(api_routes.recipes_create_url, json={"url": recipe_data.url}, headers=unique_user.token)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert json.loads(response.text) == recipe_data.expected_slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
|
||||
def test_read_update(
|
||||
api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser
|
||||
):
|
||||
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
|
||||
response = api_client.get(recipe_url, headers=user_token)
|
||||
response = api_client.get(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
recipe = json.loads(response.text)
|
||||
|
@ -39,12 +44,12 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data:
|
|||
test_categories = ["one", "two", "three"]
|
||||
recipe["recipeCategory"] = test_categories
|
||||
|
||||
response = api_client.put(recipe_url, json=recipe, headers=user_token)
|
||||
response = api_client.put(recipe_url, json=recipe, headers=unique_user.token)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.text).get("slug") == recipe_data.expected_slug
|
||||
|
||||
response = api_client.get(recipe_url, headers=user_token)
|
||||
response = api_client.get(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe = json.loads(response.text)
|
||||
|
||||
|
@ -53,9 +58,9 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data:
|
|||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
|
||||
def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
|
||||
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
|
||||
response = api_client.get(recipe_url, headers=user_token)
|
||||
response = api_client.get(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
recipe = json.loads(response.text)
|
||||
|
@ -63,7 +68,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci
|
|||
new_slug = slugify(new_name)
|
||||
recipe["name"] = new_name
|
||||
|
||||
response = api_client.put(recipe_url, json=recipe, headers=user_token)
|
||||
response = api_client.put(recipe_url, json=recipe, headers=unique_user.token)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.text).get("slug") == new_slug
|
||||
|
@ -72,7 +77,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci
|
|||
|
||||
|
||||
@pytest.mark.parametrize("recipe_data", recipe_test_data)
|
||||
def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token):
|
||||
def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
|
||||
recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug)
|
||||
response = api_client.delete(recipe_url, headers=user_token)
|
||||
response = api_client.delete(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
|
@ -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()
|
||||
|
||||
"""
|
||||
|
||||
These tests are skipped by default and only really used when troubleshooting the parser
|
||||
directly. If you are working on improve the parser you can add test cases to the `get_recipe_test_cases` function
|
||||
and then use this test case by removing the `@pytest.mark.skip` and than testing your results.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
|
24
tests/utils/factories.py
Normal file
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