Feature/authentication (#206)

* basic crud NOT SECURE

* refactor/database init on startup

* added scratch.py

* tests/user CRUD routes

* password hashing

* change app_config location

* bump python version

* formatting

* login ui starter

* change import from url design

* move components

* remove old snackbar

* refactor/Componenet folder structure rework

* refactor/remove old code

* refactor/rename componenets/js files

* remove console.logs

* refactor/ models to schema and sql to models

* new header styling for imports

* token request

* fix url scrapper

* refactor/rename schema files

* split routes file

* redesigned admin page

* enable relative imports for vue components

* refactor/switch to pages view

* add CamelCase package

* majors settings rework

* user management second pass

* super user CRUD

* refactor/consistent models names

* refactor/consistent model names

* password reset

* store refactor

* dependency update

* abstract button props

* profile page refactor

* basic password validation

* login form refactor/split v-container

* remo unused code

* hide editor buttons when not logged in

* mkdocs dev dependency

* v0.4.0 docs update

* profile image upload

* additional token routes

* Smaller recipe cards for smaller viewports

* fix admin sidebar

* add users

* change to outlined

* theme card starter

* code cleanup

* signups

* signup pages

* fix #194

* fix #193

* clarify mealie_port

* fix #184

* fixes #178

* fix blank card error on meal-plan creator

* admin signup

* formatting

* improved search bar

* improved search bar

* refresh token on page refresh

* allow mealplan with no categories

* fix card layout

* remove cdn dependencies

* start on groups

* Fixes #196

* recipe databse refactor

* changelog draft

* database refactoring

* refactor recipe schema/model

* site settings refactor

* continued model refactor

* merge docs changes from master

* site-settings work

* cleanup + tag models

* notes

* typo

* user table

* sign up data validation

* package updates

* group store init

* Fix home page settings

* group admin init

* group dashboard init

* update deps

* formatting

* bug / added libffi-dev

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-03-13 11:41:29 -09:00 committed by GitHub
commit 4a9955450c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 4255 additions and 2503 deletions

View file

@ -7,7 +7,7 @@ RUN npm run build
FROM python:3.9-alpine FROM python:3.9-alpine
RUN apk add --no-cache libxml2-dev libxslt-dev libxml2 caddy RUN apk add --no-cache libxml2-dev libxslt-dev libxml2 caddy libffi-dev
ENV ENV prod ENV ENV prod
EXPOSE 80 EXPOSE 80
WORKDIR /app WORKDIR /app

View file

@ -1,5 +1,36 @@
# Release Notes # Release Notes
## v0.4.0 Whoa, What a Release! [DRAFT]
### Bug Fixes
### Features and Improvements
- Authentication! Tons of stuff went into creating a flexible authentication platform for a lot of different use cases. Review the documentation for more information on how to use the authentication, and how everything works together. Some key features include
- Sign Up Links
- Admin and User Roles
- Group Management
- Create/Edit/Delete Restrictions
- Recipe Database Refactoring. Tons of new information is now stored for recipes in the database. Not all is accessible via the UI, but it's coming.
- Nutrition Information
- calories
- fatContent
- fiberContent
- proteinContent
- sodiumContent
- sugarContent
- recipeCuisine has been added
- "categories" has been migrated to "recipeCategory" to adhear closer to the standard schema
- "tool" - a list of tools used for the recipe
- Removed CDN dependencies
- Completed Redesign of the Admin Panel
- Profile Pages
- Side Panel Menu
- Language selector is now displayed on all pages and does not require an account
### Development / Misc
- Database Model Refactoring
- File/Folder Name Refactoring
## v0.3.0 ## v0.3.0
### Bug Fixes ### Bug Fixes

View file

@ -0,0 +1,35 @@
# Using iOS Shortcuts with Mealie
![](../img/iphone-image.png){: align=right style="height:400px;width:400px"}
User [brasilikum](https://github.com/brasilikum) opened an issue on the main repo about how they had created an [iOS shortcut](https://github.com/hay-kot/mealie/issues/103) for interested users. This is a useful utility for iOS users who browse for recipes in their web browser from their devices.
Don't know what an iOS shortcut is? Neither did I! Experienced iOS users may already be familiar with this utility but for the uninitiated, here is the official Apple explanation:
> A shortcut is a quick way to get one or more tasks done with your apps. The Shortcuts app lets you create your own shortcuts with multiple steps. For example, build a “Surf Time” shortcut that grabs the surf report, gives an ETA to the beach, and launches your surf music playlist.
Basically it is a visual scripting language that lets a user build an automation in a guided fashion. The automation can be [shared with anyone](https://www.icloud.com/shortcuts/6ae356d5fc644cfa8983a3c90f242fbb) but if it is a user creation, you'll have to jump through a few hoops to make an untrusted automation work on your device. In brasilikum's shortcut, you need to make changes for it to work. Recent updates to the project have changed some of the syntax and folder structure since its original creation.
![screenshot](../img/ios-shortcut-image.jpg){: align=right style="height:500;width:400px"}
!!! tip
You may need to change the url depending on which version you're using. Recipe is now plural and there is no trailing "/" at the end of the string.
```
api/recipe/create-url/
```
to
```
api/recipes/create-url
```
Having made those changes, you should now be able to share a website to the shortcut and have mealie grab all the necessary information!

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

View file

@ -29,6 +29,7 @@ nav:
- Getting Started: - Getting Started:
- Installation: "getting-started/install.md" - Installation: "getting-started/install.md"
- Working With Recipes: "getting-started/recipes.md" - Working With Recipes: "getting-started/recipes.md"
- iOS Shortcuts: "getting-started/ios.md"
- User Management: "getting-started/users.md" - User Management: "getting-started/users.md"
- Planning Meals: "getting-started/meal-planner.md" - Planning Meals: "getting-started/meal-planner.md"
- Site Settings: "getting-started/site-settings.md" - Site Settings: "getting-started/site-settings.md"

File diff suppressed because it is too large Load diff

View file

@ -12,33 +12,35 @@
"@adapttive/vue-markdown": "^3.0.3", "@adapttive/vue-markdown": "^3.0.3",
"@smartweb/vue-flash-message": "^0.6.10", "@smartweb/vue-flash-message": "^0.6.10",
"axios": "^0.21.1", "axios": "^0.21.1",
"core-js": "^3.8.2", "core-js": "^3.9.1",
"fast-levenshtein": "^3.0.0", "fast-levenshtein": "^3.0.0",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"qs": "^6.9.6", "qs": "^6.9.6",
"typeface-roboto": "^1.1.13",
"v-jsoneditor": "^1.4.2", "v-jsoneditor": "^1.4.2",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-i18n": "^8.22.4", "vue-i18n": "^8.24.1",
"vue-router": "^3.4.9", "vue-router": "^3.5.1",
"vuedraggable": "^2.24.3", "vuedraggable": "^2.24.3",
"vuetify": "^2.4.2", "vuetify": "^2.4.6",
"vuex": "^3.6.0", "vuex": "^3.6.2",
"vuex-persistedstate": "^4.0.0-beta.3" "vuex-persistedstate": "^4.0.0-beta.3"
}, },
"devDependencies": { "devDependencies": {
"@intlify/vue-i18n-loader": "^1.0.0", "@intlify/vue-i18n-loader": "^1.1.0",
"@vue/cli-plugin-babel": "^4.5.10", "@mdi/font": "^5.9.55",
"@vue/cli-plugin-eslint": "^4.5.10", "@vue/cli-plugin-babel": "^4.5.11",
"@vue/cli-service": "^4.5.10", "@vue/cli-plugin-eslint": "^4.5.11",
"@vue/cli-service": "^4.5.11",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^6.2.2",
"sass": "^1.32.4", "sass": "^1.32.8",
"sass-loader": "^8.0.0", "sass-loader": "^8.0.2",
"vue-cli-plugin-i18n": "~1.0.1", "vue-cli-plugin-i18n": "~1.0.1",
"vue-cli-plugin-vuetify": "^2.0.8", "vue-cli-plugin-vuetify": "^2.2.2",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"vuetify-loader": "^1.3.0" "vuetify-loader": "^1.7.2"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View file

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title> Mealie </title> <title> Mealie </title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"> <!-- <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css"> <!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css"> -->
</head> </head>
<body> <body>
<noscript> <noscript>

View file

@ -17,7 +17,6 @@
<v-expand-x-transition> <v-expand-x-transition>
<SearchBar <SearchBar
ref="mainSearchBar" ref="mainSearchBar"
class="mt-7"
v-if="search" v-if="search"
:show-results="true" :show-results="true"
@selected="navigateFromSearch" @selected="navigateFromSearch"
@ -78,6 +77,8 @@ export default {
this.$store.dispatch("initTheme"); this.$store.dispatch("initTheme");
this.$store.dispatch("requestRecentRecipes"); this.$store.dispatch("requestRecentRecipes");
this.$store.dispatch("requestHomePageSettings"); this.$store.dispatch("requestHomePageSettings");
this.$store.dispatch("requestSiteSettings");
this.$store.dispatch("refreshToken");
this.darkModeSystemCheck(); this.darkModeSystemCheck();
this.darkModeAddEventListener(); this.darkModeAddEventListener();
}, },

View file

@ -47,7 +47,7 @@ const apiReq = {
return response; return response;
} else return; } else return;
}); });
// processResponse(response); processResponse(response);
return response; return response;
}, },

View file

@ -0,0 +1,34 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const groupPrefix = baseURL + "groups";
const groupsURLs = {
groups: `${groupPrefix}`,
create: `${groupPrefix}`,
delete: id => `${groupPrefix}/${id}`,
current: `${groupPrefix}/self`,
update: id => `${groupPrefix}/${id}`,
};
export default {
async allGroups() {
let response = await apiReq.get(groupsURLs.groups);
return response.data;
},
async create(name) {
let response = await apiReq.post(groupsURLs.create, { name: name });
return response.data;
},
async delete(id) {
let response = await apiReq.delete(groupsURLs.delete(id));
return response.data;
},
async current() {
let response = await apiReq.get(groupsURLs.current);
return response.data;
},
async update(data) {
let response = await apiReq.put(groupsURLs.update(data.id), data);
return response.data;
},
};

View file

