mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-23 10:06:47 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65c35adc9d | ||
|
|
83b4846f0c | ||
|
|
6bc7ada20a | ||
|
|
8ce6f9038a | ||
|
|
e3c6d4c66c | ||
|
|
381a698220 | ||
|
|
c866557d58 | ||
|
|
bb5da2cb54 | ||
|
|
0fed5f54f6 | ||
|
|
f4bde93960 | ||
|
|
62300deea0 | ||
|
|
87f4b23711 | ||
|
|
8983745106 | ||
|
|
8872fd52cd | ||
|
|
b81b97d934 | ||
|
|
f798fafb3e | ||
|
|
dbbbe06a23 | ||
|
|
4b9eb5077a | ||
|
|
ff6db2374d | ||
|
|
3e69ea94d5 | ||
|
|
2e114cfa69 | ||
|
|
eb34ef0156 | ||
|
|
446755f678 | ||
|
|
08fe2d32b0 | ||
|
|
fb653ee2f6 | ||
|
|
a326a8c717 | ||
|
|
6e7cb5fb86 | ||
|
|
9289bd8e05 | ||
|
|
985b5634b7 | ||
|
|
2b2bc041bd | ||
|
|
6e16d4cc91 |
26
.github/workflows/scheduled-checks.yml
vendored
26
.github/workflows/scheduled-checks.yml
vendored
@@ -15,8 +15,30 @@ jobs:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update pre-commit Hooks
|
||||
uses: vrslev/pre-commit-autoupdate@v1.0.0
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Set PY
|
||||
shell: bash
|
||||
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pre-commit
|
||||
~/.cache/pip
|
||||
key: pre-commit-${{ env.PY }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
||||
- name: Install pre-commit
|
||||
shell: bash
|
||||
run: pip install -U pre-commit
|
||||
|
||||
- name: Run `pre-commit autoupdate`
|
||||
shell: bash
|
||||
run: pre-commit autoupdate --color=always
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.7.1
|
||||
rev: v0.7.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
|
||||
### General
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ----------------------------- | :-------------------: | --------------------------------------------------------------------------------------------------------- |
|
||||
| PUID | 911 | UserID permissions between host OS and container |
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||
| Variables | Default | Description |
|
||||
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
|
||||
| PUID | 911 | UserID permissions between host OS and container |
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||
|
||||
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
|
||||
|
||||
@@ -57,10 +57,19 @@
|
||||
|
||||
Changing the webworker settings may cause unforeseen memory leak issues with Mealie. It's best to leave these at the defaults unless you begin to experience issues with multiple users. Exercise caution when changing these settings
|
||||
|
||||
| Variables | Default | Description |
|
||||
| --------------- | :-----: | ----------------------------------------------------------------------------- |
|
||||
| Variables | Default | Description |
|
||||
| --------------- | :-----: | -------------------------------------------------------------------------------- |
|
||||
| UVICORN_WORKERS | 1 | Sets the number of workers for the web server. [More info here][unicorn_workers] |
|
||||
|
||||
### TLS
|
||||
|
||||
Use this only when mealie is run without a webserver or reverse proxy.
|
||||
|
||||
| Variables | Default | Description |
|
||||
| -------------------- | :-----: | ------------------------ |
|
||||
| TLS_CERTIFICATE_PATH | None | File path to Certificate |
|
||||
| TLS_PRIVATE_KEY_PATH | None | File path to private key |
|
||||
|
||||
### LDAP
|
||||
|
||||
| Variables | Default | Description |
|
||||
@@ -85,21 +94,22 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
|
||||
|
||||
For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ---------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||
| OIDC_CLIENT_ID | None | The client id of your configured client in your provider |
|
||||
| OIDC_CLIENT_SECRET <br/> :octicons-tag-24: v2.0.0 | None | The client secret of your configured client in your provider|
|
||||
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate. For more information see [this page](../authentication/oidc-v2.md#groups) |
|
||||
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be able to successfully authenticate *and* be made an admin. For more information see [this page](../authentication/oidc-v2.md#groups) |
|
||||
| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed and you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL |
|
||||
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||
| OIDC_CLIENT_ID | None | The client id of your configured client in your provider |
|
||||
| OIDC_CLIENT_SECRET <br/> :octicons-tag-24: v2.0.0 | None | The client secret of your configured client in your provider |
|
||||
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate. For more information see [this page](../authentication/oidc-v2.md#groups) |
|
||||
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be able to successfully authenticate *and* be made an admin. For more information see [this page](../authentication/oidc-v2.md#groups) |
|
||||
| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed and you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL |
|
||||
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_SCOPES_OVERRIDE | None | Advanced configuration used to override the scopes requested from the IdP. **Most users won't need to change this**. At a minimum, 'openid profile email' are required. |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
|
||||
### OpenAI
|
||||
|
||||
@@ -118,7 +128,7 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
|
||||
### Theming
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.0.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.1.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.0.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.1.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.0.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.1.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:icon="$globals.icons.pages"
|
||||
:title="$t('general.edit')"
|
||||
:title="$tc('general.edit')"
|
||||
:submit-icon="$globals.icons.save"
|
||||
:submit-text="$tc('general.save')"
|
||||
:submit-disabled="!editTarget.queryFilterString"
|
||||
@@ -25,7 +25,7 @@
|
||||
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton
|
||||
v-if="isOwnGroup"
|
||||
v-if="canEdit"
|
||||
class="mx-1"
|
||||
:edit="true"
|
||||
@click="handleEditCookbook"
|
||||
@@ -79,6 +79,15 @@
|
||||
const tab = ref(null);
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $auth.user.householdId === book.value.householdId;
|
||||
})
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
@@ -118,7 +127,7 @@
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
isOwnGroup,
|
||||
canEdit,
|
||||
dialogStates,
|
||||
editTarget,
|
||||
handleEditCookbook,
|
||||
|
||||
@@ -96,7 +96,13 @@ import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue
|
||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
||||
import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import {
|
||||
clearPageState,
|
||||
EditorMode,
|
||||
PageMode,
|
||||
usePageState,
|
||||
usePageUser,
|
||||
} from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { useRouteQuery } from "~/composables/use-router";
|
||||
@@ -170,6 +176,9 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
deactivateNavigationWarning();
|
||||
|
||||
clearPageState(props.recipe.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onUnmounted } from "@nuxtjs/composition-api";
|
||||
import { clearPageState, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
@@ -75,10 +75,6 @@ export default defineComponent({
|
||||
return households.value.find((h) => h.id === owner.householdId);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearPageState(props.recipe.slug);
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
async function uploadImage(fileObject: File) {
|
||||
if (!props.recipe || !props.recipe.slug) {
|
||||
return;
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<tr v-for="(value, key) in recipe.nutrition" :key="key">
|
||||
<template v-if="value">
|
||||
<td>{{ labels[key].label }}</td>
|
||||
<td>{{ value || '-' }}</td>
|
||||
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -322,10 +322,32 @@ li {
|
||||
}
|
||||
|
||||
.nutrition-table {
|
||||
width: 25%;
|
||||
max-width: 80%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.nutrition-table th,
|
||||
.nutrition-table td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nutrition-table th {
|
||||
font-weight: bold;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nutrition-table td:first-child {
|
||||
width: 70%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nutrition-table td:last-child {
|
||||
width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
.nutrition-table td {
|
||||
padding: 2px;
|
||||
text-align: left;
|
||||
|
||||
@@ -82,12 +82,17 @@ import { computed, defineComponent, onMounted, ref, useContext, useRoute } from
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
||||
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
|
||||
import { SidebarLinks } from "~/types/application-types";
|
||||
import { SideBarLink } from "~/types/application-types";
|
||||
import LanguageDialog from "~/components/global/LanguageDialog.vue";
|
||||
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
|
||||
import { useAppInfo } from "~/composables/api";
|
||||
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
|
||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
|
||||
@@ -99,6 +104,15 @@ export default defineComponent({
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
|
||||
|
||||
const householdsById = computed(() => {
|
||||
return households.value.reduce((acc, household) => {
|
||||
acc[household.id] = household;
|
||||
return acc;
|
||||
}, {} as { [key: string]: HouseholdSummary });
|
||||
});
|
||||
|
||||
const appInfo = useAppInfo();
|
||||
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
|
||||
@@ -113,29 +127,57 @@ export default defineComponent({
|
||||
sidebar.value = !$vuetify.breakpoint.md;
|
||||
});
|
||||
|
||||
const cookbookLinks = computed(() => {
|
||||
if (!cookbooks.value) return [];
|
||||
return cookbooks.value.map((cookbook) => {
|
||||
return {
|
||||
key: cookbook.slug,
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
interface Link {
|
||||
insertDivider: boolean;
|
||||
icon: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
to: string;
|
||||
restricted: boolean;
|
||||
hide: boolean;
|
||||
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
|
||||
return {
|
||||
key: cookbook.slug || "",
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
|
||||
restricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const createLinks = computed<Link[]>(() => [
|
||||
const currentUserHouseholdId = computed(() => $auth.user?.householdId);
|
||||
const cookbookLinks = computed<SideBarLink[]>(() => {
|
||||
if (!cookbooks.value) {
|
||||
return [];
|
||||
}
|
||||
cookbooks.value.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
|
||||
const ownLinks: SideBarLink[] = [];
|
||||
const links: SideBarLink[] = [];
|
||||
const cookbooksByHousehold = cookbooks.value.reduce((acc, cookbook) => {
|
||||
const householdName = householdsById.value[cookbook.householdId]?.name || "";
|
||||
if (!acc[householdName]) {
|
||||
acc[householdName] = [];
|
||||
}
|
||||
acc[householdName].push(cookbook);
|
||||
return acc;
|
||||
}, {} as Record<string, ReadCookBook[]>);
|
||||
|
||||
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
|
||||
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
|
||||
ownLinks.push(...cookbooks.map(cookbookAsLink));
|
||||
} else {
|
||||
links.push({
|
||||
key: householdName,
|
||||
icon: $globals.icons.book,
|
||||
title: householdName,
|
||||
children: cookbooks.map(cookbookAsLink),
|
||||
restricted: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
links.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if ($auth.user && cookbookPreferences.value.hideOtherHouseholds) {
|
||||
return ownLinks;
|
||||
} else {
|
||||
return [...ownLinks, ...links];
|
||||
}
|
||||
});
|
||||
|
||||
const createLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.link,
|
||||
@@ -165,7 +207,7 @@ export default defineComponent({
|
||||
},
|
||||
]);
|
||||
|
||||
const bottomLinks = computed<SidebarLinks>(() => [
|
||||
const bottomLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.cog,
|
||||
title: i18n.tc("general.settings"),
|
||||
@@ -174,7 +216,7 @@ export default defineComponent({
|
||||
},
|
||||
]);
|
||||
|
||||
const topLinks = computed<SidebarLinks>(() => [
|
||||
const topLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { SidebarLinks } from "~/types/application-types";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
@@ -192,13 +192,29 @@ export default defineComponent({
|
||||
const userProfileLink = computed(() => $auth.user ? "/user/profile" : undefined);
|
||||
|
||||
const state = reactive({
|
||||
dropDowns: {},
|
||||
dropDowns: {} as Record<string, boolean>,
|
||||
topSelected: null as string[] | null,
|
||||
secondarySelected: null as string[] | null,
|
||||
bottomSelected: null as string[] | null,
|
||||
hasOpenedBefore: false as boolean,
|
||||
});
|
||||
|
||||
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
|
||||
function initDropdowns() {
|
||||
allLinks.value.forEach((link) => {
|
||||
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
||||
})
|
||||
}
|
||||
watch(
|
||||
() => allLinks,
|
||||
() => {
|
||||
initDropdowns();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
userFavoritesLink,
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmo
|
||||
// casting to number is required as sometimes quantity is a string
|
||||
if (quantity && Number(quantity) !== 0) {
|
||||
if (unit && !unit.fraction) {
|
||||
returnQty = (quantity * scale).toString();
|
||||
returnQty = Number((quantity * scale).toPrecision(3)).toString();
|
||||
} else {
|
||||
const fraction = frac(quantity * scale, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
|
||||
@@ -99,10 +99,10 @@ export const useCookbooks = function () {
|
||||
|
||||
loading.value = false;
|
||||
},
|
||||
async createOne() {
|
||||
async createOne(name: string | null = null) {
|
||||
loading.value = true;
|
||||
const { data } = await api.cookbooks.createOne({
|
||||
name: i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
|
||||
name: name || i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
|
||||
position: (cookbookStore?.value?.length ?? 0) + 1,
|
||||
queryFilterString: "",
|
||||
});
|
||||
@@ -129,18 +129,18 @@ export const useCookbooks = function () {
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateOrder() {
|
||||
if (!cookbookStore?.value) {
|
||||
async updateOrder(cookbooks: ReadCookBook[]) {
|
||||
if (!cookbooks?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
cookbookStore.value.forEach((element, index) => {
|
||||
cookbooks.forEach((element, index) => {
|
||||
element.position = index + 1;
|
||||
});
|
||||
|
||||
const { data } = await api.cookbooks.updateAll(cookbookStore.value);
|
||||
const { data } = await api.cookbooks.updateAll(cookbooks);
|
||||
|
||||
if (data && cookbookStore?.value) {
|
||||
this.refreshAll();
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface UserParsingPreferences {
|
||||
parser: RegisteredParser;
|
||||
}
|
||||
|
||||
export interface UserCookbooksPreferences {
|
||||
hideOtherHouseholds: boolean;
|
||||
}
|
||||
|
||||
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"meal-planner-preferences",
|
||||
@@ -153,3 +157,17 @@ export function useParsingPreferences(): Ref<UserParsingPreferences> {
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
export function useCookbookPreferences(): Ref<UserCookbooksPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"cookbook-preferences",
|
||||
{
|
||||
hideOtherHouseholds: false,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
// we cast to a Ref because by default it will return an optional type ref
|
||||
// but since we pass defaults we know all properties are set.
|
||||
) as unknown as Ref<UserCookbooksPreferences>;
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
@@ -1327,6 +1327,8 @@
|
||||
"cookbook": {
|
||||
"cookbooks": "Cookbooks",
|
||||
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.",
|
||||
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households",
|
||||
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar",
|
||||
"public-cookbook": "Public Cookbook",
|
||||
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
|
||||
"filter-options": "Filter Options",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mealie",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
|
||||
@@ -48,20 +48,33 @@
|
||||
{{ $t('cookbook.description') }}
|
||||
</BasePageTitle>
|
||||
|
||||
<div class="my-6">
|
||||
<v-checkbox
|
||||
v-model="cookbookPreferences.hideOtherHouseholds"
|
||||
:label="$tc('cookbook.hide-cookbooks-from-other-households')"
|
||||
hide-details
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $tc("cookbook.hide-cookbooks-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create New -->
|
||||
<BaseButton create @click="createCookbook" />
|
||||
|
||||
<!-- Cookbook List -->
|
||||
<v-expansion-panels class="mt-2">
|
||||
<draggable
|
||||
v-model="cookbooks"
|
||||
v-model="myCookbooks"
|
||||
handle=".handle"
|
||||
delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
style="width: 100%"
|
||||
@change="actions.updateOrder()"
|
||||
@change="actions.updateOrder(myCookbooks)"
|
||||
>
|
||||
<v-expansion-panel v-for="cookbook in cookbooks" :key="cookbook.id" class="my-2 left-border rounded">
|
||||
<v-expansion-panel v-for="cookbook in myCookbooks" :key="cookbook.id" class="my-2 left-border rounded">
|
||||
<v-expansion-panel-header disable-icon-rotate class="headline">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon large left>
|
||||
@@ -110,11 +123,13 @@
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import { defineComponent, onBeforeUnmount, onMounted, reactive, ref } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, onBeforeUnmount, onMounted, reactive, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import draggable from "vuedraggable";
|
||||
import { useCookbooks } from "@/composables/use-group-cookbooks";
|
||||
import { useHouseholdSelf } from "@/composables/use-households";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
export default defineComponent({
|
||||
components: { CookbookEditor, draggable },
|
||||
@@ -124,13 +139,28 @@ export default defineComponent({
|
||||
create: false,
|
||||
delete: false,
|
||||
});
|
||||
const { cookbooks, actions } = useCookbooks();
|
||||
|
||||
const { $auth, i18n } = useContext();
|
||||
const { cookbooks: allCookbooks, actions } = useCookbooks();
|
||||
const myCookbooks = computed<ReadCookBook[]>({
|
||||
get: () => {
|
||||
return allCookbooks.value?.filter((cookbook) => {
|
||||
return cookbook.householdId === $auth.user?.householdId;
|
||||
}) || [];
|
||||
},
|
||||
set: (value: ReadCookBook[]) => {
|
||||
actions.updateOrder(value);
|
||||
},
|
||||
});
|
||||
const { household } = useHouseholdSelf();
|
||||
const cookbookPreferences = useCookbookPreferences()
|
||||
|
||||
// create
|
||||
const createTargetKey = ref(0);
|
||||
const createTarget = ref<ReadCookBook | null>(null);
|
||||
async function createCookbook() {
|
||||
await actions.createOne().then((cookbook) => {
|
||||
const name = i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((myCookbooks.value?.length ?? 0) + 1)]) as string
|
||||
await actions.createOne(name).then((cookbook) => {
|
||||
createTarget.value = cookbook as ReadCookBook;
|
||||
createTargetKey.value++;
|
||||
});
|
||||
@@ -177,7 +207,8 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
return {
|
||||
cookbooks,
|
||||
myCookbooks,
|
||||
cookbookPreferences,
|
||||
actions,
|
||||
dialogStates,
|
||||
// create
|
||||
|
||||
@@ -323,7 +323,7 @@ export default defineComponent({
|
||||
// we explicitly set booleans to false since forms don't POST unchecked boxes
|
||||
const createTarget = ref<CreateIngredientUnit>({
|
||||
name: "",
|
||||
fraction: false,
|
||||
fraction: true,
|
||||
useAbbreviation: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<!-- View By Label -->
|
||||
<div v-else>
|
||||
<div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6">
|
||||
<div v-for="(value, key) in itemsByLabel" :key="key" class="pb-4">
|
||||
<v-btn
|
||||
:color="getLabelColor(value[0]) ? getLabelColor(value[0]) : '#959595'"
|
||||
:style="{
|
||||
@@ -73,20 +73,20 @@
|
||||
<v-divider/>
|
||||
<v-expand-transition group>
|
||||
<div v-show="labelOpenState[key]">
|
||||
<draggable :value="value" handle=".handle" delay="250" :delay-on-touch-only="true" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUncheckedByLabel(key, $event)">
|
||||
<v-lazy v-for="(item, index) in value" :key="item.id" class="ml-2 my-2">
|
||||
<ShoppingListItem
|
||||
v-model="value[index]"
|
||||
:show-label=false
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
:recipes="recipeMap"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
/>
|
||||
</v-lazy>
|
||||
<draggable :value="value" handle=".handle" delay="250" :delay-on-touch-only="true" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUncheckedByLabel(key, $event)">
|
||||
<v-lazy v-for="(item, index) in value" :key="item.id" class="ml-2 my-2">
|
||||
<ShoppingListItem
|
||||
v-model="value[index]"
|
||||
:show-label=false
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
:recipes="recipeMap"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
/>
|
||||
</v-lazy>
|
||||
</draggable>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
@@ -470,7 +470,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
// =====================================
|
||||
// Collapsables
|
||||
// Collapsable Labels
|
||||
const labelOpenState = ref<{ [key: string]: boolean }>({});
|
||||
|
||||
const initializeLabelOpenStates = () => {
|
||||
@@ -480,8 +480,8 @@ export default defineComponent({
|
||||
let hasChanges = false;
|
||||
|
||||
for (const item of shoppingList.value.listItems) {
|
||||
const labelName = item.label?.name;
|
||||
if (labelName && !existingLabels.has(labelName) && !(labelName in labelOpenState.value)) {
|
||||
const labelName = item.label?.name || i18n.tc("shopping-list.no-label");
|
||||
if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) {
|
||||
labelOpenState.value[labelName] = true;
|
||||
hasChanges = true;
|
||||
}
|
||||
@@ -492,9 +492,13 @@ export default defineComponent({
|
||||
}
|
||||
};
|
||||
|
||||
const labelNames = computed(() =>
|
||||
new Set(shoppingList.value?.listItems?.map(item => item.label?.name).filter(Boolean) ?? [])
|
||||
);
|
||||
const labelNames = computed(() => {
|
||||
return new Set(
|
||||
shoppingList.value?.listItems
|
||||
?.map(item => item.label?.name || i18n.tc("shopping-list.no-label"))
|
||||
.filter(Boolean) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface SideBarLink {
|
||||
href?: string;
|
||||
title: string;
|
||||
children?: SideBarLink[];
|
||||
childrenStartExpanded?: boolean;
|
||||
restricted: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,20 +64,23 @@ async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
settings.model_dump_json(
|
||||
indent=4,
|
||||
exclude={
|
||||
"LDAP_QUERY_PASSWORD",
|
||||
"OPENAI_API_KEY",
|
||||
"SECRET",
|
||||
"SESSION_SECRET",
|
||||
"SFTP_PASSWORD",
|
||||
"SFTP_USERNAME",
|
||||
"DB_URL", # replace by DB_URL_PUBLIC for logs
|
||||
"DB_PROVIDER",
|
||||
"SMTP_USER",
|
||||
"SMTP_PASSWORD",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
},
|
||||
)
|
||||
)
|
||||
logger.info("------APP FEATURES------")
|
||||
logger.info("--------==SMTP==--------")
|
||||
logger.info(settings.SMTP_FEATURE)
|
||||
logger.info("--------==LDAP==--------")
|
||||
logger.info(settings.LDAP_FEATURE)
|
||||
logger.info("--------==OIDC==--------")
|
||||
logger.info(settings.OIDC_FEATURE)
|
||||
logger.info("-------==OPENAI==-------")
|
||||
logger.info(settings.OPENAI_FEATURE)
|
||||
logger.info("------------------------")
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Annotated, Any, NamedTuple
|
||||
|
||||
from dateutil.tz import tzlocal
|
||||
from pydantic import field_validator
|
||||
from pydantic import PlainSerializer, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from mealie.core.settings.themes import Theme
|
||||
@@ -19,6 +19,29 @@ class ScheduleTime(NamedTuple):
|
||||
minute: int
|
||||
|
||||
|
||||
class FeatureDetails(NamedTuple):
|
||||
enabled: bool
|
||||
"""Indicates if the feature is enabled or not"""
|
||||
description: str | None
|
||||
"""Short description describing why the feature is not ready"""
|
||||
|
||||
def __str__(self):
|
||||
s = f"Enabled: {self.enabled}"
|
||||
if not self.enabled and self.description:
|
||||
s += f"\nReason: {self.description}"
|
||||
return s
|
||||
|
||||
|
||||
MaskedNoneString = Annotated[
|
||||
str | None,
|
||||
PlainSerializer(lambda x: None if x is None else "*****", return_type=str | None),
|
||||
]
|
||||
"""
|
||||
Custom serializer for sensitive settings. If the setting is None, then will serialize as null, otherwise,
|
||||
the secret will be serialized as '*****'
|
||||
"""
|
||||
|
||||
|
||||
def determine_secrets(data_dir: Path, secret: str, production: bool) -> str:
|
||||
if not production:
|
||||
return "shh-secret-test-key"
|
||||
@@ -200,12 +223,16 @@ class AppSettings(AppLoggingSettings):
|
||||
SMTP_PORT: str | None = "587"
|
||||
SMTP_FROM_NAME: str | None = "Mealie"
|
||||
SMTP_FROM_EMAIL: str | None = None
|
||||
SMTP_USER: str | None = None
|
||||
SMTP_PASSWORD: str | None = None
|
||||
SMTP_USER: MaskedNoneString = None
|
||||
SMTP_PASSWORD: MaskedNoneString = None
|
||||
SMTP_AUTH_STRATEGY: str | None = "TLS" # Options: 'TLS', 'SSL', 'NONE'
|
||||
|
||||
@property
|
||||
def SMTP_ENABLE(self) -> bool:
|
||||
return self.SMTP_FEATURE.enabled
|
||||
|
||||
@property
|
||||
def SMTP_FEATURE(self) -> FeatureDetails:
|
||||
return AppSettings.validate_smtp(
|
||||
self.SMTP_HOST,
|
||||
self.SMTP_PORT,
|
||||
@@ -225,15 +252,30 @@ class AppSettings(AppLoggingSettings):
|
||||
strategy: str | None = None,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> bool:
|
||||
) -> FeatureDetails:
|
||||
"""Validates all SMTP variables are set"""
|
||||
required = {host, port, from_name, from_email, strategy}
|
||||
description = None
|
||||
required = {
|
||||
"SMTP_HOST": host,
|
||||
"SMTP_PORT": port,
|
||||
"SMTP_FROM_NAME": from_name,
|
||||
"SMTP_FROM_EMAIL": from_email,
|
||||
"SMTP_AUTH_STRATEGY": strategy,
|
||||
}
|
||||
missing_values = [key for (key, value) in required.items() if value is None]
|
||||
if missing_values:
|
||||
description = f"Missing required values for {missing_values}"
|
||||
|
||||
if strategy and strategy.upper() in {"TLS", "SSL"}:
|
||||
required.add(user)
|
||||
required.add(password)
|
||||
required["SMTP_USER"] = user
|
||||
required["SMTP_PASSWORD"] = password
|
||||
if not description:
|
||||
missing_values = [key for (key, value) in required.items() if value is None]
|
||||
description = f"Missing required values for {missing_values} because SMTP_AUTH_STRATEGY is not None"
|
||||
|
||||
return "" not in required and None not in required
|
||||
not_none = "" not in required.values() and None not in required.values()
|
||||
|
||||
return FeatureDetails(enabled=not_none, description=description)
|
||||
|
||||
# ===============================================
|
||||
# LDAP Configuration
|
||||
@@ -245,31 +287,43 @@ class AppSettings(AppLoggingSettings):
|
||||
LDAP_ENABLE_STARTTLS: bool = False
|
||||
LDAP_BASE_DN: str | None = None
|
||||
LDAP_QUERY_BIND: str | None = None
|
||||
LDAP_QUERY_PASSWORD: str | None = None
|
||||
LDAP_QUERY_PASSWORD: MaskedNoneString = None
|
||||
LDAP_USER_FILTER: str | None = None
|
||||
LDAP_ADMIN_FILTER: str | None = None
|
||||
LDAP_ID_ATTRIBUTE: str = "uid"
|
||||
LDAP_MAIL_ATTRIBUTE: str = "mail"
|
||||
LDAP_NAME_ATTRIBUTE: str = "name"
|
||||
|
||||
@property
|
||||
def LDAP_FEATURE(self) -> FeatureDetails:
|
||||
description = None if self.LDAP_AUTH_ENABLED else "LDAP_AUTH_ENABLED is false"
|
||||
required = {
|
||||
"LDAP_SERVER_URL": self.LDAP_SERVER_URL,
|
||||
"LDAP_BASE_DN": self.LDAP_BASE_DN,
|
||||
"LDAP_ID_ATTRIBUTE": self.LDAP_ID_ATTRIBUTE,
|
||||
"LDAP_MAIL_ATTRIBUTE": self.LDAP_MAIL_ATTRIBUTE,
|
||||
"LDAP_NAME_ATTRIBUTE": self.LDAP_NAME_ATTRIBUTE,
|
||||
}
|
||||
not_none = None not in required.values()
|
||||
if not not_none and not description:
|
||||
missing_values = [key for (key, value) in required.items() if value is None]
|
||||
description = f"Missing required values for {missing_values}"
|
||||
|
||||
return FeatureDetails(
|
||||
enabled=self.LDAP_AUTH_ENABLED and not_none,
|
||||
description=description,
|
||||
)
|
||||
|
||||
@property
|
||||
def LDAP_ENABLED(self) -> bool:
|
||||
"""Validates LDAP settings are all set"""
|
||||
required = {
|
||||
self.LDAP_SERVER_URL,
|
||||
self.LDAP_BASE_DN,
|
||||
self.LDAP_ID_ATTRIBUTE,
|
||||
self.LDAP_MAIL_ATTRIBUTE,
|
||||
self.LDAP_NAME_ATTRIBUTE,
|
||||
}
|
||||
not_none = None not in required
|
||||
return self.LDAP_AUTH_ENABLED and not_none
|
||||
return self.LDAP_FEATURE.enabled
|
||||
|
||||
# ===============================================
|
||||
# OIDC Configuration
|
||||
OIDC_AUTH_ENABLED: bool = False
|
||||
OIDC_CLIENT_ID: str | None = None
|
||||
OIDC_CLIENT_SECRET: str | None = None
|
||||
OIDC_CLIENT_SECRET: MaskedNoneString = None
|
||||
OIDC_CONFIGURATION_URL: str | None = None
|
||||
OIDC_SIGNUP_ENABLED: bool = True
|
||||
OIDC_USER_GROUP: str | None = None
|
||||
@@ -279,6 +333,7 @@ class AppSettings(AppLoggingSettings):
|
||||
OIDC_REMEMBER_ME: bool = False
|
||||
OIDC_USER_CLAIM: str = "email"
|
||||
OIDC_GROUPS_CLAIM: str | None = "groups"
|
||||
OIDC_SCOPES_OVERRIDE: str | None = None
|
||||
OIDC_TLS_CACERTFILE: str | None = None
|
||||
|
||||
@property
|
||||
@@ -286,29 +341,41 @@ class AppSettings(AppLoggingSettings):
|
||||
return self.OIDC_USER_GROUP is not None or self.OIDC_ADMIN_GROUP is not None
|
||||
|
||||
@property
|
||||
def OIDC_READY(self) -> bool:
|
||||
"""Validates OIDC settings are all set"""
|
||||
|
||||
def OIDC_FEATURE(self) -> FeatureDetails:
|
||||
description = None if self.OIDC_AUTH_ENABLED else "OIDC_AUTH_ENABLED is false"
|
||||
required = {
|
||||
self.OIDC_CLIENT_ID,
|
||||
self.OIDC_CLIENT_SECRET,
|
||||
self.OIDC_CONFIGURATION_URL,
|
||||
self.OIDC_USER_CLAIM,
|
||||
"OIDC_CLIENT_ID": self.OIDC_CLIENT_ID,
|
||||
"OIDC_CLIENT_SECRET": self.OIDC_CLIENT_SECRET,
|
||||
"OIDC_CONFIGURATION_URL": self.OIDC_CONFIGURATION_URL,
|
||||
"OIDC_USER_CLAIM": self.OIDC_USER_CLAIM,
|
||||
}
|
||||
not_none = None not in required
|
||||
valid_group_claim = True
|
||||
not_none = None not in required.values()
|
||||
if not not_none and not description:
|
||||
missing_values = [key for (key, value) in required.items() if value is None]
|
||||
description = f"Missing required values for {missing_values}"
|
||||
|
||||
valid_group_claim = True
|
||||
if self.OIDC_REQUIRES_GROUP_CLAIM and self.OIDC_GROUPS_CLAIM is None:
|
||||
if not description:
|
||||
description = "OIDC_GROUPS_CLAIM is required when OIDC_USER_GROUP or OIDC_ADMIN_GROUP are provided"
|
||||
valid_group_claim = False
|
||||
|
||||
return self.OIDC_AUTH_ENABLED and not_none and valid_group_claim
|
||||
return FeatureDetails(
|
||||
enabled=self.OIDC_AUTH_ENABLED and not_none and valid_group_claim,
|
||||
description=description,
|
||||
)
|
||||
|
||||
@property
|
||||
def OIDC_READY(self) -> bool:
|
||||
"""Validates OIDC settings are all set"""
|
||||
return self.OIDC_FEATURE.enabled
|
||||
|
||||
# ===============================================
|
||||
# OpenAI Configuration
|
||||
|
||||
OPENAI_BASE_URL: str | None = None
|
||||
"""The base URL for the OpenAI API. Leave this unset for most usecases"""
|
||||
OPENAI_API_KEY: str | None = None
|
||||
OPENAI_API_KEY: MaskedNoneString = None
|
||||
"""Your OpenAI API key. Required to enable OpenAI features"""
|
||||
OPENAI_MODEL: str = "gpt-4o"
|
||||
"""Which OpenAI model to send requests to. Leave this unset for most usecases"""
|
||||
@@ -333,6 +400,24 @@ class AppSettings(AppLoggingSettings):
|
||||
The number of seconds to wait for an OpenAI request to complete before cancelling the request
|
||||
"""
|
||||
|
||||
@property
|
||||
def OPENAI_FEATURE(self) -> FeatureDetails:
|
||||
description = None
|
||||
if not self.OPENAI_API_KEY:
|
||||
description = "OPENAI_API_KEY is not set"
|
||||
elif self.OPENAI_MODEL:
|
||||
description = "OPENAI_MODEL is not set"
|
||||
|
||||
return FeatureDetails(
|
||||
enabled=bool(self.OPENAI_API_KEY and self.OPENAI_MODEL),
|
||||
description=description,
|
||||
)
|
||||
|
||||
@property
|
||||
def OPENAI_ENABLED(self) -> bool:
|
||||
"""Validates OpenAI settings are all set"""
|
||||
return self.OPENAI_FEATURE.enabled
|
||||
|
||||
# ===============================================
|
||||
# Web Concurrency
|
||||
|
||||
@@ -346,13 +431,17 @@ class AppSettings(AppLoggingSettings):
|
||||
def WORKERS(self) -> int:
|
||||
return max(1, self.WORKER_PER_CORE * self.UVICORN_WORKERS)
|
||||
|
||||
@property
|
||||
def OPENAI_ENABLED(self) -> bool:
|
||||
"""Validates OpenAI settings are all set"""
|
||||
return bool(self.OPENAI_API_KEY and self.OPENAI_MODEL)
|
||||
|
||||
model_config = SettingsConfigDict(arbitrary_types_allowed=True, extra="allow")
|
||||
|
||||
# ===============================================
|
||||
# TLS
|
||||
|
||||
TLS_CERTIFICATE_PATH: str | os.PathLike[str] | None = None
|
||||
"""Path where the certificate resides."""
|
||||
|
||||
TLS_PRIVATE_KEY_PATH: str | os.PathLike[str] | None = None
|
||||
"""Path where the private key resides."""
|
||||
|
||||
|
||||
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:
|
||||
"""
|
||||
|
||||
@@ -151,6 +151,14 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
else:
|
||||
self.household = None
|
||||
|
||||
if self.group is None:
|
||||
raise ValueError(f"Group {group} does not exist; cannot create user")
|
||||
if self.household is None:
|
||||
raise ValueError(
|
||||
f'Household "{household}" does not exist on group '
|
||||
f'"{self.group.name}" ({self.group.id}); cannot create user'
|
||||
)
|
||||
|
||||
self.rated_recipes = []
|
||||
|
||||
self.password = password
|
||||
|
||||
@@ -13,6 +13,8 @@ def main():
|
||||
log_config=log_config(),
|
||||
workers=settings.WORKERS,
|
||||
forwarded_allow_ips=settings.HOST_IP,
|
||||
ssl_keyfile=settings.TLS_PRIVATE_KEY_PATH,
|
||||
ssl_certfile=settings.TLS_CERTIFICATE_PATH,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from collections.abc import Callable
|
||||
from logging import Logger
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
import sqlalchemy.exc
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import UUID4, BaseModel
|
||||
|
||||
@@ -57,10 +58,16 @@ class HttpRepo(Generic[C, R, U]):
|
||||
# Respond
|
||||
msg = self.get_exception_message(ex)
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
||||
)
|
||||
if isinstance(ex, sqlalchemy.exc.NoResultFound):
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
||||
)
|
||||
|
||||
def create_one(self, data: C) -> R | None:
|
||||
item: R | None = None
|
||||
|
||||
@@ -28,8 +28,12 @@ remember_me_duration = timedelta(days=14)
|
||||
settings = get_app_settings()
|
||||
if settings.OIDC_READY:
|
||||
oauth = OAuth()
|
||||
groups_claim = settings.OIDC_GROUPS_CLAIM if settings.OIDC_REQUIRES_GROUP_CLAIM else ""
|
||||
scope = f"openid email profile {groups_claim}"
|
||||
scope = None
|
||||
if settings.OIDC_SCOPES_OVERRIDE:
|
||||
scope = settings.OIDC_SCOPES_OVERRIDE
|
||||
else:
|
||||
groups_claim = settings.OIDC_GROUPS_CLAIM if settings.OIDC_REQUIRES_GROUP_CLAIM else ""
|
||||
scope = f"openid email profile {groups_claim}"
|
||||
client_args = {"scope": scope.rstrip()}
|
||||
if settings.OIDC_TLS_CACERTFILE:
|
||||
client_args["verify"] = settings.OIDC_TLS_CACERTFILE
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.core.exceptions import mealie_registered_exceptions
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base import BaseCrudController, controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.routes._base.routers import MealieCrudRoute
|
||||
@@ -26,9 +27,13 @@ router = APIRouter(prefix="/households/cookbooks", tags=["Households: Cookbooks"
|
||||
@controller(router)
|
||||
class GroupCookbookController(BaseCrudController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
def cookbooks(self):
|
||||
return self.repos.cookbooks
|
||||
|
||||
@cached_property
|
||||
def group_cookbooks(self):
|
||||
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
|
||||
|
||||
def registered_exceptions(self, ex: type[Exception]) -> str:
|
||||
registered = {
|
||||
**mealie_registered_exceptions(self.translator),
|
||||
@@ -38,14 +43,15 @@ class GroupCookbookController(BaseCrudController):
|
||||
@cached_property
|
||||
def mixins(self):
|
||||
return HttpRepo[CreateCookBook, ReadCookBook, UpdateCookBook](
|
||||
self.repo,
|
||||
self.cookbooks,
|
||||
self.logger,
|
||||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
@router.get("", response_model=CookBookPagination)
|
||||
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
|
||||
response = self.repo.page_all(
|
||||
# Fetch all cookbooks for the group, rather than the household
|
||||
response = self.group_cookbooks.page_all(
|
||||
pagination=q,
|
||||
override=ReadCookBook,
|
||||
)
|
||||
@@ -106,7 +112,8 @@ class GroupCookbookController(BaseCrudController):
|
||||
except ValueError:
|
||||
match_attr = "slug"
|
||||
|
||||
cookbook = self.repo.get_one(item_id, match_attr)
|
||||
# Allow fetching other households' cookbooks
|
||||
cookbook = self.group_cookbooks.get_one(item_id, match_attr)
|
||||
|
||||
if cookbook is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
@@ -105,8 +105,8 @@ class BaseRecipeController(BaseCrudController):
|
||||
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||
|
||||
@cached_property
|
||||
def cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
||||
return self.repos.cookbooks
|
||||
def group_cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
||||
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
|
||||
|
||||
@cached_property
|
||||
def service(self) -> RecipeService:
|
||||
@@ -354,7 +354,7 @@ class RecipeController(BaseRecipeController):
|
||||
cb_match_attr = "id"
|
||||
except ValueError:
|
||||
cb_match_attr = "slug"
|
||||
cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr)
|
||||
cookbook_data = self.group_cookbooks.get_one(search_query.cookbook, cb_match_attr)
|
||||
|
||||
if cookbook_data is None:
|
||||
raise HTTPException(status_code=404, detail="cookbook not found")
|
||||
|
||||
@@ -14,7 +14,7 @@ def main():
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
with session_context() as session:
|
||||
repos = AllRepositories(session)
|
||||
repos = AllRepositories(session, group_id=None, household_id=None)
|
||||
|
||||
user = repos.users.get_one(confirmed, "email")
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ def main():
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
with session_context() as session:
|
||||
repos = AllRepositories(session)
|
||||
repos = AllRepositories(session, group_id=None, household_id=None)
|
||||
user_service = UserService(repos)
|
||||
|
||||
locked_users = user_service.get_locked_users()
|
||||
|
||||
208
poetry.lock
generated
208
poetry.lock
generated
@@ -13,13 +13,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.13.3"
|
||||
version = "1.14.0"
|
||||
description = "A database migration tool for SQLAlchemy."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "alembic-1.13.3-py3-none-any.whl", hash = "sha256:908e905976d15235fae59c9ac42c4c5b75cfcefe3d27c0fbf7ae15a37715d80e"},
|
||||
{file = "alembic-1.13.3.tar.gz", hash = "sha256:203503117415561e203aa14541740643a611f641517f0209fcae63e9fa09f1a2"},
|
||||
{file = "alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25"},
|
||||
{file = "alembic-1.14.0.tar.gz", hash = "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -603,13 +603,13 @@ test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "extruct"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
description = "Extract embedded metadata from HTML markup"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "extruct-0.17.0-py2.py3-none-any.whl", hash = "sha256:5f1d8e307fbb0c41f64ce486ddfaf16dc67e4b8f6e9570c57b123409ee37a307"},
|
||||
{file = "extruct-0.17.0.tar.gz", hash = "sha256:a94c0be5b5fd95a8370204ecc02687bd27845d536055d8d1c69a0a30da0420c7"},
|
||||
{file = "extruct-0.18.0-py2.py3-none-any.whl", hash = "sha256:1e739985da705c3348c7614dc169e7780caf20908338fa5f4c6e48576df6f000"},
|
||||
{file = "extruct-0.18.0.tar.gz", hash = "sha256:b5b48d459003b27c05ee91527b14a5a31735231aaf85d2b1f331d4db879318dd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -627,13 +627,13 @@ cli = ["requests"]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.3"
|
||||
version = "0.115.4"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "fastapi-0.115.3-py3-none-any.whl", hash = "sha256:8035e8f9a2b0aa89cea03b6c77721178ed5358e1aea4cd8570d9466895c0638c"},
|
||||
{file = "fastapi-0.115.3.tar.gz", hash = "sha256:c091c6a35599c036d676fa24bd4a6e19fa30058d93d950216cdc672881f6f7db"},
|
||||
{file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"},
|
||||
{file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1464,13 +1464,13 @@ pyyaml = ">=5.1"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "9.5.42"
|
||||
version = "9.5.44"
|
||||
description = "Documentation that simply works"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "mkdocs_material-9.5.42-py3-none-any.whl", hash = "sha256:452a7c5d21284b373f36b981a2cbebfff59263feebeede1bc28652e9c5bbe316"},
|
||||
{file = "mkdocs_material-9.5.42.tar.gz", hash = "sha256:92779b5e9b5934540c574c11647131d217dc540dce72b05feeda088c8eb1b8f2"},
|
||||
{file = "mkdocs_material-9.5.44-py3-none-any.whl", hash = "sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca"},
|
||||
{file = "mkdocs_material-9.5.44.tar.gz", hash = "sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1598,13 +1598,13 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.52.2"
|
||||
version = "1.54.3"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.7.1"
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "openai-1.52.2-py3-none-any.whl", hash = "sha256:57e9e37bc407f39bb6ec3a27d7e8fb9728b2779936daa1fcf95df17d3edfaccc"},
|
||||
{file = "openai-1.52.2.tar.gz", hash = "sha256:87b7d0f69d85f5641678d414b7ee3082363647a5c66a462ed7f3ccb59582da0d"},
|
||||
{file = "openai-1.54.3-py3-none-any.whl", hash = "sha256:f18dbaf09c50d70c4185b892a2a553f80681d1d866323a2da7f7be2f688615d5"},
|
||||
{file = "openai-1.54.3.tar.gz", hash = "sha256:7511b74eeb894ac0b0253dc71f087a15d2e4d71d22d0088767205143d880cca6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1622,69 +1622,69 @@ datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.10.10"
|
||||
version = "3.10.11"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "orjson-3.10.10-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b788a579b113acf1c57e0a68e558be71d5d09aa67f62ca1f68e01117e550a998"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804b18e2b88022c8905bb79bd2cbe59c0cd014b9328f43da8d3b28441995cda4"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9972572a1d042ec9ee421b6da69f7cc823da5962237563fa548ab17f152f0b9b"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc6993ab1c2ae7dd0711161e303f1db69062955ac2668181bfdf2dd410e65258"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d78e4cacced5781b01d9bc0f0cd8b70b906a0e109825cb41c1b03f9c41e4ce86"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6eb2598df518281ba0cbc30d24c5b06124ccf7e19169e883c14e0831217a0bc"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23776265c5215ec532de6238a52707048401a568f0fa0d938008e92a147fe2c7"},
|
||||
{file = "orjson-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cc2a654c08755cef90b468ff17c102e2def0edd62898b2486767204a7f5cc9c"},
|
||||
{file = "orjson-3.10.10-cp310-none-win32.whl", hash = "sha256:081b3fc6a86d72efeb67c13d0ea7c030017bd95f9868b1e329a376edc456153b"},
|
||||
{file = "orjson-3.10.10-cp310-none-win_amd64.whl", hash = "sha256:ff38c5fb749347768a603be1fb8a31856458af839f31f064c5aa74aca5be9efe"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:879e99486c0fbb256266c7c6a67ff84f46035e4f8749ac6317cc83dacd7f993a"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019481fa9ea5ff13b5d5d95e6fd5ab25ded0810c80b150c2c7b1cc8660b662a7"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0dd57eff09894938b4c86d4b871a479260f9e156fa7f12f8cad4b39ea8028bb5"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbde6d70cd95ab4d11ea8ac5e738e30764e510fc54d777336eec09bb93b8576c"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2625cb37b8fb42e2147404e5ff7ef08712099197a9cd38895006d7053e69d6"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf3c20c6a7db69df58672a0d5815647ecf78c8e62a4d9bd284e8621c1fe5ccb"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:75c38f5647e02d423807d252ce4528bf6a95bd776af999cb1fb48867ed01d1f6"},
|
||||
{file = "orjson-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23458d31fa50ec18e0ec4b0b4343730928296b11111df5f547c75913714116b2"},
|
||||
{file = "orjson-3.10.10-cp311-none-win32.whl", hash = "sha256:2787cd9dedc591c989f3facd7e3e86508eafdc9536a26ec277699c0aa63c685b"},
|
||||
{file = "orjson-3.10.10-cp311-none-win_amd64.whl", hash = "sha256:6514449d2c202a75183f807bc755167713297c69f1db57a89a1ef4a0170ee269"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8564f48f3620861f5ef1e080ce7cd122ee89d7d6dacf25fcae675ff63b4d6e05"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5bf161a32b479034098c5b81f2608f09167ad2fa1c06abd4e527ea6bf4837a9"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b65c93617bcafa7f04b74ae8bc2cc214bd5cb45168a953256ff83015c6747d"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8e28406f97fc2ea0c6150f4c1b6e8261453318930b334abc419214c82314f85"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4d0d9fe174cc7a5bdce2e6c378bcdb4c49b2bf522a8f996aa586020e1b96cee"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3be81c42f1242cbed03cbb3973501fcaa2675a0af638f8be494eaf37143d999"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65f9886d3bae65be026219c0a5f32dbbe91a9e6272f56d092ab22561ad0ea33b"},
|
||||
{file = "orjson-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:730ed5350147db7beb23ddaf072f490329e90a1d059711d364b49fe352ec987b"},
|
||||
{file = "orjson-3.10.10-cp312-none-win32.whl", hash = "sha256:a8f4bf5f1c85bea2170800020d53a8877812892697f9c2de73d576c9307a8a5f"},
|
||||
{file = "orjson-3.10.10-cp312-none-win_amd64.whl", hash = "sha256:384cd13579a1b4cd689d218e329f459eb9ddc504fa48c5a83ef4889db7fd7a4f"},
|
||||
{file = "orjson-3.10.10-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44bffae68c291f94ff5a9b4149fe9d1bdd4cd0ff0fb575bcea8351d48db629a1"},
|
||||
{file = "orjson-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e27b4c6437315df3024f0835887127dac2a0a3ff643500ec27088d2588fa5ae1"},
|
||||
{file = "orjson-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca84df16d6b49325a4084fd8b2fe2229cb415e15c46c529f868c3387bb1339d"},
|
||||
{file = "orjson-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c14ce70e8f39bd71f9f80423801b5d10bf93d1dceffdecd04df0f64d2c69bc01"},
|
||||
{file = "orjson-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24ac62336da9bda1bd93c0491eff0613003b48d3cb5d01470842e7b52a40d5b4"},
|
||||
{file = "orjson-3.10.10-cp313-none-win32.whl", hash = "sha256:eb0a42831372ec2b05acc9ee45af77bcaccbd91257345f93780a8e654efc75db"},
|
||||
{file = "orjson-3.10.10-cp313-none-win_amd64.whl", hash = "sha256:f0c4f37f8bf3f1075c6cc8dd8a9f843689a4b618628f8812d0a71e6968b95ffd"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:829700cc18503efc0cf502d630f612884258020d98a317679cd2054af0259568"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0ceb5e0e8c4f010ac787d29ae6299846935044686509e2f0f06ed441c1ca949"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c25908eb86968613216f3db4d3003f1c45d78eb9046b71056ca327ff92bdbd4"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:218cb0bc03340144b6328a9ff78f0932e642199ac184dd74b01ad691f42f93ff"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2277ec2cea3775640dc81ab5195bb5b2ada2fe0ea6eee4677474edc75ea6785"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:848ea3b55ab5ccc9d7bbd420d69432628b691fba3ca8ae3148c35156cbd282aa"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e3e67b537ac0c835b25b5f7d40d83816abd2d3f4c0b0866ee981a045287a54f3"},
|
||||
{file = "orjson-3.10.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7948cfb909353fce2135dcdbe4521a5e7e1159484e0bb024c1722f272488f2b8"},
|
||||
{file = "orjson-3.10.10-cp38-none-win32.whl", hash = "sha256:78bee66a988f1a333dc0b6257503d63553b1957889c17b2c4ed72385cd1b96ae"},
|
||||
{file = "orjson-3.10.10-cp38-none-win_amd64.whl", hash = "sha256:f1d647ca8d62afeb774340a343c7fc023efacfd3a39f70c798991063f0c681dd"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5a059afddbaa6dd733b5a2d76a90dbc8af790b993b1b5cb97a1176ca713b5df8"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f9b5c59f7e2a1a410f971c5ebc68f1995822837cd10905ee255f96074537ee6"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5ef198bafdef4aa9d49a4165ba53ffdc0a9e1c7b6f76178572ab33118afea25"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf29ce0bb5d3320824ec3d1508652421000ba466abd63bdd52c64bcce9eb1fa"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dddd5516bcc93e723d029c1633ae79c4417477b4f57dad9bfeeb6bc0315e654a"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12f2003695b10817f0fa8b8fca982ed7f5761dcb0d93cff4f2f9f6709903fd7"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:672f9874a8a8fb9bb1b771331d31ba27f57702c8106cdbadad8bda5d10bc1019"},
|
||||
{file = "orjson-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dcbb0ca5fafb2b378b2c74419480ab2486326974826bbf6588f4dc62137570a"},
|
||||
{file = "orjson-3.10.10-cp39-none-win32.whl", hash = "sha256:d9bbd3a4b92256875cb058c3381b782649b9a3c68a4aa9a2fff020c2f9cfc1be"},
|
||||
{file = "orjson-3.10.10-cp39-none-win_amd64.whl", hash = "sha256:766f21487a53aee8524b97ca9582d5c6541b03ab6210fbaf10142ae2f3ced2aa"},
|
||||
{file = "orjson-3.10.10.tar.gz", hash = "sha256:37949383c4df7b4337ce82ee35b6d7471e55195efa7dcb45ab8226ceadb0fe3b"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6dade64687f2bd7c090281652fe18f1151292d567a9302b34c2dbb92a3872f1f"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82f07c550a6ccd2b9290849b22316a609023ed851a87ea888c0456485a7d196a"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd9a187742d3ead9df2e49240234d728c67c356516cf4db018833a86f20ec18c"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77b0fed6f209d76c1c39f032a70df2d7acf24b1812ca3e6078fd04e8972685a3"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63fc9d5fe1d4e8868f6aae547a7b8ba0a2e592929245fff61d633f4caccdcdd6"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65cd3e3bb4fbb4eddc3c1e8dce10dc0b73e808fcb875f9fab40c81903dd9323e"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f67c570602300c4befbda12d153113b8974a3340fdcf3d6de095ede86c06d92"},
|
||||
{file = "orjson-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1f39728c7f7d766f1f5a769ce4d54b5aaa4c3f92d5b84817053cc9995b977acc"},
|
||||
{file = "orjson-3.10.11-cp310-none-win32.whl", hash = "sha256:1789d9db7968d805f3d94aae2c25d04014aae3a2fa65b1443117cd462c6da647"},
|
||||
{file = "orjson-3.10.11-cp310-none-win_amd64.whl", hash = "sha256:5576b1e5a53a5ba8f8df81872bb0878a112b3ebb1d392155f00f54dd86c83ff6"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1444f9cb7c14055d595de1036f74ecd6ce15f04a715e73f33bb6326c9cef01b6"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdec57fe3b4bdebcc08a946db3365630332dbe575125ff3d80a3272ebd0ddafe"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eed32f33a0ea6ef36ccc1d37f8d17f28a1d6e8eefae5928f76aff8f1df85e67"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80df27dd8697242b904f4ea54820e2d98d3f51f91e97e358fc13359721233e4b"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:705f03cee0cb797256d54de6695ef219e5bc8c8120b6654dd460848d57a9af3d"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03246774131701de8e7059b2e382597da43144a9a7400f178b2a32feafc54bd5"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8b5759063a6c940a69c728ea70d7c33583991c6982915a839c8da5f957e0103a"},
|
||||
{file = "orjson-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:677f23e32491520eebb19c99bb34675daf5410c449c13416f7f0d93e2cf5f981"},
|
||||
{file = "orjson-3.10.11-cp311-none-win32.whl", hash = "sha256:a11225d7b30468dcb099498296ffac36b4673a8398ca30fdaec1e6c20df6aa55"},
|
||||
{file = "orjson-3.10.11-cp311-none-win_amd64.whl", hash = "sha256:df8c677df2f9f385fcc85ab859704045fa88d4668bc9991a527c86e710392bec"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:360a4e2c0943da7c21505e47cf6bd725588962ff1d739b99b14e2f7f3545ba51"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:496e2cb45de21c369079ef2d662670a4892c81573bcc143c4205cae98282ba97"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7dfa8db55c9792d53c5952900c6a919cfa377b4f4534c7a786484a6a4a350c19"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51f3382415747e0dbda9dade6f1e1a01a9d37f630d8c9049a8ed0e385b7a90c0"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35a1b9f50a219f470e0e497ca30b285c9f34948d3c8160d5ad3a755d9299433"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f3b7c5803138e67028dde33450e054c87e0703afbe730c105f1fcd873496d5"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f91d9eb554310472bd09f5347950b24442600594c2edc1421403d7610a0998fd"},
|
||||
{file = "orjson-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfbb2d460a855c9744bbc8e36f9c3a997c4b27d842f3d5559ed54326e6911f9b"},
|
||||
{file = "orjson-3.10.11-cp312-none-win32.whl", hash = "sha256:d4a62c49c506d4d73f59514986cadebb7e8d186ad510c518f439176cf8d5359d"},
|
||||
{file = "orjson-3.10.11-cp312-none-win_amd64.whl", hash = "sha256:f1eec3421a558ff7a9b010a6c7effcfa0ade65327a71bb9b02a1c3b77a247284"},
|
||||
{file = "orjson-3.10.11-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c46294faa4e4d0eb73ab68f1a794d2cbf7bab33b1dda2ac2959ffb7c61591899"},
|
||||
{file = "orjson-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52e5834d7d6e58a36846e059d00559cb9ed20410664f3ad156cd2cc239a11230"},
|
||||
{file = "orjson-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2fc947e5350fdce548bfc94f434e8760d5cafa97fb9c495d2fef6757aa02ec0"},
|
||||
{file = "orjson-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0efabbf839388a1dab5b72b5d3baedbd6039ac83f3b55736eb9934ea5494d258"},
|
||||
{file = "orjson-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3f29634260708c200c4fe148e42b4aae97d7b9fee417fbdd74f8cfc265f15b0"},
|
||||
{file = "orjson-3.10.11-cp313-none-win32.whl", hash = "sha256:1a1222ffcee8a09476bbdd5d4f6f33d06d0d6642df2a3d78b7a195ca880d669b"},
|
||||
{file = "orjson-3.10.11-cp313-none-win_amd64.whl", hash = "sha256:bc274ac261cc69260913b2d1610760e55d3c0801bb3457ba7b9004420b6b4270"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:19b3763e8bbf8ad797df6b6b5e0fc7c843ec2e2fc0621398534e0c6400098f87"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be83a13312e5e58d633580c5eb8d0495ae61f180da2722f20562974188af205"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afacfd1ab81f46dedd7f6001b6d4e8de23396e4884cd3c3436bd05defb1a6446"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb4d0bea56bba596723d73f074c420aec3b2e5d7d30698bc56e6048066bd560c"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96ed1de70fcb15d5fed529a656df29f768187628727ee2788344e8a51e1c1350"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4bfb30c891b530f3f80e801e3ad82ef150b964e5c38e1fb8482441c69c35c61c"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d496c74fc2b61341e3cefda7eec21b7854c5f672ee350bc55d9a4997a8a95204"},
|
||||
{file = "orjson-3.10.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:655a493bac606655db9a47fe94d3d84fc7f3ad766d894197c94ccf0c5408e7d3"},
|
||||
{file = "orjson-3.10.11-cp38-none-win32.whl", hash = "sha256:b9546b278c9fb5d45380f4809e11b4dd9844ca7aaf1134024503e134ed226161"},
|
||||
{file = "orjson-3.10.11-cp38-none-win_amd64.whl", hash = "sha256:b592597fe551d518f42c5a2eb07422eb475aa8cfdc8c51e6da7054b836b26782"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c95f2ecafe709b4e5c733b5e2768ac569bed308623c85806c395d9cca00e08af"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80c00d4acded0c51c98754fe8218cb49cb854f0f7eb39ea4641b7f71732d2cb7"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:461311b693d3d0a060439aa669c74f3603264d4e7a08faa68c47ae5a863f352d"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52ca832f17d86a78cbab86cdc25f8c13756ebe182b6fc1a97d534051c18a08de"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c57ea78a753812f528178aa2f1c57da633754c91d2124cb28991dab4c79a54"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7fcfc6f7ca046383fb954ba528587e0f9336828b568282b27579c49f8e16aad"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:86b9dd983857970c29e4c71bb3e95ff085c07d3e83e7c46ebe959bac07ebd80b"},
|
||||
{file = "orjson-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d83f87582d223e54efb2242a79547611ba4ebae3af8bae1e80fa9a0af83bb7f"},
|
||||
{file = "orjson-3.10.11-cp39-none-win32.whl", hash = "sha256:9fd0ad1c129bc9beb1154c2655f177620b5beaf9a11e0d10bac63ef3fce96950"},
|
||||
{file = "orjson-3.10.11-cp39-none-win_amd64.whl", hash = "sha256:10f416b2a017c8bd17f325fb9dee1fb5cdd7a54e814284896b7c3f2763faa017"},
|
||||
{file = "orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2174,13 +2174,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
description = "Settings management using Pydantic"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pydantic_settings-2.6.0-py3-none-any.whl", hash = "sha256:4a819166f119b74d7f8c765196b165f95cc7487ce58ea27dec8a5a26be0970e0"},
|
||||
{file = "pydantic_settings-2.6.0.tar.gz", hash = "sha256:44a1804abffac9e6a30372bb45f6cafab945ef5af25e66b1c634c01dd39e0188"},
|
||||
{file = "pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87"},
|
||||
{file = "pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2413,13 +2413,13 @@ pyasn1_modules = ">=0.1.5"
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.12"
|
||||
version = "0.0.17"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf"},
|
||||
{file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"},
|
||||
{file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"},
|
||||
{file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2796,13 +2796,13 @@ rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.9.3"
|
||||
version = "13.9.4"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
files = [
|
||||
{file = "rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283"},
|
||||
{file = "rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e"},
|
||||
{file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"},
|
||||
{file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2815,29 +2815,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.7.1"
|
||||
version = "0.7.3"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"},
|
||||
{file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"},
|
||||
{file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"},
|
||||
{file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"},
|
||||
{file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"},
|
||||
{file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"},
|
||||
{file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"},
|
||||
{file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"},
|
||||
{file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"},
|
||||
{file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"},
|
||||
{file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"},
|
||||
{file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"},
|
||||
{file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"},
|
||||
{file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"},
|
||||
{file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"},
|
||||
{file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"},
|
||||
{file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"},
|
||||
{file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"},
|
||||
{file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3416,4 +3416,4 @@ pgsql = ["psycopg2-binary"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "455f4f29104e6614f7ff7e899cf2d63302cd84dd693b59ee17d48ca05fd39543"
|
||||
content-hash = "2a3b97688c700f6c01241c0559afa48bdf039399261e7cdd68eebad96dadb44f"
|
||||
|
||||
@@ -3,7 +3,7 @@ authors = ["Hayden <hay-kot@pm.me>"]
|
||||
description = "A Recipe Manager"
|
||||
license = "AGPL"
|
||||
name = "mealie"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
start = "mealie.app:main"
|
||||
@@ -19,7 +19,7 @@ aniso8601 = "9.0.1"
|
||||
appdirs = "1.4.4"
|
||||
apprise = "^1.4.5"
|
||||
bcrypt = "^4.0.1"
|
||||
extruct = "^0.17.0"
|
||||
extruct = "^0.18.0"
|
||||
fastapi = "^0.115.0"
|
||||
httpx = "^0.27.0"
|
||||
lxml = "^5.0.0"
|
||||
@@ -31,7 +31,7 @@ python = "^3.10"
|
||||
python-dateutil = "^2.8.2"
|
||||
python-dotenv = "^1.0.0"
|
||||
python-ldap = "^3.3.1"
|
||||
python-multipart = "^0.0.12"
|
||||
python-multipart = "^0.0.17"
|
||||
python-slugify = "^8.0.0"
|
||||
recipe-scrapers = "^15.0.0"
|
||||
requests = "^2.31.0"
|
||||
|
||||
@@ -60,6 +60,8 @@ def test_create_cookbook(api_client: TestClient, unique_user: TestUser):
|
||||
page_data = get_page_data(unique_user.group_id, unique_user.household_id)
|
||||
response = api_client.post(api_routes.households_cookbooks, json=page_data, headers=unique_user.token)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["groupId"] == unique_user.group_id
|
||||
assert response.json()["householdId"] == unique_user.household_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name_input", ["", " ", "@"])
|
||||
@@ -78,9 +80,22 @@ def test_create_cookbook_bad_name(api_client: TestClient, unique_user: TestUser,
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_read_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]):
|
||||
@pytest.mark.parametrize("use_other_household", [True, False])
|
||||
def test_read_cookbook(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
h2_user: TestUser,
|
||||
cookbooks: list[TestCookbook],
|
||||
use_other_household: bool,
|
||||
):
|
||||
sample = random.choice(cookbooks)
|
||||
response = api_client.get(api_routes.households_cookbooks_item_id(sample.id), headers=unique_user.token)
|
||||
if use_other_household:
|
||||
headers = h2_user.token
|
||||
else:
|
||||
headers = unique_user.token
|
||||
|
||||
# all households should be able to fetch all cookbooks
|
||||
response = api_client.get(api_routes.households_cookbooks_item_id(sample.id), headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
page_data = response.json()
|
||||
@@ -111,6 +126,28 @@ def test_update_cookbook(api_client: TestClient, unique_user: TestUser, cookbook
|
||||
assert page_data["slug"] == update_data["name"]
|
||||
|
||||
|
||||
def test_update_cookbook_other_household(
|
||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, cookbooks: list[TestCookbook]
|
||||
):
|
||||
cookbook = random.choice(cookbooks)
|
||||
|
||||
update_data = get_page_data(unique_user.group_id, unique_user.household_id)
|
||||
|
||||
update_data["name"] = random_string(10)
|
||||
|
||||
response = api_client.put(
|
||||
api_routes.households_cookbooks_item_id(cookbook.id), json=update_data, headers=h2_user.token
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
response = api_client.get(api_routes.households_cookbooks_item_id(cookbook.id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
page_data = response.json()
|
||||
assert page_data["name"] != update_data["name"]
|
||||
assert page_data["slug"] != update_data["name"]
|
||||
|
||||
|
||||
def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]):
|
||||
pages = [x.data for x in cookbooks]
|
||||
|
||||
@@ -135,6 +172,20 @@ def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, co
|
||||
assert str(know) in server_ids
|
||||
|
||||
|
||||
def test_update_cookbooks_many_other_household(
|
||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, cookbooks: list[TestCookbook]
|
||||
):
|
||||
pages = [x.data for x in cookbooks]
|
||||
|
||||
reverse_order = sorted(pages, key=lambda x: x["position"], reverse=True)
|
||||
for x, page in enumerate(reverse_order):
|
||||
page["position"] = x
|
||||
page["group_id"] = str(unique_user.group_id)
|
||||
|
||||
response = api_client.put(api_routes.households_cookbooks, json=utils.jsonify(reverse_order), headers=h2_user.token)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]):
|
||||
sample = random.choice(cookbooks)
|
||||
response = api_client.delete(api_routes.households_cookbooks_item_id(sample.id), headers=unique_user.token)
|
||||
@@ -145,6 +196,18 @@ def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbook
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_cookbook_other_household(
|
||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, cookbooks: list[TestCookbook]
|
||||
):
|
||||
sample = random.choice(cookbooks)
|
||||
response = api_client.delete(api_routes.households_cookbooks_item_id(sample.id), headers=h2_user.token)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
response = api_client.get(api_routes.households_cookbooks_item_id(sample.slug), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"qf_string, expected_code",
|
||||
[
|
||||
|
||||
@@ -299,3 +299,16 @@ def test_cookbook_recipes_includes_all_households(api_client: TestClient, unique
|
||||
assert recipe.id in fetched_recipe_ids
|
||||
for recipe in other_recipes:
|
||||
assert recipe.id in fetched_recipe_ids
|
||||
|
||||
|
||||
def test_cookbooks_from_other_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
|
||||
h2_cookbook = h2_user.repos.cookbooks.create(
|
||||
SaveCookBook(
|
||||
name=random_string(),
|
||||
group_id=h2_user.group_id,
|
||||
household_id=h2_user.household_id,
|
||||
)
|
||||
)
|
||||
|
||||
response = api_client.get(api_routes.recipes, params={"cookbook": h2_cookbook.slug}, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import pytest
|
||||
from pytest import MonkeyPatch, Session
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.security.providers.openid_provider import OpenIDProvider
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from tests.utils.factories import random_email, random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
@@ -125,3 +127,38 @@ def test_has_admin_group_new_user(monkeypatch: MonkeyPatch, session: Session):
|
||||
user = db.users.get_one("dude2", "username")
|
||||
assert user is not None
|
||||
assert user.admin
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valid_group", [True, False])
|
||||
@pytest.mark.parametrize("valid_household", [True, False])
|
||||
def test_ldap_user_creation_invalid_group_or_household(
|
||||
monkeypatch: MonkeyPatch, session: Session, valid_group: bool, valid_household: bool
|
||||
):
|
||||
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
|
||||
monkeypatch.setenv("OIDC_ADMIN_GROUP", "mealie_admin")
|
||||
if not valid_group:
|
||||
monkeypatch.setenv("DEFAULT_GROUP", random_string())
|
||||
if not valid_household:
|
||||
monkeypatch.setenv("DEFAULT_HOUSEHOLD", random_string())
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": random_email(),
|
||||
"name": random_string(),
|
||||
"groups": ["mealie_user"],
|
||||
}
|
||||
auth_provider = OpenIDProvider(session, data)
|
||||
|
||||
if valid_group and valid_household:
|
||||
assert auth_provider.authenticate() is not None
|
||||
else:
|
||||
assert auth_provider.authenticate() is None
|
||||
|
||||
db = get_repositories(session, group_id=None, household_id=None)
|
||||
user = db.users.get_one(data["preferred_username"], "username")
|
||||
|
||||
if valid_group and valid_household:
|
||||
assert user is not None
|
||||
else:
|
||||
assert user is None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -126,13 +127,27 @@ smtp_validation_cases = [
|
||||
(
|
||||
"good_data_tls",
|
||||
SMTPValidationCase(
|
||||
"email.mealie.io", "587", "tls", "Mealie", "mealie@mealie.io", "mealie@mealie.io", "mealie-password", True
|
||||
"email.mealie.io",
|
||||
"587",
|
||||
"tls",
|
||||
"Mealie",
|
||||
"mealie@mealie.io",
|
||||
"mealie@mealie.io",
|
||||
"mealie-password",
|
||||
True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"good_data_ssl",
|
||||
SMTPValidationCase(
|
||||
"email.mealie.io", "465", "tls", "Mealie", "mealie@mealie.io", "mealie@mealie.io", "mealie-password", True
|
||||
"email.mealie.io",
|
||||
"465",
|
||||
"tls",
|
||||
"Mealie",
|
||||
"mealie@mealie.io",
|
||||
"mealie@mealie.io",
|
||||
"mealie-password",
|
||||
True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -151,6 +166,149 @@ def test_smtp_enable_with_bad_data_tls(data: SMTPValidationCase):
|
||||
data.auth_strategy,
|
||||
data.user,
|
||||
data.password,
|
||||
)
|
||||
).enabled
|
||||
|
||||
assert is_valid is data.is_valid
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EnvVar:
|
||||
name: str
|
||||
value: any
|
||||
|
||||
|
||||
class LDAPValidationCase:
|
||||
settings = list[EnvVar]
|
||||
is_valid: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled: bool,
|
||||
server_url: str | None,
|
||||
base_dn: str | None,
|
||||
is_valid: bool,
|
||||
):
|
||||
self.settings = [
|
||||
EnvVar("LDAP_AUTH_ENABLED", enabled),
|
||||
EnvVar("LDAP_SERVER_URL", server_url),
|
||||
EnvVar("LDAP_BASE_DN", base_dn),
|
||||
]
|
||||
self.is_valid = is_valid
|
||||
|
||||
|
||||
ldap_validation_cases = [
|
||||
("not enabled", LDAPValidationCase(False, None, None, False)),
|
||||
("missing url", LDAPValidationCase(True, None, "dn", False)),
|
||||
("missing base dn", LDAPValidationCase(True, "url", None, False)),
|
||||
("all good", LDAPValidationCase(True, "url", "dn", True)),
|
||||
]
|
||||
|
||||
ldap_cases = [x[1] for x in ldap_validation_cases]
|
||||
ldap_cases_ids = [x[0] for x in ldap_validation_cases]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", ldap_cases, ids=ldap_cases_ids)
|
||||
def test_ldap_settings_validation(data: LDAPValidationCase, monkeypatch: pytest.MonkeyPatch):
|
||||
for setting in data.settings:
|
||||
if setting.value is not None:
|
||||
monkeypatch.setenv(setting.name, setting.value)
|
||||
else:
|
||||
monkeypatch.delenv(setting.name, raising=False)
|
||||
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
|
||||
assert app_settings.LDAP_ENABLED is data.is_valid
|
||||
|
||||
|
||||
class OIDCValidationCase:
|
||||
settings = list[EnvVar]
|
||||
is_valid: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled: bool,
|
||||
client_id: str | None,
|
||||
client_secret: str | None,
|
||||
configuration_url: str | None,
|
||||
groups_claim: str | None,
|
||||
user_group: str | None,
|
||||
admin_group: str | None,
|
||||
is_valid: bool,
|
||||
):
|
||||
self.settings = [
|
||||
EnvVar("OIDC_AUTH_ENABLED", enabled),
|
||||
EnvVar("OIDC_CLIENT_ID", client_id),
|
||||
EnvVar("OIDC_CLIENT_SECRET", client_secret),
|
||||
EnvVar("OIDC_CONFIGURATION_URL", configuration_url),
|
||||
EnvVar("OIDC_GROUPS_CLAIM", groups_claim),
|
||||
EnvVar("OIDC_USER_GROUP", user_group),
|
||||
EnvVar("OIDC_ADMIN_GROUP", admin_group),
|
||||
]
|
||||
self.is_valid = is_valid
|
||||
|
||||
|
||||
oidc_validation_cases = [
|
||||
(
|
||||
"not enabled",
|
||||
OIDCValidationCase(False, None, None, None, None, None, None, False),
|
||||
),
|
||||
(
|
||||
"missing client id",
|
||||
OIDCValidationCase(True, None, "secret", "url", "groups", "user", "admin", False),
|
||||
),
|
||||
(
|
||||
"missing client secret",
|
||||
OIDCValidationCase(True, "id", None, "url", "groups", "user", "admin", False),
|
||||
),
|
||||
(
|
||||
"missing url",
|
||||
OIDCValidationCase(True, "id", "secret", None, "groups", "user", "admin", False),
|
||||
),
|
||||
(
|
||||
"all good no groups",
|
||||
OIDCValidationCase(True, "id", "secret", "url", None, None, None, True),
|
||||
),
|
||||
(
|
||||
"all good with groups",
|
||||
OIDCValidationCase(True, "id", "secret", "url", "groups", "user", "admin", True),
|
||||
),
|
||||
]
|
||||
|
||||
oidc_cases = [x[1] for x in oidc_validation_cases]
|
||||
oidc_cases_ids = [x[0] for x in oidc_validation_cases]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", oidc_cases, ids=oidc_cases_ids)
|
||||
def test_oidc_settings_validation(data: OIDCValidationCase, monkeypatch: pytest.MonkeyPatch):
|
||||
for setting in data.settings:
|
||||
if setting.value is not None:
|
||||
monkeypatch.setenv(setting.name, setting.value)
|
||||
else:
|
||||
monkeypatch.delenv(setting.name, raising=False)
|
||||
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
|
||||
assert app_settings.OIDC_READY is data.is_valid
|
||||
|
||||
|
||||
def test_sensitive_settings_mask(monkeypatch: pytest.MonkeyPatch):
|
||||
sensitive_settings = [
|
||||
"LDAP_QUERY_PASSWORD",
|
||||
"OPENAI_API_KEY",
|
||||
"SMTP_USER",
|
||||
"SMTP_PASSWORD",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
]
|
||||
for setting in sensitive_settings:
|
||||
monkeypatch.setenv(setting, "super_secret")
|
||||
|
||||
get_app_settings.cache_clear()
|
||||
app_settings = get_app_settings()
|
||||
settings = app_settings.model_dump()
|
||||
settings_json = json.loads(app_settings.model_dump_json())
|
||||
|
||||
for setting in sensitive_settings:
|
||||
assert settings[setting] == "*****"
|
||||
assert settings_json[setting] == "*****"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import ldap
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from mealie.core import security
|
||||
@@ -13,6 +14,7 @@ from mealie.core.security.providers.credentials_provider import (
|
||||
from mealie.core.security.providers.ldap_provider import LDAPProvider
|
||||
from mealie.db.db_setup import session_context
|
||||
from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.user.auth import CredentialsRequestForm
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from tests.utils import random_string
|
||||
@@ -92,7 +94,7 @@ class LdapConnMock:
|
||||
pass
|
||||
|
||||
|
||||
def setup_env(monkeypatch: MonkeyPatch):
|
||||
def setup_env(monkeypatch: MonkeyPatch, **kwargs):
|
||||
user = random_string(10)
|
||||
mail = random_string(10)
|
||||
name = random_string(10)
|
||||
@@ -140,11 +142,55 @@ def test_ldap_user_creation(monkeypatch: MonkeyPatch):
|
||||
provider = get_provider(session, user, password)
|
||||
result = provider.get_user()
|
||||
|
||||
app_settings = get_app_settings()
|
||||
|
||||
assert result
|
||||
assert result.username == user
|
||||
assert result.email == mail
|
||||
assert result.full_name == name
|
||||
assert result.admin is False
|
||||
assert result.group == app_settings.DEFAULT_GROUP
|
||||
assert result.household == app_settings.DEFAULT_HOUSEHOLD
|
||||
assert result.auth_method == AuthMethod.LDAP
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valid_group", [True, False])
|
||||
@pytest.mark.parametrize("valid_household", [True, False])
|
||||
def test_ldap_user_creation_invalid_group_or_household(
|
||||
unfiltered_database: AllRepositories, monkeypatch: MonkeyPatch, valid_group: bool, valid_household: bool
|
||||
):
|
||||
user, mail, name, password, query_bind, query_password = setup_env(monkeypatch)
|
||||
if not valid_group:
|
||||
monkeypatch.setenv("DEFAULT_GROUP", random_string())
|
||||
if not valid_household:
|
||||
monkeypatch.setenv("DEFAULT_HOUSEHOLD", random_string())
|
||||
|
||||
def ldap_initialize_mock(url):
|
||||
assert url == ""
|
||||
return LdapConnMock(user, password, False, query_bind, query_password, mail, name)
|
||||
|
||||
monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock)
|
||||
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
with session_context() as session:
|
||||
provider = get_provider(session, user, password)
|
||||
try:
|
||||
result = provider.get_user()
|
||||
except ValueError:
|
||||
result = None
|
||||
|
||||
if valid_group and valid_household:
|
||||
assert result
|
||||
else:
|
||||
assert not result
|
||||
|
||||
# check if the user exists in the db
|
||||
user = unfiltered_database.users.get_by_username(user)
|
||||
if valid_group and valid_household:
|
||||
assert user
|
||||
else:
|
||||
assert not user
|
||||
|
||||
|
||||
def test_ldap_user_creation_fail(monkeypatch: MonkeyPatch):
|
||||
|
||||
Reference in New Issue
Block a user