v0.2.0 Updates (#130)

* migration redesign init

* new color picker

* changelog

* added UI language selection

* fix layout issue on recipe editor

* remove git as dependency

* added UI editor for original URL

* CI/CD Tests

* test: fixed migration routes

* test todos

* bug/added docker volume

* chowdow test data

* partial image recipe image testing

* added card section card

* settings form

* homepage cetegory ui

* frontend category placeholder

* fixed broken scheduler

* remove old files

* removed temp test

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-01-29 19:31:24 -08:00 committed by GitHub
commit 874bea7fa4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 763 additions and 746 deletions

View file

@ -7,7 +7,7 @@ RUN npm run build
FROM python:3.8-alpine FROM python:3.8-alpine
RUN apk add --no-cache git curl libxml2-dev libxslt-dev libxml2 RUN apk add --no-cache libxml2-dev libxslt-dev libxml2
ENV ENV prod ENV ENV prod
EXPOSE 80 EXPOSE 80
WORKDIR /app WORKDIR /app
@ -15,6 +15,7 @@ WORKDIR /app
COPY ./pyproject.toml /app/ COPY ./pyproject.toml /app/
RUN apk add --update --no-cache --virtual .build-deps \ RUN apk add --update --no-cache --virtual .build-deps \
curl \
g++ \ g++ \
py-lxml \ py-lxml \
python3-dev \ python3-dev \
@ -34,35 +35,5 @@ COPY --from=build-stage /app/dist /app/dist
RUN rm -rf /app/test /app/.temp RUN rm -rf /app/test /app/.temp
VOLUME [ "/app_data/" ]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "80"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "80"]
# ---------------------------------- #
# Old Docker File
# ---------------------------------- #
# FROM tiangolo/uvicorn-gunicorn-fastapi:python3.8-slim
# FROM mrnr91/uvicorn-gunicorn-fastapi:python3.8
# WORKDIR /app
# RUN apt-get update -y && \
# apt-get install -y python-pip python-dev git curl python3-dev libxml2-dev libxslt1-dev zlib1g-dev --no-install-recommends && \
# rm -rf /var/lib/apt/lists/* && \
# curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
# cd /usr/local/bin && \
# ln -s /opt/poetry/bin/poetry && \
# poetry config virtualenvs.create false
# COPY ./pyproject.toml /app/
# COPY ./mealie /app
# RUN poetry install --no-root --no-dev
# COPY --from=build-stage /app/dist /app/dist
# RUN rm -rf /app/test /app/.temp
# ENV ENV prod
# ENV APP_MODULE "app:app"
# VOLUME [ "/app/data" ]

View file

@ -16,6 +16,11 @@ Don't forget to [join the Discord](https://discord.gg/R6QDyJgbD2)!
# Todo's # Todo's
Test
- [ ] Image Upload Test
- [ ] Rename and Upload Image Test
- [x] Chowdown Migration End Point Test
Frontend Frontend
- [ ] No Meal Today Page instead of Null - [ ] No Meal Today Page instead of Null
- [ ] Recipe Print Page - [ ] Recipe Print Page

View file

@ -1966,6 +1966,16 @@
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true "dev": true
}, },
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"cacache": { "cacache": {
"version": "13.0.1", "version": "13.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz", "resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz",
@ -1992,6 +2002,53 @@
"unique-filename": "^1.1.1" "unique-filename": "^1.1.1"
} }
}, },
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"optional": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"source-map": { "source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -2008,6 +2065,16 @@
"minipass": "^3.1.1" "minipass": "^3.1.1"
} }
}, },
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"terser-webpack-plugin": { "terser-webpack-plugin": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz",
@ -2024,6 +2091,18 @@
"terser": "^4.6.12", "terser": "^4.6.12",
"webpack-sources": "^1.4.3" "webpack-sources": "^1.4.3"
} }
},
"vue-loader-v16": {
"version": "npm:vue-loader@16.1.2",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.2.tgz",
"integrity": "sha512-8QTxh+Fd+HB6fiL52iEVLKqE9N1JSlMXLR92Ijm6g8PZrwIxckgpqjPDWRP5TWxdiPaHR+alUWsnu1ShQOwt+Q==",
"dev": true,
"optional": true,
"requires": {
"chalk": "^4.1.0",
"hash-sum": "^2.0.0",
"loader-utils": "^2.0.0"
}
} }
} }
}, },
@ -10175,6 +10254,11 @@
"is-plain-obj": "^1.0.0" "is-plain-obj": "^1.0.0"
} }
}, },
"sortablejs": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
},
"source-list-map": { "source-list-map": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@ -11521,87 +11605,6 @@
} }
} }
}, },
"vue-loader-v16": {
"version": "npm:vue-loader@16.1.2",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.2.tgz",
"integrity": "sha512-8QTxh+Fd+HB6fiL52iEVLKqE9N1JSlMXLR92Ijm6g8PZrwIxckgpqjPDWRP5TWxdiPaHR+alUWsnu1ShQOwt+Q==",
"dev": true,
"optional": true,
"requires": {
"chalk": "^4.1.0",
"hash-sum": "^2.0.0",
"loader-utils": "^2.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"optional": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"vue-router": { "vue-router": {
"version": "3.4.9", "version": "3.4.9",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.9.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.9.tgz",
@ -11641,6 +11644,14 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true "dev": true
}, },
"vuedraggable": {
"version": "2.24.3",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz",
"integrity": "sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==",
"requires": {
"sortablejs": "1.10.2"
}
},
"vuetify": { "vuetify": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.2.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.2.tgz",

View file

@ -17,6 +17,7 @@
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-i18n": "^8.22.4", "vue-i18n": "^8.22.4",
"vue-router": "^3.4.9", "vue-router": "^3.4.9",
"vuedraggable": "^2.24.3",
"vuetify": "^2.4.2", "vuetify": "^2.4.2",
"vuex": "^3.6.0", "vuex": "^3.6.0",
"vuex-persistedstate": "^4.0.0-beta.3" "vuex-persistedstate": "^4.0.0-beta.3"
@ -61,4 +62,4 @@
"semi": true, "semi": true,
"singleQuote": false "singleQuote": false
} }
} }

View file

