diff --git a/Dockerfile b/Dockerfile index bf8761a4b..6d833ce5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN npm run build 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 EXPOSE 80 WORKDIR /app @@ -15,6 +15,7 @@ WORKDIR /app COPY ./pyproject.toml /app/ RUN apk add --update --no-cache --virtual .build-deps \ + curl \ g++ \ py-lxml \ python3-dev \ @@ -34,35 +35,5 @@ COPY --from=build-stage /app/dist /app/dist RUN rm -rf /app/test /app/.temp - -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" ] +VOLUME [ "/app_data/" ] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "80"] \ No newline at end of file diff --git a/dev/dev-notes.md b/dev/dev-notes.md index 2062d6be5..81433795e 100644 --- a/dev/dev-notes.md +++ b/dev/dev-notes.md @@ -16,6 +16,11 @@ Don't forget to [join the Discord](https://discord.gg/R6QDyJgbD2)! # Todo's +Test +- [ ] Image Upload Test +- [ ] Rename and Upload Image Test +- [x] Chowdown Migration End Point Test + Frontend - [ ] No Meal Today Page instead of Null - [ ] Recipe Print Page diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7061fce74..7f7e1ce4e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1966,6 +1966,16 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "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": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz", @@ -1992,6 +2002,53 @@ "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": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2008,6 +2065,16 @@ "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": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz", @@ -2024,6 +2091,18 @@ "terser": "^4.6.12", "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" } }, + "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": { "version": "2.0.1", "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": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.9.tgz", @@ -11641,6 +11644,14 @@ "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", "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": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4deceb724..9d5aa8837 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "vue": "^2.6.11", "vue-i18n": "^8.22.4", "vue-router": "^3.4.9", + "vuedraggable": "^2.24.3", "vuetify": "^2.4.2", "vuex": "^3.6.0", "vuex-persistedstate": "^4.0.0-beta.3" @@ -61,4 +62,4 @@ "semi": true, "singleQuote": false } -} \ No newline at end of file +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 630fa1cce..8c043cbb6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -17,7 +17,7 @@ @selected="navigateFromSearch" /> - + mdi-magnify @@ -34,11 +34,11 @@ \ No newline at end of file diff --git a/frontend/src/components/Settings/General/HomePageSettings.vue b/frontend/src/components/Settings/General/HomePageSettings.vue new file mode 100644 index 000000000..8eb4af3bf --- /dev/null +++ b/frontend/src/components/Settings/General/HomePageSettings.vue @@ -0,0 +1,158 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/Settings/General/index.vue b/frontend/src/components/Settings/General/index.vue index 3e3ac3cef..e9261da55 100644 --- a/frontend/src/components/Settings/General/index.vue +++ b/frontend/src/components/Settings/General/index.vue @@ -1,8 +1,20 @@ - - \ No newline at end of file diff --git a/frontend/src/components/Settings/Migration/NextcloudCard.vue b/frontend/src/components/Settings/Migration/NextcloudCard.vue deleted file mode 100644 index 83230d97a..000000000 --- a/frontend/src/components/Settings/Migration/NextcloudCard.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/components/Settings/Migration/UploadMigrationButton.vue b/frontend/src/components/Settings/Migration/UploadMigrationButton.vue deleted file mode 100644 index b34ff6b27..000000000 --- a/frontend/src/components/Settings/Migration/UploadMigrationButton.vue +++ /dev/null @@ -1,49 +0,0 @@ -c - - - - \ No newline at end of file diff --git a/frontend/src/components/UI/CardSection.vue b/frontend/src/components/UI/CardSection.vue new file mode 100644 index 000000000..08614f03d --- /dev/null +++ b/frontend/src/components/UI/CardSection.vue @@ -0,0 +1,81 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/UI/RecentRecipes.vue b/frontend/src/components/UI/RecentRecipes.vue deleted file mode 100644 index 684d482bd..000000000 --- a/frontend/src/components/UI/RecentRecipes.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/frontend/src/components/UI/Search.vue b/frontend/src/components/UI/Search.vue deleted file mode 100644 index ed2cd218b..000000000 --- a/frontend/src/components/UI/Search.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index db14c5ee5..9a9589e7f 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -1,15 +1,66 @@ diff --git a/frontend/src/store/modules/recipes.js b/frontend/src/store/modules/recipes.js new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 2c0448fad..b0fd2d30f 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -18,6 +18,13 @@ const store = new Vuex.Store({ language, }, state: { + // Home Page Settings + homePageSettings: { + showRecent: true, + showLimit: 9, + categories: [], + homeCategories: [], + }, // Snackbar snackActive: false, snackText: "", @@ -29,6 +36,9 @@ const store = new Vuex.Store({ }, mutations: { + setHomePageSettings(state, payload) { + state.homePageSettings = payload; + }, setSnackBar(state, payload) { state.snackText = payload.text; state.snackType = payload.type; @@ -57,6 +67,16 @@ const store = new Vuex.Store({ this.commit("setRecentRecipes", payload); }, + + async requestHomePageSettings() { + // TODO: Query Backend for Categories + this.commit("setHomePageSettings", { + showRecent: true, + showLimit: 9, + categories: ["breakfast", "lunch", "dinner"], + homeCategories: [], + }); + }, }, getters: { @@ -65,7 +85,8 @@ const store = new Vuex.Store({ getSnackActive: state => state.snackActive, getSnackType: state => state.snackType, - getRecentRecipes: state => state.recentRecipes, + getRecentRecipes: (state) => state.recentRecipes, + getHomePageSettings: (state) => state.homePageSettings, }, }); diff --git a/mealie/app.py b/mealie/app.py index 5444c15cd..6cc299602 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles # 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 ( backup_routes, meal_routes, @@ -14,7 +14,7 @@ from routes import ( user_routes, ) -# from utils.api_docs import generate_api_docs +from utils.api_docs import generate_api_docs from utils.logger import logger app = FastAPI( @@ -26,13 +26,13 @@ app = FastAPI( ) - def mount_static_files(): app.mount("/static", StaticFiles(directory=WEB_PATH, html=True)) def api_routers(): # First + print() app.include_router(recipe_routes.router) app.include_router(meal_routes.router) app.include_router(setting_routes.router) @@ -46,6 +46,11 @@ if PRODUCTION: api_routers() + +def start_scheduler(): + import services.scheduler.scheduled_jobs + + # API 404 Catch all CALL AFTER ROUTERS @app.get("/api/{full_path:path}", status_code=404, include_in_schema=False) def invalid_api(): @@ -56,8 +61,10 @@ app.include_router(static_routes.router) # Generate API Documentation -# if not PRODUCTION: -# generate_api_docs(app) +if not PRODUCTION: + generate_api_docs(app) + +start_scheduler() if __name__ == "__main__": logger.info("-----SYSTEM STARTUP-----") diff --git a/mealie/db/database.py b/mealie/db/database.py index c8ecef7c5..1085100e9 100644 --- a/mealie/db/database.py +++ b/mealie/db/database.py @@ -1,3 +1,5 @@ +from sqlalchemy.orm.session import Session + from db.db_base import BaseDocument from db.sql.meal_models import MealPlanModel from db.sql.recipe_models import RecipeModel @@ -16,8 +18,12 @@ class _Recipes(BaseDocument): self.primary_key = "slug" self.sql_model = RecipeModel - def update_image(self, slug: str, extension: str) -> None: - pass + def update_image(self, session: Session, slug: str, extension: str) -> str: + entry = self._query_one(session, match_value=slug) + entry.image = f"{slug}.{extension}" + session.commit() + + return f"{slug}.{extension}" class _Meals(BaseDocument): @@ -31,7 +37,7 @@ class _Settings(BaseDocument): self.primary_key = "name" 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) session.add(new_settings) @@ -45,14 +51,6 @@ class _Themes(BaseDocument): self.primary_key = "name" 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: def __init__(self) -> None: diff --git a/mealie/db/db_base.py b/mealie/db/db_base.py index 7318de7ad..a7354bbbc 100644 --- a/mealie/db/db_base.py +++ b/mealie/db/db_base.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import List, Union from sqlalchemy.orm.session import Session @@ -11,7 +11,10 @@ class BaseDocument: self.store: str 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()] if limit == 1: @@ -21,7 +24,7 @@ class BaseDocument: def _query_one( 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 object. If no match key is provided the primary_key attribute will be used. @@ -43,7 +46,7 @@ class BaseDocument: def get( 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 key is provided the class objects primary key will be used to match against. @@ -67,6 +70,15 @@ class BaseDocument: return db_entry 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) session.add(new_document) return_data = new_document.dict() @@ -74,7 +86,18 @@ class BaseDocument: 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.update(session=session, **new_data) return_data = entry.dict() diff --git a/mealie/db/sql/theme_models.py b/mealie/db/sql/theme_models.py index 90c8f0ce6..7b12e34f0 100644 --- a/mealie/db/sql/theme_models.py +++ b/mealie/db/sql/theme_models.py @@ -12,7 +12,7 @@ class SiteThemeModel(SqlAlchemyBase): self.name = name 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) return self.dict() diff --git a/mealie/models/backup_models.py b/mealie/models/backup_models.py index 21bf661b6..6e8893078 100644 --- a/mealie/models/backup_models.py +++ b/mealie/models/backup_models.py @@ -22,13 +22,13 @@ class BackupOptions(BaseModel): class BackupJob(BaseModel): tag: Optional[str] options: BackupOptions - templates: Optional[List[str]] = [] + templates: Optional[List[str]] class Config: schema_extra = { "example": { "tag": "July 23rd 2021", - "options": BackupOptions, + "options": BackupOptions(), "template": ["recipes.md"], } } diff --git a/mealie/routes/recipe_routes.py b/mealie/routes/recipe_routes.py index de6ed4434..9f0df9582 100644 --- a/mealie/routes/recipe_routes.py +++ b/mealie/routes/recipe_routes.py @@ -68,7 +68,6 @@ def get_recipe_img(recipe_slug: str): return FileResponse(recipe_image) -# Recipe Creations @router.post( "/api/recipe/create-url/", status_code=201, diff --git a/mealie/routes/setting_routes.py b/mealie/routes/setting_routes.py index 0763bc74a..0c3249787 100644 --- a/mealie/routes/setting_routes.py +++ b/mealie/routes/setting_routes.py @@ -1,9 +1,8 @@ from db.db_setup import generate_session from fastapi import APIRouter, Depends, HTTPException -from services.scheduler_services import post_webhooks from services.settings_services import SiteSettings, SiteTheme from sqlalchemy.orm.session import Session -from utils.global_scheduler import scheduler +from utils.post_webhooks import post_webhooks from utils.snackbar import SnackResponse 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") # ) - # scheduler.reschedule_webhooks() #! Need to fix Scheduler return SnackResponse.success("Settings Updated") diff --git a/mealie/services/recipe_services.py b/mealie/services/recipe_services.py index 939e2d3ca..0dbbaca0c 100644 --- a/mealie/services/recipe_services.py +++ b/mealie/services/recipe_services.py @@ -82,15 +82,6 @@ class Recipe(BaseModel): slug = calc_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 def get_by_slug(cls, session, slug: str): """ Returns a Recipe Object by Slug """ @@ -132,8 +123,15 @@ class Recipe(BaseModel): return updated_slug.get("slug") @staticmethod - def update_image(slug: str, extension: str): - db.recipes.update_image(slug, extension) + def update_image(slug: str, extension: str) -> 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(slug, extension) @staticmethod def get_all(session: Session): diff --git a/mealie/services/scheduler/global_scheduler.py b/mealie/services/scheduler/global_scheduler.py new file mode 100644 index 000000000..cffbf8e7a --- /dev/null +++ b/mealie/services/scheduler/global_scheduler.py @@ -0,0 +1,3 @@ +from apscheduler.schedulers.background import BackgroundScheduler + +scheduler = BackgroundScheduler() \ No newline at end of file diff --git a/mealie/services/scheduler/scheduled_jobs.py b/mealie/services/scheduler/scheduled_jobs.py new file mode 100644 index 000000000..b273a8446 --- /dev/null +++ b/mealie/services/scheduler/scheduled_jobs.py @@ -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() diff --git a/mealie/services/scheduler/scheduler_utils.py b/mealie/services/scheduler/scheduler_utils.py new file mode 100644 index 000000000..eb6240f4a --- /dev/null +++ b/mealie/services/scheduler/scheduler_utils.py @@ -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 diff --git a/mealie/services/scheduler_services.py b/mealie/services/scheduler_services.py deleted file mode 100644 index 96aa49d42..000000000 --- a/mealie/services/scheduler_services.py +++ /dev/null @@ -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()) diff --git a/mealie/services/settings_services.py b/mealie/services/settings_services.py index f562b63cc..3e3c8398e 100644 --- a/mealie/services/settings_services.py +++ b/mealie/services/settings_services.py @@ -1,7 +1,7 @@ from typing import List, Optional 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 sqlalchemy.orm.session import Session from utils.logger import logger @@ -103,7 +103,7 @@ class SiteTheme(BaseModel): db.themes.save_new(session, self.dict()) def update_document(self, session: Session): - db.themes.update(session, self.dict()) + db.themes.update(session, self.name, self.dict()) @staticmethod def delete_theme(session: Session, theme_name: str) -> str: diff --git a/mealie/tests/conftest.py b/mealie/tests/conftest.py index 550557742..2ec4a9e3b 100644 --- a/mealie/tests/conftest.py +++ b/mealie/tests/conftest.py @@ -1,9 +1,13 @@ +from pathlib import Path + from app import app from app_config import SQLITE_DIR from db.db_setup import generate_session, sql_global_init from fastapi.testclient import TestClient from pytest import fixture +from tests.test_config import TEST_DATA + SQLITE_FILE = SQLITE_DIR.joinpath("test.db") SQLITE_FILE.unlink(missing_ok=True) @@ -26,3 +30,8 @@ def api_client(): yield TestClient(app) SQLITE_FILE.unlink() + + +@fixture(scope="session") +def test_image(): + return TEST_DATA.joinpath("test_image.jpg") diff --git a/mealie/tests/data/migrations/chowdown/test_chowdown-gh-pages.zip b/mealie/tests/data/migrations/chowdown/test_chowdown-gh-pages.zip new file mode 100644 index 000000000..bd90e08d4 Binary files /dev/null and b/mealie/tests/data/migrations/chowdown/test_chowdown-gh-pages.zip differ diff --git a/mealie/tests/data/migrations/nextcloud/new_nextcloud.zip b/mealie/tests/data/migrations/nextcloud/new_nextcloud.zip deleted file mode 100644 index a420370ff..000000000 Binary files a/mealie/tests/data/migrations/nextcloud/new_nextcloud.zip and /dev/null differ diff --git a/mealie/tests/data/test_image.jpg b/mealie/tests/data/test_image.jpg new file mode 100644 index 000000000..11a2cd49e Binary files /dev/null and b/mealie/tests/data/test_image.jpg differ diff --git a/mealie/tests/test_routes/test_migration_routes.py b/mealie/tests/test_routes/test_migration_routes.py index e74950e82..8706a02c1 100644 --- a/mealie/tests/test_routes/test_migration_routes.py +++ b/mealie/tests/test_routes/test_migration_routes.py @@ -3,25 +3,57 @@ import shutil import pytest from app_config import MIGRATION_DIR -from tests.test_config import 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 +from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR +### 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") def nextcloud_zip(): zip = TEST_NEXTCLOUD_DIR.joinpath("nextcloud.zip") @@ -30,7 +62,9 @@ def nextcloud_zip(): shutil.copy(zip, zip_copy) - return zip_copy + yield zip_copy + + zip_copy.unlink() 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 -def test_delete_migration_data(api_client, nextcloud_zip): +def test_delete__nextcloud_migration_data(api_client, nextcloud_zip): selection = nextcloud_zip.name response = api_client.delete(f"/api/migrations/nextcloud/{selection}/delete/") diff --git a/mealie/tests/test_routes/test_recipe_routes.py b/mealie/tests/test_routes/test_recipe_routes.py index 0946c0424..1543df416 100644 --- a/mealie/tests/test_routes/test_recipe_routes.py +++ b/mealie/tests/test_routes/test_recipe_routes.py @@ -2,9 +2,12 @@ import json import pytest from slugify import slugify -from tests.test_routes.utils.routes_data import (RecipeTestData, - raw_recipe_dict, - recipe_test_data) +from tests.test_routes.utils.routes_data import ( + RecipeTestData, + raw_recipe, + raw_recipe_no_image, + 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): - 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 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): response = api_client.post( "/api/all-recipes/", json={"properties": ["slug", "description", "rating"]} diff --git a/mealie/tests/test_routes/utils/routes_data.py b/mealie/tests/test_routes/utils/routes_data.py index f7ca2852d..ca580ffaa 100644 --- a/mealie/tests/test_routes/utils/routes_data.py +++ b/mealie/tests/test_routes/utils/routes_data.py @@ -16,7 +16,7 @@ recipe_test_data = [ ] -raw_recipe_dict = { +raw_recipe = { "name": "Banana Bread", "description": "From Angie's mom", "image": "banana-bread.jpg", @@ -62,3 +62,50 @@ raw_recipe_dict = { "orgURL": None, "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": {}, +} diff --git a/mealie/tests/utils.py b/mealie/tests/utils.py index e69de29bb..6e02cf326 100644 --- a/mealie/tests/utils.py +++ b/mealie/tests/utils.py @@ -0,0 +1 @@ +test_ \ No newline at end of file diff --git a/mealie/utils/global_scheduler.py b/mealie/utils/global_scheduler.py deleted file mode 100644 index 9b30ebb2e..000000000 --- a/mealie/utils/global_scheduler.py +++ /dev/null @@ -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 diff --git a/mealie/utils/post_webhooks.py b/mealie/utils/post_webhooks.py new file mode 100644 index 000000000..56bbedc33 --- /dev/null +++ b/mealie/utils/post_webhooks.py @@ -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() diff --git a/mealie/utils/startup.py b/mealie/utils/startup.py deleted file mode 100644 index 04be244e3..000000000 --- a/mealie/utils/startup.py +++ /dev/null @@ -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 diff --git a/mealie/utils/unzip.py b/mealie/utils/unzip.py index 24c626845..b8e53767b 100644 --- a/mealie/utils/unzip.py +++ b/mealie/utils/unzip.py @@ -6,6 +6,7 @@ from app_config import TEMP_DIR def unpack_zip(selection: Path) -> tempfile.TemporaryDirectory: + TEMP_DIR.mkdir(parents=True, exist_ok=True) temp_dir = tempfile.TemporaryDirectory(dir=TEMP_DIR) temp_dir_path = Path(temp_dir.name) if selection.suffix == ".zip":