@ -9,9 +9,12 @@ import category from "./category";
import meta from "./meta"; import meta from "./meta";
import users from "./users"; import users from "./users";
import signUps from "./signUps"; import signUps from "./signUps";
import groups from "./groups";
import siteSettings from "./siteSettings";
export default { export default {
recipes: recipe, recipes: recipe,
siteSettings: siteSettings,
backups: backup, backups: backup,
mealPlans: mealplan, mealPlans: mealplan,
settings: settings, settings: settings,
@ -22,4 +25,5 @@ export default {
meta: meta, meta: meta,
users: users, users: users,
signUps: signUps, signUps: signUps,
groups: groups,
}; };

View file

@ -70,7 +70,7 @@ export default {
router.push(`/`); router.push(`/`);
}, },
async allByKeys(recipeKeys, num = 999) { async allByKeys(recipeKeys, num = 9999) {
const response = await apiReq.get(recipeURLs.allRecipes, { const response = await apiReq.get(recipeURLs.allRecipes, {
params: { params: {
keys: recipeKeys, keys: recipeKeys,

View file

@ -0,0 +1,22 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const settingsBase = baseURL + "site-settings";
const settingsURLs = {
siteSettings: `${settingsBase}`,
updateSiteSettings: `${settingsBase}`,
testWebhooks: `${settingsBase}/webhooks/test`,
};
export default {
async get() {
let response = await apiReq.get(settingsURLs.siteSettings);
return response.data;
},
async update(body) {
let response = await apiReq.put(settingsURLs.updateSiteSettings, body);
return response.data;
},
};

View file

@ -1,10 +1,12 @@
import { baseURL } from "./api-utils"; import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils"; import { apiReq } from "./api-utils";
import axios from "axios";
const authPrefix = baseURL + "auth"; const authPrefix = baseURL + "auth";
const userPrefix = baseURL + "users"; const userPrefix = baseURL + "users";
const authURLs = { const authURLs = {
token: `${authPrefix}/token`, token: `${authPrefix}/token`,
refresh: `${authPrefix}/refresh`,
}; };
@ -24,6 +26,12 @@ export default {
}); });
return response; return response;
}, },
async refresh() {
let response = await axios.get(authURLs.refresh).catch(function(event) {
console.log("Fetch failed", event);
});
return response.data ? response.data : false;
},
async allUsers() { async allUsers() {
let response = await apiReq.get(usersURLs.users); let response = await apiReq.get(usersURLs.users);
return response.data; return response.data;

View file

@ -3,9 +3,12 @@
<v-card-text> <v-card-text>
<h2 class="mt-1 mb-1">{{ $t("settings.homepage.home-page") }}</h2> <h2 class="mt-1 mb-1">{{ $t("settings.homepage.home-page") }}</h2>
<v-row align="center" justify="center" dense class="mb-n7 pb-n5"> <v-row align="center" justify="center" dense class="mb-n7 pb-n5">
<v-col cols="1">
<LanguageMenu @select-lang="writeLang" :site-settings="true" />
</v-col>
<v-col cols="12" sm="3" md="2"> <v-col cols="12" sm="3" md="2">
<v-switch <v-switch
v-model="showRecent" v-model="settings.showRecent"
:label="$t('settings.homepage.show-recent')" :label="$t('settings.homepage.show-recent')"
></v-switch> ></v-switch>
</v-col> </v-col>
@ -13,7 +16,7 @@
<v-slider <v-slider
class="pt-sm-4" class="pt-sm-4"
:label="$t('settings.homepage.card-per-section')" :label="$t('settings.homepage.card-per-section')"
v-model="showLimit" v-model="settings.cardsPerSection"
max="30" max="30"
dense dense
color="primary" color="primary"
@ -35,7 +38,7 @@
</v-icon> </v-icon>
<v-toolbar-title class="headline"> <v-toolbar-title class="headline">
Home Page Categories Home Page Sections
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -43,14 +46,14 @@
<v-list height="300" dense style="overflow:auto"> <v-list height="300" dense style="overflow:auto">
<v-list-item-group> <v-list-item-group>
<draggable <draggable
v-model="homeCategories" v-model="settings.categories"
group="categories" group="categories"
:style="{ :style="{
minHeight: `150px`, minHeight: `150px`,
}" }"
> >
<v-list-item <v-list-item
v-for="(item, index) in homeCategories" v-for="(item, index) in settings.categories"
:key="`${item.name}-${index}`" :key="`${item.name}-${index}`"
> >
<v-list-item-icon> <v-list-item-icon>
@ -85,14 +88,14 @@
<v-list height="300" dense style="overflow:auto"> <v-list height="300" dense style="overflow:auto">
<v-list-item-group> <v-list-item-group>
<draggable <draggable
v-model="categories" v-model="allCategories"
group="categories" group="categories"
:style="{ :style="{
minHeight: `150px`, minHeight: `150px`,
}" }"
> >
<v-list-item <v-list-item
v-for="(item, index) in categories" v-for="(item, index) in allCategories"
:key="`${item.name}-${index}`" :key="`${item.name}-${index}`"
> >
<v-list-item-icon> <v-list-item-icon>
@ -127,47 +130,50 @@
<script> <script>
import api from "@/api"; import api from "@/api";
import LanguageMenu from "@/components/UI/LanguageMenu";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
export default { export default {
components: { components: {
draggable, draggable,
LanguageMenu,
}, },
data() { data() {
return { return {
homeCategories: null, settings: {
showLimit: null, language: "en",
showRecent: true, showRecent: null,
cardsPerSection: null,
categories: [],
},
}; };
}, },
mounted() { mounted() {
this.getOptions(); this.getOptions();
}, },
computed: { computed: {
categories() { allCategories() {
return this.$store.getters.getCategories; return this.$store.getters.getCategories;
}, },
}, },
methods: { methods: {
writeLang(val) {
console.log(val);
this.settings.language = val;
},
deleteCategoryfromDatabase(category) { deleteCategoryfromDatabase(category) {
api.categories.delete(category); api.categories.delete(category);
this.$store.dispatch("requestHomePageSettings");
}, },
getOptions() { async getOptions() {
this.showLimit = this.$store.getters.getShowLimit; this.settings = await api.siteSettings.get();
this.showRecent = this.$store.getters.getShowRecent;
this.homeCategories = this.$store.getters.getHomeCategories;
}, },
deleteActiveCategory(index) { deleteActiveCategory(index) {
this.homeCategories.splice(index, 1); this.settings.categories.splice(index, 1);
}, },
saveSettings() { async saveSettings() {
this.homeCategories.forEach((element, index) => { await api.siteSettings.update(this.settings);
element.position = index + 1; this.getOptions();
});
this.$store.commit("setShowRecent", this.showRecent);
this.$store.commit("setShowLimit", this.showLimit);
this.$store.commit("setHomeCategories", this.homeCategories);
}, },
}, },
}; };

View file

@ -0,0 +1,123 @@
<template>
<div>
<Confirmation
ref="deleteGroupConfirm"
title="Confirm Group Deletion"
:message="`Are you sure you want to delete <b>${group.name}<b/>`"
icon="mdi-alert"
@confirm="deleteGroup"
:width="450"
@close="closeGroupDelete"
/>
<v-card class="ma-auto" tile min-height="325px">
<v-list dense>
<v-card-title class="py-1">{{ group.name }}</v-card-title>
<v-divider></v-divider>
<v-subheader>Group ID: {{ group.id }}</v-subheader>
<v-list-item-group color="primary">
<v-list-item v-for="property in groupProps" :key="property.text">
<v-list-item-icon>
<v-icon> {{ property.icon || "mdi-account" }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="pl-4 flex row justify-space-between">
<div>{{ property.text }}</div>
<div>{{ property.value }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
small
color="error"
@click="confirmDelete"
:disabled="ableToDelete"
>
Delete
</v-btn>
<!-- Coming Soon! -->
<v-btn small color="success" disabled>
Edit
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
const RENDER_EVENT = "update";
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
export default {
components: { Confirmation },
props: {
group: {
default: {
name: "DEFAULT_NAME",
id: 1,
users: [],
mealplans: [],
categories: [],
webhookUrls: [],
webhookTime: "00:00",
webhookEnable: false,
},
},
},
data() {
return {
groupProps: {},
};
},
computed: {
ableToDelete() {
return this.group.users.length >= 1 ? true : false;
},
},
mounted() {
this.buildData();
},
methods: {
confirmDelete() {
this.$refs.deleteGroupConfirm.open();
},
async deleteGroup() {
await api.groups.delete(this.group.id);
this.$emit(RENDER_EVENT);
},
closeGroupDelete() {
console.log("Close Delete");
},
buildData() {
this.groupProps = [
{
text: "Total Users",
icon: "mdi-account",
value: this.group.users.length,
},
{
text: "Total MealPlans",
icon: "mdi-food",
value: this.group.mealplans.length,
},
{
text: "Webhooks Enabled",
icon: "mdi-webhook",
value: this.group.webhookEnable ? "True" : "False",
},
{
text: "Webhook Time",
icon: "mdi-clock-outline",
value: this.group.webhookTime,
},
];
},
},
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,120 @@
<template>
<div>
<v-card outlined class="mt-n1">
<v-card-actions>
<v-spacer></v-spacer>
<div width="100px">
<v-text-field
v-model="filter"
clearable
class="mr-2 pt-0"
append-icon="mdi-filter"
label="Filter"
single-line
hide-details
></v-text-field>
</div>
<v-dialog v-model="groupDialog" max-width="400">
<template v-slot:activator="{ on, attrs }">
<v-btn
class="mx-2"
small
color="success"
dark
v-bind="attrs"
v-on="on"
>
Create Group
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account-group
</v-icon>
<v-toolbar-title class="headline">
Create Group
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<v-form ref="newGroup">
<v-text-field
v-model="newGroupName"
label="Group Name"
:rules="[existsRule]"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="groupDialog = false">
Cancel
</v-btn>
<v-btn color="primary" @click="createGroup">
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card-actions>
<v-card-text>
<v-row>
<v-col
:sm="6"
:md="6"
:lg="3"
:xl="3"
v-for="group in groups"
:key="group.id"
>
<GroupCard
:group="group"
@update="$store.dispatch('requestAllGroups')"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</template>
<script>
import { validators } from "@/mixins/validators";
import api from "@/api";
import GroupCard from "@/components/Admin/ManageUsers/GroupCard";
export default {
components: { GroupCard },
mixins: [validators],
data() {
return {
filter: "",
groupDialog: false,
newGroupName: "",
};
},
computed: {
groups() {
return this.$store.getters.getGroups;
},
},
methods: {
async createGroup() {
this.groupLoading = true;
let response = await api.groups.create(this.newGroupName);
if (response.created) {
this.groupLoading = false;
this.groupDialog = false;
this.$store.dispatch("requestAllGroups");
}
},
},
};
</script>
<style>
</style>

View file

@ -1,252 +0,0 @@
<template>
<v-card outlined class="mt-n1">
<Confirmation
ref="deleteUserDialog"
title="Confirm User Deletion"
:message="
`Are you sure you want to delete the user <b>${activeName} ID: ${activeId}<b/>`
"
icon="mdi-alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
<v-toolbar flat>
<v-icon large color="accent" class="mr-1">
mdi-account-group
</v-icon>
<v-toolbar-title class="headine">
User Groups
</v-toolbar-title>
<v-spacer> </v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
Create Group
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account
</v-icon>
<v-toolbar-title class="headline">
{{ formTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-title class="headline">
User ID: {{ editedItem.id }}
</v-toolbar-title>
</v-app-bar>
<v-card-text>
<v-form ref="newUser">
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
label="Full Name"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
label="Email"
:rules="[existsRule, emailRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field
v-model="editedItem.password"
label="User Password"
:rules="[existsRule, minRule]"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-switch v-model="editedItem.admin" label="Admin"></v-switch>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
Cancel
</v-btn>
<v-btn color="primary" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="users" sort-by="calories">
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-btn small color="success" @click="editItem(item)">
<v-icon small left class="mr-2">
mdi-pencil
</v-icon>
Edit
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">
Reset
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: { Confirmation },
mixins: [validators],
data: () => ({
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: "User ID",
align: "start",
sortable: false,
value: "id",
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
users: [],
editedIndex: -1,
editedItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
}),
computed: {
formTitle() {
return this.editedIndex === -1 ? "New User" : "Edit User";
},
showPassword() {
return this.editedIndex === -1 ? true : false;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
async initialize() {
this.users = await api.users.allUsers();
},
async deleteUser() {
await api.users.delete(this.activeId);
this.initialize();
},
editItem(item) {
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.id;
this.activeName = item.fullName;
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.users.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
api.users.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.users.create(this.editedItem);
this.close();
}
await this.initialize();
},
},
};
</script>
<style>
</style>

View file

@ -68,6 +68,7 @@
</v-dialog> </v-dialog>
</v-toolbar> </v-toolbar>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>
<v-data-table :headers="headers" :items="links" sort-by="calories"> <v-data-table :headers="headers" :items="links" sort-by="calories">
<template v-slot:item.token="{ item }"> <template v-slot:item.token="{ item }">

View file

@ -12,14 +12,18 @@
@close="closeDelete" @close="closeDelete"
/> />
<v-toolbar flat> <v-toolbar flat>
<v-icon large color="accent" class="mr-1">
mdi-account
</v-icon>
<v-toolbar-title class="headine">
Users
</v-toolbar-title>
<v-spacer> </v-spacer> <v-spacer> </v-spacer>
<div width="100px">
<v-text-field
v-model="search"
class="mr-2"
append-icon="mdi-filter"
label="Filter"
single-line
hide-details
></v-text-field>
</div>
<v-dialog v-model="dialog" max-width="600px"> <v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }"> <template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on"> <v-btn small color="success" dark v-bind="attrs" v-on="on">
@ -62,13 +66,16 @@
></v-text-field> ></v-text-field>
</v-col> </v-col>
<v-col cols="12" sm="12" md="6"> <v-col cols="12" sm="12" md="6">
<v-text-field <v-select
v-model="editedItem.family" dense
label="Family Group" v-model="editedItem.group"
></v-text-field> :items="existingGroups"
label="User Group"
></v-select>
</v-col> </v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword"> <v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field <v-text-field
dense
v-model="editedItem.password" v-model="editedItem.password"
label="User Password" label="User Password"
:rules="[existsRule, minRule]" :rules="[existsRule, minRule]"
@ -95,7 +102,12 @@
</v-toolbar> </v-toolbar>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>
<v-data-table :headers="headers" :items="users" sort-by="calories"> <v-data-table
:headers="headers"
:items="users"
sort-by="calories"
:search="search"
>
<template v-slot:item.actions="{ item }"> <template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)"> <v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left> <v-icon small left>
@ -131,6 +143,7 @@ export default {
components: { Confirmation }, components: { Confirmation },
mixins: [validators], mixins: [validators],
data: () => ({ data: () => ({
search: "",
dialog: false, dialog: false,
activeId: null, activeId: null,
activeName: null, activeName: null,
@ -143,7 +156,7 @@ export default {
}, },
{ text: "Full Name", value: "fullName" }, { text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" }, { text: "Email", value: "email" },
{ text: "Family", value: "family" }, { text: "Group", value: "group" },
{ text: "Admin", value: "admin" }, { text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" }, { text: "", value: "actions", sortable: false, align: "center" },
], ],
@ -154,7 +167,7 @@ export default {
fullName: "", fullName: "",
password: "", password: "",
email: "", email: "",
family: "", group: "",
admin: false, admin: false,
}, },
defaultItem: { defaultItem: {
@ -162,7 +175,7 @@ export default {
fullName: "", fullName: "",
password: "", password: "",
email: "", email: "",
family: "", group: "",
admin: false, admin: false,
}, },
}), }),
@ -174,6 +187,9 @@ export default {
showPassword() { showPassword() {
return this.editedIndex === -1 ? true : false; return this.editedIndex === -1 ? true : false;
}, },
existingGroups() {
return this.$store.getters.getGroupNames;
},
}, },
watch: { watch: {

View file

@ -7,6 +7,7 @@
<UploadBtn <UploadBtn
class="mt-1" class="mt-1"
:url="`/api/migrations/${folder}/upload`" :url="`/api/migrations/${folder}/upload`"
fileName="archive"
@uploaded="$emit('refresh')" @uploaded="$emit('refresh')"
/> />
</span> </span>

View file

@ -13,11 +13,11 @@
<h3>{{ theme.name }} {{ current ? "(Current)" : "" }}</h3> <h3>{{ theme.name }} {{ current ? "(Current)" : "" }}</h3>
</v-card-text> </v-card-text>
<v-card-text> <v-card-text>
<v-row dense> <v-row flex align-center>
<v-card <v-card
v-for="(color, index) in theme.colors" v-for="(color, index) in theme.colors"
:key="index" :key="index"
class="mx-1" class="ma-1 mx-auto"
height="34" height="34"
width="36" width="36"
:color="color" :color="color"

View file

@ -21,12 +21,13 @@
have a valid invitation link. If you haven't recieved an invitation you have a valid invitation link. If you haven't recieved an invitation you
are unable to sign-up. To recieve a link, contact the sites administrator. are unable to sign-up. To recieve a link, contact the sites administrator.
<v-divider class="mt-3"></v-divider> <v-divider class="mt-3"></v-divider>
<v-form> <v-form ref="signUpForm">
<v-text-field <v-text-field
v-model="user.name" v-model="user.name"
light="light" light="light"
prepend-icon="mdi-account" prepend-icon="mdi-account"
validate-on-blur validate-on-blur
:rules="[existsRule]"
label="Display Name" label="Display Name"
type="email" type="email"
></v-text-field> ></v-text-field>
@ -35,6 +36,7 @@
light="light" light="light"
prepend-icon="mdi-email" prepend-icon="mdi-email"
validate-on-blur validate-on-blur
:rules="[existsRule, emailRule]"
:label="$t('login.email')" :label="$t('login.email')"
type="email" type="email"
></v-text-field> ></v-text-field>
@ -43,10 +45,10 @@
light="light" light="light"
class="mb-2s" class="mb-2s"
prepend-icon="mdi-lock" prepend-icon="mdi-lock"
validate-on-blur
:label="$t('login.password')" :label="$t('login.password')"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" :rules="[minRule]"
@click:append="showPassword = !showPassword"
></v-text-field> ></v-text-field>
<v-text-field <v-text-field
v-model="user.passwordConfirm" v-model="user.passwordConfirm"
@ -56,6 +58,9 @@
:label="$t('login.password')" :label="$t('login.password')"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:rules="[
user.password === user.passwordConfirm || 'Password must match',
]"
@click:append="showPassword = !showPassword" @click:append="showPassword = !showPassword"
></v-text-field> ></v-text-field>
</v-form> </v-form>
@ -80,7 +85,9 @@
<script> <script>
import api from "@/api"; import api from "@/api";
import { validators } from "@/mixins/validators";
export default { export default {
mixins: [validators],
data() { data() {
return { return {
loading: false, loading: false,
@ -121,15 +128,23 @@ export default {
const userData = { const userData = {
fullName: this.user.name, fullName: this.user.name,
email: this.user.email, email: this.user.email,
family: "public", group: "default",
password: this.user.password, password: this.user.password,
admin: false, admin: false,
}; };
await api.signUps.createUser(this.token, userData); let successUser = false;
if (this.$refs.signUpForm.validate()) {
let response = await api.signUps.createUser(this.token, userData);
successUser = response.snackbar.text.includes("Created");
}
this.$emit("user-created"); this.$emit("user-created");
this.loading = false; this.loading = false;
if (successUser) {
this.$router.push("/");
}
}, },
}, },
}; };

View file

@ -118,6 +118,19 @@ export default {
async mounted() { async mounted() {
let settings = await api.settings.requestAll(); let settings = await api.settings.requestAll();
this.items = await api.recipes.getAllByCategory(settings.planCategories); this.items = await api.recipes.getAllByCategory(settings.planCategories);
console.log(this.items);
if (this.items.length === 0) {
const keys = [
"name",
"slug",
"image",
"description",
"dateAdded",
"rating",
];
this.items = await api.recipes.allByKeys(keys);
}
}, },
computed: { computed: {

View file

@ -118,7 +118,7 @@
chips chips
item-color="secondary" item-color="secondary"
deletable-chips deletable-chips
v-model="value.categories" v-model="value.recipeCategory"
hide-selected hide-selected
:items="categories" :items="categories"
text="name" text="name"
@ -359,7 +359,7 @@ export default {
this.value.notes.splice(index, 1); this.value.notes.splice(index, 1);
}, },
removeCategory(index) { removeCategory(index) {
this.value.categories.splice(index, 1); this.value.recipeCategory.splice(index, 1);
}, },
removeTags(index) { removeTags(index) {
this.value.tags.splice(index, 1); this.value.tags.splice(index, 1);

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-if="items[0]"> <div v-if="items && items.length > 0">
<h2 class="mt-4">{{ title }}</h2> <h2 class="mt-4">{{ title }}</h2>
<v-chip <v-chip
class="ma-1" class="ma-1"

View file

@ -0,0 +1,53 @@
<template>
<div>
<v-dialog v-model="dialog" :width="modalWidth + 'px'">
<v-app-bar dark :color="color" class="mt-n1 mb-2">
<v-icon large left v-if="!loading">
{{ titleIcon }}
</v-icon>
<v-progress-circular
v-else
indeterminate
color="white"
large
class="mr-2"
>
</v-progress-circular>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
color: {
default: "primary",
},
title: {
default: "Modal Title",
},
titleIcon: {
default: "mdi-account",
},
modalWidth: {
default: "500",
},
},
data() {
return {
dialog: false,
};
},
methods: {
open() {
this.dialog = true;
},
},
};
</script>
<style scoped>
</style>

View file

@ -8,10 +8,10 @@
@keydown.esc="cancel" @keydown.esc="cancel"
> >
<v-card> <v-card>
<v-toolbar v-if="Boolean(title)" :color="color" dense flat dark> <v-app-bar v-if="Boolean(title)" :color="color" dense flat dark>
<v-icon v-if="Boolean(icon)" left> {{ icon }}</v-icon> <v-icon v-if="Boolean(icon)" left> {{ icon }}</v-icon>
<v-toolbar-title v-text="title" /> <v-toolbar-title v-text="title" />
</v-toolbar> </v-app-bar>
<v-card-text <v-card-text
v-show="!!message" v-show="!!message"

View file

@ -1,6 +1,5 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<LoginDialog ref="loginDialog" />
<v-menu <v-menu
transition="slide-x-transition" transition="slide-x-transition"
bottom bottom
@ -35,10 +34,12 @@
</template> </template>
<script> <script>
import LoginDialog from "../Login/LoginDialog"; const SELECT_EVENT = "select-lang";
export default { export default {
components: { props: {
LoginDialog, siteSettings: {
default: false,
},
}, },
data: function() { data: function() {
return { return {
@ -68,7 +69,11 @@ export default {
methods: { methods: {
setLanguage(selectedLanguage) { setLanguage(selectedLanguage) {
this.$store.commit("setLang", selectedLanguage); if (this.siteSettings) {
this.$emit(SELECT_EVENT, selectedLanguage);
} else {
this.$store.commit("setLang", selectedLanguage);
}
}, },
}, },
}; };

View file

@ -1,31 +1,37 @@
<template> <template>
<div> <v-menu v-model="menuModel" offset-y readonly max-width="450">
<v-autocomplete <template #activator="{ attrs }">
:items="autoResults" <v-text-field
v-model="searchSlug" class="mt-6"
item-value="item.slug" v-model="search"
item-text="item.name" v-bind="attrs"
dense dense
light light
:label="$t('search.search-mealie')" :label="$t('search.search-mealie')"
:search-input.sync="search" solo
hide-no-data autofocus
cache-items style="max-width: 450px;"
solo @focus="onFocus"
autofocus
auto-select-first
>
<template
v-if="showResults"
v-slot:item="{ item }"
style="max-width: 750px"
> >
<v-list-item-avatar> </v-text-field>
<v-img :src="getImage(item.item.image)"></v-img> </template>
</v-list-item-avatar> <v-card v-if="showResults" max-height="500" min-width="98%" class="">
<v-list-item-content @click="selected(item.item.slug)"> <v-card-text class="py-1">Results</v-card-text>
<v-list-item-title> <v-divider></v-divider>
{{ item.item.name }} <v-list scrollable>
<v-list-item
v-for="(item, index) in autoResults"
:key="index"
:to="showResults ? `/recipe/${item.item.slug}` : null"
>
<v-list-item-avatar>
<v-img :src="getImage(item.item.image)"></v-img>
</v-list-item-avatar>
<v-list-item-content
@click="showResults ? null : selected(item.item.slug)"
>
<v-list-item-title v-html="highlight(item.item.name)">
</v-list-item-title>
<v-rating <v-rating
dense dense
v-if="item.item.rating" v-if="item.item.rating"
@ -33,14 +39,13 @@
size="12" size="12"
> >
</v-rating> </v-rating>
</v-list-item-title> <v-list-item-subtitle v-html="highlight(item.item.description)">
<v-list-item-subtitle> </v-list-item-subtitle>
{{ item.item.description }} </v-list-item-content>
</v-list-item-subtitle> </v-list-item>
</v-list-item-content> </v-list>
</template> </v-card>
</v-autocomplete> </v-menu>
</div>
</template> </template>
<script> <script>
@ -56,7 +61,8 @@ export default {
data() { data() {
return { return {
searchSlug: "", searchSlug: "",
search: " ", search: "",
menuModel: false,
data: [], data: [],
result: [], result: [],
autoResults: [], autoResults: [],
@ -66,9 +72,10 @@ export default {
threshold: 0.6, threshold: 0.6,
location: 0, location: 0,
distance: 100, distance: 100,
findAllMatches: true,
maxPatternLength: 32, maxPatternLength: 32,
minMatchCharLength: 1, minMatchCharLength: 2,
keys: ["name", "slug", "description"], keys: ["name", "description"],
}, },
}; };
}, },
@ -80,8 +87,15 @@ export default {
fuse() { fuse() {
return new Fuse(this.data, this.options); return new Fuse(this.data, this.options);
}, },
isSearching() {
return this.search && this.search.length > 0;
},
}, },
watch: { watch: {
isSearching(val) {
val ? (this.menuModel = true) : null;
},
search() { search() {
try { try {
this.result = this.fuse.search(this.search.trim()); this.result = this.fuse.search(this.search.trim());
@ -101,18 +115,34 @@ export default {
}, },
}, },
methods: { methods: {
highlight(string) {
if (!this.search) {
return string;
}
return string.replace(
new RegExp(this.search, "gi"),
match => `<mark>${match}</mark>`
);
},
getImage(image) { getImage(image) {
return utils.getImageURL(image); return utils.getImageURL(image);
}, },
selected(slug) { selected(slug) {
this.$emit("selected", slug); this.$emit("selected", slug);
}, },
async onFocus() {
clearTimeout(this.timeout);
this.isFocused = true;
},
}, },
}; };
</script> </script>
<style> <style scoped>
.color-transition { .color-transition {
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
} }
</style>
<style lang="sass" scoped>
</style> </style>

View file

@ -87,9 +87,6 @@ export default {
}, },
mounted() {}, mounted() {},
computed: { computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
filteredItems() { filteredItems() {
if (this.loggedIn) { if (this.loggedIn) {
return this.items.filter(x => x.restricted == true); return this.items.filter(x => x.restricted == true);
@ -97,6 +94,9 @@ export default {
return this.items.filter(x => x.restricted == false); return this.items.filter(x => x.restricted == false);
} }
}, },
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
}, },
methods: { methods: {

View file

@ -6,6 +6,8 @@ import VueRouter from "vue-router";
import { routes } from "./routes"; import { routes } from "./routes";
import i18n from "./i18n"; import i18n from "./i18n";
import FlashMessage from "@smartweb/vue-flash-message"; import FlashMessage from "@smartweb/vue-flash-message";
import "@mdi/font/css/materialdesignicons.css";
import "typeface-roboto/index.css";
Vue.use(FlashMessage); Vue.use(FlashMessage);
Vue.config.productionTip = false; Vue.config.productionTip = false;

View file

@ -26,7 +26,7 @@
</v-tab> </v-tab>
</v-tabs> </v-tabs>
<v-tabs-items v-model="tab" > <v-tabs-items v-model="tab">
<v-tab-item> <v-tab-item>
<TheUserTable /> <TheUserTable />
</v-tab-item> </v-tab-item>
@ -34,7 +34,7 @@
<TheSignUpTable /> <TheSignUpTable />
</v-tab-item> </v-tab-item>
<v-tab-item> <v-tab-item>
<TheGroupTable /> <GroupDashboard />
</v-tab-item> </v-tab-item>
</v-tabs-items> </v-tabs-items>
</v-card> </v-card>
@ -43,15 +43,18 @@
<script> <script>
import TheUserTable from "@/components/Admin/ManageUsers/TheUserTable"; import TheUserTable from "@/components/Admin/ManageUsers/TheUserTable";
import TheGroupTable from "@/components/Admin/ManageUsers/TheGroupTable"; import GroupDashboard from "@/components/Admin/ManageUsers/GroupDashboard";
import TheSignUpTable from "@/components/Admin/ManageUsers/TheSignUpTable"; import TheSignUpTable from "@/components/Admin/ManageUsers/TheSignUpTable";
export default { export default {
components: { TheUserTable, TheGroupTable, TheSignUpTable }, components: { TheUserTable, GroupDashboard, TheSignUpTable },
data() { data() {
return { return {
tab: 0, tab: 0,
}; };
}, },
mounted() {
this.$store.dispatch("requestAllGroups");
},
}; };
</script> </script>

View file

@ -13,13 +13,17 @@
outlined outlined
:flat="isFlat" :flat="isFlat"
elavation="0" elavation="0"
v-model="planCategories" v-model="groupSettings.categories"
:items="categories" :items="categories"
item-text="name" item-text="name"
item-value="name" return-object
multiple multiple
chips chips
:hint="$t('meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans')" :hint="
$t(
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
)
"
class="mt-2" class="mt-2"
persistent-hint persistent-hint
> >
@ -50,12 +54,15 @@
"settings.webhooks.the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at" "settings.webhooks.the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at"
) )
}} }}
<strong>{{ time }}</strong> <strong>{{ groupSettings.webhookTime }}</strong>
</p> </p>
<v-row dense align="center"> <v-row dense align="center">
<v-col cols="12" md="2" sm="5"> <v-col cols="12" md="2" sm="5">
<v-switch v-model="enabled" :label="$t('general.enabled')"></v-switch> <v-switch
v-model="groupSettings.webhookEnable"
:label="$t('general.enabled')"
></v-switch>
</v-col> </v-col>
<v-col cols="12" md="3" sm="5"> <v-col cols="12" md="3" sm="5">
<TimePickerDialog @save-time="saveTime" /> <TimePickerDialog @save-time="saveTime" />
@ -68,7 +75,12 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row v-for="(url, index) in webhooks" :key="index" align="center" dense> <v-row
v-for="(url, index) in groupSettings.webhookUrls"
:key="index"
align="center"
dense
>
<v-col cols="1"> <v-col cols="1">
<v-btn icon color="error" @click="removeWebhook(index)"> <v-btn icon color="error" @click="removeWebhook(index)">
<v-icon>mdi-minus</v-icon> <v-icon>mdi-minus</v-icon>
@ -76,7 +88,7 @@
</v-col> </v-col>
<v-col> <v-col>
<v-text-field <v-text-field
v-model="webhooks[index]" v-model="groupSettings.webhookUrls[index]"
:label="$t('settings.webhooks.webhook-url')" :label="$t('settings.webhooks.webhook-url')"
></v-text-field> ></v-text-field>
</v-col> </v-col>
@ -87,7 +99,7 @@
<v-icon>mdi-plus</v-icon> <v-icon>mdi-plus</v-icon>
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="success" @click="saveWebhooks" class="mr-2 mb-1"> <v-btn color="success" @click="saveGroupSettings" class="mr-2 mb-1">
<v-icon left> mdi-content-save </v-icon> <v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }} {{ $t("general.save") }}
</v-btn> </v-btn>
@ -104,14 +116,19 @@ export default {
}, },
data() { data() {
return { return {
name: "main", groupSettings: {
webhooks: [], name: "home",
enabled: false, id: 1,
time: "", mealplans: [],
planCategories: [], categories: [],
webhookUrls: [],
webhookTime: "00:00",
webhookEnable: false,
},
}; };
}, },
mounted() { async mounted() {
await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings(); this.getSiteSettings();
}, },
computed: { computed: {
@ -119,44 +136,39 @@ export default {
return this.$store.getters.getCategories; return this.$store.getters.getCategories;
}, },
isFlat() { isFlat() {
return this.planCategories ? true : false; return this.groupSettings.categories >= 1 ? true : false;
}, },
}, },
methods: { methods: {
saveTime(value) { saveTime(value) {
this.time = value; this.groupSettings.webhookTime = value;
}, },
async getSiteSettings() { getSiteSettings() {
let settings = await api.settings.requestAll(); let settings = this.$store.getters.getCurrentGroup;
this.webhooks = settings.webhooks.webhookURLs;
this.name = settings.name; this.groupSettings.name = settings.name;
this.time = settings.webhooks.webhookTime; this.groupSettings.id = settings.id;
this.enabled = settings.webhooks.enabled; this.groupSettings.categories = settings.categories;
this.planCategories = settings.planCategories; this.groupSettings.webhookUrls = settings.webhookUrls;
this.groupSettings.webhookTime = settings.webhookTime;
this.groupSettings.webhookEnable = settings.webhookEnable;
}, },
addWebhook() { addWebhook() {
this.webhooks.push(" "); this.groupSettings.webhookUrls.push(" ");
}, },
removeWebhook(index) { removeWebhook(index) {
this.webhooks.splice(index, 1); this.groupSettings.webhookUrls.splice(index, 1);
}, },
saveWebhooks() { async saveGroupSettings() {
const body = { await api.groups.update(this.groupSettings);
name: this.name, await this.$store.dispatch("requestCurrentGroup");
planCategories: this.planCategories, this.getSiteSettings();
webhooks: {
webhookURLs: this.webhooks,
webhookTime: this.time,
enabled: this.enabled,
},
};
api.settings.update(body);
}, },
testWebhooks() { testWebhooks() {
api.settings.testWebhooks(); api.settings.testWebhooks();
}, },
removeCategory(index) { removeCategory(index) {
this.planCategories.splice(index, 1); this.groupSettings.categories.splice(index, 1);
}, },
}, },
}; };

View file

@ -55,11 +55,11 @@
> >
</v-text-field> </v-text-field>
<v-text-field <v-text-field
label="Family" label="Group"
readonly readonly
v-model="user.family" v-model="user.group"
persistent-hint persistent-hint
hint="Family groups can only be set by administrators" hint="Group groups can only be set by administrators"
> >
</v-text-field> </v-text-field>
</v-form> </v-form>
@ -167,7 +167,7 @@ export default {
user: { user: {
fullName: "Change Me", fullName: "Change Me",
email: "changeme@email.com", email: "changeme@email.com",
family: "public", group: "public",
admin: true, admin: true,
id: 1, id: 1,
}, },

View file

@ -1,11 +1,13 @@
<template> <template>
<v-container> <div>
<AdminSidebar /> <v-container height="100%">
<v-slide-x-transition hide-on-leave> <AdminSidebar />
<router-view></router-view> <v-slide-x-transition hide-on-leave>
</v-slide-x-transition> <router-view></router-view>
<!-- <v-footer fixed> </v-slide-x-transition>
<v-col class="text-center" cols="12"> </v-container>
<!-- <v-footer absolute>
<div class="flex text-center" cols="12">
{{ $t("settings.current") }} {{ $t("settings.current") }}
{{ version }} | {{ version }} |
{{ $t("settings.latest") }} {{ $t("settings.latest") }}
@ -21,9 +23,9 @@
> >
{{ $t("settings.contribute") }} {{ $t("settings.contribute") }}
</a> </a>
</v-col> </div>
</v-footer> --> </v-footer> -->
</v-container> </div>
</template> </template>
<script> <script>

View file

@ -34,7 +34,7 @@
:description="recipeDetails.description" :description="recipeDetails.description"
:instructions="recipeDetails.recipeInstructions" :instructions="recipeDetails.recipeInstructions"
:tags="recipeDetails.tags" :tags="recipeDetails.tags"
:categories="recipeDetails.categories" :categories="recipeDetails.recipeCategory"
:notes="recipeDetails.notes" :notes="recipeDetails.notes"
:rating="recipeDetails.rating" :rating="recipeDetails.rating"
:yields="recipeDetails.recipeYield" :yields="recipeDetails.recipeYield"

View file

@ -5,22 +5,25 @@ import createPersistedState from "vuex-persistedstate";
import userSettings from "./modules/userSettings"; import userSettings from "./modules/userSettings";
import language from "./modules/language"; import language from "./modules/language";
import homePage from "./modules/homePage"; import homePage from "./modules/homePage";
import siteSettings from "./modules/siteSettings";
import groups from "./modules/groups";
Vue.use(Vuex); Vue.use(Vuex);
const store = new Vuex.Store({ const store = new Vuex.Store({
plugins: [ plugins: [
createPersistedState({ createPersistedState({
paths: ["userSettings", "language", "homePage"], paths: ["userSettings", "language", "homePage", "SideSettings"],
}), }),
], ],
modules: { modules: {
userSettings, userSettings,
language, language,
homePage, homePage,
siteSettings,
groups,
}, },
state: { state: {
// All Recipe Data Store // All Recipe Data Store
recentRecipes: [], recentRecipes: [],
allRecipes: [], allRecipes: [],

View file

@ -0,0 +1,39 @@
import api from "@/api";
const state = {
groups: [],
currentGroup: {},
};
const mutations = {
setGroups(state, payload) {
state.groups = payload;
},
setCurrentGroup(state, payload) {
state.currentGroup = payload;
},
};
const actions = {
async requestAllGroups({ commit }) {
const groups = await api.groups.allGroups();
commit("setGroups", groups);
},
async requestCurrentGroup({ commit }) {
const group = await api.groups.current();
commit("setCurrentGroup", group);
},
};
const getters = {
getGroups: state => state.groups,
getGroupNames: state => Array.from(state.groups, x => x.name),
getCurrentGroup: state => state.currentGroup
};
export default {
state,
mutations,
actions,
getters,
};

View file

@ -0,0 +1,34 @@
import api from "@/api";
const state = {
siteSettings: {
language: "en",
showRecent: true,
cardsPerSection: 9,
categories: [],
},
};
const mutations = {
setSettings(state, payload) {
state.settings = payload;
},
};
const actions = {
async requestSiteSettings() {
let settings = await api.siteSettings.get();
this.commit("setSettings", settings);
},
};
const getters = {
getSiteSettings: state => state.siteSettings,
};
export default {
state,
mutations,
actions,
getters,
};

View file

@ -63,6 +63,20 @@ const actions = {
} }
}, },
async refreshToken({ commit, getters }) {
if (!getters.getIsLoggedIn) {
commit("setIsLoggedIn", false); // This is to be here... for some reasons? ¯\_(ツ)_/¯
console.log("Not Logged In");
return;
}
try {
let authResponse = await api.users.refresh();
commit("setToken", authResponse.access_token);
} catch {
console.log("Failed Token Refresh, Logging Out...");
commit("setIsLoggedIn", false);
}
},
async initTheme({ dispatch, getters }) { async initTheme({ dispatch, getters }) {
//If theme is empty resetTheme //If theme is empty resetTheme

View file

@ -1,6 +1,6 @@
import { vueApp } from "../main"; import { vueApp } from "../main";
// TODO: Migrate to Mixins
const notifyHelpers = { const notifyHelpers = {
baseCSS: "notify-base", baseCSS: "notify-base",
error: "notify-error-color", error: "notify-error-color",

View file

@ -1,8 +1,9 @@
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.logger import logger
# import utils.startup as startup # import utils.startup as startup
from core.config import APP_VERSION, PORT, SECRET, docs_url, redoc_url from core.config import APP_VERSION, PORT, docs_url, redoc_url
from db.db_setup import sql_exists from db.db_setup import sql_exists
from db.init_db import init_db from db.init_db import init_db
from routes import ( from routes import (
@ -13,6 +14,7 @@ from routes import (
setting_routes, setting_routes,
theme_routes, theme_routes,
) )
from routes.groups import groups
from routes.recipe import ( from routes.recipe import (
all_recipe_routes, all_recipe_routes,
category_routes, category_routes,
@ -20,7 +22,6 @@ from routes.recipe import (
tag_routes, tag_routes,
) )
from routes.users import users from routes.users import users
from fastapi.logger import logger
app = FastAPI( app = FastAPI(
title="Mealie", title="Mealie",
@ -42,11 +43,13 @@ def start_scheduler():
def api_routers(): def api_routers():
# Authentication # Authentication
app.include_router(users.router) app.include_router(users.router)
app.include_router(groups.router)
# Recipes # Recipes
app.include_router(all_recipe_routes.router) app.include_router(all_recipe_routes.router)
app.include_router(category_routes.router) app.include_router(category_routes.router)
app.include_router(tag_routes.router) app.include_router(tag_routes.router)
app.include_router(recipe_crud_routes.router) app.include_router(recipe_crud_routes.router)
# Meal Routes # Meal Routes
app.include_router(meal_routes.router) app.include_router(meal_routes.router)
# Settings Routes # Settings Routes

View file

@ -83,6 +83,7 @@ else:
# Mongo Database # Mongo Database
MEALIE_DB_NAME = os.getenv("mealie_db_name", "mealie") MEALIE_DB_NAME = os.getenv("mealie_db_name", "mealie")
DEFAULT_GROUP = os.getenv("default_group", "home")
DB_USERNAME = os.getenv("db_username", "root") DB_USERNAME = os.getenv("db_username", "root")
DB_PASSWORD = os.getenv("db_password", "example") DB_PASSWORD = os.getenv("db_password", "example")
DB_HOST = os.getenv("db_host", "mongo") DB_HOST = os.getenv("db_host", "mongo")

View file

@ -1,23 +1,28 @@
from schema.category import RecipeCategoryResponse, RecipeTagResponse
from schema.meal import MealPlanInDB
from schema.recipe import Recipe
from schema.settings import SiteSettings as SiteSettingsSchema
from schema.sign_up import SignUpOut
from schema.theme import SiteTheme
from schema.user import GroupInDB, UserInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from db.db_base import BaseDocument from db.db_base import BaseDocument
from db.models.group import Group
from db.models.mealplan import MealPlanModel from db.models.mealplan import MealPlanModel
from db.models.recipe import Category, RecipeModel, Tag from db.models.recipe.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettingsModel from db.models.settings import SiteSettings
from db.models.sign_up import SignUp from db.models.sign_up import SignUp
from db.models.theme import SiteThemeModel from db.models.theme import SiteThemeModel
from db.models.users import User from db.models.users import User
"""
# TODO
- [ ] Abstract Classes to use save_new, and update from base models
"""
class _Recipes(BaseDocument): class _Recipes(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model: RecipeModel = RecipeModel self.sql_model: RecipeModel = RecipeModel
self.orm_mode = True
self.schema: Recipe = Recipe
def update_image(self, session: Session, slug: str, extension: str = None) -> str: def update_image(self, session: Session, slug: str, extension: str = None) -> str:
entry: RecipeModel = self._query_one(session, match_value=slug) entry: RecipeModel = self._query_one(session, match_value=slug)
@ -31,51 +36,71 @@ class _Categories(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model = Category self.sql_model = Category
self.orm_mode = True
self.schema = RecipeCategoryResponse
class _Tags(BaseDocument): class _Tags(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model = Tag self.sql_model = Tag
self.orm_mode = True
self.schema = RecipeTagResponse
class _Meals(BaseDocument): class _Meals(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "uid" self.primary_key = "uid"
self.sql_model = MealPlanModel self.sql_model = MealPlanModel
self.orm_mode = True
self.schema = MealPlanInDB
class _Settings(BaseDocument): class _Settings(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "name" self.primary_key = "id"
self.sql_model = SiteSettingsModel self.sql_model = SiteSettings
self.orm_mode = True
self.schema = SiteSettingsSchema
class _Themes(BaseDocument): class _Themes(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "name" self.primary_key = "name"
self.sql_model = SiteThemeModel self.sql_model = SiteThemeModel
self.orm_mode = True
self.schema = SiteTheme
class _Users(BaseDocument): class _Users(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "id" self.primary_key = "id"
self.sql_model = User self.sql_model = User
self.orm_mode = True
self.schema = UserInDB
def update_password(self, session, id, password: str): def update_password(self, session, id, password: str):
entry = self._query_one(session=session, match_value=id) entry = self._query_one(session=session, match_value=id)
entry.update_password(password) entry.update_password(password)
return_data = entry.dict()
session.commit() session.commit()
return return_data return self.schema.from_orm(entry)
class _Groups(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = Group
self.orm_mode = True
self.schema = GroupInDB
class _SignUps(BaseDocument): class _SignUps(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "token" self.primary_key = "token"
self.sql_model = SignUp self.sql_model = SignUp
self.orm_mode = True
self.schema = SignUpOut
class Database: class Database:
@ -88,6 +113,7 @@ class Database:
self.tags = _Tags() self.tags = _Tags()
self.users = _Users() self.users = _Users()
self.sign_ups = _SignUps() self.sign_ups = _SignUps()
self.groups = _Groups()
db = Database() db = Database()

View file

@ -1,5 +1,6 @@
from typing import List from typing import List
from pydantic import BaseModel
from sqlalchemy.orm import load_only from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -11,11 +12,20 @@ class BaseDocument:
self.primary_key: str self.primary_key: str
self.store: str self.store: str
self.sql_model: SqlAlchemyBase self.sql_model: SqlAlchemyBase
self.orm_mode = False
self.schema: BaseModel
# TODO: Improve Get All Query Functionality # TODO: Improve Get All Query Functionality
def get_all( def get_all(
self, session: Session, limit: int = None, order_by: str = None self, session: Session, limit: int = None, order_by: str = None
) -> List[dict]: ) -> List[dict]:
if self.orm_mode:
return [
self.schema.from_orm(x)
for x in session.query(self.sql_model).limit(limit).all()
]
list = [x.dict() for x in session.query(self.sql_model).limit(limit).all()] list = [x.dict() for x in session.query(self.sql_model).limit(limit).all()]
if limit == 1: if limit == 1:
@ -105,15 +115,13 @@ class BaseDocument:
.limit(limit) .limit(limit)
.all() .all()
) )
db_entries = [x.dict() for x in result]
if limit == 1: if limit == 1:
try: try:
return db_entries[0] return self.schema.from_orm(result[0])
except IndexError: except IndexError:
return None return None
return [self.schema.from_orm(x) for x in result]
return db_entries
def create(self, session: Session, document: dict) -> dict: def create(self, session: Session, document: dict) -> dict:
"""Creates a new database entry for the given SQL Alchemy Model. """Creates a new database entry for the given SQL Alchemy Model.
@ -128,6 +136,10 @@ class BaseDocument:
new_document = self.sql_model(session=session, **document) new_document = self.sql_model(session=session, **document)
session.add(new_document) session.add(new_document)
session.commit() session.commit()
if self.orm_mode:
return self.schema.from_orm(new_document)
return_data = new_document.dict() return_data = new_document.dict()
return return_data return return_data
@ -145,9 +157,13 @@ class BaseDocument:
entry = self._query_one(session=session, match_value=match_value) entry = self._query_one(session=session, match_value=match_value)
entry.update(session=session, **new_data) entry.update(session=session, **new_data)
if self.orm_mode:
session.commit()
return self.schema.from_orm(entry)
return_data = entry.dict() return_data = entry.dict()
session.commit() session.commit()
return return_data return return_data
def delete(self, session: Session, primary_key_value) -> dict: def delete(self, session: Session, primary_key_value) -> dict:

View file

@ -1,6 +1,8 @@
from core.config import DEFAULT_GROUP
from core.security import get_password_hash from core.security import get_password_hash
from fastapi.logger import logger from fastapi.logger import logger
from schema.settings import SiteSettings, Webhooks from schema.settings import SiteSettings
from schema.theme import SiteTheme
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -12,6 +14,7 @@ def init_db(db: Session = None) -> None:
if not db: if not db:
db = create_session() db = create_session()
default_group_init(db)
default_settings_init(db) default_settings_init(db)
default_theme_init(db) default_theme_init(db)
default_user_init(db) default_user_init(db)
@ -20,34 +23,25 @@ def init_db(db: Session = None) -> None:
def default_theme_init(session: Session): def default_theme_init(session: Session):
default_theme = { db.themes.create(session, SiteTheme().dict())
"name": "default",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"info": "#4990BA",
"warning": "#FF4081",
"error": "#EF5350",
},
}
try: try:
db.themes.create(session, default_theme)
logger.info("Generating default theme...") logger.info("Generating default theme...")
except: except:
logger.info("Default Theme Exists.. skipping generation") logger.info("Default Theme Exists.. skipping generation")
def default_settings_init(session: Session): def default_settings_init(session: Session):
try: data = {"language": "en", "home_page_settings": {"categories": []}}
webhooks = Webhooks() document = db.settings.create(session, SiteSettings().dict())
default_entry = SiteSettings(name="main", webhooks=webhooks) logger.info(f"Created Site Settings: \n {document}")
document = db.settings.create(session, default_entry.dict())
logger.info(f"Created Site Settings: \n {document}")
except: def default_group_init(session: Session):
pass default_group = {"name": DEFAULT_GROUP}
logger.info("Generating Default Group")
db.groups.create(session, default_group)
pass
def default_user_init(session: Session): def default_user_init(session: Session):
@ -55,7 +49,7 @@ def default_user_init(session: Session):
"full_name": "Change Me", "full_name": "Change Me",
"email": "changeme@email.com", "email": "changeme@email.com",
"password": get_password_hash("MyPassword"), "password": get_password_hash("MyPassword"),
"family": "public", "group": DEFAULT_GROUP,
"admin": True, "admin": True,
} }

View file

@ -1,6 +1,7 @@
from db.models.mealplan import * from db.models.mealplan import *
from db.models.recipe import * from db.models.recipe.recipe import *
from db.models.settings import * from db.models.settings import *
from db.models.theme import * from db.models.theme import *
from db.models.users import * from db.models.users import *
from db.models.sign_up import * from db.models.sign_up import *
from db.models.group import *

75
mealie/db/models/group.py Normal file
View file

@ -0,0 +1,75 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from core.config import DEFAULT_GROUP
from db.models.model_base import BaseMixins, SqlAlchemyBase
from db.models.recipe.category import Category, group2categories
from fastapi.logger import logger
from sqlalchemy.orm.session import Session
class WebhookURLModel(SqlAlchemyBase):
__tablename__ = "webhook_urls"
id = sa.Column(sa.Integer, primary_key=True)
url = sa.Column(sa.String)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
mealplans = orm.relationship(
"MealPlanModel", back_populates="group", single_parent=True
)
categories = orm.relationship(
"Category", secondary=group2categories, single_parent=True
)
# Webhook Settings
webhook_enable = sa.Column(sa.Boolean, default=False)
webhook_time = sa.Column(sa.String, default="00:00")
webhook_urls = orm.relationship(
"WebhookURLModel", uselist=True, cascade="all, delete"
)
def __init__(
self,
name,
id=None,
users=None,
mealplans=None,
categories=[],
session=None,
webhook_enable=False,
webhook_time="00:00",
webhook_urls=[],
) -> None:
self.name = name
self.categories = [
Category.get_ref(session=session, slug=cat.get("slug"))
for cat in categories
]
self.webhook_enable = webhook_enable
self.webhook_time = webhook_time
self.webhook_urls = [WebhookURLModel(url=x) for x in webhook_urls]
def update(self, session: Session, *args, **kwargs):
self._sql_remove_list(session, [WebhookURLModel], self.id)
self.__init__(session=session, *args, **kwargs)
@staticmethod
def create_if_not_exist(session, name: str = DEFAULT_GROUP):
try:
result = session.query(Group).filter(Group.name == name).one()
if result:
logger.info("Category exists, associating recipe")
return result
else:
logger.info("Category doesn't exists, creating tag")
return Group(name=name)
except:
logger.info("Category doesn't exists, creating category")
return Group(name=name)

View file

@ -13,32 +13,16 @@ class Meal(SqlAlchemyBase):
slug = sa.Column(sa.String) slug = sa.Column(sa.String)
name = sa.Column(sa.String) name = sa.Column(sa.String)
date = sa.Column(sa.Date) date = sa.Column(sa.Date)
dateText = sa.Column(sa.String)
image = sa.Column(sa.String) image = sa.Column(sa.String)
description = sa.Column(sa.String) description = sa.Column(sa.String)
def __init__( def __init__(self, slug, name, date, image, description, session=None) -> None:
self, slug, name, date, dateText, image, description, session=None
) -> None:
self.slug = slug self.slug = slug
self.name = name self.name = name
self.date = date self.date = date
self.dateText = dateText
self.image = image self.image = image
self.description = description self.description = description
def dict(self) -> dict:
data = {
"slug": self.slug,
"name": self.name,
"date": self.date,
"dateText": self.dateText,
"image": self.image,
"description": self.description,
}
return data
class MealPlanModel(SqlAlchemyBase, BaseMixins): class MealPlanModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "mealplan" __tablename__ = "mealplan"
@ -46,6 +30,8 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins):
startDate = sa.Column(sa.Date) startDate = sa.Column(sa.Date)
endDate = sa.Column(sa.Date) endDate = sa.Column(sa.Date)
meals: List[Meal] = orm.relation(Meal) meals: List[Meal] = orm.relation(Meal)
group_id = sa.Column(sa.String, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="mealplans")
def __init__(self, startDate, endDate, meals, uid=None, session=None) -> None: def __init__(self, startDate, endDate, meals, uid=None, session=None) -> None:
self.startDate = startDate self.startDate = startDate
@ -56,13 +42,3 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins):
MealPlanModel._sql_remove_list(session, [Meal], uid) MealPlanModel._sql_remove_list(session, [Meal], uid)
self.__init__(startDate, endDate, meals) self.__init__(startDate, endDate, meals)
def dict(self) -> dict:
data = {
"uid": self.uid,
"startDate": self.startDate,
"endDate": self.endDate,
"meals": [meal.dict() for meal in self.meals],
}
return data

View file

@ -1,16 +1,16 @@
from typing import List from typing import List
import sqlalchemy.ext.declarative as dec import sqlalchemy.ext.declarative as dec
from sqlalchemy.orm.session import Session
SqlAlchemyBase = dec.declarative_base() SqlAlchemyBase = dec.declarative_base()
class BaseMixins: class BaseMixins:
@staticmethod @staticmethod
def _sql_remove_list(session, list_of_tables: list, parent_id): def _sql_remove_list(session: Session, list_of_tables: list, parent_id):
for table in list_of_tables: for table in list_of_tables:
session.query(table).filter_by(parent_id=parent_id).delete() session.query(table).filter(parent_id == parent_id).delete()
@staticmethod @staticmethod
def _flatten_dict(list_of_dict: List[dict]): def _flatten_dict(list_of_dict: List[dict]):
@ -20,3 +20,29 @@ class BaseMixins:
finalMap.update(d.dict()) finalMap.update(d.dict())
return finalMap return finalMap
# ! Don't use!
def update_generics(func):
"""An experimental function that does the initial work of updating attributes on a class
and passing "complex" data types recuresively to an "self.update()" function if one exists.
Args:
func ([type]): [description]
"""
def wrapper(class_object, session, new_data: dict):
complex_attributed = {}
for key, value in new_data.items():
attribute = getattr(class_object, key, None)
if attribute and isinstance(attribute, SqlAlchemyBase):
attribute.update(session, value)
elif attribute:
setattr(class_object, key, value)
print("Complex", complex_attributed)
func(class_object, complex_attributed)
return wrapper

View file

@ -1,352 +0,0 @@
import datetime
from datetime import date
from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.models.model_base import BaseMixins, SqlAlchemyBase
from slugify import slugify
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import validates
from fastapi.logger import logger
class ApiExtras(SqlAlchemyBase):
__tablename__ = "api_extras"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
key_name = sa.Column(sa.String, unique=True)
value = sa.Column(sa.String)
def __init__(self, key, value) -> None:
self.key_name = key
self.value = value
def dict(self):
return {self.key_name: self.value}
recipes2categories = sa.Table(
"recipes2categories",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
)
recipes2tags = sa.Table(
"recipes2tags",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")),
)
class Category(SqlAlchemyBase):
__tablename__ = "categories"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship(
"RecipeModel", secondary=recipes2categories, back_populates="categories"
)
@validates("name")
def validate_name(self, key, name):
assert not name == ""
return name
def __init__(self, name) -> None:
self.name = name.strip()
self.slug = slugify(name)
@staticmethod
def create_if_not_exist(session, name: str = None):
test_slug = slugify(name)
try:
result = session.query(Category).filter(Category.slug == test_slug).one()
if result:
logger.info("Category exists, associating recipe")
return result
else:
logger.info("Category doesn't exists, creating tag")
return Category(name=name)
except:
logger.info("Category doesn't exists, creating category")
return Category(name=name)
def to_str(self):
return self.name
def dict(self):
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"recipes": [x.dict() for x in self.recipes],
}
def dict_no_recipes(self):
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
}
class Tag(SqlAlchemyBase):
__tablename__ = "tags"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship(
"RecipeModel", secondary=recipes2tags, back_populates="tags"
)
@validates("name")
def validate_name(self, key, name):
assert not name == ""
return name
def to_str(self):
return self.name
def __init__(self, name) -> None:
self.name = name.strip()
self.slug = slugify(self.name)
def dict(self):
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"recipes": [x.dict() for x in self.recipes],
}
@staticmethod
def create_if_not_exist(session, name: str = None):
test_slug = slugify(name)
try:
result = session.query(Tag).filter(Tag.slug == test_slug).first()
if result:
logger.info("Tag exists, associating recipe")
return result
else:
logger.info("Tag doesn't exists, creating tag")
return Tag(name=name)
except:
logger.info("Tag doesn't exists, creating tag")
return Tag(name=name)
class Note(SqlAlchemyBase):
__tablename__ = "notes"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
title = sa.Column(sa.String)
text = sa.Column(sa.String)
def __init__(self, title, text) -> None:
self.title = title
self.text = text
def dict(self):
return {"title": self.title, "text": self.text}
class RecipeIngredient(SqlAlchemyBase):
__tablename__ = "recipes_ingredients"
id = sa.Column(sa.Integer, primary_key=True)
position = sa.Column(sa.Integer)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
ingredient = sa.Column(sa.String)
def update(self, ingredient):
self.ingredient = ingredient
def to_str(self):
return self.ingredient
class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
position = sa.Column(sa.Integer)
type = sa.Column(sa.String, default="")
text = sa.Column(sa.String)
def dict(self):
data = {"@type": self.type, "text": self.text}
return data
class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes"
# Database Specific
id = sa.Column(sa.Integer, primary_key=True)
# General Recipe Properties
name = sa.Column(sa.String, nullable=False)
description = sa.Column(sa.String)
image = sa.Column(sa.String)
recipeYield = sa.Column(sa.String)
recipeIngredient: List[RecipeIngredient] = orm.relationship(
"RecipeIngredient",
cascade="all, delete",
order_by="RecipeIngredient.position",
collection_class=ordering_list("position"),
)
recipeInstructions: List[RecipeInstruction] = orm.relationship(
"RecipeInstruction",
cascade="all, delete",
order_by="RecipeInstruction.position",
collection_class=ordering_list("position"),
)
# How to Properties
totalTime = sa.Column(sa.String)
prepTime = sa.Column(sa.String)
performTime = sa.Column(sa.String)
# Mealie Specific
slug = sa.Column(sa.String, index=True, unique=True)
categories: List = orm.relationship(
"Category", secondary=recipes2categories, back_populates="recipes"
)
tags: List[Tag] = orm.relationship(
"Tag", secondary=recipes2tags, back_populates="recipes"
)
dateAdded = sa.Column(sa.Date, default=date.today)
notes: List[Note] = orm.relationship("Note", cascade="all, delete")
rating = sa.Column(sa.Integer)
orgURL = sa.Column(sa.String)
extras: List[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete")
@validates("name")
def validate_name(self, key, name):
assert not name == ""
return name
def __init__(
self,
session,
name: str = None,
description: str = None,
image: str = None,
recipeYield: str = None,
recipeIngredient: List[str] = None,
recipeInstructions: List[dict] = None,
totalTime: str = None,
prepTime: str = None,
performTime: str = None,
slug: str = None,
categories: List[str] = None,
tags: List[str] = None,
dateAdded: datetime.date = None,
notes: List[dict] = None,
rating: int = None,
orgURL: str = None,
extras: dict = None,
) -> None:
self.name = name
self.description = description
self.image = image
self.recipeYield = recipeYield
self.recipeIngredient = [
RecipeIngredient(ingredient=ingr) for ingr in recipeIngredient
]
self.recipeInstructions = [
RecipeInstruction(text=instruc.get("text"), type=instruc.get("@type", None))
for instruc in recipeInstructions
]
self.totalTime = totalTime
self.prepTime = prepTime
self.performTime = performTime
# Mealie Specific
self.slug = slug
self.categories = [
Category.create_if_not_exist(session=session, name=cat)
for cat in categories
]
self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags]
self.dateAdded = dateAdded
self.notes = [Note(**note) for note in notes]
self.rating = rating
self.orgURL = orgURL
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
def update(
self,
session,
name: str = None,
description: str = None,
image: str = None,
recipeYield: str = None,
recipeIngredient: List[str] = None,
recipeInstructions: List[dict] = None,
totalTime: str = None,
prepTime: str = None,
performTime: str = None,
slug: str = None,
categories: List[str] = None,
tags: List[str] = None,
dateAdded: datetime.date = None,
notes: List[dict] = None,
rating: int = None,
orgURL: str = None,
extras: dict = None,
):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
list_of_tables = [RecipeIngredient, RecipeInstruction, ApiExtras]
RecipeModel._sql_remove_list(session, list_of_tables, self.id)
self.__init__(
session=session,
name=name,
description=description,
image=image,
recipeYield=recipeYield,
recipeIngredient=recipeIngredient,
recipeInstructions=recipeInstructions,
totalTime=totalTime,
prepTime=prepTime,
performTime=performTime,
slug=slug,
categories=categories,
tags=tags,
dateAdded=dateAdded,
notes=notes,
rating=rating,
orgURL=orgURL,
extras=extras,
)
def dict(self):
data = {
"name": self.name,
"description": self.description,
"image": self.image,
"recipeYield": self.recipeYield,
"recipeIngredient": [x.to_str() for x in self.recipeIngredient],
"recipeInstructions": [x.dict() for x in self.recipeInstructions],
"totalTime": self.totalTime,
"prepTime": self.prepTime,
"performTime": self.performTime,
# Mealie
"slug": self.slug,
"categories": [x.to_str() for x in self.categories],
"tags": [x.to_str() for x in self.tags],
"dateAdded": self.dateAdded,
"notes": [x.dict() for x in self.notes],
"rating": self.rating,
"orgURL": self.orgURL,
"extras": RecipeModel._flatten_dict(self.extras),
}
return data

View file

@ -0,0 +1,16 @@
from datetime import date
import sqlalchemy as sa
from db.models.model_base import SqlAlchemyBase
class ApiExtras(SqlAlchemyBase):
__tablename__ = "api_extras"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
key_name = sa.Column(sa.String, unique=True)
value = sa.Column(sa.String)
def __init__(self, key, value) -> None:
self.key_name = key
self.value = value

View file

@ -0,0 +1,65 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.models.model_base import SqlAlchemyBase
from fastapi.logger import logger
from slugify import slugify
from sqlalchemy.orm import validates
site_settings2categories = sa.Table(
"site_settings2categoories",
SqlAlchemyBase.metadata,
sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
)
group2categories = sa.Table(
"group2categories",
SqlAlchemyBase.metadata,
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
)
recipes2categories = sa.Table(
"recipes2categories",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
)
class Category(SqlAlchemyBase):
__tablename__ = "categories"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship(
"RecipeModel", secondary=recipes2categories, back_populates="recipeCategory"
)
@validates("name")
def validate_name(self, key, name):
assert not name == ""
return name
def __init__(self, name) -> None:
self.name = name.strip()
self.slug = slugify(name)
@staticmethod
def get_ref(session, slug: str):
return session.query(Category).filter(Category.slug == slug).one()
@staticmethod
def create_if_not_exist(session, name: str = None):
test_slug = slugify(name)
try:
result = session.query(Category).filter(Category.slug == test_slug).one()
if result:
logger.info("Category exists, associating recipe")
return result
else:
logger.info("Category doesn't exists, creating tag")
return Category(name=name)
except:
logger.info("Category doesn't exists, creating category")
return Category(name=name)

View file

@ -0,0 +1,13 @@
import sqlalchemy as sa
from db.models.model_base import SqlAlchemyBase
class RecipeIngredient(SqlAlchemyBase):
__tablename__ = "recipes_ingredients"
id = sa.Column(sa.Integer, primary_key=True)
position = sa.Column(sa.Integer)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
ingredient = sa.Column(sa.String)
def update(self, ingredient):
self.ingredient = ingredient

View file

@ -0,0 +1,11 @@
import sqlalchemy as sa
from db.models.model_base import SqlAlchemyBase
class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
position = sa.Column(sa.Integer)
type = sa.Column(sa.String, default="")
text = sa.Column(sa.String)

View file

@ -0,0 +1,15 @@
import sqlalchemy as sa
from db.models.model_base import SqlAlchemyBase
class Note(SqlAlchemyBase):
__tablename__ = "notes"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
title = sa.Column(sa.String)
text = sa.Column(sa.String)
def __init__(self, title, text) -> None:
self.title = title
self.text = text

View file

@ -0,0 +1,31 @@
import sqlalchemy as sa
from db.models.model_base import SqlAlchemyBase
class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
calories = sa.Column(sa.String)
fatContent = sa.Column(sa.String)
fiberContent = sa.Column(sa.String)
proteinContent = sa.Column(sa.String)
sodiumContent = sa.Column(sa.String)
sugarContent = sa.Column(sa.String)
def __init__(
self,
calories=None,
fatContent=None,
fiberContent=None,
proteinContent=None,
sodiumContent=None,
sugarContent=None,
) -> None:
self.calories = calories
self.fatContent = fatContent
self.fiberContent = fiberContent
self.proteinContent = proteinContent
self.sodiumContent = sodiumContent
self.sugarContent = sugarContent

View file

@ -0,0 +1,184 @@
import datetime
from datetime import date
from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.models.model_base import BaseMixins, SqlAlchemyBase
from db.models.recipe.api_extras import ApiExtras
from db.models.recipe.category import Category, recipes2categories
from db.models.recipe.ingredient import RecipeIngredient
from db.models.recipe.instruction import RecipeInstruction
from db.models.recipe.note import Note
from db.models.recipe.nutrition import Nutrition
from db.models.recipe.tag import Tag, recipes2tags
from db.models.recipe.tool import Tool
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import validates
class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes"
# Database Specific
id = sa.Column(sa.Integer, primary_key=True)
# General Recipe Properties
name = sa.Column(sa.String, nullable=False)
description = sa.Column(sa.String)
image = sa.Column(sa.String)
totalTime = sa.Column(sa.String)
prepTime = sa.Column(sa.String)
performTime = sa.Column(sa.String)
cookTime = sa.Column(sa.String)
recipeYield = sa.Column(sa.String)
recipeCuisine = sa.Column(sa.String)
tool: List[Tool] = orm.relationship("Tool", cascade="all, delete")
nutrition: Nutrition = orm.relationship(
"Nutrition", uselist=False, cascade="all, delete"
)
recipeCategory: List = orm.relationship(
"Category", secondary=recipes2categories, back_populates="recipes"
)
recipeIngredient: List[RecipeIngredient] = orm.relationship(
"RecipeIngredient",
cascade="all, delete",
order_by="RecipeIngredient.position",
collection_class=ordering_list("position"),
)
recipeInstructions: List[RecipeInstruction] = orm.relationship(
"RecipeInstruction",
cascade="all, delete",
order_by="RecipeInstruction.position",
collection_class=ordering_list("position"),
)
# Mealie Specific
slug = sa.Column(sa.String, index=True, unique=True)
tags: List[Tag] = orm.relationship(
"Tag", secondary=recipes2tags, back_populates="recipes"
)
dateAdded = sa.Column(sa.Date, default=date.today)
notes: List[Note] = orm.relationship("Note", cascade="all, delete")
rating = sa.Column(sa.Integer)
orgURL = sa.Column(sa.String)
extras: List[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete")
@validates("name")
def validate_name(self, key, name):
assert not name == ""
return name
def __init__(
self,
session,
name: str = None,
description: str = None,
image: str = None,
recipeYield: str = None,
recipeIngredient: List[str] = None,
recipeInstructions: List[dict] = None,
recipeCuisine: str = None,
totalTime: str = None,
prepTime: str = None,
nutrition: dict = None,
tool: list[str] = [],
performTime: str = None,
slug: str = None,
recipeCategory: List[str] = None,
tags: List[str] = None,
dateAdded: datetime.date = None,
notes: List[dict] = None,
rating: int = None,
orgURL: str = None,
extras: dict = None,
) -> None:
self.name = name
self.description = description
self.image = image
self.recipeCuisine = recipeCuisine
if self.nutrition:
self.nutrition = Nutrition(**nutrition)
else:
self.nutrition = Nutrition()
self.tool = [Tool(tool=x) for x in tool] if tool else []
self.recipeYield = recipeYield
self.recipeIngredient = [
RecipeIngredient(ingredient=ingr) for ingr in recipeIngredient
]
self.recipeInstructions = [
RecipeInstruction(text=instruc.get("text"), type=instruc.get("@type", None))
for instruc in recipeInstructions
]
self.totalTime = totalTime
self.prepTime = prepTime
self.performTime = performTime
self.recipeCategory = [
Category.create_if_not_exist(session=session, name=cat)
for cat in recipeCategory
]
# Mealie Specific
self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags]
self.slug = slug
self.dateAdded = dateAdded
self.notes = [Note(**note) for note in notes]
self.rating = rating
self.orgURL = orgURL
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
def update(
self,
session,
name: str = None,
description: str = None,
image: str = None,
recipeYield: str = None,
recipeIngredient: List[str] = None,
recipeInstructions: List[dict] = None,
recipeCuisine: str = None,
totalTime: str = None,
tool: list[str] = [],
prepTime: str = None,
performTime: str = None,
nutrition: dict = None,
slug: str = None,
recipeCategory: List[str] = None,
tags: List[str] = None,
dateAdded: datetime.date = None,
notes: List[dict] = None,
rating: int = None,
orgURL: str = None,
extras: dict = None,
):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
list_of_tables = [RecipeIngredient, RecipeInstruction, ApiExtras, Tool]
RecipeModel._sql_remove_list(session, list_of_tables, self.id)
self.__init__(
session=session,
name=name,
description=description,
image=image,
recipeYield=recipeYield,
recipeIngredient=recipeIngredient,
recipeInstructions=recipeInstructions,
totalTime=totalTime,
recipeCuisine=recipeCuisine,
prepTime=prepTime,
performTime=performTime,
nutrition=nutrition,
tool=tool,
slug=slug,
recipeCategory=recipeCategory,
tags=tags,
dateAdded=dateAdded,
notes=notes,
rating=rating,
orgURL=orgURL,
extras=extras,
)

View file

@ -0,0 +1,50 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.models.model_base import SqlAlchemyBase
from fastapi.logger import logger
from slugify import slugify
from sqlalchemy.orm import validates
recipes2tags = sa.Table(
"recipes2tags",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")),
)
class Tag(SqlAlchemyBase):
__tablename__ = "tags"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship(
"RecipeModel", secondary=recipes2tags, back_populates="tags"
)
@validates("name")
def validate_name(self, key, name):
assert not name == ""
return name
def __init__(self, name) -> None:
self.name = name.strip()
self.slug = slugify(self.name)
@staticmethod
def create_if_not_exist(session, name: str = None):
test_slug = slugify(name)
try:
result = session.query(Tag).filter(Tag.slug == test_slug).first()
if result:
logger.info("Tag exists, associating recipe")
return result
else:
logger.info("Tag doesn't exists, creating tag")
return Tag(name=name)
except:
logger.info("Tag doesn't exists, creating tag")
return Tag(name=name)

View file

@ -0,0 +1,15 @@
import sqlalchemy as sa
from db.models.model_base import SqlAlchemyBase
class Tool(SqlAlchemyBase):
__tablename__ = "tools"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
tool = sa.Column(sa.String)
def __init__(self, tool) -> None:
self.tool = tool
def str(self):
return self.tool

View file

@ -1,93 +1,38 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from db.models.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
from db.models.recipe.category import Category, site_settings2categories
from sqlalchemy.orm import Session
class SiteSettingsModel(SqlAlchemyBase, BaseMixins): class SiteSettings(SqlAlchemyBase, BaseMixins):
__tablename__ = "site_settings" __tablename__ = "site_settings"
name = sa.Column(sa.String, primary_key=True) id = sa.Column(sa.Integer, primary_key=True)
planCategories = orm.relationship( language = sa.Column(sa.String)
"MealCategory", uselist=True, cascade="all, delete" categories = orm.relationship(
"Category",
secondary=site_settings2categories,
single_parent=True,
) )
webhooks = orm.relationship("WebHookModel", uselist=False, cascade="all, delete") show_recent = sa.Column(sa.Boolean, default=True)
cards_per_section = sa.Column(sa.Integer)
def __init__( def __init__(
self, name: str = None, webhooks: dict = None, planCategories=[], session=None self,
session: Session = None,
language="en",
categories: list = [],
show_recent=True,
cards_per_section: int = 9,
) -> None: ) -> None:
self.name = name session.commit()
self.planCategories = [MealCategory(cat) for cat in planCategories] self.language = language
self.webhooks = WebHookModel(**webhooks) self.cards_per_section = cards_per_section
self.show_recent = show_recent
self.categories = [
Category.get_ref(session=session, name=cat.get("slug"))
for cat in categories
]
def update(self, session, name, webhooks: dict, planCategories=[]) -> dict: def update(self, *args, **kwarg):
self.__init__(*args, **kwarg)
self._sql_remove_list(session, [MealCategory], self.name)
self.name = name
self.planCategories = [MealCategory(x) for x in planCategories]
self.webhooks.update(session=session, **webhooks)
return
def dict(self):
data = {
"name": self.name,
"planCategories": [cat.to_str() for cat in self.planCategories],
"webhooks": self.webhooks.dict(),
}
return data
class MealCategory(SqlAlchemyBase):
__tablename__ = "meal_plan_categories"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("site_settings.name"))
def __init__(self, name) -> None:
self.name = name
def to_str(self):
return self.name
class WebHookModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "webhook_settings"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("site_settings.name"))
webhookURLs = orm.relationship(
"WebhookURLModel", uselist=True, cascade="all, delete"
)
webhookTime = sa.Column(sa.String, default="00:00")
enabled = sa.Column(sa.Boolean, default=False)
def __init__(
self, webhookURLs: list, webhookTime: str, enabled: bool = False, session=None
) -> None:
self.webhookURLs = [WebhookURLModel(url=x) for x in webhookURLs]
self.webhookTime = webhookTime
self.enabled = enabled
def update(
self, session, webhookURLs: list, webhookTime: str, enabled: bool
) -> None:
self._sql_remove_list(session, [WebhookURLModel], self.id)
self.__init__(webhookURLs, webhookTime, enabled)
def dict(self):
data = {
"webhookURLs": [url.to_str() for url in self.webhookURLs],
"webhookTime": self.webhookTime,
"enabled": self.enabled,
}
return data
class WebhookURLModel(SqlAlchemyBase):
__tablename__ = "webhook_urls"
id = sa.Column(sa.Integer, primary_key=True)
url = sa.Column(sa.String)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("webhook_settings.id"))
def to_str(self):
return self.url

View file

@ -1,5 +1,5 @@
from db.models.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Column, Integer, String, Boolean from sqlalchemy import Boolean, Column, Integer, String
class SignUp(SqlAlchemyBase, BaseMixins): class SignUp(SqlAlchemyBase, BaseMixins):
@ -19,11 +19,3 @@ class SignUp(SqlAlchemyBase, BaseMixins):
self.token = token self.token = token
self.name = name self.name = name
self.admin = admin self.admin = admin
def dict(self):
return {
"id": self.id,
"name": self.name,
"token": self.token,
"admin": self.admin
}

View file

@ -1,6 +1,6 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from db.models.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import SqlAlchemyBase
class SiteThemeModel(SqlAlchemyBase): class SiteThemeModel(SqlAlchemyBase):
@ -14,11 +14,7 @@ class SiteThemeModel(SqlAlchemyBase):
def update(self, session=None, name: str = None, colors: dict = None) -> dict: def update(self, session=None, name: str = None, colors: dict = None) -> dict:
self.colors.update(**colors) self.colors.update(**colors)
return self.dict() return self
def dict(self):
data = {"name": self.name, "colors": self.colors.dict()}
return data
class ThemeColorsModel(SqlAlchemyBase): class ThemeColorsModel(SqlAlchemyBase):
@ -50,15 +46,3 @@ class ThemeColorsModel(SqlAlchemyBase):
self.info = info self.info = info
self.warning = warning self.warning = warning
self.error = error self.error = error
def dict(self):
data = {
"primary": self.primary,
"accent": self.accent,
"secondary": self.secondary,
"success": self.success,
"info": self.info,
"warning": self.warning,
"error": self.error,
}
return data

View file

@ -1,5 +1,13 @@
from core.config import DEFAULT_GROUP
from db.models.group import Group
from db.models.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, Integer, String from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
# I'm not sure this is necessasry, browser based settings may be sufficient
# class UserSettings(SqlAlchemyBase, BaseMixins):
# __tablename__ = "user_settings"
# id = Column(Integer, primary_key=True, index=True)
# parent_id = Column(String, ForeignKey("users.id"))
class User(SqlAlchemyBase, BaseMixins): class User(SqlAlchemyBase, BaseMixins):
@ -8,9 +16,9 @@ class User(SqlAlchemyBase, BaseMixins):
full_name = Column(String, index=True) full_name = Column(String, index=True)
email = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True)
password = Column(String) password = Column(String)
is_active = Column(Boolean(), default=True) group_id = Column(String, ForeignKey("groups.id"))
family = Column(String) group = orm.relationship("Group", back_populates="users")
admin = Column(Boolean(), default=False) admin = Column(Boolean, default=False)
def __init__( def __init__(
self, self,
@ -18,29 +26,21 @@ class User(SqlAlchemyBase, BaseMixins):
full_name, full_name,
email, email,
password, password,
family="public", group: str = DEFAULT_GROUP,
admin=False, admin=False,
) -> None: ) -> None:
group = group if group else DEFAULT_GROUP
self.full_name = full_name self.full_name = full_name
self.email = email self.email = email
self.family = family self.group = Group.create_if_not_exist(session, group)
self.admin = admin self.admin = admin
self.password = password self.password = password
def dict(self): def update(self, full_name, email, group, admin, session=None):
return {
"id": self.id,
"full_name": self.full_name,
"email": self.email,
"admin": self.admin,
"family": self.family,
"password": self.password,
}
def update(self, full_name, email, family, admin, session=None):
self.full_name = full_name self.full_name = full_name
self.email = email self.email = email
self.family = family self.group = Group.create_if_not_exist(session, group)
self.admin = admin self.admin = admin
def update_password(self, password): def update_password(self, password):

View file

@ -5,11 +5,11 @@ from core.config import BACKUP_DIR, TEMPLATE_DIR
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from schema.backup import BackupJob, ImportJob, Imports, LocalBackup from schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from schema.snackbar import SnackResponse
from services.backups.exports import backup_all from services.backups.exports import backup_all
from services.backups.imports import ImportDatabase from services.backups.imports import ImportDatabase
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse from starlette.responses import FileResponse
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/backups", tags=["Backups"]) router = APIRouter(prefix="/api/backups", tags=["Backups"])

View file

@ -20,5 +20,5 @@ def query_user(user_email: str, session: Session = None) -> UserInDB:
session = session if session else create_session() session = session if session else create_session()
user = db.users.get(session, user_email, "email") user = db.users.get(session, user_email, "email")
session.close() session.close()
return UserInDB(**user) return user

View file

@ -0,0 +1,80 @@
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from routes.deps import manager
from schema.snackbar import SnackResponse
from schema.user import GroupBase, GroupInDB, UpdateGroup, UserInDB
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/groups", tags=["Groups"])
@router.get("", response_model=list[GroupInDB])
async def get_all_groups(
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Returns a list of all groups in the database """
return db.groups.get_all(session)
@router.get("/self", response_model=GroupInDB)
async def get_current_user_group(
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Returns the Group Data for the Current User """
current_user: UserInDB
return db.groups.get(session, current_user.group, "name")
@router.post("")
async def create_group(
group_data: GroupBase,
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Creates a Group in the Database """
try:
db.groups.create(session, group_data.dict())
return SnackResponse.success("User Group Created", {"created": True})
except:
return SnackResponse.error("User Group Creation Failed")
@router.put("/{id}")
async def update_group_data(
id: int,
group_data: UpdateGroup,
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Updates a User Group """
db.groups.update(session, id, group_data.dict())
return SnackResponse.success("Group Settings Updated")
@router.delete("/{id}")
async def delete_user_group(
id: int, current_user=Depends(manager), session: Session = Depends(generate_session)
):
""" Removes a user group from the database """
if id == 1:
return SnackResponse.error("Cannot delete default group")
group: GroupInDB = db.groups.get(session, id)
if not group:
return SnackResponse.error("Group not found")
if not group.users == []:
return SnackResponse.error("Cannot delete group with users")
db.groups.delete(session, id)
return

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from routes.groups import crud
router = APIRouter()
router.include_router(crud.router)

View file

@ -1,20 +1,23 @@
from datetime import date
from typing import List from typing import List
from db.database import db from db.database import db
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from schema.meal import MealPlanBase, MealPlanInDB
from schema.recipe import Recipe
from schema.snackbar import SnackResponse from schema.snackbar import SnackResponse
from services.meal_services import MealPlan from services.meal_services import get_todays_meal, process_meals
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@router.get("/all", response_model=List[MealPlan]) @router.get("/all", response_model=List[MealPlanInDB])
def get_all_meals(session: Session = Depends(generate_session)): def get_all_meals(session: Session = Depends(generate_session)):
""" Returns a list of all available Meal Plan """ """ Returns a list of all available Meal Plan """
return MealPlan.get_all(session) return db.meals.get_all(session)
@router.get("/{id}/shopping-list") @router.get("/{id}/shopping-list")
@ -22,10 +25,11 @@ def get_shopping_list(id: str, session: Session = Depends(generate_session)):
#! Refactor into Single Database Call #! Refactor into Single Database Call
mealplan = db.meals.get(session, id) mealplan = db.meals.get(session, id)
slugs = [x.get("slug") for x in mealplan.get("meals")] mealplan: MealPlanInDB
recipes = [db.recipes.get(session, x) for x in slugs] slugs = [x.slug for x in mealplan.meals]
recipes: list[Recipe] = [db.recipes.get(session, x) for x in slugs]
ingredients = [ ingredients = [
{"name": x.get("name"), "recipeIngredient": x.get("recipeIngredient")} {"name": x.name, "recipeIngredient": x.recipeIngredient}
for x in recipes for x in recipes
if x if x
] ]
@ -34,28 +38,29 @@ def get_shopping_list(id: str, session: Session = Depends(generate_session)):
@router.post("/create") @router.post("/create")
def set_meal_plan(data: MealPlan, session: Session = Depends(generate_session)): def create_meal_plan(data: MealPlanBase, session: Session = Depends(generate_session)):
""" Creates a meal plan database entry """ """ Creates a meal plan database entry """
data.process_meals(session) processed_plan = process_meals(session, data)
data.save_to_db(session) db.meals.create(session, processed_plan.dict())
return SnackResponse.success("Mealplan Created") return SnackResponse.success("Mealplan Created")
@router.get("/this-week", response_model=MealPlan) @router.get("/this-week", response_model=MealPlanInDB)
def get_this_week(session: Session = Depends(generate_session)): def get_this_week(session: Session = Depends(generate_session)):
""" Returns the meal plan data for this week """ """ Returns the meal plan data for this week """
return MealPlan.this_week(session) return db.meals.get_all(session, limit=1, order_by="startDate")
@router.put("/{plan_id}") @router.put("/{plan_id}")
def update_meal_plan( def update_meal_plan(
plan_id: str, meal_plan: MealPlan, session: Session = Depends(generate_session) plan_id: str, meal_plan: MealPlanBase, session: Session = Depends(generate_session)
): ):
""" Updates a meal plan based off ID """ """ Updates a meal plan based off ID """
meal_plan.process_meals(session) processed_plan = process_meals(session, meal_plan)
meal_plan.update(session, plan_id) processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict())
db.meals.update(session, plan_id, processed_plan.dict())
return SnackResponse.info("Mealplan Updated") return SnackResponse.info("Mealplan Updated")
@ -64,7 +69,7 @@ def update_meal_plan(
def delete_meal_plan(plan_id, session: Session = Depends(generate_session)): def delete_meal_plan(plan_id, session: Session = Depends(generate_session)):
""" Removes a meal plan from the database """ """ Removes a meal plan from the database """
MealPlan.delete(session, plan_id) db.meals.delete(session, plan_id)
return SnackResponse.error("Mealplan Deleted") return SnackResponse.error("Mealplan Deleted")
@ -76,4 +81,4 @@ def get_today(session: Session = Depends(generate_session)):
If no meal is scheduled nothing is returned If no meal is scheduled nothing is returned
""" """
return MealPlan.today(session) return get_todays_meal(session)

View file

@ -1,13 +1,13 @@
from db.database import db
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, Form, HTTPException from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.logger import logger from fastapi.logger import logger
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from schema.recipe import RecipeURLIn from schema.recipe import Recipe, RecipeURLIn
from schema.snackbar import SnackResponse
from services.image_services import read_image, write_image from services.image_services import read_image, write_image
from services.recipe_services import Recipe
from services.scraper.scraper import create_from_url from services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from schema.snackbar import SnackResponse
router = APIRouter( router = APIRouter(
prefix="/api/recipes", prefix="/api/recipes",
@ -16,49 +16,47 @@ router = APIRouter(
@router.post("/create", status_code=201, response_model=str) @router.post("/create", status_code=201, response_model=str)
def create_from_json(data: Recipe, db: Session = Depends(generate_session)) -> str: def create_from_json(data: Recipe, session: Session = Depends(generate_session)) -> str:
""" Takes in a JSON string and loads data into the database as a new entry""" """ Takes in a JSON string and loads data into the database as a new entry"""
new_recipe_slug = data.save_to_db(db) recipe: Recipe = db.recipes.create(session, data.dict())
return new_recipe_slug return recipe.slug
@router.post("/create-url", status_code=201, response_model=str) @router.post("/create-url", status_code=201, response_model=str)
def parse_recipe_url(url: RecipeURLIn, db: Session = Depends(generate_session)): def parse_recipe_url(url: RecipeURLIn, session: Session = Depends(generate_session)):
""" Takes in a URL and attempts to scrape data and load it into the database """ """ Takes in a URL and attempts to scrape data and load it into the database """
recipe = create_from_url(url.url) recipe = create_from_url(url.url)
recipe: Recipe = db.recipes.create(session, recipe.dict())
recipe.save_to_db(db)
return recipe.slug return recipe.slug
@router.get("/{recipe_slug}", response_model=Recipe) @router.get("/{recipe_slug}", response_model=Recipe)
def get_recipe(recipe_slug: str, db: Session = Depends(generate_session)): def get_recipe(recipe_slug: str, session: Session = Depends(generate_session)):
""" Takes in a recipe slug, returns all data for a recipe """ """ Takes in a recipe slug, returns all data for a recipe """
recipe = Recipe.get_by_slug(db, recipe_slug)
return recipe return db.recipes.get(session, recipe_slug)
@router.put("/{recipe_slug}") @router.put("/{recipe_slug}")
def update_recipe( def update_recipe(
recipe_slug: str, data: Recipe, db: Session = Depends(generate_session) recipe_slug: str, data: Recipe, session: Session = Depends(generate_session)
): ):
""" Updates a recipe by existing slug and data. """ """ Updates a recipe by existing slug and data. """
new_slug = data.update(db, recipe_slug) recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
return new_slug return recipe.slug
@router.delete("/{recipe_slug}") @router.delete("/{recipe_slug}")
def delete_recipe(recipe_slug: str, db: Session = Depends(generate_session)): def delete_recipe(recipe_slug: str, session: Session = Depends(generate_session)):
""" Deletes a recipe by slug """ """ Deletes a recipe by slug """
try: try:
Recipe.delete(db, recipe_slug) db.recipes.delete(session, recipe_slug)
except: except:
raise HTTPException( raise HTTPException(
status_code=404, detail=SnackResponse.error("Unable to Delete Recipe") status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")
@ -86,6 +84,6 @@ def update_recipe_image(
): ):
""" Removes an existing image and replaces it with the incoming file. """ """ Removes an existing image and replaces it with the incoming file. """
response = write_image(recipe_slug, image, extension) response = write_image(recipe_slug, image, extension)
Recipe.update_image(session, recipe_slug, extension) db.recipes.update_image(session, recipe_slug, extension)
return response return response

View file

@ -2,9 +2,9 @@ from db.database import db
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from schema.settings import SiteSettings from schema.settings import SiteSettings
from schema.snackbar import SnackResponse
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.post_webhooks import post_webhooks from utils.post_webhooks import post_webhooks
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) router = APIRouter(prefix="/api/site-settings", tags=["Settings"])
@ -13,17 +13,15 @@ router = APIRouter(prefix="/api/site-settings", tags=["Settings"])
def get_main_settings(session: Session = Depends(generate_session)): def get_main_settings(session: Session = Depends(generate_session)):
""" Returns basic site settings """ """ Returns basic site settings """
try: data = db.settings.get(session, 1)
data = db.settings.get(session, "main")
except:
return
return data return data
@router.put("") @router.put("")
def update_settings(data: SiteSettings, session: Session = Depends(generate_session)): def update_settings(data: SiteSettings, session: Session = Depends(generate_session)):
""" Returns Site Settings """ """ Returns Site Settings """
db.settings.update(session, "main", data.dict()) db.settings.update(session, 1, data.dict())
return SnackResponse.success("Settings Updated") return SnackResponse.success("Settings Updated")

View file

@ -10,7 +10,7 @@ from schema.snackbar import SnackResponse
from schema.user import UserInDB from schema.user import UserInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/auth", tags=["Auth"]) router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@router.post("/token") @router.post("/token")
@ -60,10 +60,8 @@ def get_long_token(
) )
@router.post("/refresh") @router.get("/refresh")
async def refresh_token( async def refresh_token(current_user: UserInDB = Depends(manager)):
current_user: UserInDB = Depends(manager),
):
""" Use a valid token to get another token""" """ Use a valid token to get another token"""
access_token = manager.create_access_token( access_token = manager.create_access_token(
data=dict(sub=current_user.email), expires=timedelta(hours=1) data=dict(sub=current_user.email), expires=timedelta(hours=1)

View file

@ -1,5 +1,6 @@
import shutil import shutil
from datetime import timedelta from datetime import timedelta
from os import access
from core.config import USER_DIR from core.config import USER_DIR
from core.security import get_password_hash, verify_password from core.security import get_password_hash, verify_password
@ -65,9 +66,10 @@ async def update_user(
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
): ):
access_token = None
if current_user.id == id or current_user.admin: if current_user.id == id or current_user.admin:
updated_user = db.users.update(session, id, new_data.dict()) updated_user: UserInDB = db.users.update(session, id, new_data.dict())
email = updated_user.get("email") email = updated_user.email
if current_user.id == id: if current_user.id == id:
access_token = manager.create_access_token( access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2) data=dict(sub=email), expires=timedelta(hours=2)
@ -82,7 +84,6 @@ async def get_user_image(id: str):
""" Returns a users profile picture """ """ Returns a users profile picture """
user_dir = USER_DIR.joinpath(id) user_dir = USER_DIR.joinpath(id)
for recipe_image in user_dir.glob("profile_image.*"): for recipe_image in user_dir.glob("profile_image.*"):
print(recipe_image)
return FileResponse(recipe_image) return FileResponse(recipe_image)
else: else:
return False return False
@ -128,7 +129,6 @@ async def update_password(
match_passwords = verify_password( match_passwords = verify_password(
password_change.current_password, current_user.password password_change.current_password, current_user.password
) )
print(match_passwords)
match_id = current_user.id == id match_id = current_user.id == id
if match_passwords and match_id: if match_passwords and match_id:

View file

@ -56,12 +56,12 @@ async def create_user_with_token(
""" Creates a user with a valid sign up token """ """ Creates a user with a valid sign up token """
# Validate Token # Validate Token
db_entry = db.sign_ups.get(session, token, limit=1) db_entry: SignUpOut = db.sign_ups.get(session, token, limit=1)
if not db_entry: if not db_entry:
return {"details": "invalid token"} return SnackResponse.error("Invalid Token")
# Create User # Create User
new_user.admin = db_entry.get("admin") new_user.admin = db_entry.admin
new_user.password = get_password_hash(new_user.password) new_user.password = get_password_hash(new_user.password)
data = db.users.create(session, new_user.dict()) data = db.users.create(session, new_user.dict())

View file

@ -5,4 +5,5 @@ router = APIRouter()
router.include_router(sign_up.router) router.include_router(sign_up.router)
router.include_router(auth.router) router.include_router(auth.router)
router.include_router(sign_up.router)
router.include_router(crud.router) router.include_router(crud.router)

View file

@ -3,6 +3,8 @@
## Migrations ## Migrations
# TODO # TODO
# Database Init
## Web Server ## Web Server
caddy start --config ./Caddyfile caddy start --config ./Caddyfile

View file

@ -1,14 +1,29 @@
from typing import List, Optional from typing import List, Optional
from pydantic.main import BaseModel from fastapi_camelcase import CamelModel
from services.recipe_services import Recipe
from schema.recipe import Recipe
class RecipeCategoryResponse(BaseModel): class CategoryBase(CamelModel):
id: int id: int
name: str name: str
slug: str slug: str
class Config:
orm_mode = True
class RecipeCategoryResponse(CategoryBase):
recipes: Optional[List[Recipe]] recipes: Optional[List[Recipe]]
class Config: class Config:
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}} schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
class TagBase(CategoryBase):
pass
class RecipeTagResponse(TagBase):
pass

View file

@ -1,29 +1,48 @@
from datetime import date from datetime import date
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel, validator
class Meal(BaseModel): class MealIn(BaseModel):
slug: Optional[str]
name: Optional[str] name: Optional[str]
date: date slug: Optional[str]
dateText: str date: Optional[date]
class MealOut(MealIn):
image: Optional[str] image: Optional[str]
description: Optional[str] description: Optional[str]
class Config:
orm_mode = True
class MealData(BaseModel):
name: Optional[str] class MealPlanBase(BaseModel):
slug: str startDate: date
dateText: str endDate: date
meals: List[MealIn]
@validator("endDate")
def endDate_after_startDate(cls, v, values, **kwargs):
if "startDate" in values and v < values["startDate"]:
raise ValueError("EndDate should be greater than StartDate")
return v
class MealPlanProcessed(MealPlanBase):
meals: list[MealOut]
class MealPlanInDB(MealPlanProcessed):
uid: str
class Config:
orm_mode = True
class MealPlan(BaseModel): class MealPlan(BaseModel):
uid: Optional[str] uid: Optional[str]
startDate: date
endDate: date
meals: List[Meal]
class Config: class Config:
schema_extra = { schema_extra = {

View file

@ -1,7 +1,9 @@
import datetime import datetime
from typing import Any, List, Optional from typing import Any, List, Optional
from db.models.recipe.recipe import RecipeModel
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from pydantic.utils import GetterDict
from slugify import slugify from slugify import slugify
@ -9,19 +11,38 @@ class RecipeNote(BaseModel):
title: str title: str
text: str text: str
class Config:
orm_mode = True
class RecipeStep(BaseModel): class RecipeStep(BaseModel):
text: str text: str
class Config:
orm_mode = True
class Nutrition(BaseModel):
calories: Optional[str]
fatContent: Optional[str]
fiberContent: Optional[str]
proteinContent: Optional[str]
sodiumContent: Optional[str]
sugarContent: Optional[str]
class Config:
orm_mode = True
class Recipe(BaseModel): class Recipe(BaseModel):
# Standard Schema
name: str name: str
description: Optional[str] description: Optional[str]
image: Optional[Any] image: Optional[Any]
recipeYield: Optional[str] recipeYield: Optional[str]
recipeIngredient: Optional[list] recipeCategory: Optional[List[str]] = []
recipeInstructions: Optional[list] recipeIngredient: Optional[list[str]]
recipeInstructions: Optional[list[RecipeStep]]
nutrition: Optional[Nutrition]
totalTime: Optional[str] = None totalTime: Optional[str] = None
prepTime: Optional[str] = None prepTime: Optional[str] = None
@ -29,7 +50,6 @@ class Recipe(BaseModel):
# Mealie Specific # Mealie Specific
slug: Optional[str] = "" slug: Optional[str] = ""
categories: Optional[List[str]] = []
tags: Optional[List[str]] = [] tags: Optional[List[str]] = []
dateAdded: Optional[datetime.date] dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = [] notes: Optional[List[RecipeNote]] = []
@ -38,6 +58,18 @@ class Recipe(BaseModel):
extras: Optional[dict] = {} extras: Optional[dict] = {}
class Config: class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm: RecipeModel):
return {
**GetterDict(name_orm),
"recipeIngredient": [x.ingredient for x in name_orm.recipeIngredient],
"recipeCategory": [x.name for x in name_orm.recipeCategory],
"tags": [x.name for x in name_orm.tags],
"extras": {x.key_name: x.value for x in name_orm.extras},
}
schema_extra = { schema_extra = {
"example": { "example": {
"name": "Chicken and Rice With Leeks and Salsa Verde", "name": "Chicken and Rice With Leeks and Salsa Verde",
@ -56,7 +88,7 @@ class Recipe(BaseModel):
], ],
"slug": "chicken-and-rice-with-leeks-and-salsa-verde", "slug": "chicken-and-rice-with-leeks-and-salsa-verde",
"tags": ["favorite", "yummy!"], "tags": ["favorite", "yummy!"],
"categories": ["Dinner", "Pasta"], "recipeCategory": ["Dinner", "Pasta"],
"notes": [{"title": "Watch Out!", "text": "Prep the day before!"}], "notes": [{"title": "Watch Out!", "text": "Prep the day before!"}],
"orgURL": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde", "orgURL": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde",
"rating": 3, "rating": 3,

View file

@ -1,28 +1,27 @@
from typing import List, Optional from typing import Optional
from pydantic import BaseModel from fastapi_camelcase import CamelModel
from schema.category import CategoryBase
class Webhooks(BaseModel): class SiteSettings(CamelModel):
webhookTime: str = "00:00" language: str = "en"
webhookURLs: Optional[List[str]] = [] show_recent: bool = True
enabled: bool = False cards_per_section: int = 9
categories: Optional[list[CategoryBase]] = []
class SiteSettings(BaseModel):
name: str = "main"
planCategories: list[str] = []
webhooks: Webhooks
class Config: class Config:
orm_mode = True
schema_extra = { schema_extra = {
"example": { "example": {
"name": "main", "language": "en",
"planCategories": ["dinner", "lunch"], "showRecent": True,
"webhooks": { "categories": [
"webhookTime": "00:00", {"id": 1, "name": "thanksgiving", "slug": "thanksgiving"},
"webhookURLs": ["https://mywebhookurl.com/webhook"], {"id": 2, "name": "homechef", "slug": "homechef"},
"enable": False, {"id": 3, "name": "potatoes", "slug": "potatoes"},
}, ],
} }
} }

View file

@ -12,3 +12,6 @@ class SignUpToken(SignUpIn):
class SignUpOut(SignUpToken): class SignUpOut(SignUpToken):
id: int id: int
class Config:
orm_mode = True

View file

@ -1,20 +1,25 @@
from pydantic import BaseModel from pydantic import BaseModel
class Colors(BaseModel): class Colors(BaseModel):
primary: str primary: str = "#E58325"
accent: str accent: str = "#00457A"
secondary: str secondary: str = "#973542"
success: str success: str = "#4CAF50"
info: str info: str = "#4990BA"
warning: str warning: str = "#FF4081"
error: str error: str = "#EF5350"
class Config:
orm_mode = True
class SiteTheme(BaseModel): class SiteTheme(BaseModel):
name: str name: str = "default"
colors: Colors colors: Colors = Colors()
class Config: class Config:
orm_mode = True
schema_extra = { schema_extra = {
"example": { "example": {
"name": "default", "name": "default",
@ -28,4 +33,4 @@ class SiteTheme(BaseModel):
"error": "#EF5350", "error": "#EF5350",
}, },
} }
} }

View file

@ -1,8 +1,13 @@
from typing import Optional from typing import Any, Optional
from core.config import DEFAULT_GROUP
from db.models.group import Group
from db.models.users import User
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic.utils import GetterDict
# from pydantic import EmailStr from schema.category import CategoryBase
from schema.meal import MealPlanInDB
class ChangePassword(CamelModel): class ChangePassword(CamelModel):
@ -10,17 +15,33 @@ class ChangePassword(CamelModel):
new_password: str new_password: str
class GroupBase(CamelModel):
name: str
class Config:
orm_mode = True
class UserBase(CamelModel): class UserBase(CamelModel):
full_name: Optional[str] = None full_name: Optional[str] = None
email: str email: str
family: str
admin: bool admin: bool
group: Optional[str]
class Config: class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm: User):
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
}
schema_extra = { schema_extra = {
"fullName": "Change Me", "fullName": "Change Me",
"email": "changeme@email.com", "email": "changeme@email.com",
"family": "public", "group": DEFAULT_GROUP,
"admin": "false", "admin": "false",
} }
@ -31,7 +52,47 @@ class UserIn(UserBase):
class UserOut(UserBase): class UserOut(UserBase):
id: int id: int
group: str
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, ormModel: User):
return {
**GetterDict(ormModel),
"group": ormModel.group.name,
}
class UserInDB(UserIn, UserOut): class UserInDB(UserOut):
password: str
pass pass
class Config:
orm_mode = True
class UpdateGroup(GroupBase):
id: int
name: str
categories: Optional[list[CategoryBase]] = []
webhook_urls: list[str] = []
webhook_time: str = "00:00"
webhook_enable: bool
class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanInDB]]
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, orm_model: Group):
return {
**GetterDict(orm_model),
"webhook_urls": [x.url for x in orm_model.webhook_urls if x],
}

View file

@ -8,8 +8,7 @@ from db.database import db
from db.db_setup import create_session from db.db_setup import create_session
from fastapi.logger import logger from fastapi.logger import logger
from jinja2 import Template from jinja2 import Template
from services.meal_services import MealPlan from schema.recipe import Recipe
from services.recipe_services import Recipe
class ExportDatabase: class ExportDatabase:
@ -57,26 +56,27 @@ class ExportDatabase:
dir.mkdir(parents=True, exist_ok=True) dir.mkdir(parents=True, exist_ok=True)
def export_recipes(self): def export_recipes(self):
all_recipes = Recipe.get_all(self.session) all_recipes = db.recipes.get_all(self.session)
for recipe in all_recipes: for recipe in all_recipes:
recipe: Recipe
logger.info(f"Backing Up Recipes: {recipe}") logger.info(f"Backing Up Recipes: {recipe}")
filename = recipe.get("slug") + ".json" filename = recipe.slug + ".json"
file_path = self.recipe_dir.joinpath(filename) file_path = self.recipe_dir.joinpath(filename)
ExportDatabase._write_json_file(recipe, file_path) ExportDatabase._write_json_file(recipe.dict(), file_path)
if self.templates: if self.templates:
self._export_template(recipe) self._export_template(recipe)
def _export_template(self, recipe_data: dict): def _export_template(self, recipe_data: Recipe):
for template_path in self.templates: for template_path in self.templates:
with open(template_path, "r") as f: with open(template_path, "r") as f:
template = Template(f.read()) template = Template(f.read())
filename = recipe_data.get("name") + template_path.suffix filename = recipe_data.name + template_path.suffix
out_file = self.templates_dir.joinpath(filename) out_file = self.templates_dir.joinpath(filename)
content = template.render(recipe=recipe_data) content = template.render(recipe=recipe_data)
@ -101,7 +101,7 @@ class ExportDatabase:
def export_meals(self): def export_meals(self):
#! Problem Parseing Datetime Objects... May come back to this #! Problem Parseing Datetime Objects... May come back to this
meal_plans = MealPlan.get_all(self.session) meal_plans = db.meals.get_all(self.session)
if meal_plans: if meal_plans:
meal_plans = [x.dict() for x in meal_plans] meal_plans = [x.dict() for x in meal_plans]

View file

@ -6,11 +6,12 @@ from typing import List
from core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR from core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR
from db.database import db from db.database import db
from db.db_setup import create_session
from fastapi.logger import logger
from schema.recipe import Recipe
from schema.restore import RecipeImport, SettingsImport, ThemeImport from schema.restore import RecipeImport, SettingsImport, ThemeImport
from schema.theme import SiteTheme from schema.theme import SiteTheme
from services.recipe_services import Recipe
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from fastapi.logger import logger
class ImportDatabase: class ImportDatabase:
@ -75,6 +76,7 @@ class ImportDatabase:
} }
def import_recipes(self): def import_recipes(self):
session = create_session()
recipe_dir: Path = self.import_dir.joinpath("recipes") recipe_dir: Path = self.import_dir.joinpath("recipes")
imports = [] imports = []
@ -85,8 +87,12 @@ class ImportDatabase:
recipe_dict = json.loads(f.read()) recipe_dict = json.loads(f.read())
recipe_dict = ImportDatabase._recipe_migration(recipe_dict) recipe_dict = ImportDatabase._recipe_migration(recipe_dict)
try: try:
if recipe_dict.get("categories", False):
recipe_dict["recipeCategory"] = recipe_dict.get("categories")
del recipe_dict["categories"]
recipe_obj = Recipe(**recipe_dict) recipe_obj = Recipe(**recipe_dict)
recipe_obj.save_to_db(self.session) db.recipes.create(session, recipe_obj.dict())
import_status = RecipeImport( import_status = RecipeImport(
name=recipe_obj.name, slug=recipe_obj.slug, status=True name=recipe_obj.name, slug=recipe_obj.slug, status=True
) )

View file

@ -1,111 +1,43 @@
from datetime import date, timedelta from datetime import date, timedelta
from typing import List, Optional
from db.database import db from db.database import db
from pydantic import BaseModel, validator from schema.meal import MealIn, MealOut, MealPlanBase, MealPlanProcessed
from schema.recipe import Recipe
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from services.recipe_services import Recipe
def process_meals(session: Session, meal_plan_base: MealPlanBase) -> MealPlanProcessed:
meals = []
for x, meal in enumerate(meal_plan_base.meals):
meal: MealIn
try:
recipe: Recipe = db.recipes.get(session, meal.slug)
meal_data = MealOut(
slug=recipe.slug,
name=recipe.name,
date=meal_plan_base.startDate + timedelta(days=x),
image=recipe.image,
description=recipe.description,
)
except:
meal_data = MealOut(
date=meal_plan_base.startDate + timedelta(days=x),
)
meals.append(meal_data)
return MealPlanProcessed(
meals=meals, startDate=meal_plan_base.startDate, endDate=meal_plan_base.endDate
)
class Meal(BaseModel): def get_todays_meal(session):
slug: Optional[str] meal_plan = db.meals.get_all(session, limit=1, order_by="startDate")
name: Optional[str]
date: date
dateText: str
image: Optional[str]
description: Optional[str]
for meal in meal_plan:
class MealData(BaseModel): meal: MealOut
name: Optional[str] if meal.date == date.today():
slug: str return meal.slug
dateText: str
class MealPlan(BaseModel):
uid: Optional[str]
startDate: date
endDate: date
meals: List[Meal]
class Config:
schema_extra = {
"example": {
"startDate": date.today(),
"endDate": date.today(),
"meals": [
{"slug": "Packed Mac and Cheese", "date": date.today()},
{"slug": "Eggs and Toast", "date": date.today()},
],
}
}
@validator('endDate')
def endDate_after_startDate(cls, v, values, **kwargs):
if 'startDate' in values and v < values['startDate']:
raise ValueError('EndDate should be greater than StartDate')
return v
def process_meals(self, session: Session):
meals = []
for x, meal in enumerate(self.meals):
try:
recipe = Recipe.get_by_slug(session, meal.slug)
meal_data = {
"slug": recipe.slug,
"name": recipe.name,
"date": self.startDate + timedelta(days=x),
"dateText": meal.dateText,
"image": recipe.image,
"description": recipe.description,
}
except:
meal_data = {
"date": self.startDate + timedelta(days=x),
"dateText": meal.dateText,
}
meals.append(Meal(**meal_data))
self.meals = meals
def save_to_db(self, session: Session):
db.meals.create(session, self.dict())
@staticmethod
def get_all(session: Session) -> List:
all_meals = [
MealPlan(**x) for x in db.meals.get_all(session, order_by="startDate")
]
return all_meals
def update(self, session, uid):
db.meals.update(session, uid, self.dict())
@staticmethod
def delete(session, uid):
db.meals.delete(session, uid)
@staticmethod
def today(session: Session) -> str:
""" Returns the meal slug for Today """
meal_plan = db.meals.get_all(session, limit=1, order_by="startDate")
meal_docs = [Meal(**meal) for meal in meal_plan["meals"]]
for meal in meal_docs:
if meal.date == date.today():
return meal.slug
return "No Meal Today"
@staticmethod
def this_week(session: Session):
meal_plan = db.meals.get_all(session, limit=1, order_by="startDate")
return meal_plan

View file

@ -3,7 +3,8 @@ from pathlib import Path
import yaml import yaml
from core.config import IMG_DIR, TEMP_DIR from core.config import IMG_DIR, TEMP_DIR
from services.recipe_services import Recipe from db.database import db
from schema.recipe import Recipe
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.unzip import unpack_zip from utils.unzip import unpack_zip
@ -49,32 +50,42 @@ def read_chowdown_file(recipe_file: Path) -> Recipe:
"tags": recipe_data.get("tags").split(","), "tags": recipe_data.get("tags").split(","),
} }
new_recipe = Recipe(**reformat_data) print(reformat_data)
reformated_list = [] reformated_list = []
for instruction in new_recipe.recipeInstructions: for instruction in reformat_data["recipeInstructions"]:
reformated_list.append({"text": instruction}) reformated_list.append({"text": instruction})
reformat_data["recipeInstructions"] = reformated_list
new_recipe.recipeInstructions = reformated_list return Recipe(**reformat_data)
return new_recipe
def chowdown_migrate(session: Session, zip_file: Path): def chowdown_migrate(session: Session, zip_file: Path):
temp_dir = unpack_zip(zip_file)
temp_dir = unpack_zip(zip_file)
print(temp_dir.name)
path = Path(temp_dir.name)
for p in path.iterdir():
print("ItterDir", p)
for p in p.iterdir():
print("Sub Itter", p)
with temp_dir as dir: with temp_dir as dir:
image_dir = TEMP_DIR.joinpath(dir, zip_file.stem, "images") chow_dir = next(Path(dir).iterdir())
recipe_dir = TEMP_DIR.joinpath(dir, zip_file.stem, "_recipes") image_dir = TEMP_DIR.joinpath(chow_dir, "images")
recipe_dir = TEMP_DIR.joinpath(chow_dir, "_recipes")
print(image_dir.exists())
print(recipe_dir.exists())
failed_recipes = [] failed_recipes = []
successful_recipes = [] successful_recipes = []
for recipe in recipe_dir.glob("*.md"): for recipe in recipe_dir.glob("*.md"):
try: try:
new_recipe = read_chowdown_file(recipe) new_recipe = read_chowdown_file(recipe)
new_recipe.save_to_db(session) db.recipes.create(session, new_recipe.dict())
successful_recipes.append(recipe.stem) successful_recipes.append(new_recipe.name)
except: except Exception as inst:
failed_recipes.append(recipe.stem) failed_recipes.append(recipe.stem)
failed_images = [] failed_images = []
@ -82,7 +93,8 @@ def chowdown_migrate(session: Session, zip_file: Path):
try: try:
if not image.stem in failed_recipes: if not image.stem in failed_recipes:
shutil.copy(image, IMG_DIR.joinpath(image.name)) shutil.copy(image, IMG_DIR.joinpath(image.name))
except: except Exception as inst:
print(inst)
failed_images.append(image.name) failed_images.append(image.name)
report = {"successful": successful_recipes, "failed": failed_recipes} report = {"successful": successful_recipes, "failed": failed_recipes}

View file

@ -5,9 +5,10 @@ import zipfile
from pathlib import Path from pathlib import Path
from core.config import IMG_DIR, MIGRATION_DIR, TEMP_DIR from core.config import IMG_DIR, MIGRATION_DIR, TEMP_DIR
from services.recipe_services import Recipe from schema.recipe import Recipe
from services.scraper.cleaner import Cleaner from services.scraper.cleaner import Cleaner
from core.config import IMG_DIR, TEMP_DIR from core.config import IMG_DIR, TEMP_DIR
from db.database import db
def process_selection(selection: Path) -> Path: def process_selection(selection: Path) -> Path:
@ -77,7 +78,8 @@ def migrate(session, selection: str):
try: try:
recipe = import_recipes(dir) recipe = import_recipes(dir)
recipe.save_to_db(session) db.recipes.create(session, recipe.dict())
successful_imports.append(recipe.name) successful_imports.append(recipe.name)
except: except:
logging.error(f"Failed Nextcloud Import: {dir.name}") logging.error(f"Failed Nextcloud Import: {dir.name}")

View file

@ -1,130 +0,0 @@
import datetime
from pathlib import Path
from typing import Any, List, Optional
from db.database import db
from pydantic import BaseModel, validator
from slugify import slugify
from sqlalchemy.orm.session import Session
from services.image_services import delete_image
class RecipeNote(BaseModel):
title: str
text: str
class RecipeStep(BaseModel):
text: str
class Recipe(BaseModel):
# Standard Schema
name: str
description: Optional[str]
image: Optional[Any]
recipeYield: Optional[str]
recipeIngredient: Optional[list]
recipeInstructions: Optional[list]
totalTime: Optional[str] = None
prepTime: Optional[str] = None
performTime: Optional[str] = None
# Mealie Specific
slug: Optional[str] = ""
categories: Optional[List[str]] = []
tags: Optional[List[str]] = []
dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = []
rating: Optional[int] = 0
orgURL: Optional[str] = ""
extras: Optional[dict] = {}
class Config:
schema_extra = {
"example": {
"name": "Chicken and Rice With Leeks and Salsa Verde",
"description": "This one-skillet dinner gets deep oniony flavor from lots of leeks cooked down to jammy tenderness.",
"image": "chicken-and-rice-with-leeks-and-salsa-verde.jpg",
"recipeYield": "4 Servings",
"recipeIngredient": [
"1 1/2 lb. skinless, boneless chicken thighs (4-8 depending on size)",
"Kosher salt, freshly ground pepper",
"3 Tbsp. unsalted butter, divided",
],
"recipeInstructions": [
{
"text": "Season chicken with salt and pepper.",
},
],
"slug": "chicken-and-rice-with-leeks-and-salsa-verde",
"tags": ["favorite", "yummy!"],
"categories": ["Dinner", "Pasta"],
"notes": [{"title": "Watch Out!", "text": "Prep the day before!"}],
"orgURL": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde",
"rating": 3,
"extras": {"message": "Don't forget to defrost the chicken!"},
}
}
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values):
name: str = values["name"]
calc_slug: str = slugify(name)
if slug == calc_slug:
return slug
else:
slug = calc_slug
return slug
@classmethod
def get_by_slug(cls, session, slug: str):
""" Returns a Recipe Object by Slug """
document = db.recipes.get(session, slug, "slug")
return cls(**document)
def save_to_db(self, session) -> str:
recipe_dict = self.dict()
try:
extension = Path(recipe_dict["image"]).suffix
recipe_dict["image"] = recipe_dict.get("slug") + extension
except:
recipe_dict["image"] = "no image"
recipe_doc = db.recipes.create(session, recipe_dict)
recipe = Recipe(**recipe_doc)
return recipe.slug
@staticmethod
def delete(session: Session, recipe_slug: str) -> str:
""" Removes the recipe from the database by slug """
delete_image(recipe_slug)
db.recipes.delete(session, recipe_slug)
return "Document Deleted"
def update(self, session: Session, recipe_slug: str):
""" Updates the recipe from the database by slug"""
updated_slug = db.recipes.update(session, recipe_slug, self.dict())
return updated_slug.get("slug")
@staticmethod
def update_image(session: Session, slug: str, extension: str = None) -> str:
"""A helper function to pass the new image name and extension
into the database.
Args:
slug (str): The current recipe slug
extension (str): the file extension of the new image
"""
return db.recipes.update_image(session, slug, extension)
@staticmethod
def get_all(session: Session):
return db.recipes.get_all(session)

View file

@ -8,7 +8,7 @@ from schema.settings import SiteSettings
from db.database import db from db.database import db
from utils.post_webhooks import post_webhooks from utils.post_webhooks import post_webhooks
# TODO Fix Scheduler
@scheduler.scheduled_job(trigger="interval", minutes=15) @scheduler.scheduled_job(trigger="interval", minutes=15)
def update_webhook_schedule(): def update_webhook_schedule():
""" """

View file

@ -1,5 +1,6 @@
import html import html
import re import re
from datetime import datetime
from typing import List from typing import List
from slugify import slugify from slugify import slugify
@ -24,10 +25,16 @@ class Cleaner:
Returns: Returns:
dict: cleaned recipe dictionary dict: cleaned recipe dictionary
""" """
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime"))
recipe_data["description"] = Cleaner.html(recipe_data.get("description", "")) recipe_data["description"] = Cleaner.html(recipe_data.get("description", ""))
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime"))
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime")) # Times
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime", None))
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime", None))
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime", None))
recipe_data["recipeCategory"] = Cleaner.category(
recipe_data.get("recipeCategory", [])
)
recipe_data["recipeYield"] = Cleaner.yield_amount( recipe_data["recipeYield"] = Cleaner.yield_amount(
recipe_data.get("recipeYield") recipe_data.get("recipeYield")
) )
@ -41,8 +48,16 @@ class Cleaner:
recipe_data["slug"] = slugify(recipe_data.get("name")) recipe_data["slug"] = slugify(recipe_data.get("name"))
recipe_data["orgURL"] = url recipe_data["orgURL"] = url
return recipe_data return recipe_data
@staticmethod
def category(category: str):
if type(category) == type(str):
return [category]
else:
return []
@staticmethod @staticmethod
def html(raw_html): def html(raw_html):
cleanr = re.compile("<.*?>") cleanr = re.compile("<.*?>")
@ -68,7 +83,7 @@ class Cleaner:
return [] return []
# One long string split by (possibly multiple) new lines # One long string split by (possibly multiple) new lines
if type(instructions) == str: if isinstance(instructions, str):
return [ return [
{"text": Cleaner._instruction(line)} {"text": Cleaner._instruction(line)}
for line in instructions.splitlines() for line in instructions.splitlines()
@ -95,7 +110,7 @@ class Cleaner:
sectionSteps = [] sectionSteps = []
for step in instructions: for step in instructions:
if step["@type"] == "HowToSection": if step["@type"] == "HowToSection":
[sectionSteps.append(item) for item in step["itemListELement"]] [sectionSteps.append(item) for item in step["itemListElement"]]
if len(sectionSteps) > 0: if len(sectionSteps) > 0:
return [ return [
@ -144,8 +159,13 @@ class Cleaner:
return yld return yld
@staticmethod @staticmethod
def time(time_entry) -> str: def time(time_entry):
if type(time_entry) == type(None): print(time_entry, type(time_entry))
if time_entry == None:
return None return None
elif type(time_entry) == datetime:
print(time_entry)
elif type(time_entry) != str: elif type(time_entry) != str:
return str(time_entry) return str(time_entry)
elif time_entry != None:
return time_entry

View file

@ -6,7 +6,7 @@ import scrape_schema_recipe
from core.config import DEBUG_DIR from core.config import DEBUG_DIR
from fastapi.logger import logger from fastapi.logger import logger
from services.image_services import scrape_image from services.image_services import scrape_image
from services.recipe_services import Recipe from schema.recipe import Recipe
from services.scraper import open_graph from services.scraper import open_graph
from services.scraper.cleaner import Cleaner from services.scraper.cleaner import Cleaner
@ -25,6 +25,7 @@ def create_from_url(url: str) -> Recipe:
""" """
r = requests.get(url) r = requests.get(url)
new_recipe = extract_recipe_from_html(r.text, url) new_recipe = extract_recipe_from_html(r.text, url)
print(new_recipe)
new_recipe = Cleaner.clean(new_recipe, url) new_recipe = Cleaner.clean(new_recipe, url)
new_recipe = download_image_for_recipe(new_recipe) new_recipe = download_image_for_recipe(new_recipe)

View file

@ -1,7 +1,8 @@
from pathlib import Path from pathlib import Path
from core.config import TEMP_DIR
import pytest import pytest
from core.config import TEMP_DIR from core.config import TEMP_DIR
from schema.recipe import Recipe
from services.image_services import IMG_DIR from services.image_services import IMG_DIR
from services.migrations.nextcloud import ( from services.migrations.nextcloud import (
cleanup, cleanup,
@ -9,7 +10,6 @@ from services.migrations.nextcloud import (
prep, prep,
process_selection, process_selection,
) )
from services.recipe_services import Recipe
from tests.test_config import TEST_NEXTCLOUD_DIR from tests.test_config import TEST_NEXTCLOUD_DIR
CWD = Path(__file__).parent CWD = Path(__file__).parent

View file

@ -19,12 +19,10 @@ def get_meal_plan_template(first=None, second=None):
{ {
"slug": first, "slug": first,
"date": "2021-1-17", "date": "2021-1-17",
"dateText": "Monday, January 18, 2021",
}, },
{ {
"slug": second, "slug": second,
"date": "2021-1-18", "date": "2021-1-18",
"dateText": "Tueday, January 19, 2021",
}, },
], ],
} }

View file

@ -63,7 +63,7 @@ def test_read_update(api_client, recipe_data):
recipe["notes"] = test_notes recipe["notes"] = test_notes
test_categories = ["one", "two", "three"] test_categories = ["one", "two", "three"]
recipe["categories"] = test_categories recipe["recipeCategory"] = test_categories
response = api_client.put( response = api_client.put(
f"{RECIPES_PREFIX}/{recipe_data.expected_slug}", json=recipe f"{RECIPES_PREFIX}/{recipe_data.expected_slug}", json=recipe
@ -77,7 +77,7 @@ def test_read_update(api_client, recipe_data):
recipe = json.loads(response.content) recipe = json.loads(response.content)
assert recipe["notes"] == test_notes assert recipe["notes"] == test_notes
assert recipe["categories"].sort() == test_categories.sort() assert recipe["recipeCategory"].sort() == test_categories.sort()
@pytest.mark.parametrize("recipe_data", recipe_test_data) @pytest.mark.parametrize("recipe_data", recipe_test_data)

View file

@ -1,4 +1,6 @@
import json import json
from schema.settings import SiteSettings
from schema.theme import SiteTheme
import pytest import pytest
from tests.utils.routes import ( from tests.utils.routes import (
@ -11,30 +13,12 @@ from tests.utils.routes import (
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def default_settings(): def default_settings():
return { return SiteSettings().dict(by_alias=True)
"name": "main",
"planCategories": [],
"webhooks": {"webhookTime": "00:00", "webhookURLs": [], "enabled": False},
}
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def default_theme(api_client): def default_theme():
return SiteTheme().dict()
default_theme = {
"name": "default",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"info": "#4990BA",
"warning": "#FF4081",
"error": "#EF5350",
},
}
return default_theme
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -62,11 +46,8 @@ def test_default_settings(api_client, default_settings):
def test_update_settings(api_client, default_settings): def test_update_settings(api_client, default_settings):
default_settings["webhooks"]["webhookURLs"] = [ default_settings["language"] = "fr"
"https://test1.url.com", default_settings["showRecent"] = False
"https://test2.url.com",
"https://test3.url.com",
]
response = api_client.put(SETTINGS_UPDATE, json=default_settings) response = api_client.put(SETTINGS_UPDATE, json=default_settings)

View file

@ -16,7 +16,7 @@ def default_user():
"id": 1, "id": 1,
"fullName": "Change Me", "fullName": "Change Me",
"email": "changeme@email.com", "email": "changeme@email.com",
"family": "public", "group": "home",
"admin": True "admin": True
} }
@ -27,7 +27,7 @@ def new_user():
"id": 2, "id": 2,
"fullName": "My New User", "fullName": "My New User",
"email": "newuser@email.com", "email": "newuser@email.com",
"family": "public", "group": "home",
"admin": False "admin": False
} }
@ -54,7 +54,7 @@ def test_create_user(api_client: requests, token, new_user):
"fullName": "My New User", "fullName": "My New User",
"email": "newuser@email.com", "email": "newuser@email.com",
"password": "MyStrongPassword", "password": "MyStrongPassword",
"family": "public", "group": "home",
"admin": False "admin": False
} }
@ -78,7 +78,7 @@ def test_update_user(api_client: requests, token):
"id": 1, "id": 1,
"fullName": "Updated Name", "fullName": "Updated Name",
"email": "updated@email.com", "email": "updated@email.com",
"family": "public", "group": "home",
"admin": True "admin": True
} }
response = api_client.put(f"{BASE}/1", headers=token, json=update_data) response = api_client.put(f"{BASE}/1", headers=token, json=update_data)

Some files were not shown because too many files have changed in this diff Show more