From 7e4da3e5a483346b03759534d8fd40e31759c0cb Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Fri, 6 May 2022 11:18:06 -0800 Subject: [PATCH] feat: improved registration signup flow (#1188) refactored signup flow for entire registration process. Utilized seed data option for optional seeding of Foods, Units, and Labels. Localized registration page. --- .../api/class-interfaces/user-registration.ts | 13 +- frontend/api/public-api.ts | 12 + frontend/api/public/validators.ts | 32 + frontend/components/global/AutoForm.vue | 3 +- frontend/composables/api/api-client.ts | 16 +- frontend/composables/use-password-field.ts | 22 - frontend/composables/use-passwords.ts | 94 +++ frontend/composables/use-validators.ts | 40 +- frontend/lang/messages/en-US.json | 35 +- frontend/pages/login.vue | 2 +- frontend/pages/register.vue | 187 ------ frontend/pages/register/index.ts | 2 + frontend/pages/register/register.vue | 603 ++++++++++++++++++ frontend/pages/register/states.ts | 66 ++ frontend/types/api-types/user.ts | 2 + mealie/repos/repository_generic.py | 2 +- mealie/routes/users/registration.py | 2 +- mealie/routes/validators/validators.py | 20 +- mealie/schema/_mealie/validators.py | 38 ++ mealie/schema/group/group_seeder.py | 43 +- mealie/schema/user/registration.py | 12 +- .../user_services/registration_service.py | 15 +- tests/integration_tests/test_validators.py | 111 +++- 23 files changed, 1056 insertions(+), 316 deletions(-) create mode 100644 frontend/api/public-api.ts create mode 100644 frontend/api/public/validators.ts delete mode 100644 frontend/composables/use-password-field.ts create mode 100644 frontend/composables/use-passwords.ts delete mode 100644 frontend/pages/register.vue create mode 100644 frontend/pages/register/index.ts create mode 100644 frontend/pages/register/register.vue create mode 100644 frontend/pages/register/states.ts create mode 100644 mealie/schema/_mealie/validators.py diff --git a/frontend/api/class-interfaces/user-registration.ts b/frontend/api/class-interfaces/user-registration.ts index 42df307b1c1b..dfc54c7b9a3e 100644 --- a/frontend/api/class-interfaces/user-registration.ts +++ b/frontend/api/class-interfaces/user-registration.ts @@ -1,14 +1,5 @@ import { BaseAPI } from "../_base"; - -export interface RegisterPayload { - group: string; - groupToken: string; - email: string; - password: string; - passwordConfirm: string; - advanced: boolean; - private: boolean; -} +import { CreateUserRegistration } from "~/types/api-types/user"; const prefix = "/api"; @@ -19,7 +10,7 @@ const routes = { export class RegisterAPI extends BaseAPI { /** Returns a list of avaiable .zip files for import into Mealie. */ - async register(payload: RegisterPayload) { + async register(payload: CreateUserRegistration) { return await this.requests.post(routes.register, payload); } } diff --git a/frontend/api/public-api.ts b/frontend/api/public-api.ts new file mode 100644 index 000000000000..d6f691db91f2 --- /dev/null +++ b/frontend/api/public-api.ts @@ -0,0 +1,12 @@ +import { ValidatorsApi } from "./public/validators"; +import { ApiRequestInstance } from "~/types/api"; + +export class PublicApi { + public validators: ValidatorsApi; + + constructor(requests: ApiRequestInstance) { + this.validators = new ValidatorsApi(requests); + + Object.freeze(this); + } +} diff --git a/frontend/api/public/validators.ts b/frontend/api/public/validators.ts new file mode 100644 index 000000000000..d20fe752e062 --- /dev/null +++ b/frontend/api/public/validators.ts @@ -0,0 +1,32 @@ +import { BaseAPI } from "../_base"; + +export type Validation = { + valid: boolean; +}; + +const prefix = "/api"; + +const routes = { + group: (name: string) => `${prefix}/validators/group?name=${name}`, + user: (name: string) => `${prefix}/validators/user/name?name=${name}`, + email: (name: string) => `${prefix}/validators/user/email?email=${name}`, + recipe: (groupId: string, name: string) => `${prefix}/validators/group/recipe?group_id=${groupId}?name=${name}`, +}; + +export class ValidatorsApi extends BaseAPI { + async group(name: string) { + return await this.requests.get(routes.group(name)); + } + + async username(name: string) { + return await this.requests.get(routes.user(name)); + } + + async email(email: string) { + return await this.requests.get(routes.email(email)); + } + + async recipe(groupId: string, name: string) { + return await this.requests.get(routes.recipe(groupId, name)); + } +} diff --git a/frontend/components/global/AutoForm.vue b/frontend/components/global/AutoForm.vue index 788d4ff02bc0..d0d396a08ffe 100644 --- a/frontend/components/global/AutoForm.vue +++ b/frontend/components/global/AutoForm.vue @@ -183,9 +183,10 @@ export default defineComponent({ return []; } - const list = [] as ((v: string) => (boolean | string))[]; + const list = [] as ((v: string) => boolean | string)[]; keys.forEach((key) => { if (key in validators) { + // @ts-ignore TODO: fix this list.push(validators[key]); } }); diff --git a/frontend/composables/api/api-client.ts b/frontend/composables/api/api-client.ts index bad527ad741a..16f185d69897 100644 --- a/frontend/composables/api/api-client.ts +++ b/frontend/composables/api/api-client.ts @@ -3,9 +3,14 @@ import { useContext } from "@nuxtjs/composition-api"; import { NuxtAxiosInstance } from "@nuxtjs/axios"; import { AdminAPI, Api } from "~/api"; import { ApiRequestInstance, RequestResponse } from "~/types/api"; +import { PublicApi } from "~/api/public-api"; const request = { - async safe(funcCall: (url: string, data: U) => Promise>, url: string, data: U): Promise> { + async safe( + funcCall: (url: string, data: U) => Promise>, + url: string, + data: U + ): Promise> { let error = null; const response = await funcCall(url, data).catch(function (e) { console.log(e); @@ -66,6 +71,13 @@ export const useUserApi = function (): Api { $axios.setHeader("Accept-Language", i18n.locale); const requests = getRequests($axios); - return new Api(requests); }; + +export const usePublicApi = function (): PublicApi { + const { $axios, i18n } = useContext(); + $axios.setHeader("Accept-Language", i18n.locale); + + const requests = getRequests($axios); + return new PublicApi(requests); +}; diff --git a/frontend/composables/use-password-field.ts b/frontend/composables/use-password-field.ts deleted file mode 100644 index de89b22c7b34..000000000000 --- a/frontend/composables/use-password-field.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { computed, ref, useContext } from "@nuxtjs/composition-api"; - -export function usePasswordField() { - const show = ref(false); - - const { $globals } = useContext(); - - const passwordIcon = computed(() => { - return show.value ? $globals.icons.eyeOff : $globals.icons.eye; - }); - const inputType = computed(() => (show.value ? "text" : "password")); - - const togglePasswordShow = () => { - show.value = !show.value; - }; - - return { - inputType, - togglePasswordShow, - passwordIcon, - }; -} diff --git a/frontend/composables/use-passwords.ts b/frontend/composables/use-passwords.ts new file mode 100644 index 000000000000..49bcfaa15b6d --- /dev/null +++ b/frontend/composables/use-passwords.ts @@ -0,0 +1,94 @@ +import { computed, Ref, ref, useContext } from "@nuxtjs/composition-api"; + +export function usePasswordField() { + const show = ref(false); + + const { $globals } = useContext(); + + const passwordIcon = computed(() => { + return show.value ? $globals.icons.eyeOff : $globals.icons.eye; + }); + const inputType = computed(() => (show.value ? "text" : "password")); + + const togglePasswordShow = () => { + show.value = !show.value; + }; + + return { + inputType, + togglePasswordShow, + passwordIcon, + }; +} + +function scorePassword(pass: string): number { + let score = 0; + if (!pass) return score; + + const flaggedWords = ["password", "mealie", "admin", "qwerty", "login"]; + + if (pass.length < 6) return score; + + // Check for flagged words + for (const word of flaggedWords) { + if (pass.toLowerCase().includes(word)) { + score -= 100; + } + } + + // award every unique letter until 5 repetitions + const letters: { [key: string]: number } = {}; + + for (let i = 0; i < pass.length; i++) { + letters[pass[i]] = (letters[pass[i]] || 0) + 1; + score += 5.0 / letters[pass[i]]; + } + + // bonus points for mixing it up + const variations: { [key: string]: boolean } = { + digits: /\d/.test(pass), + lower: /[a-z]/.test(pass), + upper: /[A-Z]/.test(pass), + nonWords: /\W/.test(pass), + }; + + let variationCount = 0; + for (const check in variations) { + variationCount += variations[check] === true ? 1 : 0; + } + score += (variationCount - 1) * 10; + + return score; +} + +export const usePasswordStrength = (password: Ref) => { + const score = computed(() => { + return scorePassword(password.value); + }); + + const strength = computed(() => { + if (score.value < 50) { + return "Weak"; + } else if (score.value < 80) { + return "Good"; + } else if (score.value < 100) { + return "Strong"; + } else { + return "Very Strong"; + } + }); + + const color = computed(() => { + if (score.value < 50) { + return "error"; + } else if (score.value < 80) { + return "warning"; + } else if (score.value < 100) { + return "info"; + } else { + return "success"; + } + }); + + return { score, strength, color }; +}; diff --git a/frontend/composables/use-validators.ts b/frontend/composables/use-validators.ts index 38bc1ce253e6..bfdc705d4b46 100644 --- a/frontend/composables/use-validators.ts +++ b/frontend/composables/use-validators.ts @@ -1,14 +1,46 @@ +import { ref, Ref } from "@nuxtjs/composition-api"; +import { RequestResponse } from "~/types/api"; +import { Validation } from "~/api/public/validators"; + const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; -export const validators: {[key: string]: (v: string) => boolean | string} = { +export const validators = { required: (v: string) => !!v || "This Field is Required", email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid", whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed", url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL", - // TODO These appear to be unused? - // minLength: (min: number) => (v: string) => !v || v.length >= min || `Must Be At Least ${min} Characters`, - // maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} Characters`, + minLength: (min: number) => (v: string) => !v || v.length >= min || `Must Be At Least ${min} Characters`, + maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} Characters`, +}; + +/** + * useAsyncValidator us a factory function that returns an async function that + * when called will validate the input against the backend database and set the + * error messages when applicable to the ref. + */ +export const useAsyncValidator = ( + value: Ref, + validatorFunc: (v: string) => Promise>, + validatorMessage: string, + errorMessages: Ref +) => { + const valid = ref(false); + + const validate = async () => { + errorMessages.value = []; + const { data } = await validatorFunc(value.value); + + if (!data?.valid) { + valid.value = false; + errorMessages.value.push(validatorMessage); + return; + } + + valid.value = true; + }; + + return { validate, valid }; }; diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 03644c5db131..704035b88ecd 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -132,7 +132,9 @@ "wednesday": "Wednesday", "yes": "Yes", "foods": "Foods", - "units": "Units" + "units": "Units", + "back": "Back", + "next": "Next" }, "group": { "are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete {groupName}?", @@ -152,7 +154,11 @@ "manage-groups": "Manage Groups", "user-group": "User Group", "user-group-created": "User Group Created", - "user-group-creation-failed": "User Group Creation Failed" + "user-group-creation-failed": "User Group Creation Failed", + "settings": { + "keep-my-recipes-private": "Keep My Recipes Private", + "keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later." + } }, "meal-plan": { "create-a-new-meal-plan": "Create a New Meal Plan", @@ -281,9 +287,7 @@ "sugar-content": "Sugar", "title": "Title", "total-time": "Total Time", - "unable-to-delete-recipe": "Unable to Delete Recipe" - }, - "reicpe": { + "unable-to-delete-recipe": "Unable to Delete Recipe", "no-recipe": "No Recipe" }, "search": { @@ -473,6 +477,7 @@ "password-reset-failed": "Password reset failed", "password-updated": "Password updated", "password": "Password", + "password-strength": "Password is {strength}", "register": "Register", "reset-password": "Reset Password", "sign-in": "Sign in", @@ -496,7 +501,9 @@ "webhook-time": "Webhook Time", "webhooks-enabled": "Webhooks Enabled", "you-are-not-allowed-to-create-a-user": "You are not allowed to create a user", - "you-are-not-allowed-to-delete-this-user": "You are not allowed to delete this user" + "you-are-not-allowed-to-delete-this-user": "You are not allowed to delete this user", + "enable-advanced-content": "Enable Advanced Content", + "enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later" }, "language-dialog": { "translated": "translated", @@ -513,5 +520,21 @@ "seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually." } + }, + "user-registration": { + "user-registration": "User Registration", + "join-a-group": "Join a Group", + "create-a-new-group": "Create a New Group", + "provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.", + "group-details": "Group Details", + "group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!", + "use-seed-data": "Use Seed Data", + "use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes.", + "account-details": "Account Details" + }, + "validation": { + "group-name-is-taken": "Group name is taken", + "username-is-taken": "Username is taken", + "email-is-taken": "Email is taken" } } diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index fc9760524d4b..159cd248e307 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -112,7 +112,7 @@ import { defineComponent, ref, useContext, computed, reactive } from "@nuxtjs/composition-api"; import { useDark } from "@vueuse/core"; import { useAppInfo } from "~/composables/api"; -import { usePasswordField } from "~/composables/use-password-field"; +import { usePasswordField } from "~/composables/use-passwords"; import { alert } from "~/composables/use-toast"; import { useToggleDarkMode } from "~/composables/use-utils"; export default defineComponent({ diff --git a/frontend/pages/register.vue b/frontend/pages/register.vue deleted file mode 100644 index 57a776547741..000000000000 --- a/frontend/pages/register.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - diff --git a/frontend/pages/register/index.ts b/frontend/pages/register/index.ts new file mode 100644 index 000000000000..1255b7516c29 --- /dev/null +++ b/frontend/pages/register/index.ts @@ -0,0 +1,2 @@ +import Register from "./register.vue"; +export default Register; diff --git a/frontend/pages/register/register.vue b/frontend/pages/register/register.vue new file mode 100644 index 000000000000..28baecdb7205 --- /dev/null +++ b/frontend/pages/register/register.vue @@ -0,0 +1,603 @@ + + + + + diff --git a/frontend/pages/register/states.ts b/frontend/pages/register/states.ts new file mode 100644 index 000000000000..5073c5fe57fd --- /dev/null +++ b/frontend/pages/register/states.ts @@ -0,0 +1,66 @@ +import { reactive } from "@nuxtjs/composition-api"; + +export enum States { + Initial, + ProvideToken, + ProvideGroupDetails, + ProvideCredentials, + ProvideAccountDetails, + SelectGroupOptions, + Confirmation, +} + +export enum RegistrationType { + Unknown, + JoinGroup, + CreateGroup, + InitialGroup, +} + +interface Context { + state: States; + type: RegistrationType; +} + +interface RegistrationContext { + ctx: Context; + setState(state: States): void; + setType(type: RegistrationType): void; + back(): void; +} + +export function useRegistration(): RegistrationContext { + const context = reactive({ + state: States.Initial, + type: RegistrationType.Unknown, + history: [ + { + state: States.Initial, + }, + ], + }); + + function saveHistory() { + context.history.push({ + state: context.state, + }); + } + + const back = () => { + const last = context.history.pop(); + if (last) { + context.state = last.state; + } + }; + + const setState = (state: States) => { + saveHistory(); + context.state = state; + }; + + const setType = (t: RegistrationType) => { + context.type = t; + }; + + return { ctx: context, setType, setState, back }; +} diff --git a/frontend/types/api-types/user.ts b/frontend/types/api-types/user.ts index e29739814219..4ac866de03e7 100644 --- a/frontend/types/api-types/user.ts +++ b/frontend/types/api-types/user.ts @@ -28,6 +28,8 @@ export interface CreateUserRegistration { passwordConfirm: string; advanced?: boolean; private?: boolean; + seedData?: boolean; + locale?: string; } export interface ForgotPassword { email: string; diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 4fc48155391e..a7076f56b74d 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -159,7 +159,7 @@ class RepositoryGeneric(Generic[T, D]): if any_case: search_attr = getattr(self.sql_model, key) - q = q.filter(func.lower(search_attr) == key.lower()).filter_by(**self._filter_builder()) + q = q.filter(func.lower(search_attr) == str(value).lower()).filter_by(**self._filter_builder()) else: q = self.session.query(self.sql_model).filter_by(**self._filter_builder(**{key: value})) diff --git a/mealie/routes/users/registration.py b/mealie/routes/users/registration.py index eb036d3857ef..227b025c9697 100644 --- a/mealie/routes/users/registration.py +++ b/mealie/routes/users/registration.py @@ -22,5 +22,5 @@ class RegistrationController(BasePublicController): status_code=status.HTTP_403_FORBIDDEN, detail=ErrorResponse.respond("User Registration is Disabled") ) - registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session)) + registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session), self.deps.t) return registration_service.register_user(data) diff --git a/mealie/routes/validators/validators.py b/mealie/routes/validators/validators.py index c6a31070c4dd..6ef0cf17eb68 100644 --- a/mealie/routes/validators/validators.py +++ b/mealie/routes/validators/validators.py @@ -1,6 +1,7 @@ from uuid import UUID from fastapi import APIRouter, Depends +from slugify import slugify from sqlalchemy.orm.session import Session from mealie.db.db_setup import generate_session @@ -10,15 +11,23 @@ from mealie.schema.response import ValidationResponse router = APIRouter() -@router.get("/user/{name}", response_model=ValidationResponse) +@router.get("/user/name", response_model=ValidationResponse) def validate_user(name: str, session: Session = Depends(generate_session)): """Checks if a user with the given name exists""" db = get_repositories(session) - existing_element = db.users.get_by_username(name) + existing_element = db.users.get_one(name, "username", any_case=True) return ValidationResponse(valid=existing_element is None) -@router.get("/group/{name}", response_model=ValidationResponse) +@router.get("/user/email", response_model=ValidationResponse) +def validate_user_email(email: str, session: Session = Depends(generate_session)): + """Checks if a user with the given name exists""" + db = get_repositories(session) + existing_element = db.users.get_one(email, "email", any_case=True) + return ValidationResponse(valid=existing_element is None) + + +@router.get("/group", response_model=ValidationResponse) def validate_group(name: str, session: Session = Depends(generate_session)): """Checks if a group with the given name exists""" db = get_repositories(session) @@ -26,9 +35,10 @@ def validate_group(name: str, session: Session = Depends(generate_session)): return ValidationResponse(valid=existing_element is None) -@router.get("/recipe/{group_id}/{slug}", response_model=ValidationResponse) -def validate_recipe(group_id: UUID, slug: str, session: Session = Depends(generate_session)): +@router.get("/recipe", response_model=ValidationResponse) +def validate_recipe(group_id: UUID, name: str, session: Session = Depends(generate_session)): """Checks if a group with the given slug exists""" db = get_repositories(session) + slug = slugify(name) existing_element = db.recipes.get_by_slug(group_id, slug) return ValidationResponse(valid=existing_element is None) diff --git a/mealie/schema/_mealie/validators.py b/mealie/schema/_mealie/validators.py new file mode 100644 index 000000000000..71be2972e678 --- /dev/null +++ b/mealie/schema/_mealie/validators.py @@ -0,0 +1,38 @@ +def validate_locale(locale: str) -> bool: + valid = { + "el-GR", + "it-IT", + "ko-KR", + "es-ES", + "ja-JP", + "zh-CN", + "tr-TR", + "ar-SA", + "hu-HU", + "pt-PT", + "no-NO", + "sv-SE", + "ro-RO", + "sk-SK", + "uk-UA", + "fr-CA", + "pl-PL", + "da-DK", + "pt-BR", + "de-DE", + "ca-ES", + "sr-SP", + "cs-CZ", + "fr-FR", + "zh-TW", + "af-ZA", + "ru-RU", + "he-IL", + "nl-NL", + "en-US", + "en-GB", + "fi-FI", + "vi-VN", + } + + return locale in valid diff --git a/mealie/schema/group/group_seeder.py b/mealie/schema/group/group_seeder.py index a4ad15fe49cb..1514b4dfe234 100644 --- a/mealie/schema/group/group_seeder.py +++ b/mealie/schema/group/group_seeder.py @@ -1,46 +1,7 @@ from pydantic import validator from mealie.schema._mealie.mealie_model import MealieModel - - -def validate_locale(locale: str) -> bool: - valid = { - "el-GR", - "it-IT", - "ko-KR", - "es-ES", - "ja-JP", - "zh-CN", - "tr-TR", - "ar-SA", - "hu-HU", - "pt-PT", - "no-NO", - "sv-SE", - "ro-RO", - "sk-SK", - "uk-UA", - "fr-CA", - "pl-PL", - "da-DK", - "pt-BR", - "de-DE", - "ca-ES", - "sr-SP", - "cs-CZ", - "fr-FR", - "zh-TW", - "af-ZA", - "ru-RU", - "he-IL", - "nl-NL", - "en-US", - "en-GB", - "fi-FI", - "vi-VN", - } - - return locale in valid +from mealie.schema._mealie.validators import validate_locale class SeederConfig(MealieModel): @@ -49,5 +10,5 @@ class SeederConfig(MealieModel): @validator("locale") def valid_locale(cls, v, values, **kwargs): if not validate_locale(v): - raise ValueError("passwords do not match") + raise ValueError("invalid locale") return v diff --git a/mealie/schema/user/registration.py b/mealie/schema/user/registration.py index 79926e418d5a..6eadda5648e3 100644 --- a/mealie/schema/user/registration.py +++ b/mealie/schema/user/registration.py @@ -2,6 +2,7 @@ from pydantic import validator from pydantic.types import NoneStr, constr from mealie.schema._mealie import MealieModel +from mealie.schema._mealie.validators import validate_locale class CreateUserRegistration(MealieModel): @@ -14,6 +15,15 @@ class CreateUserRegistration(MealieModel): advanced: bool = False private: bool = False + seed_data: bool = False + locale: str = "en-US" + + @validator("locale") + def valid_locale(cls, v, values, **kwargs): + if not validate_locale(v): + raise ValueError("invalid locale") + return v + @validator("password_confirm") @classmethod def passwords_match(cls, value, values): @@ -24,7 +34,7 @@ class CreateUserRegistration(MealieModel): @validator("group_token", always=True) @classmethod def group_or_token(cls, value, values): - if bool(value) is False and bool(values["group"]) is False: + if not bool(value) and not bool(values["group"]): raise ValueError("group or group_token must be provided") return value diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index ee97d337b3ce..01422489523e 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -4,22 +4,23 @@ from uuid import uuid4 from fastapi import HTTPException, status from mealie.core.security import hash_password -from mealie.lang import local_provider +from mealie.lang.providers import Translator from mealie.repos.repository_factory import AllRepositories from mealie.schema.group.group_preferences import CreateGroupPreferences from mealie.schema.user.registration import CreateUserRegistration from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn from mealie.services.group_services.group_service import GroupService +from mealie.services.seeder.seeder_service import SeederService class RegistrationService: logger: Logger repos: AllRepositories - def __init__(self, logger: Logger, db: AllRepositories): + def __init__(self, logger: Logger, db: AllRepositories, t: Translator): self.logger = logger self.repos = db - self.t = local_provider() + self.t = t def _create_new_user(self, group: GroupInDB, new_group: bool) -> PrivateUser: new_user = UserIn( @@ -79,6 +80,14 @@ class RegistrationService: user = self._create_new_user(group, new_group) + if new_group and registration.seed_data: + + seeder_service = SeederService(self.repos, user, group) + + seeder_service.seed_foods(registration.locale) + seeder_service.seed_labels(registration.locale) + seeder_service.seed_units(registration.locale) + if token_entry and user: token_entry.uses_left = token_entry.uses_left - 1 diff --git a/tests/integration_tests/test_validators.py b/tests/integration_tests/test_validators.py index f5c708e30ee0..5b7c45fbd0d8 100644 --- a/tests/integration_tests/test_validators.py +++ b/tests/integration_tests/test_validators.py @@ -1,46 +1,97 @@ +from dataclasses import dataclass +from uuid import UUID + from fastapi.testclient import TestClient -from mealie.db.db_setup import create_session +from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe.recipe import Recipe +from tests.utils import random_string from tests.utils.fixture_schemas import TestUser class Routes: - user = "/api/validators/user" - recipe = "/api/validators/recipe" + base = "/api/validators" + + @staticmethod + def username(username: str): + return f"{Routes.base}/user/name?name={username}" + + @staticmethod + def email(email: str): + return f"{Routes.base}/user/email?email={email}" + + @staticmethod + def group(group_name: str): + return f"{Routes.base}/group?name={group_name}" + + @staticmethod + def recipe(group_id, name) -> str: + return f"{Routes.base}/recipe?group_id={group_id}&name={name}" -def test_validators_user(api_client: TestClient, unique_user: TestUser): - session = create_session() +@dataclass(slots=True) +class SimpleCase: + value: str + is_valid: bool - # Test existing user - response = api_client.get(Routes.user + f"/{unique_user.username}") - assert response.status_code == 200 - response_data = response.json() - assert not response_data["valid"] - # Test non-existing user - response = api_client.get(Routes.user + f"/{unique_user.username}2") - assert response.status_code == 200 - response_data = response.json() - assert response_data["valid"] +def test_validators_username(api_client: TestClient, unique_user: TestUser): + users = [ + SimpleCase(value=unique_user.username, is_valid=False), + SimpleCase(value=random_string(), is_valid=True), + ] - session.close() + for user in users: + response = api_client.get(Routes.username(user.value)) + assert response.status_code == 200 + response_data = response.json() + assert response_data["valid"] == user.is_valid + + +def test_validators_email(api_client: TestClient, unique_user: TestUser): + emails = [ + SimpleCase(value=unique_user.email, is_valid=False), + SimpleCase(value=f"{random_string()}@email.com", is_valid=True), + ] + + for user in emails: + response = api_client.get(Routes.email(user.value)) + assert response.status_code == 200 + response_data = response.json() + assert response_data["valid"] == user.is_valid + + +def test_validators_group_name(api_client: TestClient, unique_user: TestUser, database: AllRepositories): + group = database.groups.get_one(unique_user.group_id) + + groups = [ + SimpleCase(value=group.name, is_valid=False), + SimpleCase(value=random_string(), is_valid=True), + ] + + for user in groups: + response = api_client.get(Routes.group(user.value)) + assert response.status_code == 200 + response_data = response.json() + assert response_data["valid"] == user.is_valid + + +@dataclass(slots=True) +class RecipeValidators: + name: str + group: UUID | str + is_valid: bool def test_validators_recipe(api_client: TestClient, random_recipe: Recipe): - session = create_session() + recipes = [ + RecipeValidators(name=random_recipe.name, group=random_recipe.group_id, is_valid=False), + RecipeValidators(name=random_string(), group=random_recipe.group_id, is_valid=True), + RecipeValidators(name=random_string(), group=random_recipe.group_id, is_valid=True), + ] - # Test existing user - response = api_client.get(Routes.recipe + f"/{random_recipe.group_id}/{random_recipe.slug}") - assert response.status_code == 200 - response_data = response.json() - assert not response_data["valid"] - - # Test non-existing user - response = api_client.get(Routes.recipe + f"/{random_recipe.group_id}/{random_recipe.slug}-test") - assert response.status_code == 200 - response_data = response.json() - assert response_data["valid"] - - session.close() + for recipe in recipes: + response = api_client.get(Routes.recipe(recipe.group, recipe.name)) + assert response.status_code == 200 + response_data = response.json() + assert response_data["valid"] == recipe.is_valid