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
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
EXPOSE 80
WORKDIR /app

View file

@ -1,5 +1,36 @@
# 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
### 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:
- Installation: "getting-started/install.md"
- Working With Recipes: "getting-started/recipes.md"
- iOS Shortcuts: "getting-started/ios.md"
- User Management: "getting-started/users.md"
- Planning Meals: "getting-started/meal-planner.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",
"@smartweb/vue-flash-message": "^0.6.10",
"axios": "^0.21.1",
"core-js": "^3.8.2",
"core-js": "^3.9.1",
"fast-levenshtein": "^3.0.0",
"fuse.js": "^6.4.6",
"qs": "^6.9.6",
"typeface-roboto": "^1.1.13",
"v-jsoneditor": "^1.4.2",
"vue": "^2.6.11",
"vue-i18n": "^8.22.4",
"vue-router": "^3.4.9",
"vue-i18n": "^8.24.1",
"vue-router": "^3.5.1",
"vuedraggable": "^2.24.3",
"vuetify": "^2.4.2",
"vuex": "^3.6.0",
"vuetify": "^2.4.6",
"vuex": "^3.6.2",
"vuex-persistedstate": "^4.0.0-beta.3"
},
"devDependencies": {
"@intlify/vue-i18n-loader": "^1.0.0",
"@vue/cli-plugin-babel": "^4.5.10",
"@vue/cli-plugin-eslint": "^4.5.10",
"@vue/cli-service": "^4.5.10",
"@intlify/vue-i18n-loader": "^1.1.0",
"@mdi/font": "^5.9.55",
"@vue/cli-plugin-babel": "^4.5.11",
"@vue/cli-plugin-eslint": "^4.5.11",
"@vue/cli-service": "^4.5.11",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.32.4",
"sass-loader": "^8.0.0",
"sass": "^1.32.8",
"sass-loader": "^8.0.2",
"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",
"vuetify-loader": "^1.3.0"
"vuetify-loader": "^1.7.2"
},
"eslintConfig": {
"root": true,

View file

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<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://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
<!-- <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"> -->
</head>
<body>
<noscript>

View file

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

View file

@ -47,7 +47,7 @@ const apiReq = {
return response;
} else return;
});
// processResponse(response);
processResponse(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 users from "./users";
import signUps from "./signUps";
import groups from "./groups";
import siteSettings from "./siteSettings";
export default {
recipes: recipe,
siteSettings: siteSettings,
backups: backup,
mealPlans: mealplan,
settings: settings,
@ -22,4 +25,5 @@ export default {
meta: meta,
users: users,
signUps: signUps,
groups: groups,
};

View file

@ -70,7 +70,7 @@ export default {
router.push(`/`);
},
async allByKeys(recipeKeys, num = 999) {
async allByKeys(recipeKeys, num = 9999) {
const response = await apiReq.get(recipeURLs.allRecipes, {
params: {
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 { apiReq } from "./api-utils";
import axios from "axios";
const authPrefix = baseURL + "auth";
const userPrefix = baseURL + "users";
const authURLs = {
token: `${authPrefix}/token`,
refresh: `${authPrefix}/refresh`,
};
@ -24,6 +26,12 @@ export default {
});
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() {
let response = await apiReq.get(usersURLs.users);
return response.data;

View file

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

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-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="links" sort-by="calories">
<template v-slot:item.token="{ item }">

View file

@ -12,14 +12,18 @@
@close="closeDelete"
/>
<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>
<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">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
@ -62,13 +66,16 @@
></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-select
dense
v-model="editedItem.group"
:items="existingGroups"
label="User Group"
></v-select>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field
dense
v-model="editedItem.password"
label="User Password"
:rules="[existsRule, minRule]"
@ -95,7 +102,12 @@
</v-toolbar>
<v-divider></v-divider>
<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 }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
@ -131,6 +143,7 @@ export default {
components: { Confirmation },
mixins: [validators],
data: () => ({
search: "",
dialog: false,
activeId: null,
activeName: null,
@ -143,7 +156,7 @@ export default {
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Group", value: "group" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
@ -154,7 +167,7 @@ export default {
fullName: "",
password: "",
email: "",
family: "",
group: "",
admin: false,
},
defaultItem: {
@ -162,7 +175,7 @@ export default {
fullName: "",
password: "",
email: "",
family: "",
group: "",
admin: false,
},
}),
@ -174,6 +187,9 @@ export default {
showPassword() {
return this.editedIndex === -1 ? true : false;
},
existingGroups() {
return this.$store.getters.getGroupNames;
},
},
watch: {

View file

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

View file

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

View file

@ -21,12 +21,13 @@
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.
<v-divider class="mt-3"></v-divider>
<v-form>
<v-form ref="signUpForm">
<v-text-field
v-model="user.name"
light="light"
prepend-icon="mdi-account"
validate-on-blur
:rules="[existsRule]"
label="Display Name"
type="email"
></v-text-field>
@ -35,6 +36,7 @@
light="light"
prepend-icon="mdi-email"
validate-on-blur
:rules="[existsRule, emailRule]"
:label="$t('login.email')"
type="email"
></v-text-field>
@ -43,10 +45,10 @@
light="light"
class="mb-2s"
prepend-icon="mdi-lock"
validate-on-blur
:label="$t('login.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
:rules="[minRule]"
></v-text-field>
<v-text-field
v-model="user.passwordConfirm"
@ -56,6 +58,9 @@
:label="$t('login.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:rules="[
user.password === user.passwordConfirm || 'Password must match',
]"
@click:append="showPassword = !showPassword"
></v-text-field>
</v-form>
@ -80,7 +85,9 @@
<script>
import api from "@/api";
import { validators } from "@/mixins/validators";
export default {
mixins: [validators],
data() {
return {
loading: false,
@ -121,15 +128,23 @@ export default {
const userData = {
fullName: this.user.name,
email: this.user.email,
family: "public",
group: "default",
password: this.user.password,
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.loading = false;
if (successUser) {
this.$router.push("/");
}
},
},
};

View file

@ -118,6 +118,19 @@ export default {
async mounted() {
let settings = await api.settings.requestAll();
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: {

View file

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

View file

@ -1,5 +1,5 @@
<template>
<div v-if="items[0]">
<div v-if="items && items.length > 0">
<h2 class="mt-4">{{ title }}</h2>
<v-chip
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"
>
<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-toolbar-title v-text="title" />
</v-toolbar>
</v-app-bar>
<v-card-text
v-show="!!message"

View file

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

View file

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

View file

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

View file

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

View file

@ -13,13 +13,17 @@
outlined
:flat="isFlat"
elavation="0"
v-model="planCategories"
v-model="groupSettings.categories"
:items="categories"
item-text="name"
item-value="name"
return-object
multiple
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"
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"
)
}}
<strong>{{ time }}</strong>
<strong>{{ groupSettings.webhookTime }}</strong>
</p>
<v-row dense align="center">
<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 cols="12" md="3" sm="5">
<TimePickerDialog @save-time="saveTime" />
@ -68,7 +75,12 @@
</v-col>
</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-btn icon color="error" @click="removeWebhook(index)">
<v-icon>mdi-minus</v-icon>
@ -76,7 +88,7 @@
</v-col>
<v-col>
<v-text-field
v-model="webhooks[index]"
v-model="groupSettings.webhookUrls[index]"
:label="$t('settings.webhooks.webhook-url')"
></v-text-field>
</v-col>
@ -87,7 +99,7 @@
<v-icon>mdi-plus</v-icon>
</v-btn>
<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>
{{ $t("general.save") }}
</v-btn>
@ -104,14 +116,19 @@ export default {
},
data() {
return {
name: "main",
webhooks: [],
enabled: false,
time: "",
planCategories: [],
groupSettings: {
name: "home",
id: 1,
mealplans: [],
categories: [],
webhookUrls: [],
webhookTime: "00:00",
webhookEnable: false,
},
};
},
mounted() {
async mounted() {
await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings();
},
computed: {
@ -119,44 +136,39 @@ export default {
return this.$store.getters.getCategories;
},
isFlat() {
return this.planCategories ? true : false;
return this.groupSettings.categories >= 1 ? true : false;
},
},
methods: {
saveTime(value) {
this.time = value;
this.groupSettings.webhookTime = value;
},
async getSiteSettings() {
let settings = await api.settings.requestAll();
this.webhooks = settings.webhooks.webhookURLs;
this.name = settings.name;
this.time = settings.webhooks.webhookTime;
this.enabled = settings.webhooks.enabled;
this.planCategories = settings.planCategories;
getSiteSettings() {
let settings = this.$store.getters.getCurrentGroup;
this.groupSettings.name = settings.name;
this.groupSettings.id = settings.id;
this.groupSettings.categories = settings.categories;
this.groupSettings.webhookUrls = settings.webhookUrls;
this.groupSettings.webhookTime = settings.webhookTime;
this.groupSettings.webhookEnable = settings.webhookEnable;
},
addWebhook() {
this.webhooks.push(" ");
this.groupSettings.webhookUrls.push(" ");
},
removeWebhook(index) {
this.webhooks.splice(index, 1);
this.groupSettings.webhookUrls.splice(index, 1);
},
saveWebhooks() {
const body = {
name: this.name,
planCategories: this.planCategories,
webhooks: {
webhookURLs: this.webhooks,
webhookTime: this.time,
enabled: this.enabled,
},
};
api.settings.update(body);
async saveGroupSettings() {
await api.groups.update(this.groupSettings);
await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings();
},
testWebhooks() {
api.settings.testWebhooks();
},
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
label="Family"
label="Group"
readonly
v-model="user.family"
v-model="user.group"
persistent-hint
hint="Family groups can only be set by administrators"
hint="Group groups can only be set by administrators"
>
</v-text-field>
</v-form>
@ -167,7 +167,7 @@ export default {
user: {
fullName: "Change Me",
email: "changeme@email.com",
family: "public",
group: "public",
admin: true,
id: 1,
},

View file

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

View file

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

View file

@ -5,22 +5,25 @@ import createPersistedState from "vuex-persistedstate";
import userSettings from "./modules/userSettings";
import language from "./modules/language";
import homePage from "./modules/homePage";
import siteSettings from "./modules/siteSettings";
import groups from "./modules/groups";
Vue.use(Vuex);
const store = new Vuex.Store({
plugins: [
createPersistedState({
paths: ["userSettings", "language", "homePage"],
paths: ["userSettings", "language", "homePage", "SideSettings"],
}),
],
modules: {
userSettings,
language,
homePage,
siteSettings,
groups,
},
state: {
// All Recipe Data Store
recentRecipes: [],
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 }) {
//If theme is empty resetTheme

View file

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

View file

@ -1,8 +1,9 @@
import uvicorn
from fastapi import FastAPI
from fastapi.logger import logger
# 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.init_db import init_db
from routes import (
@ -13,6 +14,7 @@ from routes import (
setting_routes,
theme_routes,
)
from routes.groups import groups
from routes.recipe import (
all_recipe_routes,
category_routes,
@ -20,7 +22,6 @@ from routes.recipe import (
tag_routes,
)
from routes.users import users
from fastapi.logger import logger
app = FastAPI(
title="Mealie",
@ -42,11 +43,13 @@ def start_scheduler():
def api_routers():
# Authentication
app.include_router(users.router)
app.include_router(groups.router)
# Recipes
app.include_router(all_recipe_routes.router)
app.include_router(category_routes.router)
app.include_router(tag_routes.router)
app.include_router(recipe_crud_routes.router)
# Meal Routes
app.include_router(meal_routes.router)
# Settings Routes

View file

@ -83,6 +83,7 @@ else:
# Mongo Database
MEALIE_DB_NAME = os.getenv("mealie_db_name", "mealie")
DEFAULT_GROUP = os.getenv("default_group", "home")
DB_USERNAME = os.getenv("db_username", "root")
DB_PASSWORD = os.getenv("db_password", "example")
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 db.db_base import BaseDocument
from db.models.group import Group
from db.models.mealplan import MealPlanModel
from db.models.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettingsModel
from db.models.recipe.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettings
from db.models.sign_up import SignUp
from db.models.theme import SiteThemeModel
from db.models.users import User
"""
# TODO
- [ ] Abstract Classes to use save_new, and update from base models
"""
class _Recipes(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
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:
entry: RecipeModel = self._query_one(session, match_value=slug)
@ -31,51 +36,71 @@ class _Categories(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
self.sql_model = Category
self.orm_mode = True
self.schema = RecipeCategoryResponse
class _Tags(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
self.sql_model = Tag
self.orm_mode = True
self.schema = RecipeTagResponse
class _Meals(BaseDocument):
def __init__(self) -> None:
self.primary_key = "uid"
self.sql_model = MealPlanModel
self.orm_mode = True
self.schema = MealPlanInDB
class _Settings(BaseDocument):
def __init__(self) -> None:
self.primary_key = "name"
self.sql_model = SiteSettingsModel
self.primary_key = "id"
self.sql_model = SiteSettings
self.orm_mode = True
self.schema = SiteSettingsSchema
class _Themes(BaseDocument):
def __init__(self) -> None:
self.primary_key = "name"
self.sql_model = SiteThemeModel
self.orm_mode = True
self.schema = SiteTheme
class _Users(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = User
self.orm_mode = True
self.schema = UserInDB
def update_password(self, session, id, password: str):
entry = self._query_one(session=session, match_value=id)
entry.update_password(password)
return_data = entry.dict()
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):
def __init__(self) -> None:
self.primary_key = "token"
self.sql_model = SignUp
self.orm_mode = True
self.schema = SignUpOut
class Database:
@ -88,6 +113,7 @@ class Database:
self.tags = _Tags()
self.users = _Users()
self.sign_ups = _SignUps()
self.groups = _Groups()
db = Database()

View file

@ -1,5 +1,6 @@
from typing import List
from pydantic import BaseModel
from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session
@ -11,11 +12,20 @@ class BaseDocument:
self.primary_key: str
self.store: str
self.sql_model: SqlAlchemyBase
self.orm_mode = False
self.schema: BaseModel
# TODO: Improve Get All Query Functionality
def get_all(
self, session: Session, limit: int = None, order_by: str = None
) -> 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()]
if limit == 1:
@ -105,15 +115,13 @@ class BaseDocument:
.limit(limit)
.all()
)
db_entries = [x.dict() for x in result]
if limit == 1:
try:
return db_entries[0]
return self.schema.from_orm(result[0])
except IndexError:
return None
return db_entries
return [self.schema.from_orm(x) for x in result]
def create(self, session: Session, document: dict) -> dict:
"""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)
session.add(new_document)
session.commit()
if self.orm_mode:
return self.schema.from_orm(new_document)
return_data = new_document.dict()
return return_data
@ -145,9 +157,13 @@ class BaseDocument:
entry = self._query_one(session=session, match_value=match_value)
entry.update(session=session, **new_data)
if self.orm_mode:
session.commit()
return self.schema.from_orm(entry)
return_data = entry.dict()
session.commit()
return return_data
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 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.session import Session
@ -12,6 +14,7 @@ def init_db(db: Session = None) -> None:
if not db:
db = create_session()
default_group_init(db)
default_settings_init(db)
default_theme_init(db)
default_user_init(db)
@ -20,34 +23,25 @@ def init_db(db: Session = None) -> None:
def default_theme_init(session: Session):
default_theme = {
"name": "default",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"info": "#4990BA",
"warning": "#FF4081",
"error": "#EF5350",
},
}
db.themes.create(session, SiteTheme().dict())
try:
db.themes.create(session, default_theme)
logger.info("Generating default theme...")
except:
logger.info("Default Theme Exists.. skipping generation")
def default_settings_init(session: Session):
try:
webhooks = Webhooks()
default_entry = SiteSettings(name="main", webhooks=webhooks)
document = db.settings.create(session, default_entry.dict())
logger.info(f"Created Site Settings: \n {document}")
except:
pass
data = {"language": "en", "home_page_settings": {"categories": []}}
document = db.settings.create(session, SiteSettings().dict())
logger.info(f"Created Site Settings: \n {document}")
def default_group_init(session: Session):
default_group = {"name": DEFAULT_GROUP}
logger.info("Generating Default Group")
db.groups.create(session, default_group)
pass
def default_user_init(session: Session):
@ -55,7 +49,7 @@ def default_user_init(session: Session):
"full_name": "Change Me",
"email": "changeme@email.com",
"password": get_password_hash("MyPassword"),
"family": "public",
"group": DEFAULT_GROUP,
"admin": True,
}

View file

@ -1,6 +1,7 @@
from db.models.mealplan import *
from db.models.recipe import *
from db.models.recipe.recipe import *
from db.models.settings import *
from db.models.theme import *
from db.models.users 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)
name = sa.Column(sa.String)
date = sa.Column(sa.Date)
dateText = sa.Column(sa.String)
image = sa.Column(sa.String)
description = sa.Column(sa.String)
def __init__(
self, slug, name, date, dateText, image, description, session=None
) -> None:
def __init__(self, slug, name, date, image, description, session=None) -> None:
self.slug = slug
self.name = name
self.date = date
self.dateText = dateText
self.image = image
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):
__tablename__ = "mealplan"
@ -46,6 +30,8 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins):
startDate = sa.Column(sa.Date)
endDate = sa.Column(sa.Date)
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:
self.startDate = startDate
@ -56,13 +42,3 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins):
MealPlanModel._sql_remove_list(session, [Meal], uid)
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
import sqlalchemy.ext.declarative as dec
from sqlalchemy.orm.session import Session
SqlAlchemyBase = dec.declarative_base()
class BaseMixins:
@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:
session.query(table).filter_by(parent_id=parent_id).delete()
session.query(table).filter(parent_id == parent_id).delete()
@staticmethod
def _flatten_dict(list_of_dict: List[dict]):
@ -20,3 +20,29 @@ class BaseMixins:
finalMap.update(d.dict())
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.orm as orm
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"
name = sa.Column(sa.String, primary_key=True)
planCategories = orm.relationship(
"MealCategory", uselist=True, cascade="all, delete"
id = sa.Column(sa.Integer, primary_key=True)
language = sa.Column(sa.String)
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__(
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:
self.name = name
self.planCategories = [MealCategory(cat) for cat in planCategories]
self.webhooks = WebHookModel(**webhooks)
session.commit()
self.language = language
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:
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
def update(self, *args, **kwarg):
self.__init__(*args, **kwarg)

View file

@ -1,5 +1,5 @@
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):
@ -19,11 +19,3 @@ class SignUp(SqlAlchemyBase, BaseMixins):
self.token = token
self.name = name
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.orm as orm
from db.models.model_base import BaseMixins, SqlAlchemyBase
from db.models.model_base import SqlAlchemyBase
class SiteThemeModel(SqlAlchemyBase):
@ -14,11 +14,7 @@ class SiteThemeModel(SqlAlchemyBase):
def update(self, session=None, name: str = None, colors: dict = None) -> dict:
self.colors.update(**colors)
return self.dict()
def dict(self):
data = {"name": self.name, "colors": self.colors.dict()}
return data
return self
class ThemeColorsModel(SqlAlchemyBase):
@ -50,15 +46,3 @@ class ThemeColorsModel(SqlAlchemyBase):
self.info = info
self.warning = warning
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 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):
@ -8,9 +16,9 @@ class User(SqlAlchemyBase, BaseMixins):
full_name = Column(String, index=True)
email = Column(String, unique=True, index=True)
password = Column(String)
is_active = Column(Boolean(), default=True)
family = Column(String)
admin = Column(Boolean(), default=False)
group_id = Column(String, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
admin = Column(Boolean, default=False)
def __init__(
self,
@ -18,29 +26,21 @@ class User(SqlAlchemyBase, BaseMixins):
full_name,
email,
password,
family="public",
group: str = DEFAULT_GROUP,
admin=False,
) -> None:
group = group if group else DEFAULT_GROUP
self.full_name = full_name
self.email = email
self.family = family
self.group = Group.create_if_not_exist(session, group)
self.admin = admin
self.password = password
def dict(self):
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):
def update(self, full_name, email, group, admin, session=None):
self.full_name = full_name
self.email = email
self.family = family
self.group = Group.create_if_not_exist(session, group)
self.admin = admin
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 fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from schema.snackbar import SnackResponse
from services.backups.exports import backup_all
from services.backups.imports import ImportDatabase
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from schema.snackbar import SnackResponse
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()
user = db.users.get(session, user_email, "email")
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 db.database import db
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 services.meal_services import MealPlan
from services.meal_services import get_todays_meal, process_meals
from sqlalchemy.orm.session import Session
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)):
""" Returns a list of all available Meal Plan """
return MealPlan.get_all(session)
return db.meals.get_all(session)
@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
mealplan = db.meals.get(session, id)
slugs = [x.get("slug") for x in mealplan.get("meals")]
recipes = [db.recipes.get(session, x) for x in slugs]
mealplan: MealPlanInDB
slugs = [x.slug for x in mealplan.meals]
recipes: list[Recipe] = [db.recipes.get(session, x) for x in slugs]
ingredients = [
{"name": x.get("name"), "recipeIngredient": x.get("recipeIngredient")}
{"name": x.name, "recipeIngredient": x.recipeIngredient}
for x in recipes
if x
]
@ -34,28 +38,29 @@ def get_shopping_list(id: str, session: Session = Depends(generate_session)):
@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 """
data.process_meals(session)
data.save_to_db(session)
processed_plan = process_meals(session, data)
db.meals.create(session, processed_plan.dict())
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)):
""" 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}")
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 """
meal_plan.process_meals(session)
meal_plan.update(session, plan_id)
processed_plan = process_meals(session, meal_plan)
processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict())
db.meals.update(session, plan_id, processed_plan.dict())
return SnackResponse.info("Mealplan Updated")
@ -64,7 +69,7 @@ def update_meal_plan(
def delete_meal_plan(plan_id, session: Session = Depends(generate_session)):
""" Removes a meal plan from the database """
MealPlan.delete(session, plan_id)
db.meals.delete(session, plan_id)
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
"""
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 fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.logger import logger
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.recipe_services import Recipe
from services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session
from schema.snackbar import SnackResponse
router = APIRouter(
prefix="/api/recipes",
@ -16,49 +16,47 @@ router = APIRouter(
@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"""
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)
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 """
recipe = create_from_url(url.url)
recipe.save_to_db(db)
recipe: Recipe = db.recipes.create(session, recipe.dict())
return recipe.slug
@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 """
recipe = Recipe.get_by_slug(db, recipe_slug)
return recipe
return db.recipes.get(session, recipe_slug)
@router.put("/{recipe_slug}")
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. """
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}")
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 """
try:
Recipe.delete(db, recipe_slug)
db.recipes.delete(session, recipe_slug)
except:
raise HTTPException(
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. """
response = write_image(recipe_slug, image, extension)
Recipe.update_image(session, recipe_slug, extension)
db.recipes.update_image(session, recipe_slug, extension)
return response

View file

@ -2,9 +2,9 @@ from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from schema.settings import SiteSettings
from schema.snackbar import SnackResponse
from sqlalchemy.orm.session import Session
from utils.post_webhooks import post_webhooks
from schema.snackbar import SnackResponse
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)):
""" Returns basic site settings """
try:
data = db.settings.get(session, "main")
except:
return
data = db.settings.get(session, 1)
return data
@router.put("")
def update_settings(data: SiteSettings, session: Session = Depends(generate_session)):
""" Returns Site Settings """
db.settings.update(session, "main", data.dict())
db.settings.update(session, 1, data.dict())
return SnackResponse.success("Settings Updated")

View file

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

View file

@ -1,5 +1,6 @@
import shutil
from datetime import timedelta
from os import access
from core.config import USER_DIR
from core.security import get_password_hash, verify_password
@ -65,9 +66,10 @@ async def update_user(
session: Session = Depends(generate_session),
):
access_token = None
if current_user.id == id or current_user.admin:
updated_user = db.users.update(session, id, new_data.dict())
email = updated_user.get("email")
updated_user: UserInDB = db.users.update(session, id, new_data.dict())
email = updated_user.email
if current_user.id == id:
access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2)
@ -82,7 +84,6 @@ async def get_user_image(id: str):
""" Returns a users profile picture """
user_dir = USER_DIR.joinpath(id)
for recipe_image in user_dir.glob("profile_image.*"):
print(recipe_image)
return FileResponse(recipe_image)
else:
return False
@ -128,7 +129,6 @@ async def update_password(
match_passwords = verify_password(
password_change.current_password, current_user.password
)
print(match_passwords)
match_id = current_user.id == 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 """
# 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:
return {"details": "invalid token"}
return SnackResponse.error("Invalid Token")
# Create User
new_user.admin = db_entry.get("admin")
new_user.admin = db_entry.admin
new_user.password = get_password_hash(new_user.password)
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(auth.router)
router.include_router(sign_up.router)
router.include_router(crud.router)

View file

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

View file

@ -1,14 +1,29 @@
from typing import List, Optional
from pydantic.main import BaseModel
from services.recipe_services import Recipe
from fastapi_camelcase import CamelModel
from schema.recipe import Recipe
class RecipeCategoryResponse(BaseModel):
class CategoryBase(CamelModel):
id: int
name: str
slug: str
class Config:
orm_mode = True
class RecipeCategoryResponse(CategoryBase):
recipes: Optional[List[Recipe]]
class Config:
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 typing import List, Optional
from pydantic import BaseModel
from pydantic import BaseModel, validator
class Meal(BaseModel):
slug: Optional[str]
class MealIn(BaseModel):
name: Optional[str]
date: date
dateText: str
slug: Optional[str]
date: Optional[date]
class MealOut(MealIn):
image: Optional[str]
description: Optional[str]
class Config:
orm_mode = True
class MealData(BaseModel):
name: Optional[str]
slug: str
dateText: str
class MealPlanBase(BaseModel):
startDate: date
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):
uid: Optional[str]
startDate: date
endDate: date
meals: List[Meal]
class Config:
schema_extra = {

View file

@ -1,7 +1,9 @@
import datetime
from typing import Any, List, Optional
from db.models.recipe.recipe import RecipeModel
from pydantic import BaseModel, validator
from pydantic.utils import GetterDict
from slugify import slugify
@ -9,19 +11,38 @@ class RecipeNote(BaseModel):
title: str
text: str
class Config:
orm_mode = True
class RecipeStep(BaseModel):
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):
# Standard Schema
name: str
description: Optional[str]
image: Optional[Any]
recipeYield: Optional[str]
recipeIngredient: Optional[list]
recipeInstructions: Optional[list]
recipeCategory: Optional[List[str]] = []
recipeIngredient: Optional[list[str]]
recipeInstructions: Optional[list[RecipeStep]]
nutrition: Optional[Nutrition]
totalTime: Optional[str] = None
prepTime: Optional[str] = None
@ -29,7 +50,6 @@ class Recipe(BaseModel):
# Mealie Specific
slug: Optional[str] = ""
categories: Optional[List[str]] = []
tags: Optional[List[str]] = []
dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = []
@ -38,6 +58,18 @@ class Recipe(BaseModel):
extras: Optional[dict] = {}
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 = {
"example": {
"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",
"tags": ["favorite", "yummy!"],
"categories": ["Dinner", "Pasta"],
"recipeCategory": ["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,

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):
webhookTime: str = "00:00"
webhookURLs: Optional[List[str]] = []
enabled: bool = False
class SiteSettings(BaseModel):
name: str = "main"
planCategories: list[str] = []
webhooks: Webhooks
class SiteSettings(CamelModel):
language: str = "en"
show_recent: bool = True
cards_per_section: int = 9
categories: Optional[list[CategoryBase]] = []
class Config:
orm_mode = True
schema_extra = {
"example": {
"name": "main",
"planCategories": ["dinner", "lunch"],
"webhooks": {
"webhookTime": "00:00",
"webhookURLs": ["https://mywebhookurl.com/webhook"],
"enable": False,
},
"language": "en",
"showRecent": True,
"categories": [
{"id": 1, "name": "thanksgiving", "slug": "thanksgiving"},
{"id": 2, "name": "homechef", "slug": "homechef"},
{"id": 3, "name": "potatoes", "slug": "potatoes"},
],
}
}

View file

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

View file

@ -1,20 +1,25 @@
from pydantic import BaseModel
class Colors(BaseModel):
primary: str
accent: str
secondary: str
success: str
info: str
warning: str
error: str
primary: str = "#E58325"
accent: str = "#00457A"
secondary: str = "#973542"
success: str = "#4CAF50"
info: str = "#4990BA"
warning: str = "#FF4081"
error: str = "#EF5350"
class Config:
orm_mode = True
class SiteTheme(BaseModel):
name: str
colors: Colors
name: str = "default"
colors: Colors = Colors()
class Config:
orm_mode = True
schema_extra = {
"example": {
"name": "default",
@ -28,4 +33,4 @@ class SiteTheme(BaseModel):
"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 pydantic.utils import GetterDict
# from pydantic import EmailStr
from schema.category import CategoryBase
from schema.meal import MealPlanInDB
class ChangePassword(CamelModel):
@ -10,17 +15,33 @@ class ChangePassword(CamelModel):
new_password: str
class GroupBase(CamelModel):
name: str
class Config:
orm_mode = True
class UserBase(CamelModel):
full_name: Optional[str] = None
email: str
family: str
admin: bool
group: Optional[str]
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm: User):
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
}
schema_extra = {
"fullName": "Change Me",
"email": "changeme@email.com",
"family": "public",
"group": DEFAULT_GROUP,
"admin": "false",
}
@ -31,7 +52,47 @@ class UserIn(UserBase):
class UserOut(UserBase):
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
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 fastapi.logger import logger
from jinja2 import Template
from services.meal_services import MealPlan
from services.recipe_services import Recipe
from schema.recipe import Recipe
class ExportDatabase:
@ -57,26 +56,27 @@ class ExportDatabase:
dir.mkdir(parents=True, exist_ok=True)
def export_recipes(self):
all_recipes = Recipe.get_all(self.session)
all_recipes = db.recipes.get_all(self.session)
for recipe in all_recipes:
recipe: Recipe
logger.info(f"Backing Up Recipes: {recipe}")
filename = recipe.get("slug") + ".json"
filename = recipe.slug + ".json"
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:
self._export_template(recipe)
def _export_template(self, recipe_data: dict):
def _export_template(self, recipe_data: Recipe):
for template_path in self.templates:
with open(template_path, "r") as f:
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)
content = template.render(recipe=recipe_data)
@ -101,7 +101,7 @@ class ExportDatabase:
def export_meals(self):
#! 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:
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 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.theme import SiteTheme
from services.recipe_services import Recipe
from sqlalchemy.orm.session import Session
from fastapi.logger import logger
class ImportDatabase:
@ -75,6 +76,7 @@ class ImportDatabase:
}
def import_recipes(self):
session = create_session()
recipe_dir: Path = self.import_dir.joinpath("recipes")
imports = []
@ -85,8 +87,12 @@ class ImportDatabase:
recipe_dict = json.loads(f.read())
recipe_dict = ImportDatabase._recipe_migration(recipe_dict)
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.save_to_db(self.session)
db.recipes.create(session, recipe_obj.dict())
import_status = RecipeImport(
name=recipe_obj.name, slug=recipe_obj.slug, status=True
)

View file

@ -1,111 +1,43 @@
from datetime import date, timedelta
from typing import List, Optional
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 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):
slug: Optional[str]
name: Optional[str]
date: date
dateText: str
image: Optional[str]
description: Optional[str]
def get_todays_meal(session):
meal_plan = db.meals.get_all(session, limit=1, order_by="startDate")
class MealData(BaseModel):
name: Optional[str]
slug: str
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
for meal in meal_plan:
meal: MealOut
if meal.date == date.today():
return meal.slug

View file

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

View file

@ -5,9 +5,10 @@ import zipfile
from pathlib import Path
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 core.config import IMG_DIR, TEMP_DIR
from db.database import db
def process_selection(selection: Path) -> Path:
@ -77,7 +78,8 @@ def migrate(session, selection: str):
try:
recipe = import_recipes(dir)
recipe.save_to_db(session)
db.recipes.create(session, recipe.dict())
successful_imports.append(recipe.name)
except:
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 utils.post_webhooks import post_webhooks
# TODO Fix Scheduler
@scheduler.scheduled_job(trigger="interval", minutes=15)
def update_webhook_schedule():
"""

View file

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

View file

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

View file

@ -19,12 +19,10 @@ def get_meal_plan_template(first=None, second=None):
{
"slug": first,
"date": "2021-1-17",
"dateText": "Monday, January 18, 2021",
},
{
"slug": second,
"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
test_categories = ["one", "two", "three"]
recipe["categories"] = test_categories
recipe["recipeCategory"] = test_categories
response = api_client.put(
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)
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)

View file

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

View file

@ -16,7 +16,7 @@ def default_user():
"id": 1,
"fullName": "Change Me",
"email": "changeme@email.com",
"family": "public",
"group": "home",
"admin": True
}
@ -27,7 +27,7 @@ def new_user():
"id": 2,
"fullName": "My New User",
"email": "newuser@email.com",
"family": "public",
"group": "home",
"admin": False
}
@ -54,7 +54,7 @@ def test_create_user(api_client: requests, token, new_user):
"fullName": "My New User",
"email": "newuser@email.com",
"password": "MyStrongPassword",
"family": "public",
"group": "home",
"admin": False
}
@ -78,7 +78,7 @@ def test_update_user(api_client: requests, token):
"id": 1,
"fullName": "Updated Name",
"email": "updated@email.com",
"family": "public",
"group": "home",
"admin": True
}
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