@ -17,7 +17,7 @@
@selected="navigateFromSearch" @selected="navigateFromSearch"
/> />
</v-expand-x-transition> </v-expand-x-transition>
<v-btn icon @click="toggleSearch"> <v-btn icon @click="search = !search">
<v-icon>mdi-magnify</v-icon> <v-icon>mdi-magnify</v-icon>
</v-btn> </v-btn>
@ -34,11 +34,11 @@
</template> </template>
<script> <script>
import Menu from "./components/UI/Menu" import Menu from "./components/UI/Menu";
import SearchBar from "./components/UI/SearchBar" import SearchBar from "./components/UI/SearchBar";
import AddRecipeFab from "./components/UI/AddRecipeFab" import AddRecipeFab from "./components/UI/AddRecipeFab";
import SnackBar from "./components/UI/SnackBar" import SnackBar from "./components/UI/SnackBar";
import Vuetify from "./plugins/vuetify" import Vuetify from "./plugins/vuetify";
export default { export default {
name: "App", name: "App",
@ -51,16 +51,17 @@ export default {
watch: { watch: {
$route() { $route() {
this.search = false this.search = false;
}, },
}, },
mounted() { mounted() {
this.$store.dispatch("initTheme") this.$store.dispatch("initTheme");
this.$store.dispatch("requestRecentRecipes") this.$store.dispatch("requestRecentRecipes");
this.$store.dispatch("initLang") this.$store.dispatch("requestHomePageSettings");
this.darkModeSystemCheck() this.$store.dispatch("initLang");
this.darkModeAddEventListener() this.darkModeSystemCheck();
this.darkModeAddEventListener();
}, },
data: () => ({ data: () => ({
@ -74,30 +75,22 @@ export default {
if (this.$store.getters.getDarkMode === "system") if (this.$store.getters.getDarkMode === "system")
Vuetify.framework.theme.dark = window.matchMedia( Vuetify.framework.theme.dark = window.matchMedia(
"(prefers-color-scheme: dark)" "(prefers-color-scheme: dark)"
).matches ).matches;
}, },
/** /**
* This will monitor the OS level darkmode and call to update dark mode. * This will monitor the OS level darkmode and call to update dark mode.
*/ */
darkModeAddEventListener() { darkModeAddEventListener() {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkMediaQuery.addEventListener("change", () => { darkMediaQuery.addEventListener("change", () => {
this.darkModeSystemCheck() this.darkModeSystemCheck();
}) });
},
toggleSearch() {
if (this.search === true) {
this.search = false
} else {
this.search = true
}
}, },
navigateFromSearch(slug) { navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`) this.$router.push(`/recipe/${slug}`);
}, },
}, },
} };
</script> </script>
<style> <style>

View file

@ -1,90 +0,0 @@
<template>
<div>
<ImportDialog
:name="selectedName"
:date="selectedDate"
ref="import_dialog"
@import="importBackup"
@delete="deleteBackup"
/>
<v-row>
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="4"
v-for="backup in backups"
:key="backup.name"
>
<v-card @click="openDialog(backup)">
<v-card-text>
<v-row align="center">
<v-col cols="12" sm="2">
<v-icon color="primary"> mdi-backup-restore </v-icon>
</v-col>
<v-col cols="12" sm="10">
<div>
<strong>{{ backup.name }}</strong>
</div>
<div>{{ readableTime(backup.date) }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
import ImportDialog from "./ImportDialog";
import api from "../../../api";
import utils from "../../../utils";
export default {
props: {
backups: Array,
},
components: {
ImportDialog,
},
data() {
return {
selectedName: "",
selectedDate: "",
loading: false,
};
},
methods: {
openDialog(backup) {
this.selectedDate = this.readableTime(backup.date);
this.selectedName = backup.name;
this.$refs.import_dialog.open();
},
readableTime(timestamp) {
let date = new Date(timestamp);
return utils.getDateAsText(date);
},
async importBackup(data) {
this.$emit("loading");
let response = await api.backups.import(data.name, data);
let failed = response.data.failed;
let succesful = response.data.successful;
this.$emit("finished", succesful, failed);
},
deleteBackup(data) {
this.$emit("loading");
api.backups.delete(data.name);
this.selectedBackup = null;
this.backupLoading = false;
this.$emit("finished");
},
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,158 @@
<template>
<v-card flat>
<v-card-text>
<h2 class="mt-1 mb-1">Home Page</h2>
<v-row align="center" justify="center" dense class="mb-n7 pb-n5">
<v-col sm="2">
<v-switch v-model="showRecent" label="Show Recent"></v-switch>
</v-col>
<v-col>
<v-slider
class="pt-4"
label="Card Per Section"
v-model="showLimit"
max="30"
dense
color="primary"
min="3"
thumb-label
>
</v-slider>
</v-col>
<v-spacer></v-spacer>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col>
<v-card outlined min-height="250">
<v-card-text class="pt-2 pb-1">
<h3>Homepage Categories</h3>
</v-card-text>
<v-divider></v-divider>
<v-list min-height="200px" dense>
<v-list-item-group>
<draggable
v-model="homeCategories"
group="categories"
:style="{
minHeight: `200px`,
}"
>
<v-list-item
v-for="(item, index) in homeCategories"
:key="item"
>
<v-list-item-icon>
<v-icon>mdi-menu</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="item"></v-list-item-title>
</v-list-item-content>
<v-list-item-icon @click="deleteActiveCategory(index)">
<v-icon>mdi-delete</v-icon>
</v-list-item-icon>
</v-list-item>
</draggable>
</v-list-item-group>
</v-list>
</v-card>
</v-col>
<v-col>
<v-card outlined min-height="250px">
<v-card-text class="pt-2 pb-1">
<h3>
All Categories
<span>
<v-btn absolute right x-small color="success" icon>
<v-icon>mdi-plus</v-icon></v-btn
>
</span>
</h3>
</v-card-text>
<v-divider></v-divider>
<v-list min-height="200px" dense>
<v-list-item-group>
<draggable
v-model="categories"
group="categories"
:style="{
minHeight: `200px`,
}"
>
<v-list-item v-for="item in categories" :key="item">
<v-list-item-icon>
<v-icon>mdi-menu</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="item"></v-list-item-title>
</v-list-item-content>
<v-list-item-icon @click="deleteActiveCategory(index)">
<v-icon>mdi-delete</v-icon>
</v-list-item-icon>
</v-list-item>
</draggable>
</v-list-item-group>
</v-list>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" @click="saveSettings" class="mr-2">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import draggable from "vuedraggable";
export default {
components: {
draggable,
},
data() {
return {
homeCategories: [],
showLimit: null,
categories: ["breakfast"],
showRecent: true,
};
},
mounted() {
this.getOptions();
},
methods: {
getOptions() {
let options = this.$store.getters.getHomePageSettings;
this.showLimit = options.showLimit;
this.categories = options.categories;
this.showRecent = options.showRecent;
this.homeCategories = options.homeCategories;
},
deleteActiveCategory(index) {
this.homeCategories.splice(index, 1);
},
saveSettings() {
let payload = {
showRecent: this.showRecent,
showLimit: this.showLimit,
categories: this.categories,
homeCategories: this.homeCategories,
};
this.$store.commit("setHomePageSettings", payload);
},
},
};
</script>
<style>
</style>

View file

@ -1,8 +1,20 @@
<template> <template>
<v-card> <v-card>
<v-card-title> General Settings </v-card-title> <v-card-title>
General Settings
<v-spacer></v-spacer>
<span>
<v-btn class="pt-1" text href="/docs">
<v-icon left>mdi-link</v-icon>
Local API
</v-btn>
</span>
</v-card-title>
<v-divider></v-divider>
<HomePageSettings />
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>
<h2 class="mt-1 mb-1">Language</h2>
<v-row> <v-row>
<v-col> <v-col>
<v-select <v-select
@ -18,21 +30,35 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
</v-row> </v-row>
</v-card-text> </v-card-text>
<v-divider></v-divider>
</v-card> </v-card>
</template> </template>
<script> <script>
import HomePageSettings from "./HomePageSettings";
export default { export default {
components: {
HomePageSettings,
},
data() { data() {
return { return {
categories: ["cat 1", "cat 2", "cat 3"],
usedCategories: ["recent"],
langOptions: [], langOptions: [],
selectedLang: "en", selectedLang: "en",
homeOptions: {
recipesToShow: 10,
},
}; };
}, },
mounted() { mounted() {
this.getOptions(); this.getOptions();
}, },
watch: { watch: {
usedCategories() {
console.log(this.usedCategories);
},
selectedLang() { selectedLang() {
this.$store.commit("setLang", this.selectedLang); this.$store.commit("setLang", this.selectedLang);
}, },

View file

@ -1,79 +0,0 @@
<template>
<v-card-text>
<p>
{{
$t(
"migration.currently-chowdown-via-public-repo-url-is-the-only-supported-type-of-migration"
)
}}
</p>
<v-form ref="form">
<v-row dense align="center">
<v-col cols="12" md="5" sm="5">
<v-text-field
v-model="repo"
:label="$t('migration.chowdown-repo-url')"
:rules="[rules.required]"
>
</v-text-field>
</v-col>
<v-col cols="12" md="4" sm="5">
<v-btn text color="info" @click="importRepo">
<v-icon left> mdi-import </v-icon>
{{ $t("migration.migrate") }}
</v-btn>
</v-col>
</v-row>
</v-form>
<v-alert v-if="failedRecipes[1]" outlined dense type="error">
<h4>{{ $t("migration.failed-recipes") }}</h4>
<v-list dense>
<v-list-item v-for="fail in this.failedRecipes" :key="fail">
{{ fail }}
</v-list-item>
</v-list>
</v-alert>
<v-alert v-if="failedImages[1]" outlined dense type="error">
<h4>{{ $t("migration.failed-images") }}</h4>
<v-list dense>
<v-list-item v-for="fail in this.failedImages" :key="fail">
{{ fail }}
</v-list-item>
</v-list>
</v-alert>
</v-card-text>
</template>
<script>
import api from "../../../api";
// import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
// import TimePicker from "./Webhooks/TimePicker";
export default {
data() {
return {
processRan: false,
failedImages: [],
failedRecipes: [],
repo: "",
rules: {
required: v => !!v || "Selection Required",
},
};
},
methods: {
async importRepo() {
if (this.$refs.form.validate()) {
this.$emit("loading");
let response = await api.migrations.migrateChowdown(this.repo);
this.failedImages = response.failedImages;
this.failedRecipes = response.failedRecipes;
this.$emit("finished");
this.processRan = true;
}
},
},
};
</script>
<style>
</style>

View file

@ -1,112 +0,0 @@
<template>
<v-card-text>
<p>
{{
$t(
"migration.you-can-import-recipes-from-either-a-zip-file-or-a-directory-located-in-the-app-data-migraiton-folder-please-review-the-documentation-to-ensure-your-directory-structure-matches-what-is-expected"
)
}}
</p>
<v-form ref="form">
<v-row align="center">
<v-col cols="12" md="5" sm="12">
<v-select
:items="availableImports"
v-model="selectedImport"
:label="$t('migration.nextcloud-data')"
:rules="[rules.required]"
></v-select>
</v-col>
<v-col md="1" sm="12">
<v-btn-toggle group>
<v-btn text color="info" @click="importRecipes">
<v-icon left> mdi-import </v-icon>
{{ $t("migration.migrate") }}
</v-btn>
<v-btn text color="error" @click="deleteImportValidation">
<v-icon left> mdi-delete </v-icon>
{{ $t("general.delete") }}
</v-btn>
<UploadBtn
url="/api/migration/upload/"
class="mt-1"
@uploaded="getAvaiableImports"
/>
<Confirmation
:title="$t('general.delete-data')"
:message="$t('migration.delete-confirmation')"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteImport()"
/>
</v-btn-toggle>
</v-col>
<v-spacer></v-spacer>
</v-row>
</v-form>
<SuccessFailureAlert
:success-header="$t('migration.successfully-imported-from-nextcloud')"
:success="successfulImports"
failed-header="$t('migration.failed-imports')"
:failed="failedImports"
/>
</v-card-text>
</template>
<script>
import api from "../../../api";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
import Confirmation from "../../UI/Confirmation";
import UploadBtn from "../../UI/UploadBtn";
export default {
components: {
SuccessFailureAlert,
Confirmation,
UploadBtn,
},
data() {
return {
successfulImports: [],
failedImports: [],
availableImports: [],
selectedImport: null,
rules: {
required: v => !!v || "Selection Required",
},
};
},
async mounted() {
this.getAvaiableImports();
},
methods: {
async getAvaiableImports() {
this.availableImports = await api.migrations.getNextcloudImports();
},
async importRecipes() {
if (this.$refs.form.validate()) {
this.$emit("loading");
let data = await api.migrations.importNextcloud(this.selectedImport);
this.successfulImports = data.successful;
this.failedImports = data.failed;
this.$emit("finished");
}
},
deleteImportValidation() {
if (this.$refs.form.validate()) {
this.$refs.deleteThemeConfirm.open();
}
},
async deleteImport() {
await api.migrations.delete(this.selectedImport);
this.getAvaiableImports();
},
},
};
</script>
<style>
</style>

View file

@ -1,49 +0,0 @@
<template>
<v-form ref="file">
<v-file-input
:loading="loading"
:label="$t('migration.upload-an-archive')"
v-model="file"
accept=".zip"
@change="upload"
:prepend-icon="icon"
class="file-icon"
>
</v-file-input>
</v-form>
</template>c
<script>
import api from "../../../api";
export default {
data() {
return {
file: null,
loading: false,
icon: "mdi-paperclip",
};
},
methods: {
async upload() {
if (this.file != null) {
this.loading = true;
let formData = new FormData();
formData.append("archive", this.file);
await api.migrations.uploadFile(formData);
this.loading = false;
this.$emit("uploaded");
this.file = null;
this.icon = "mdi-check";
}
},
},
};
</script>
<style>
.file-icon {
transition-duration: 5s;
}
</style>

View file

@ -0,0 +1,81 @@
<template>
<div class="mt-n5">
<v-card flat class="transparent" height="60px">
<v-card-text>
<v-row>
<v-col>
<v-btn-toggle group>
<v-btn text :to="`/recipes/category/${title.toLowerCase()}`">
{{ title.toUpperCase() }}
</v-btn>
</v-btn-toggle>
</v-col>
<v-spacer></v-spacer>
<v-col align="end">
<v-menu offset-y v-if="sortable">
<template v-slot:activator="{ on, attrs }">
<v-btn-toggle group>
<v-btn text v-bind="attrs" v-on="on"> Sort </v-btn>
</v-btn-toggle>
</template>
<v-list>
<v-list-item @click="$emit('sort-recent')">
<v-list-item-title> Recent </v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('sort')">
<v-list-item-title> A-Z </v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-row>
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="recipe in recipes.slice(0, cardLimit)"
:key="recipe.name"
>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
/>
</v-col>
</v-row>
</div>
</template>
<script>
import RecipeCard from "./RecipeCard";
export default {
components: {
RecipeCard,
},
props: {
sortable: {
default: false,
},
title: String,
recipes: Array,
cardLimit: {
default: 6,
},
},
data() {
return {};
},
};
</script>
<style>
.transparent {
opacity: 1;
}
</style>

View file

@ -1,40 +0,0 @@
<template>
<v-row>
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="recipe in recipes"
:key="recipe.name"
>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
/>
</v-col>
</v-row>
</template>
<script>
import RecipeCard from "./RecipeCard";
export default {
components: {
RecipeCard,
},
data: () => ({}),
mounted() {},
computed: {
recipes() {
return this.$store.getters.getRecentRecipes;
},
},
};
</script>
<style scoped>
</style>

View file

@ -1,63 +0,0 @@
<template>
<v-autocomplete
:items="items"
:loading="isLoading"
v-model="selected"
clearable
return
dense
hide-details
hide-selected
item-text="slug"
:label="$t('search.search-for-a-recipe')"
single-line
@keyup.enter.native="moreInfo(selected)"
>
<template v-slot:no-data>
<v-list-item>
<v-list-item-title :v-html="$t('search.search-for-your-favorite-recipe')">
</v-list-item-title>
</v-list-item>
</template>
<template v-slot:item="{ item }">
<v-list-item-avatar
color="primary"
class="headline font-weight-light white--text"
>
<v-img :src="getImage(item.image)"></v-img>
</v-list-item-avatar>
<v-list-item-content @click="moreInfo(item.slug)">
<v-list-item-title v-text="item.name"></v-list-item-title>
</v-list-item-content>
</template>
</v-autocomplete>
</template>
<script>
import utils from "../../utils";
export default {
data: () => ({
selected: null,
isLoading: false,
}),
computed: {
items() {
return this.$store.getters.getRecentRecipes;
},
},
methods: {
moreInfo(recipeSlug) {
this.$router.push(`/recipe/${recipeSlug}`);
},
getImage(image) {
return utils.getImageURL(image);
},
},
};
</script>
<style>
</style>

View file

@ -1,15 +1,66 @@
<template> <template>
<div> <div>
<RecentRecipes /> <CardSection
v-if="pageSettings.showRecent"
title="Recent"
:recipes="recentRecipes"
:card-limit="pageSettings.showLimit"
/>
<CardSection
:sortable="true"
v-for="(section, index) in recipeByCategory"
:key="index"
:title="section.title"
:recipes="section.recipes"
:card-limit="pageSettings.showLimit"
@sort="sortAZ(index)"
@sort-recent="sortRecent(index)"
/>
</div> </div>
</template> </template>
<script> <script>
import RecentRecipes from "../components/UI/RecentRecipes"; import CardSection from "../components/UI/CardSection";
export default { export default {
components: { components: {
RecentRecipes, CardSection,
},
data() {
return {
recipeByCategory: [
{
title: "Title 1",
recipes: this.$store.getters.getRecentRecipes,
},
{
title: "Title 2",
recipes: this.$store.getters.getRecentRecipes,
},
],
};
},
computed: {
pageSettings() {
return this.$store.getters.getHomePageSettings;
},
recentRecipes() {
return this.$store.getters.getRecentRecipes;
},
},
methods: {
getRecentRecipes() {
this.$store.dispatch("requestRecentRecipes");
},
sortAZ(index) {
this.recipeByCategory[index].recipes.sort((a, b) =>
a.name > b.name ? 1 : -1
);
},
sortRecent(index) {
this.recipeByCategory[index].recipes.sort((a, b) =>
a.dateAdded > b.dateAdded ? -1 : 1
);
},
}, },
}; };
</script> </script>

View file

View file

@ -18,6 +18,13 @@ const store = new Vuex.Store({
language, language,
}, },
state: { state: {
// Home Page Settings
homePageSettings: {
showRecent: true,
showLimit: 9,
categories: [],
homeCategories: [],
},
// Snackbar // Snackbar
snackActive: false, snackActive: false,
snackText: "", snackText: "",
@ -29,6 +36,9 @@ const store = new Vuex.Store({
}, },
mutations: { mutations: {
setHomePageSettings(state, payload) {
state.homePageSettings = payload;
},
setSnackBar(state, payload) { setSnackBar(state, payload) {
state.snackText = payload.text; state.snackText = payload.text;
state.snackType = payload.type; state.snackType = payload.type;
@ -57,6 +67,16 @@ const store = new Vuex.Store({
this.commit("setRecentRecipes", payload); this.commit("setRecentRecipes", payload);
}, },
async requestHomePageSettings() {
// TODO: Query Backend for Categories
this.commit("setHomePageSettings", {
showRecent: true,
showLimit: 9,
categories: ["breakfast", "lunch", "dinner"],
homeCategories: [],
});
},
}, },
getters: { getters: {
@ -65,7 +85,8 @@ const store = new Vuex.Store({
getSnackActive: state => state.snackActive, getSnackActive: state => state.snackActive,
getSnackType: state => state.snackType, getSnackType: state => state.snackType,
getRecentRecipes: state => state.recentRecipes, getRecentRecipes: (state) => state.recentRecipes,
getHomePageSettings: (state) => state.homePageSettings,
}, },
}); });

View file

@ -3,7 +3,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
# import utils.startup as startup # import utils.startup as startup
from app_config import PORT, PRODUCTION, SQLITE_FILE, WEB_PATH, docs_url, redoc_url from app_config import PORT, PRODUCTION, WEB_PATH, docs_url, redoc_url
from routes import ( from routes import (
backup_routes, backup_routes,
meal_routes, meal_routes,
@ -14,7 +14,7 @@ from routes import (
user_routes, user_routes,
) )
# from utils.api_docs import generate_api_docs from utils.api_docs import generate_api_docs
from utils.logger import logger from utils.logger import logger
app = FastAPI( app = FastAPI(
@ -26,13 +26,13 @@ app = FastAPI(
) )
def mount_static_files(): def mount_static_files():
app.mount("/static", StaticFiles(directory=WEB_PATH, html=True)) app.mount("/static", StaticFiles(directory=WEB_PATH, html=True))
def api_routers(): def api_routers():
# First # First
print()
app.include_router(recipe_routes.router) app.include_router(recipe_routes.router)
app.include_router(meal_routes.router) app.include_router(meal_routes.router)
app.include_router(setting_routes.router) app.include_router(setting_routes.router)
@ -46,6 +46,11 @@ if PRODUCTION:
api_routers() api_routers()
def start_scheduler():
import services.scheduler.scheduled_jobs
# API 404 Catch all CALL AFTER ROUTERS # API 404 Catch all CALL AFTER ROUTERS
@app.get("/api/{full_path:path}", status_code=404, include_in_schema=False) @app.get("/api/{full_path:path}", status_code=404, include_in_schema=False)
def invalid_api(): def invalid_api():
@ -56,8 +61,10 @@ app.include_router(static_routes.router)
# Generate API Documentation # Generate API Documentation
# if not PRODUCTION: if not PRODUCTION:
# generate_api_docs(app) generate_api_docs(app)
start_scheduler()
if __name__ == "__main__": if __name__ == "__main__":
logger.info("-----SYSTEM STARTUP-----") logger.info("-----SYSTEM STARTUP-----")

View file

@ -1,3 +1,5 @@
from sqlalchemy.orm.session import Session
from db.db_base import BaseDocument from db.db_base import BaseDocument
from db.sql.meal_models import MealPlanModel from db.sql.meal_models import MealPlanModel
from db.sql.recipe_models import RecipeModel from db.sql.recipe_models import RecipeModel
@ -16,8 +18,12 @@ class _Recipes(BaseDocument):
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model = RecipeModel self.sql_model = RecipeModel
def update_image(self, slug: str, extension: str) -> None: def update_image(self, session: Session, slug: str, extension: str) -> str:
pass entry = self._query_one(session, match_value=slug)
entry.image = f"{slug}.{extension}"
session.commit()
return f"{slug}.{extension}"
class _Meals(BaseDocument): class _Meals(BaseDocument):
@ -31,7 +37,7 @@ class _Settings(BaseDocument):
self.primary_key = "name" self.primary_key = "name"
self.sql_model = SiteSettingsModel self.sql_model = SiteSettingsModel
def save_new(self, session, main: dict, webhooks: dict) -> str: def save_new(self, session: Session, main: dict, webhooks: dict) -> str:
new_settings = self.sql_model(main.get("name"), webhooks) new_settings = self.sql_model(main.get("name"), webhooks)
session.add(new_settings) session.add(new_settings)
@ -45,14 +51,6 @@ class _Themes(BaseDocument):
self.primary_key = "name" self.primary_key = "name"
self.sql_model = SiteThemeModel self.sql_model = SiteThemeModel
def update(self, session, data: dict) -> dict:
theme_model = self._query_one(
session=session, match_value=data["name"], match_key="name"
)
theme_model.update(**data)
session.commit()
class Database: class Database:
def __init__(self) -> None: def __init__(self) -> None:

View file

@ -1,4 +1,4 @@
from typing import Union from typing import List, Union
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -11,7 +11,10 @@ class BaseDocument:
self.store: str self.store: str
self.sql_model: SqlAlchemyBase self.sql_model: SqlAlchemyBase
def get_all(self, session: Session, limit: int = None, order_by: str = None): # TODO: Improve Get All Query Functionality
def get_all(
self, session: Session, limit: int = None, order_by: str = None
) -> List[dict]:
list = [x.dict() for x in session.query(self.sql_model).all()] list = [x.dict() for x in session.query(self.sql_model).all()]
if limit == 1: if limit == 1:
@ -21,7 +24,7 @@ class BaseDocument:
def _query_one( def _query_one(
self, session: Session, match_value: str, match_key: str = None self, session: Session, match_value: str, match_key: str = None
) -> Union[Session, SqlAlchemyBase]: ) -> SqlAlchemyBase:
"""Query the sql database for one item an return the sql alchemy model """Query the sql database for one item an return the sql alchemy model
object. If no match key is provided the primary_key attribute will be used. object. If no match key is provided the primary_key attribute will be used.
@ -43,7 +46,7 @@ class BaseDocument:
def get( def get(
self, session: Session, match_value: str, match_key: str = None, limit=1 self, session: Session, match_value: str, match_key: str = None, limit=1
) -> dict or list[dict]: ) -> dict or List[dict]:
"""Retrieves an entry from the database by matching a key/value pair. If no """Retrieves an entry from the database by matching a key/value pair. If no
key is provided the class objects primary key will be used to match against. key is provided the class objects primary key will be used to match against.
@ -67,6 +70,15 @@ class BaseDocument:
return db_entry return db_entry
def save_new(self, session: Session, document: dict) -> dict: def save_new(self, session: Session, document: dict) -> dict:
"""Creates a new database entry for the given SQL Alchemy Model.
Args:
session (Session): A Database Session
document (dict): A python dictionary representing the data structure
Returns:
dict: A dictionary representation of the database entry
"""
new_document = self.sql_model(**document) new_document = self.sql_model(**document)
session.add(new_document) session.add(new_document)
return_data = new_document.dict() return_data = new_document.dict()
@ -74,7 +86,18 @@ class BaseDocument:
return return_data return return_data
def update(self, session: Session, match_value, new_data) -> dict: def update(self, session: Session, match_value: str, new_data: str) -> dict:
"""Update a database entry.
Args:
session (Session): Database Session
match_value (str): Match "key"
new_data (str): Match "value"
Returns:
dict: Returns a dictionary representation of the database entry
"""
entry = self._query_one(session=session, match_value=match_value) entry = self._query_one(session=session, match_value=match_value)
entry.update(session=session, **new_data) entry.update(session=session, **new_data)
return_data = entry.dict() return_data = entry.dict()

View file

@ -12,7 +12,7 @@ class SiteThemeModel(SqlAlchemyBase):
self.name = name self.name = name
self.colors = ThemeColorsModel(**colors) self.colors = ThemeColorsModel(**colors)
def update(self, name, colors: dict) -> dict: def update(self, session=None, name: str = None, colors: dict = None) -> dict:
self.colors.update(**colors) self.colors.update(**colors)
return self.dict() return self.dict()

View file

@ -22,13 +22,13 @@ class BackupOptions(BaseModel):
class BackupJob(BaseModel): class BackupJob(BaseModel):
tag: Optional[str] tag: Optional[str]
options: BackupOptions options: BackupOptions
templates: Optional[List[str]] = [] templates: Optional[List[str]]
class Config: class Config:
schema_extra = { schema_extra = {
"example": { "example": {
"tag": "July 23rd 2021", "tag": "July 23rd 2021",
"options": BackupOptions, "options": BackupOptions(),
"template": ["recipes.md"], "template": ["recipes.md"],
} }
} }

View file

@ -68,7 +68,6 @@ def get_recipe_img(recipe_slug: str):
return FileResponse(recipe_image) return FileResponse(recipe_image)
# Recipe Creations
@router.post( @router.post(
"/api/recipe/create-url/", "/api/recipe/create-url/",
status_code=201, status_code=201,

View file

@ -1,9 +1,8 @@
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from services.scheduler_services import post_webhooks
from services.settings_services import SiteSettings, SiteTheme from services.settings_services import SiteSettings, SiteTheme
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.global_scheduler import scheduler from utils.post_webhooks import post_webhooks
from utils.snackbar import SnackResponse from utils.snackbar import SnackResponse
router = APIRouter(tags=["Settings"]) router = APIRouter(tags=["Settings"])
@ -34,7 +33,6 @@ def update_settings(data: SiteSettings, db: Session = Depends(generate_session))
# status_code=400, detail=SnackResponse.error("Unable to Save Settings") # status_code=400, detail=SnackResponse.error("Unable to Save Settings")
# ) # )
# scheduler.reschedule_webhooks() #! Need to fix Scheduler
return SnackResponse.success("Settings Updated") return SnackResponse.success("Settings Updated")

View file

@ -82,15 +82,6 @@ class Recipe(BaseModel):
slug = calc_slug slug = calc_slug
return slug return slug
@classmethod
def _unpack_doc(cls, document):
document = json.loads(document.to_json())
del document["_id"]
document["dateAdded"] = document["dateAdded"]["$date"]
return cls(**document)
@classmethod @classmethod
def get_by_slug(cls, session, slug: str): def get_by_slug(cls, session, slug: str):
""" Returns a Recipe Object by Slug """ """ Returns a Recipe Object by Slug """
@ -132,8 +123,15 @@ class Recipe(BaseModel):
return updated_slug.get("slug") return updated_slug.get("slug")
@staticmethod @staticmethod
def update_image(slug: str, extension: str): def update_image(slug: str, extension: str) -> str:
db.recipes.update_image(slug, extension) """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(slug, extension)
@staticmethod @staticmethod
def get_all(session: Session): def get_all(session: Session):

View file

@ -0,0 +1,3 @@
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()

View file

@ -0,0 +1,62 @@
from apscheduler.schedulers.background import BackgroundScheduler
from db.db_setup import create_session
from services.backups.exports import auto_backup_job
from services.scheduler.global_scheduler import scheduler
from services.scheduler.scheduler_utils import Cron, cron_parser
from services.settings_services import SiteSettings
from utils.logger import logger
from utils.post_webhooks import post_webhooks
@scheduler.scheduled_job(trigger="interval", minutes=15)
def update_webhook_schedule():
"""
A scheduled background job that runs every 15 minutes to
poll the database for changes and reschedule the webhook time
"""
session = create_session()
settings = SiteSettings.get_site_settings(session=session)
time = cron_parser(settings.webhooks.webhookTime)
job = JOB_STORE.get("webhooks")
scheduler.reschedule_job(
job.scheduled_task.id,
trigger="cron",
hour=time.hours,
minute=time.minutes,
)
session.close()
logger.info(scheduler.print_jobs())
class ScheduledFunction:
def __init__(
self, scheduler: BackgroundScheduler, function, cron: Cron, name: str
) -> None:
self.scheduled_task = scheduler.add_job(
function,
trigger="cron",
name=name,
hour=cron.hours,
minute=cron.minutes,
max_instances=1,
replace_existing=True,
)
logger.info("New Function Scheduled")
logger.info(scheduler.print_jobs())
logger.info("----INIT SCHEDULE OBJECT-----")
JOB_STORE = {
"backup_job": ScheduledFunction(
scheduler, auto_backup_job, Cron(hours=00, minutes=00), "backups"
),
"webhooks": ScheduledFunction(
scheduler, post_webhooks, Cron(hours=00, minutes=00), "webhooks"
),
}
scheduler.start()

View file

@ -0,0 +1,10 @@
import collections
Cron = collections.namedtuple("Cron", "hours minutes")
def cron_parser(time_str: str) -> Cron:
time = time_str.split(":")
cron = Cron(hours=int(time[0]), minutes=int(time[1]))
return cron

View file

@ -1,73 +0,0 @@
import collections
import json
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from db.db_setup import create_session
from utils.logger import logger
from services.backups.exports import auto_backup_job
from services.meal_services import MealPlan
from services.recipe_services import Recipe
from services.settings_services import SiteSettings
Cron = collections.namedtuple("Cron", "hours minutes")
def cron_parser(time_str: str) -> Cron:
time = time_str.split(":")
cron = Cron(hours=int(time[0]), minutes=int(time[1]))
return cron
def post_webhooks():
all_settings = SiteSettings.get_site_settings()
if all_settings.webhooks.enabled:
todays_meal = Recipe.get_by_slug(MealPlan.today()).dict()
urls = all_settings.webhooks.webhookURLs
for url in urls:
requests.post(url, json.dumps(todays_meal, default=str))
class Scheduler:
def startup_scheduler(self):
self.scheduler = BackgroundScheduler()
logger.info("----INIT SCHEDULE OBJECT-----")
self.scheduler.start()
self.scheduler.add_job(
auto_backup_job, trigger="cron", hour="3", max_instances=1
)
settings = SiteSettings.get_site_settings(create_session())
time = cron_parser(settings.webhooks.webhookTime)
self.webhook = self.scheduler.add_job(
post_webhooks,
trigger="cron",
name="webhooks",
hour=time.hours,
minute=time.minutes,
max_instances=1,
)
logger.info(self.scheduler.print_jobs())
def reschedule_webhooks(self):
"""
Reads the site settings database entry to reschedule the webhooks task
Called after each post to the webhooks endpoint.
"""
settings = SiteSettings.get_site_settings()
time = cron_parser(settings.webhooks.webhookTime)
self.scheduler.reschedule_job(
self.webhook.id,
trigger="cron",
hour=time.hours,
minute=time.minutes,
)
logger.info(self.scheduler.print_jobs())

View file

@ -1,7 +1,7 @@
from typing import List, Optional from typing import List, Optional
from db.database import db from db.database import db
from db.db_setup import create_session, generate_session, sql_exists from db.db_setup import create_session, sql_exists
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.logger import logger from utils.logger import logger
@ -103,7 +103,7 @@ class SiteTheme(BaseModel):
db.themes.save_new(session, self.dict()) db.themes.save_new(session, self.dict())
def update_document(self, session: Session): def update_document(self, session: Session):
db.themes.update(session, self.dict()) db.themes.update(session, self.name, self.dict())
@staticmethod @staticmethod
def delete_theme(session: Session, theme_name: str) -> str: def delete_theme(session: Session, theme_name: str) -> str:

View file

@ -1,9 +1,13 @@
from pathlib import Path
from app import app from app import app
from app_config import SQLITE_DIR from app_config import SQLITE_DIR
from db.db_setup import generate_session, sql_global_init from db.db_setup import generate_session, sql_global_init
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pytest import fixture from pytest import fixture
from tests.test_config import TEST_DATA
SQLITE_FILE = SQLITE_DIR.joinpath("test.db") SQLITE_FILE = SQLITE_DIR.joinpath("test.db")
SQLITE_FILE.unlink(missing_ok=True) SQLITE_FILE.unlink(missing_ok=True)
@ -26,3 +30,8 @@ def api_client():
yield TestClient(app) yield TestClient(app)
SQLITE_FILE.unlink() SQLITE_FILE.unlink()
@fixture(scope="session")
def test_image():
return TEST_DATA.joinpath("test_image.jpg")

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -3,25 +3,57 @@ import shutil
import pytest import pytest
from app_config import MIGRATION_DIR from app_config import MIGRATION_DIR
from tests.test_config import TEST_NEXTCLOUD_DIR from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR
#! Broken
# def test_import_chowdown_recipes(api_client):
# response = api_client.post(
# "/api/migration/chowdown/repo/",
# json={"url": "https://github.com/hay-kot/chowdown"},
# )
# assert response.status_code == 200
# test_slug = "banana-bread"
# response = api_client.get(f"/api/recipe/{test_slug}/")
# assert response.status_code == 200
# recipe = json.loads(response.content)
# assert recipe["slug"] == test_slug
### Chowdown
@pytest.fixture(scope="session")
def chowdown_zip():
zip = TEST_CHOWDOWN_DIR.joinpath("test_chowdown-gh-pages.zip")
zip_copy = TEST_CHOWDOWN_DIR.joinpath("chowdown-gh-pages.zip")
shutil.copy(zip, zip_copy)
yield zip_copy
zip_copy.unlink()
def test_upload_chowdown_zip(api_client, chowdown_zip):
response = api_client.post(
"/api/migrations/chowdown/upload/", files={"archive": chowdown_zip.open("rb")}
)
assert response.status_code == 200
assert MIGRATION_DIR.joinpath("chowdown", chowdown_zip.name).is_file()
def test_import_chowdown_directory(api_client, chowdown_zip):
selection = chowdown_zip.name
response = api_client.post(f"/api/migrations/chowdown/{selection}/import/")
assert response.status_code == 200
report = json.loads(response.content)
assert report["failed"] == []
expected_slug = "roasted-okra"
response = api_client.get(f"/api/recipe/{expected_slug}/")
assert response.status_code == 200
def test_delete_chowdown_migration_data(api_client, chowdown_zip):
selection = chowdown_zip.name
response = api_client.delete(f"/api/migrations/chowdown/{selection}/delete/")
assert response.status_code == 200
assert not MIGRATION_DIR.joinpath(chowdown_zip.name).is_file()
### Nextcloud
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def nextcloud_zip(): def nextcloud_zip():
zip = TEST_NEXTCLOUD_DIR.joinpath("nextcloud.zip") zip = TEST_NEXTCLOUD_DIR.joinpath("nextcloud.zip")
@ -30,7 +62,9 @@ def nextcloud_zip():
shutil.copy(zip, zip_copy) shutil.copy(zip, zip_copy)
return zip_copy yield zip_copy
zip_copy.unlink()
def test_upload_nextcloud_zip(api_client, nextcloud_zip): def test_upload_nextcloud_zip(api_client, nextcloud_zip):
@ -58,7 +92,7 @@ def test_import_nextcloud_directory(api_client, nextcloud_zip):
assert response.status_code == 200 assert response.status_code == 200
def test_delete_migration_data(api_client, nextcloud_zip): def test_delete__nextcloud_migration_data(api_client, nextcloud_zip):
selection = nextcloud_zip.name selection = nextcloud_zip.name
response = api_client.delete(f"/api/migrations/nextcloud/{selection}/delete/") response = api_client.delete(f"/api/migrations/nextcloud/{selection}/delete/")

View file

@ -2,9 +2,12 @@ import json
import pytest import pytest
from slugify import slugify from slugify import slugify
from tests.test_routes.utils.routes_data import (RecipeTestData, from tests.test_routes.utils.routes_data import (
raw_recipe_dict, RecipeTestData,
recipe_test_data) raw_recipe,
raw_recipe_no_image,
recipe_test_data,
)
@pytest.mark.parametrize("recipe_data", recipe_test_data) @pytest.mark.parametrize("recipe_data", recipe_test_data)
@ -15,12 +18,31 @@ def test_create_by_url(api_client, recipe_data: RecipeTestData):
def test_create_by_json(api_client): def test_create_by_json(api_client):
response = api_client.post("/api/recipe/create/", json=raw_recipe_dict) response = api_client.post("/api/recipe/create/", json=raw_recipe)
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.text) == "banana-bread" assert json.loads(response.text) == "banana-bread"
def test_create_no_image(api_client):
response = api_client.post("/api/recipe/create/", json=raw_recipe_no_image)
assert response.status_code == 200
assert json.loads(response.text) == "banana-bread-no-image"
# def test_upload_image(api_client, test_image):
# data = {"image": test_image.open("rb").read(), "extension": "jpg"}
# response = api_client.post(
# "/api/recipe/banana-bread-no-image/update/image/", files=data
# )
# assert response.status_code == 200
# response = api_client.get("/api/recipe/banana-bread-no-image/update/image/")
def test_read_all_post(api_client): def test_read_all_post(api_client):
response = api_client.post( response = api_client.post(
"/api/all-recipes/", json={"properties": ["slug", "description", "rating"]} "/api/all-recipes/", json={"properties": ["slug", "description", "rating"]}

View file

@ -16,7 +16,7 @@ recipe_test_data = [
] ]
raw_recipe_dict = { raw_recipe = {
"name": "Banana Bread", "name": "Banana Bread",
"description": "From Angie's mom", "description": "From Angie's mom",
"image": "banana-bread.jpg", "image": "banana-bread.jpg",
@ -62,3 +62,50 @@ raw_recipe_dict = {
"orgURL": None, "orgURL": None,
"extras": {}, "extras": {},
} }
raw_recipe_no_image = {
"name": "Banana Bread No Image",
"description": "From Angie's mom",
"image": "",
"recipeYield": "",
"recipeIngredient": [
"4 bananas",
"1/2 cup butter",
"1/2 cup sugar",
"2 eggs",
"2 cups flour",
"1/2 tsp baking soda",
"1 tsp baking powder",
"pinch salt",
"1/4 cup nuts (we like pecans)",
],
"recipeInstructions": [
{
"@type": "Beat the eggs, then cream with the butter and sugar",
"text": "Beat the eggs, then cream with the butter and sugar",
},
{
"@type": "Mix in bananas, then flour, baking soda/powder, salt, and nuts",
"text": "Mix in bananas, then flour, baking soda/powder, salt, and nuts",
},
{
"@type": "Add to greased and floured pan",
"text": "Add to greased and floured pan",
},
{
"@type": "Bake until brown/cracked, toothpick comes out clean",
"text": "Bake until brown/cracked, toothpick comes out clean",
},
],
"totalTime": "None",
"prepTime": None,
"performTime": None,
"slug": "",
"categories": [],
"tags": ["breakfast", " baking"],
"dateAdded": "2021-01-12",
"notes": [],
"rating": 0,
"orgURL": None,
"extras": {},
}

View file

@ -0,0 +1 @@
test_

View file

@ -1,11 +0,0 @@
from services.scheduler_services import Scheduler
def start_scheduler():
global scheduler
scheduler = Scheduler()
scheduler.startup_scheduler()
return scheduler
scheduler = start_scheduler

View file

@ -0,0 +1,21 @@
import json
import requests
from db.db_setup import create_session
from services.meal_services import MealPlan
from services.recipe_services import Recipe
from services.settings_services import SiteSettings
def post_webhooks():
session = create_session()
all_settings = SiteSettings.get_site_settings(session)
if all_settings.webhooks.enabled:
todays_meal = Recipe.get_by_slug(MealPlan.today()).dict()
urls = all_settings.webhooks.webhookURLs
for url in urls:
requests.post(url, json.dumps(todays_meal, default=str))
session.close()

View file

@ -1,17 +0,0 @@
from pathlib import Path
from app_config import REQUIRED_DIRS
from services.settings_services import default_theme_init
CWD = Path(__file__).parent
def post_start():
default_theme_init()
if __name__ == "__main__":
pass

View file

@ -6,6 +6,7 @@ from app_config import TEMP_DIR
def unpack_zip(selection: Path) -> tempfile.TemporaryDirectory: def unpack_zip(selection: Path) -> tempfile.TemporaryDirectory:
TEMP_DIR.mkdir(parents=True, exist_ok=True)
temp_dir = tempfile.TemporaryDirectory(dir=TEMP_DIR) temp_dir = tempfile.TemporaryDirectory(dir=TEMP_DIR)
temp_dir_path = Path(temp_dir.name) temp_dir_path = Path(temp_dir.name)
if selection.suffix == ".zip": if selection.suffix == ".zip":