Repository: heldplayer/komidabot-docker Branch: master Commit: f27efe79aa55 Files: 198 Total size: 1.4 MB Directory structure: gitextract_ikrdaoko/ ├── .dockerignore ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── codeql-analysis.yml │ └── tests.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── app.py ├── breaking-responses/ │ ├── cde-2020-10-26.json │ └── cmu-2020-09-25.json ├── commands.txt ├── config.py ├── database/ │ ├── .dockerignore │ ├── Dockerfile │ └── create.sql ├── docker-compose.yml ├── entrypoint.sh ├── extensions.py ├── komidabot/ │ ├── api_utils.py │ ├── app.py │ ├── blueprint.py │ ├── blueprint_api.py │ ├── blueprint_authentication.py │ ├── bot.py │ ├── config.py │ ├── debug/ │ │ ├── administration.py │ │ └── state.py │ ├── external_menu.py │ ├── facebook/ │ │ ├── api_interface.py │ │ ├── constants.py │ │ ├── messages.py │ │ ├── nlp_dates.py │ │ ├── postbacks.py │ │ ├── triggers.py │ │ └── users.py │ ├── features.py │ ├── komidabot.py │ ├── localisation.py │ ├── menu.py │ ├── messages.py │ ├── models.py │ ├── models_training.py │ ├── models_users.py │ ├── rate_limit.py │ ├── subscriptions/ │ │ ├── __init__.py │ │ └── daily_menu.py │ ├── translation.py │ ├── triggers.py │ ├── users.py │ ├── util.py │ └── web/ │ ├── constants.py │ ├── messages.py │ └── users.py ├── learning-data/ │ ├── .gitignore │ └── .gitkeep ├── manage.py ├── manual_menu_scraper.py ├── migrations/ │ ├── README │ ├── alembic.ini │ ├── env.py │ ├── script.py.mako │ └── versions/ │ ├── 1a2e04608ee9_.py │ ├── 1dafd2bf730a_.py │ ├── 276ad61a41a5_.py │ ├── 2887dcc37788_.py │ ├── 3806b46f7f00_.py │ ├── 4fafafd2400f_.py │ ├── 528821121657_.py │ ├── 55696107a6b9_.py │ ├── 5cd86de4dffe_.py │ ├── 5ee455656a96_.py │ ├── 7751a57b029e_.py │ ├── 79e0c9de90f0_.py │ ├── 85b659320f83_.py │ ├── 92e4e9f8ff64_.py │ ├── 93b9de63cd7b_.py │ ├── 9b9afdcf4e4e_.py │ ├── a223b578f7b0_.py │ ├── aa31c90dc353_.py │ ├── b384f281e755_.py │ ├── bc1ef0083bb4_.py │ ├── bd04cd56036f_.py │ ├── d225cbda8c77_.py │ ├── daf22dcadb8d_.py │ ├── ddf5bd871988_.py │ ├── e18b14ed6b98_.py │ ├── ea6e1f581a7b_.py │ ├── ecce0e669d8c_.py │ ├── eda0c928c279_.py │ ├── ee24af8d3121_.py │ ├── fe4aca6853a2_.py │ └── fe7bda58c5a4_.py ├── requirements.txt ├── schemas/ │ ├── DELETE_api_subscribe.json │ ├── GET_api_authorized.response.json │ ├── GET_api_learning.response.json │ ├── POST_api_learning.json │ ├── POST_api_login.json │ ├── POST_api_subscribe.json │ ├── POST_api_trigger.json │ ├── PUT_api_subscribe.json │ ├── api_response_base.json │ └── api_response_strict.json ├── tests/ │ ├── __init__.py │ ├── base.py │ ├── external_menus/ │ │ ├── .gitignore │ │ ├── 2019-11-25_cde.parsed.expected.yaml │ │ ├── 2019-11-25_cde.processed.expected.yaml │ │ ├── 2019-11-25_cde.raw.json │ │ ├── 2019-11-25_cmi.parsed.expected.yaml │ │ ├── 2019-11-25_cmi.raw.json │ │ ├── 2019-11-25_cmu.parsed.expected.yaml │ │ ├── 2019-11-25_cmu.raw.json │ │ ├── 2019-11-25_cst.parsed.expected.yaml │ │ ├── 2019-11-25_cst.raw.json │ │ ├── 2019-11-25_hzs.parsed.expected.yaml │ │ ├── 2019-11-25_hzs.raw.json │ │ ├── 2019-12-12_cde.parsed.expected.yaml │ │ ├── 2019-12-12_cde.processed.expected.yaml │ │ ├── 2019-12-12_cde.raw.json │ │ ├── 2019-12-12_cgb.raw.json │ │ ├── 2019-12-12_cmi.raw.json │ │ ├── 2019-12-12_cmu.raw.json │ │ ├── 2019-12-12_cst.raw.json │ │ ├── 2019-12-12_hzs.raw.json │ │ ├── 2019-12-19_cde.parsed.expected.yaml │ │ ├── 2019-12-19_cde.processed.expected.yaml │ │ ├── 2019-12-19_cde.raw.json │ │ ├── 2019-12-19_cgb.raw.json │ │ ├── 2019-12-19_cmi.raw.json │ │ ├── 2019-12-19_cmu.raw.json │ │ ├── 2019-12-19_cst.raw.json │ │ ├── 2019-12-19_hzs.raw.json │ │ ├── 2020-02-10_cde.raw.json │ │ ├── 2020-02-10_cgb.raw.json │ │ ├── 2020-02-10_cmi.raw.json │ │ ├── 2020-02-10_cmu.parsed.expected.yaml │ │ ├── 2020-02-10_cmu.raw.json │ │ ├── 2020-02-10_cst.raw.json │ │ ├── 2020-02-10_hzs.raw.json │ │ ├── 2020-02-13_cde.raw.json │ │ ├── 2020-02-13_cgb.raw.json │ │ ├── 2020-02-13_cmi.raw.json │ │ ├── 2020-02-13_cmu.raw.json │ │ ├── 2020-02-13_cst.raw.json │ │ ├── 2020-02-13_hzs.raw.json │ │ ├── 2020-03-12_cde.raw.json │ │ ├── 2020-03-12_cgb.raw.json │ │ ├── 2020-03-12_cmi.parsed.expected.yaml │ │ ├── 2020-03-12_cmi.raw.json │ │ ├── 2020-03-12_cmu.raw.json │ │ ├── 2020-03-12_cst.raw.json │ │ ├── 2020-03-12_hzs.raw.json │ │ ├── 2020-03-16_cde.raw.json │ │ ├── 2020-03-16_cgb.raw.json │ │ ├── 2020-03-16_cmi.raw.json │ │ ├── 2020-03-16_cmu.raw.json │ │ ├── 2020-03-16_cst.raw.json │ │ ├── 2020-03-16_hzs.raw.json │ │ ├── 2020-09-25_cde.raw.json │ │ ├── 2020-09-25_cgb.raw.json │ │ ├── 2020-09-25_cmi.raw.json │ │ ├── 2020-09-25_cmu.raw.json │ │ ├── 2020-09-25_cst.raw.json │ │ ├── 2020-09-25_hzs.raw.json │ │ ├── 2020-09-28_cde.raw.json │ │ ├── 2020-09-28_cgb.raw.json │ │ ├── 2020-09-28_cmi.raw.json │ │ ├── 2020-09-28_cmu.raw.json │ │ ├── 2020-09-28_cst.parsed.expected.yaml │ │ ├── 2020-09-28_cst.processed.expected.yaml │ │ ├── 2020-09-28_cst.raw.json │ │ ├── 2020-09-28_hzs.raw.json │ │ ├── 2020-10-26_cde.raw.json │ │ ├── 2020-10-26_cgb.raw.json │ │ ├── 2020-10-26_cmi.raw.json │ │ ├── 2020-10-26_cst.raw.json │ │ ├── 2020-10-26_hzs.raw.json │ │ ├── download_external_jsons.py │ │ ├── parsed.schema.json │ │ ├── processed.schema.json │ │ └── raw.schema.json │ ├── test_debug_state.py │ ├── test_external_menu.py │ ├── test_models_campus.py │ ├── test_models_closing_days.py │ ├── test_models_menu.py │ ├── test_models_menu_item.py │ ├── test_models_registered_user.py │ ├── test_models_translations.py │ ├── test_subscriptions.py │ ├── test_test_utils.py │ ├── test_triggers.py │ ├── test_users_base.py │ ├── users_stub.py │ └── utils.py └── wait-postgres.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .dockerignore .git .gitignore .idea venv __pycache__ learning-data config-*.env docker-compose.yml Dockerfile ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" assignees: - "heldplayer" ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] # schedule: # - cron: '0 17 * * 3' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: [ master ] pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Prepare run: touch config-prod.env config-dev.env - name: Build the Docker image run: | docker network prune -f docker-compose build komidabot-dev komidabot-db - name: Start supporting services run: docker-compose up -d komidabot-db - name: Run the tests run: docker-compose run --rm komidabot-dev python -W default manage.py test - name: Cleanup run: docker-compose stop ================================================ FILE: .gitignore ================================================ *.env dump*.txt *.pem .idea/ __pycache__/ venv/ out.png page.xml scratches/ ================================================ FILE: Dockerfile ================================================ # base image FROM python:3.10-slim ENV TZ=Europe/Brussels # install dependencies RUN set -eu ; \ apt-get -qq update ; \ apt-get -y -qq upgrade ; \ apt-get -y -qq install netcat-openbsd bash ; \ apt-get -y -qq install gcc build-essential ; \ # apt-get -y -qq install postgresql-dev ; \ apt-get -y -qq install libxml2 libxml2-dev libxslt1.1 libxslt1-dev libjpeg-dev poppler-utils ; \ apt-get -y -qq install locales-all # set working directory WORKDIR /usr/src/app # add and install requirements COPY ./requirements.txt /usr/src/app/requirements.txt RUN pip install --upgrade pip RUN pip install --no-cache-dir -r requirements.txt # get some space back #RUN apk del build-deps RUN set -eu ; \ apt-get -y -qq autoremove gcc build-essential ; \ apt-get clean # add entrypoint.sh COPY ./entrypoint.sh /usr/src/app/entrypoint.sh RUN chmod +x /usr/src/app/entrypoint.sh # add app COPY . /usr/src/app # run server ENTRYPOINT ["/bin/bash", "/usr/src/app/entrypoint.sh"] ================================================ FILE: Makefile ================================================ .PHONY: test run-prod run-dev stop test: docker-compose build komidabot-dev && \ docker-compose run --rm komidabot-dev python -W default manage.py test run-prod: docker-compose up --build komidabot-prod run-dev: docker-compose up --build komidabot-dev stop: docker-compose stop ================================================ FILE: README.md ================================================ # komidabot-docker ![CodeQL](https://github.com/heldplayer/komidabot-docker/workflows/CodeQL/badge.svg) ![Tests](https://github.com/heldplayer/komidabot-docker/workflows/Tests/badge.svg) ================================================ FILE: app.py ================================================ import locale import logging import os from flask import Flask from flask.cli import ScriptInfo from werkzeug.middleware.proxy_fix import ProxyFix from extensions import db, login, migrate, session from komidabot.app import App as KomidabotApp from komidabot.features import update_active_features def create_app(*, app_settings: str = None): locale.setlocale(locale.LC_MONETARY, 'nl_BE.utf8') # instantiate the app app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app) # set config if app_settings is None: app_settings = os.getenv('APP_SETTINGS') app.config.from_object(app_settings) # print("The script config is", script_info, flush=True) # print(" - Data: ", script_info.data, flush=True) # print("The database URI is", app.config.get('SQLALCHEMY_DATABASE_URI'), flush=True) # set up extensions session.init_app(app) db.init_app(app) migrate.init_app(app) login.init_app(app) # Make sure database models are registered # noinspection PyUnresolvedReferences import komidabot.models # noinspection PyUnresolvedReferences import komidabot.models_training # noinspection PyUnresolvedReferences import komidabot.models_users # register blueprints from komidabot.blueprint import blueprint as webhook_blueprint from komidabot.blueprint_api import blueprint as api_blueprint from komidabot.blueprint_authentication import blueprint as authentication_blueprint app.register_blueprint(webhook_blueprint, url_prefix='/webhook') app.register_blueprint(api_blueprint, url_prefix='/api') app.register_blueprint(authentication_blueprint, url_prefix='/api') # Shares the api prefix # shell context for flask cli @app.shell_context_processor def ctx(): return {'app': app, 'db': db} app.logger.setLevel(logging.DEBUG) if os.environ.get("KOMIDABOT_SKIP_INITIALISATION") == "true": # Don't initialise anything if run from the CLI return app if app.config['TESTING']: # noinspection PyCallByClass,PyTypeChecker KomidabotApp.__init__(app, app.config) return app if not app.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true": if os.environ.get("WERKZEUG_RUN_MAIN") == "true": print(" * Worker processes PID: {}".format(os.getpid()), flush=True) with app.app_context(): update_active_features() # TODO: Check if we need to initialise the database and blueprints only once as well # The app is not in debug mode or we are in the reloaded process # noinspection PyCallByClass,PyTypeChecker KomidabotApp.__init__(app, app.config) return app ================================================ FILE: breaking-responses/cde-2020-10-26.json ================================================ { "COMMENT": "Tomato soup appears twice in this response, which causes issues when the bot tries to update the menu", "id": 1072, "menuDate": "2020-10-26T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 8760, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 0, "menuItemContents": [ { "id": 11148, "menuItemId": 8760, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 8410, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 1, "menuItemContents": [ { "id": 10718, "menuItemId": 8410, "courseId": 2323, "sortOrder": 0, "course": { "id": 2323, "dispNameNl": "Bladerdeeg met geitenkaas, rauwkostsalade en frietjes", "dispNameEn": "xx Goat cheese puff pastry with crucités and French fries", "nameNl": "bladerdeeg geitenkaas (rauwkostsalade + frietjes), zvv, dd, z", "nameEn": "", "weight": "400g", "extra": "veggie", "preparation": "bladerdeegjes openleggen op platte plaatjes met boterpapier. - afbakken op 180 °c. + zomerslaatje erbij serveren (zie lijst zomer rauwkostsalades)", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2323, "allergenId": 200 }, { "courseId": 2323, "allergenId": 201 }, { "courseId": 2323, "allergenId": 202 }, { "courseId": 2323, "allergenId": 203 }, { "courseId": 2323, "allergenId": 204 }, { "courseId": 2323, "allergenId": 205 }, { "courseId": 2323, "allergenId": 206 }, { "courseId": 2323, "allergenId": 207 }, { "courseId": 2323, "allergenId": 208 }, { "courseId": 2323, "allergenId": 209 }, { "courseId": 2323, "allergenId": 210 }, { "courseId": 2323, "allergenId": 211 }, { "courseId": 2323, "allergenId": 212 }, { "courseId": 2323, "allergenId": 213 } ], "course_CourseLogos": [ { "courseId": 2323, "courseLogoId": 204 }, { "courseId": 2323, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9108, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 2, "menuItemContents": [ { "id": 11504, "menuItemId": 9108, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8679, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11056, "menuItemId": 8679, "courseId": 5226, "sortOrder": 0, "course": { "id": 5226, "dispNameNl": "Regenboog quinoa bowl", "dispNameEn": "Rainbow quinoa bowl", "nameNl": "00 regenboog quinoa bowl,z (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rode quinoa klaar volgens de bereidingswijze op de verpakking en laat afkoelen.- maak de humusdressing met sojayoghurt en humus, breng op smaak met peper en zout. - maak de bowl: doe de quinoa in een kom, vervolgens baby leaf, rode en groene paprikareepjes, reepjes komkommer, kerstomaten en kikkererwten.- werk af met de prei scheuten, granaatappelpitjes, sesamzaad en de tahini dressing", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5226, "allergenId": 201 }, { "courseId": 5226, "allergenId": 205 }, { "courseId": 5226, "allergenId": 209 }, { "courseId": 5226, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5226, "courseLogoId": 209 }, { "courseId": 5226, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8689, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11066, "menuItemId": 8689, "courseId": 3488, "sortOrder": 0, "course": { "id": 3488, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, z", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - lenteui schuin versnijden, mengen met sojascheuten en paprikablokjes en kokoschilfers- opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3488, "allergenId": 200 }, { "courseId": 3488, "allergenId": 201 }, { "courseId": 3488, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3488, "courseLogoId": 202 }, { "courseId": 3488, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8709, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11086, "menuItemId": 8709, "courseId": 1865, "sortOrder": 0, "course": { "id": 1865, "dispNameNl": "Salade Dolce Vita", "dispNameEn": "Dolce Vita salad", "nameNl": "salade dolce vita (penne, mozzarella, courgette), dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "meer dan de helft van de salade moet bestaan uit groenten. courgette en aubergine snijden en kort roosteren.pasta koken. - kerstomaat en notensla wassen - olijven laten uitlekken. groenten mengen en afsmaken.- pasta groenten en mozzarella assembleren afwerken met notensla en hennepzaad", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1865, "allergenId": 200 }, { "courseId": 1865, "allergenId": 201 }, { "courseId": 1865, "allergenId": 203 }, { "courseId": 1865, "allergenId": 204 }, { "courseId": 1865, "allergenId": 206 }, { "courseId": 1865, "allergenId": 209 }, { "courseId": 1865, "allergenId": 210 }, { "courseId": 1865, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1865, "courseLogoId": 204 }, { "courseId": 1865, "courseLogoId": 209 }, { "courseId": 1865, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9128, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11538, "menuItemId": 9128, "courseId": 525, "sortOrder": 0, "course": { "id": 525, "dispNameNl": "Caesar salad on a bun", "dispNameEn": "Caesar salad on a bun", "nameNl": "caesar salad on a bun, dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": null, "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 525, "allergenId": 200 }, { "courseId": 525, "allergenId": 203 }, { "courseId": 525, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 525, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9135, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11545, "menuItemId": 9135, "courseId": 4169, "sortOrder": 0, "course": { "id": 4169, "dispNameNl": "Nordic cottage cheese", "dispNameEn": "Nordic cottage cheese", "nameNl": "cottage cheese nordic (ger. zalm)", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4169, "allergenId": 200 }, { "courseId": 4169, "allergenId": 201 }, { "courseId": 4169, "allergenId": 203 }, { "courseId": 4169, "allergenId": 205 }, { "courseId": 4169, "allergenId": 210 }, { "courseId": 4169, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4169, "courseLogoId": 204 }, { "courseId": 4169, "courseLogoId": 210 }, { "courseId": 4169, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9138, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11548, "menuItemId": 9138, "courseId": 1552, "sortOrder": 0, "course": { "id": 1552, "dispNameNl": "Crunchy yoghurt", "dispNameEn": "Crunchy yoghurt", "nameNl": "crunchy yoghurt", "nameEn": "", "weight": "300ml", "extra": null, "preparation": "potjes vullen met krieken en bijvullen met yoghurt - dekseltje vullen met crunchy en op potje zetten keuze om volle of magere yoghurt te gebruiken keuze uit krieken bereid uit blik of krieken uit bokaal (wat je in stock heb)", "price": 1.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1552, "allergenId": 200 }, { "courseId": 1552, "allergenId": 201 }, { "courseId": 1552, "allergenId": 203 }, { "courseId": 1552, "allergenId": 205 }, { "courseId": 1552, "allergenId": 210 }, { "courseId": 1552, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1552, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9141, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11551, "menuItemId": 9141, "courseId": 1532, "sortOrder": 0, "course": { "id": 1532, "dispNameNl": "Panna cotta", "dispNameEn": "Panna cotta", "nameNl": "panna cotta", "nameEn": "", "weight": "160g", "extra": "garnituur: rote grutze/speculoos", "preparation": "zie verpakking - speculoos of rote grutze er bovenop serveren", "price": 1.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1532, "allergenId": 200 }, { "courseId": 1532, "allergenId": 201 }, { "courseId": 1532, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1532, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9200, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11617, "menuItemId": 9200, "courseId": 3283, "sortOrder": 0, "course": { "id": 3283, "dispNameNl": "New York cheesecake", "dispNameEn": "New York cheese cake", "nameNl": "new york cheesecake", "nameEn": "", "weight": null, "extra": null, "preparation": null, "price": 1.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3283, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9206, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11623, "menuItemId": 9206, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] } ] } ================================================ FILE: breaking-responses/cmu-2020-09-25.json ================================================ { "COMMENT": "Komidabot interpreted the 9th item (Rijstpap) as a main course because there is no snack or salad specifier.", "id": 983, "menuDate": "2020-09-25T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7575, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 1, "menuItemContents": [ { "id": 9808, "menuItemId": 7575, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "‘Classic’ tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan dressing erover - dan basilicum - zonnebloempitten en romeinse sla bovenaan - geen dressingpotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7592, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 2, "menuItemContents": [ { "id": 9823, "menuItemId": 7592, "courseId": 3866, "sortOrder": 0, "course": { "id": 3866, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, w (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter 6 falafels per persoon- pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox gele wortel in fijne reepjes snijden (optioneel toevoegen) hzs: falafel in oven bakken", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3866, "allergenId": 201 }, { "courseId": 3866, "allergenId": 208 }, { "courseId": 3866, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3866, "courseLogoId": 209 }, { "courseId": 3866, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7581, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 3, "menuItemContents": [ { "id": 9813, "menuItemId": 7581, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7598, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 4, "menuItemContents": [ { "id": 9829, "menuItemId": 7598, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7605, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 5, "menuItemContents": [ { "id": 9835, "menuItemId": 7605, "courseId": 550, "sortOrder": 0, "course": { "id": 550, "dispNameNl": "Kalkoenfinesse met zomergroenten", "dispNameEn": "Turkey finesse with summer vegetables", "nameNl": "kalkoenfinesse met zomergroentjes dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 550, "allergenId": 200 }, { "courseId": 550, "allergenId": 201 }, { "courseId": 550, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 550, "courseLogoId": 202 }, { "courseId": 550, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7611, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 6, "menuItemContents": [ { "id": 9967, "menuItemId": 7611, "courseId": 661, "sortOrder": 0, "course": { "id": 661, "dispNameNl": "Brie-appelbagnat ", "dispNameEn": "Pan bagnat with brie and apple ", "nameNl": "brie - appel bagnat (brie, appel, noten) dd, z (veggie)", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "walnoten in stukken hakken indien nodig - citroensap voor bij de appeltjes", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 661, "allergenId": 201 }, { "courseId": 661, "allergenId": 203 }, { "courseId": 661, "allergenId": 204 }, { "courseId": 661, "allergenId": 205 }, { "courseId": 661, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 661, "courseLogoId": 204 }, { "courseId": 661, "courseLogoId": 210 }, { "courseId": 661, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7617, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 7, "menuItemContents": [ { "id": 9845, "menuItemId": 7617, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7740, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 11, "menuItemContents": [ { "id": 9980, "menuItemId": 7740, "courseId": 1109, "sortOrder": 0, "course": { "id": 1109, "dispNameNl": "Hamburger", "dispNameEn": "Hamburger", "nameNl": "hamburger standaard, z", "nameEn": "", "weight": "300g", "extra": null, "preparation": "keuze om ketchup 3 liter of 1 liter te gebruiken", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1109, "allergenId": 200 }, { "courseId": 1109, "allergenId": 201 }, { "courseId": 1109, "allergenId": 204 }, { "courseId": 1109, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1109, "courseLogoId": 202 }, { "courseId": 1109, "courseLogoId": 208 }, { "courseId": 1109, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7744, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 11, "menuItemContents": [ { "id": 9984, "menuItemId": 7744, "courseId": 1537, "sortOrder": 0, "course": { "id": 1537, "dispNameNl": "Rijstpap", "dispNameEn": "Rice pudding", "nameNl": "rijstpap kant-en klaar", "nameEn": "", "weight": "160g", "extra": "garnituur: bruine suiker", "preparation": "potjes vullen met de kant- en klare rijstpap en bruine suiker erbij serveren", "price": 1.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1537, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1537, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ] } ================================================ FILE: commands.txt ================================================ Create a new migration script after a schema change: docker-compose exec komidabot-dev flask db migrate Run tests: docker-compose exec komidabot-dev python manage.py test ================================================ FILE: config.py ================================================ import os from collections import namedtuple from typing import List, Optional, TypedDict POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'localhost') POSTGRES_USER = os.getenv('POSTGRES_USER', 'postgres') POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD', '') # NOTE: While this is a different namedtuple from UserId, this will still properly handle equality checks between other # named tuples (including typing.NamedTuple) _UserId = namedtuple('_UserId', ['id', 'provider']) def _get_user(string: str) -> _UserId: split = string.split('/', 2) if len(split) == 1: return _UserId(split[0], 'facebook') else: return _UserId(split[1], split[0]) def _get_postgres_uri(host, user, password, db): if not db: raise ValueError('Invalid database') if password: return f'postgresql://{user}:{password}@{host}:5432/{db}' else: return f'postgresql://{user}@{host}:5432/{db}' class ConfigType(TypedDict): TESTING: bool TESTING: bool PRODUCTION: bool DISABLED: bool VERBOSE: bool DUMP_FILE: Optional[str] PAGE_ACCESS_TOKEN: Optional[str] VERIFY_TOKEN: Optional[str] APP_SECRET: Optional[str] ADMIN_IDS: List[_UserId] VAPID_PRIVATE_KEY: Optional[str] VAPID_PUBLIC_KEY: Optional[str] AUTH_GOOGLE_CLIENT_ID: Optional[str] AUTH_GOOGLE_CLIENT_SECRET: Optional[str] AUTH_GOOGLE_DISCOVERY_URL: str COVID19_DISABLED: int class BaseConfig: """Base configuration""" PRODUCTION = False DISABLED = int(os.getenv('DISABLED', '0')) != 0 VERBOSE = int(os.getenv('VERBOSE', '0')) != 0 DUMP_FILE = os.getenv('DUMP_FILE') PAGE_ACCESS_TOKEN = os.getenv('PAGE_ACCESS_TOKEN') VERIFY_TOKEN = os.getenv('VERIFY_TOKEN') APP_SECRET = os.getenv('APP_SECRET') ADMIN_IDS = [_get_user(split) for split in os.getenv('ADMIN_IDS', '').split(':')] VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '') VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '') AUTH_GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None) AUTH_GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", None) AUTH_GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration' COVID19_DISABLED = int(os.getenv('COVID19_DISABLED', '0')) != 0 # Flask options SESSION_REFRESH_EACH_REQUEST = False # Flask-SQLAlchemy options SQLALCHEMY_TRACK_MODIFICATIONS = False # Flask-Session options SESSION_COOKIE_PATH = '/api/' SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = int(os.getenv('LIVE_VERSION', '0')) != 0 SESSION_COOKIE_SAMESITE = 'Lax' SESSION_TYPE = 'filesystem' SESSION_PERMANENT = False SESSION_FILE_DIR = '/var/flask_session' class ProductionConfig(BaseConfig): """Production configuration""" PRODUCTION = True # Flask-SQLAlchemy options SQLALCHEMY_DATABASE_URI = _get_postgres_uri(POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, 'komidabot_prod') class DevelopmentConfig(BaseConfig): """Development configuration""" VERBOSE = int(os.getenv('VERBOSE', '1')) != 0 # Flask-SQLAlchemy options SQLALCHEMY_DATABASE_URI = _get_postgres_uri(POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, 'komidabot_dev') # Flask-Session options SESSION_COOKIE_NAME = 'session_dev' class TestingConfig(BaseConfig): """Testing configuration""" DISABLED = False VERBOSE = False TESTING = True PAGE_ACCESS_TOKEN = None VERIFY_TOKEN = None APP_SECRET = None # Flask-SQLAlchemy options SQLALCHEMY_DATABASE_URI = _get_postgres_uri(POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, 'komidabot_test') ================================================ FILE: database/.dockerignore ================================================ .dockerignore Dockerfile ================================================ FILE: database/Dockerfile ================================================ # base image FROM postgres:11-alpine # run create.sql on init ADD create.sql /docker-entrypoint-initdb.d ================================================ FILE: database/create.sql ================================================ CREATE DATABASE komidabot_prod; CREATE DATABASE komidabot_dev; CREATE DATABASE komidabot_test; ================================================ FILE: docker-compose.yml ================================================ version: '3.7' services: komidabot-db: build: context: ./database dockerfile: Dockerfile restart: on-failure environment: POSTGRES_USER: postgres POSTGRES_HOST_AUTH_METHOD: trust ports: - "127.0.0.1:5432:5432" volumes: - pgdata:/var/lib/postgresql/data komidabot-prod: build: context: . dockerfile: Dockerfile restart: "no" stop_signal: SIGINT environment: POSTGRES_HOST: komidabot-db POSTGRES_USER: postgres POSTGRES_DB: komidabot_prod APP_SETTINGS: config.ProductionConfig volumes: - prod_sessions:/var/flask_session env_file: - config-prod.env ports: - "5000:5000" depends_on: - komidabot-db komidabot-dev: build: context: . dockerfile: Dockerfile restart: "no" stop_signal: SIGINT environment: POSTGRES_HOST: komidabot-db POSTGRES_USER: postgres POSTGRES_DB: komidabot_dev APP_SETTINGS: config.DevelopmentConfig FLASK_DEBUG: 1 volumes: - .:/usr/src/app - dev_sessions:/var/flask_session env_file: - config-dev.env ports: - "5001:5000" depends_on: - komidabot-db volumes: pgdata: driver: local dev_sessions: driver: local prod_sessions: driver: local ================================================ FILE: entrypoint.sh ================================================ #!/usr/bin/env /bin/bash export PYTHONDONTWRITEBYTECODE=1 ./wait-postgres.sh if [ $# -eq 0 ]; then trap 'kill -TERM $PID' TERM INT KOMIDABOT_SKIP_INITIALISATION=true flask db upgrade PID=$! wait $PID trap - TERM INT wait $PID if [[ "$FLASK_ENV" = "production" ]]; then exec gunicorn --bind 0.0.0.0:5000 --log-level debug --workers 1 "app:create_app()" else exec python3 manage.py run -h 0.0.0.0 fi else echo "Running custom command:" echo "$@" exec /usr/bin/env "$@" fi ================================================ FILE: extensions.py ================================================ from flask_login import LoginManager from flask_migrate import Migrate from flask_session import Session from flask_sqlalchemy import BaseQuery, Model, SQLAlchemy __all__ = ['session', 'db', 'migrate', 'login', 'ModelBase', 'Table'] session = Session() db = SQLAlchemy() migrate = Migrate(db=db) login = LoginManager() class _ModelBase(Model): query: BaseQuery metadata = None ModelBase: _ModelBase = db.Model Table = db.Table ================================================ FILE: komidabot/api_utils.py ================================================ import json import os import sys import traceback from functools import wraps from flask import jsonify, request from jsonschema import ValidationError, Draft7Validator, RefResolver from werkzeug.exceptions import HTTPException from werkzeug.http import HTTP_STATUS_CODES from komidabot.app import get_app from komidabot.debug.state import DebuggableException __all__ = ['expects_schema', 'wrap_exceptions'] def response_ok(): return jsonify({'status': 200, 'message': HTTP_STATUS_CODES[200]}), 200 def response_bad_request(): return jsonify({'status': 400, 'message': HTTP_STATUS_CODES[400]}), 200 def response_unauthorized(): return jsonify({'status': 401, 'message': HTTP_STATUS_CODES[401]}), 200 def wrap_exceptions(func): @wraps(func) def decorated_func(*args, **kwargs): try: return func(*args, **kwargs) except HTTPException as e: return jsonify({'status': e.code, 'message': HTTP_STATUS_CODES[e.code]}), e.code except DebuggableException as e: app = get_app() app.bot.notify_error(e) e.print_info(app.logger) return jsonify({'status': 500, 'message': HTTP_STATUS_CODES[500]}), 500 except Exception as e: # noinspection PyBroadException try: get_app().bot.notify_error(e) except Exception: pass traceback.print_tb(e.__traceback__) print(e, flush=True, file=sys.stderr) return jsonify({'status': 500, 'message': HTTP_STATUS_CODES[500]}), 500 return decorated_func def expects_schema(input_schema: str = None, output_schema: str = None): in_schema = None if input_schema is not None: input_schema = os.path.join(os.getcwd(), 'schemas', input_schema + '.json') with open(input_schema) as f: in_schema = json.load(f) Draft7Validator.check_schema(in_schema) in_resolver = RefResolver(base_uri='file:{}'.format(input_schema), referrer=in_schema) in_validator = Draft7Validator(in_schema, resolver=in_resolver) out_schema = None if output_schema is not None: output_schema = os.path.join(os.getcwd(), 'schemas', output_schema + '.json') with open(output_schema) as f: out_schema = json.load(f) Draft7Validator.check_schema(out_schema) out_resolver = RefResolver(base_uri='file:{}'.format(output_schema), referrer=out_schema) out_validator = Draft7Validator(out_schema, resolver=out_resolver) def decorator(func): @wraps(func) def decorated_func(*args, **kwargs): if in_schema is not None: data = request.get_json(force=False) if data is None: return response_bad_request() try: in_validator.validate(data) except ValidationError: return response_bad_request() output = func(*args, **kwargs) if out_schema is not None: response = output[0] if isinstance(output, tuple) else output if response is None or not callable(getattr(response, 'get_data', None)): raise DebuggableException('Response is probably not a response object') out_data = response.get_data() try: out_validator.validate(json.loads(out_data)) except ValidationError as e: raise DebuggableException('Schema validation failed') from e return output return decorated_func return decorator ================================================ FILE: komidabot/app.py ================================================ import logging from flask import current_app as _current_app from config import ConfigType def get_app() -> 'App': return _current_app class App: def __init__(self, config): import atexit, sys from concurrent.futures import ThreadPoolExecutor as PyThreadPoolExecutor from komidabot.facebook.api_interface import ApiInterface from komidabot.facebook.users import UserManager as FBUserManager from komidabot.web.users import UserManager as WebUserManager from komidabot.subscriptions.daily_menu import Channel as DailyMenuChannel from komidabot.subscriptions import SubscriptionManager from komidabot.komidabot import Komidabot from komidabot.translation import GoogleTranslationService, TranslationService from komidabot.users import UnifiedUserManager, UserId, UserManager self.logger: logging.Logger self.bot_interfaces = dict() # TODO: Deprecate? self.bot_interfaces['facebook'] = { 'api_interface': ApiInterface(config.get('PAGE_ACCESS_TOKEN')) } user_manager = UnifiedUserManager() self.user_manager: UserManager = user_manager user_manager.register_manager(FBUserManager()) user_manager.register_manager(WebUserManager()) self.subscription_manager = SubscriptionManager() self.subscription_manager.register_channel(DailyMenuChannel()) self.translator: TranslationService = GoogleTranslationService() self.bot = Komidabot(self) # TODO: This could probably also be moved to the Komidabot class self.task_executor = PyThreadPoolExecutor(max_workers=5) atexit.register(PyThreadPoolExecutor.shutdown, self.task_executor) # Ensure cleanup of resources # XXX: Convert from _UserId type in config to the actually used UserId self.admin_ids = [UserId(user.id, user.provider) for user in config.get('ADMIN_IDS', [])] with self.app_context(): self.user_manager.initialise() if not config['TESTING']: self.bot.start_scheduler() with self.app_context(): from komidabot.models import AppSettings AppSettings.create_entries() def app_context(self): raise NotImplementedError() @property def config(self) -> 'ConfigType': raise NotImplementedError() def _get_current_object(self): raise NotImplementedError ================================================ FILE: komidabot/blueprint.py ================================================ import hashlib import hmac import json import pprint import sys import time import traceback from functools import wraps from flask import Blueprint, abort, escape, request import komidabot.facebook.constants as fb_constants import komidabot.facebook.postbacks as postbacks import komidabot.facebook.triggers as triggers import komidabot.localisation as localisation import komidabot.models as models import komidabot.web.constants as web_constants from extensions import db from komidabot.app import get_app from komidabot.debug.state import DebuggableException from komidabot.facebook.users import User as FacebookUser from komidabot.komidabot import Bot from komidabot.messages import TextMessage from komidabot.users import UserId from komidabot.web.users import User as WebUser blueprint = Blueprint('komidabot', __name__) pp = pprint.PrettyPrinter(indent=2) @blueprint.route('/', methods=['GET']) def handle_facebook_verification(): if request.args.get("hub.mode") == "subscribe" and request.args.get("hub.challenge"): if request.args.get('hub.verify_token', '') == get_app().config['VERIFY_TOKEN']: print("Verified") return escape(request.args.get('hub.challenge', '')) else: print("Wrong token") return "Error, wrong validation token" else: return abort(401) def validate_signature(func): @wraps(func) def decorated_func(*args, **kwargs): if get_app().config['TESTING']: # Skip validating signature if we're testing return func(*args, **kwargs) advertised = request.headers.get("X-Hub-Signature") if advertised is None: return False advertised = advertised.replace("sha1=", "", 1) data = request.get_data() received = hmac.new( key=get_app().config['APP_SECRET'].encode('raw_unicode_escape'), msg=data, digestmod=hashlib.sha1 ).hexdigest() if hmac.compare_digest(advertised, received): return func(*args, **kwargs) return abort(401) return decorated_func @blueprint.route('/', methods=['POST']) @validate_signature def handle_facebook_webhook(): try: app = get_app() data = request.get_json() if data and data['object'] == 'page': for entry in data['entry']: entry: dict if 'messaging' not in entry: continue for event in entry['messaging']: sender = event["sender"]["id"] # recipient = event["recipient"]["id"] user_manager = app.user_manager user: FacebookUser = user_manager.get_user(UserId(sender, fb_constants.PROVIDER_ID), event=event) if not isinstance(user, FacebookUser): # FIXME: Rather have a check that when the user supports "read" markers, we mark as read raise RuntimeError('Expected Facebook User') app.task_executor.submit(_do_handle_facebook_webhook, event, user, app._get_current_object()) return 'ok', 200 print(pprint.pformat(data, indent=2), flush=True) return abort(400) except DebuggableException as e: app = get_app() app.bot.notify_error(e) e.print_info(app.logger) except Exception as e: try: get_app().bot.notify_error(e) except Exception: pass traceback.print_tb(e.__traceback__) print(e, flush=True, file=sys.stderr) return 'ok', 200 def _do_handle_facebook_webhook(event, user: FacebookUser, app): time.sleep(0.1) # Yield with app.app_context(): trigger = triggers.Trigger(aspects=[triggers.SenderAspect(user)]) needs_commit = False if user.get_db_user() is None: trigger.add_aspect(triggers.NewUserAspect()) print('Adding new user to the database {}'.format(user.id), flush=True) user.add_to_db() needs_commit = True bot: Bot = app.bot locale = user.get_locale() try: print('Handling message in new path for {}'.format(user.id), flush=True) # print(pprint.pformat(event, indent=2), flush=True) if 'message' in event: message = event['message'] user.mark_message_seen() # print(pprint.pformat(message, indent=2), flush=True) # TODO: Is this the preferred way to differentiate inputs? # What about messages that include attachments or other things? # TODO: This now works with aspects rather than inheritance, so in theory this could be done if 'text' in message: message_text = message['text'] trigger = triggers.TextTrigger.extend(trigger, message_text) if '@admin' in message_text: trigger.add_aspect(triggers.AtAdminAspect()) if 'nlp' in message: if 'detected_locales' in message['nlp'] and len(message['nlp']['detected_locales']) > 0: # Get the locale that has the highest confidence locale_entry = max(message['nlp']['detected_locales'], key=lambda x: x['confidence']) trigger.add_aspect(triggers.LocaleAspect(locale_entry['locale'], locale_entry['confidence'])) locale = locale_entry['locale'] if 'entities' in message['nlp']: entities = message['nlp']['entities'] if 'datetime' in entities: for entity in entities['datetime']: if 'value' in entity: # Specific date given, vs. date range # FIXME: Do we want to add range datetimes? trigger.add_aspect(triggers.DatetimeAspect(entity['value'], entity['grain'])) if user.is_admin() and message_text == 'sub': # Simulate subscription instead trigger = triggers.SubscriptionTrigger.extend(trigger) if app.config.get('DISABLED'): if not user.is_admin(): if triggers.AtAdminAspect not in trigger: user.send_message(TextMessage(trigger, localisation.DOWN_FOR_MAINTENANCE(locale))) return # sender_obj.send_text_message('Note: The bot is currently disabled') elif 'postback' in event: # print(pprint.pformat(event, indent=2), flush=True) user.mark_message_seen() if app.config.get('DISABLED'): if not user.is_admin(): if triggers.AtAdminAspect not in trigger: user.send_message(TextMessage(trigger, localisation.DOWN_FOR_MAINTENANCE(locale))) return postback: dict = event['postback'] payload = postback.get('payload') try: data: dict = json.loads(payload) except json.JSONDecodeError: raise trigger = triggers.PostbackTrigger.extend(trigger, data['name'], data['args'], data['kwargs']) # TODO: This will be cleaner if we work with intents (see komidabot.py) postback_obj = postbacks.lookup_postback(trigger.name) if postback_obj: trigger = postback_obj.call_postback(trigger, *trigger.args, **trigger.kwargs) if trigger is None: return # Indicates the trigger was processed # TODO: Again, this will be cleaner if we work with intents (see komidabot.py) else: get_app().bot.message_admins(TextMessage(triggers.Trigger(), 'Unknown postback type received!')) user.send_message(TextMessage(trigger, localisation.ERROR_POSTBACK(locale))) return elif 'request_thread_control' in event: request_thread_control: dict = event['request_thread_control'] requested_owner_app_id = request_thread_control['requested_owner_app_id'] metadata = request_thread_control['metadata'] if requested_owner_app_id == 263902037430900: # Page Inbox app id # We'll allow the request app.bot_interfaces['facebook']['api_interface'].post_pass_thread_control({ 'recipient': {'id': user.id.id}, 'target_app_id': requested_owner_app_id, 'metadata': metadata }) return elif 'pass_thread_control' in event: return # Right now we don't need to handle this one else: print(pprint.pformat(event, indent=2), flush=True) get_app().bot.message_admins(TextMessage(triggers.Trigger(), 'Unknown message type received!')) return bot.trigger_received(trigger) if needs_commit: db.session.commit() except DebuggableException as e: app = get_app() app.bot.notify_error(e) e.print_info(app.logger) except Exception as e: try: app.logger.error('Error while handling event:\n{}'.format(pprint.pformat(event, indent=2))) get_app().bot.notify_error(e) except Exception: pass user.send_message(TextMessage(trigger, localisation.INTERNAL_ERROR(locale))) app.logger.exception(e) @blueprint.route('/subscription', methods=['POST']) def handle_web_push_subscription(): try: app = get_app() data = request.get_json() print(pprint.pformat(data, indent=2), flush=True) if data and 'subscription' in data: subscription = data['subscription'] if 'endpoint' not in subscription: return abort(400) if 'keys' not in subscription: return abort(400) endpoint = subscription['endpoint'] keys = subscription['keys'] needs_commit = False user_manager = app.user_manager user: WebUser = user_manager.get_user(UserId(endpoint, web_constants.PROVIDER_ID)) if user.get_db_user() is None: print('Adding new subscription to the database {}'.format(user.id), flush=True) user.add_to_db() user.set_data({ 'keys': keys }) needs_commit = True if 'days' in subscription: days = subscription['days'] if len(days) != 5: return abort(400) for i in range(5): if i >= 5: break day = models.week_days[i] campus_id = days[i] campus = user.get_campus_for_day(day) if campus_id is None: if user.disable_subscription_for_day(day): needs_commit = True elif campus is None or campus.id != campus_id: campus = models.Campus.get_by_id(campus_id) if campus is None: continue user.set_campus_for_day(campus, day) needs_commit = True if needs_commit: db.session.commit() return '{}', 200 return abort(400) except DebuggableException as e: app = get_app() app.bot.notify_error(e) e.print_info(app.logger) return abort(500) except Exception as e: try: get_app().bot.notify_error(e) except Exception: pass traceback.print_tb(e.__traceback__) print(e, flush=True, file=sys.stderr) return abort(500) ================================================ FILE: komidabot/blueprint_api.py ================================================ import json from datetime import date, timedelta from typing import Any, Dict, TypedDict, Union from flask import Blueprint, abort, jsonify, request from flask_login import current_user, login_required, UserMixin from werkzeug.http import HTTP_STATUS_CODES import komidabot.api_utils as api_utils import komidabot.messages as messages import komidabot.models as models import komidabot.triggers as triggers import komidabot.web.constants as web_constants from extensions import db, login from komidabot.app import get_app from komidabot.debug.administration import notify_admins from komidabot.models_training import LearningDatapoint from komidabot.models_users import RegisteredUser from komidabot.users import UserId from komidabot.web.users import User as WebUser blueprint = Blueprint('komidabot api', __name__) current_user: 'Union[RegisteredUser, UserMixin]' def translatable_to_object(translatable: models.Translatable): result = {} for translation in translatable.translations: result[translation.language] = translation.translation return result @blueprint.route('/subscribe', methods=['POST']) @api_utils.wrap_exceptions @api_utils.expects_schema(input_schema='POST_api_subscribe', output_schema='api_response_strict') def post_subscribe(): class PostData(TypedDict): endpoint: str keys: Dict[str, str] channel: str data: Any if not current_user.is_role('admin'): return api_utils.response_unauthorized() post_data: PostData = request.get_json() endpoint = post_data['endpoint'] keys = post_data['keys'] channel = post_data['channel'] data = post_data['data'] if 'data' in post_data else None if channel == 'administration': if not current_user.is_authenticated: return login.unauthorized() current_user.add_subscription(endpoint, keys) db.session.commit() return api_utils.response_ok() else: # FIXME: This code is not really done, but until we can send out daily menus in a consistent manner it'll # have to be this way return api_utils.response_bad_request() # app = get_app() # user: WebUser = app.user_manager.get_user(UserId(endpoint, web_constants.PROVIDER_ID)) # # if user.get_db_user() is None: # user.add_to_db() # user.set_data({ # 'keys': keys # }) # # # if not channel.user_supported(user): # # return api_utils.response_bad_request() # # if app.subscription_manager.user_subscribe(user, channel, data=data): # return api_utils.response_ok() # else: # return api_utils.response_bad_request() @blueprint.route('/subscribe', methods=['DELETE']) @api_utils.wrap_exceptions @api_utils.expects_schema(input_schema='DELETE_api_subscribe', output_schema='api_response_strict') def delete_subscribe(): class PostData(TypedDict): endpoint: str channel: str if not current_user.is_role('admin'): return api_utils.response_unauthorized() post_data: PostData = request.get_json() endpoint = post_data['endpoint'] channel = post_data['channel'] if channel == 'administration': if not current_user.is_authenticated: return login.unauthorized() current_user.remove_subscription(endpoint) db.session.commit() return api_utils.response_ok() else: # FIXME: This code is not really done, but until we can send out daily menus in a consistent manner it'll # have to be this way return api_utils.response_bad_request() # app = get_app() # user: WebUser = app.user_manager.get_user(UserId(endpoint, web_constants.PROVIDER_ID)) # # if user.get_db_user() is None: # return api_utils.response_ok() # # if app.subscription_manager.user_unsubscribe(user, channel): # return api_utils.response_ok() # else: # return api_utils.response_bad_request() @blueprint.route('/subscribe', methods=['PUT']) @api_utils.wrap_exceptions @api_utils.expects_schema(input_schema='PUT_api_subscribe', output_schema='api_response_strict') def put_subscribe(): class PostData(TypedDict): old_endpoint: str endpoint: str keys: Dict[str, str] if not current_user.is_role('admin'): return api_utils.response_unauthorized() post_data: PostData = request.get_json() old_endpoint = post_data['old_endpoint'] endpoint = post_data['endpoint'] keys = post_data['keys'] app = get_app() user: WebUser = app.user_manager.get_user(UserId(old_endpoint, web_constants.PROVIDER_ID)) # FIXME: Change internal ID of user and keys # FIXME: Change admin subscriptions as well? Need to verify this # FIXME: This code is not really done, but until we can send out daily menus in a consistent manner it'll # have to be this way return api_utils.response_bad_request() @blueprint.route('/trigger', methods=['POST']) @api_utils.wrap_exceptions @api_utils.expects_schema(input_schema='POST_api_trigger', output_schema='api_response_strict') @login_required def post_trigger(): class PostData(TypedDict): trigger: str if not current_user.is_role('admin'): return api_utils.response_unauthorized() post_data: PostData = request.get_json() trigger = post_data['trigger'] if trigger == 'notification_test_error': try: raise RuntimeError('Test exception') except RuntimeError as e: notify_admins(messages.ExceptionMessage(triggers.Trigger(), e)) return api_utils.response_ok() elif trigger == 'notification_test_text': notify_admins(messages.TextMessage(triggers.Trigger(), 'Test notification')) return api_utils.response_ok() elif trigger == 'menu_update': from komidabot.komidabot import update_menus update_menus() return api_utils.response_ok() else: return api_utils.response_bad_request() @blueprint.route('/learning', methods=['GET']) @api_utils.wrap_exceptions @api_utils.expects_schema(output_schema='GET_api_learning.response') @login_required def get_learning(): if not current_user.is_role('learner'): return api_utils.response_unauthorized() datapoint = LearningDatapoint.get_random(current_user) if datapoint is None: return jsonify({'status': 200, 'message': HTTP_STATUS_CODES[200], 'data': None}), 200 processed = json.loads(datapoint.processed_data) result = { 'id': str(datapoint.id), 'screenshot': datapoint.screenshot, 'course_name': processed['name']['nl'], 'course_type': models.CourseType[processed['course_type']].value, 'course_sub_type': models.CourseSubType[processed['course_sub_type']].value, 'price_students': processed['price_students'], 'price_staff': processed['price_staff'], } return jsonify({'status': 200, 'message': HTTP_STATUS_CODES[200], 'data': result}), 200 @blueprint.route('/learning', methods=['POST']) @api_utils.wrap_exceptions @api_utils.expects_schema(input_schema='POST_api_learning', output_schema='api_response_strict') @login_required def post_learning(): class PostData(TypedDict): id: str course_name_correct: bool course_type: int course_sub_type: int price_students_correct: bool price_staff_correct: bool if not current_user.is_role('learner'): return api_utils.response_unauthorized() post_data: PostData = request.get_json() datapoint = LearningDatapoint.find_by_id(int(post_data['id'])) datapoint.user_submit(current_user, { 'course_name_correct': post_data['course_name_correct'], 'course_type': post_data['course_type'], 'course_sub_type': post_data['course_sub_type'], 'price_students_correct': post_data['price_students_correct'], 'price_staff_correct': post_data['price_staff_correct'] }) db.session.commit() return jsonify({'status': 200, 'message': HTTP_STATUS_CODES[200]}), 200 @blueprint.route('/campus', methods=['GET']) # TODO: @api_utils.wrap_exceptions # TODO: @api_utils.expects_schema def get_campus_list(): """ Gets a list of all available campuses. """ result = [] campuses = models.Campus.get_all_active() for campus in campuses: result.append({ 'id': campus.id, 'name': campus.name, 'short_name': campus.short_name, # TODO: Needs opening hours }) return jsonify(result) @blueprint.route('/campus/closing_days/', methods=['GET'], defaults={'short_name': None}) @blueprint.route('/campus//closing_days/', methods=['GET']) # TODO: @api_utils.wrap_exceptions # TODO: @api_utils.expects_schema def get_active_closing_days(short_name: str, week_str: str): """ Gets all currently active closures. """ if short_name is None: campuses = models.Campus.get_all_active() else: campus = models.Campus.get_by_short_name(short_name) if campus is None: return abort(400) campuses = [models.Campus.get_by_short_name(short_name)] try: week_day = date.fromisoformat(week_str) except ValueError: return abort(400) week_start = week_day + timedelta(days=-week_day.weekday()) # Start on Monday result = {} for campus in campuses: current_campus = result[campus.short_name] = [] for i in range(5): closed_data = models.ClosingDays.find_is_closed(campus, week_start + timedelta(days=i)) if closed_data is not None: current_campus.append({ 'first_day': closed_data.first_day.isoformat(), 'last_day': closed_data.last_day.isoformat() if closed_data.last_day is not None else None, 'reason': translatable_to_object(closed_data.translatable), }) else: current_campus.append(None) return jsonify(result) @blueprint.route('/campus//menu/', methods=['GET']) # TODO: @api_utils.wrap_exceptions # TODO: @api_utils.expects_schema def get_menu(short_name: str, day_str: str): """ Gets the menu for a specific campus on a day. """ campus = models.Campus.get_by_short_name(short_name) if campus is None: return abort(400) try: day_date = date.fromisoformat(day_str) except ValueError: return abort(400) menu = models.Menu.get_menu(campus, day_date) result = [] if menu is None: return jsonify(result) for menu_item in menu.menu_items: menu_item: models.MenuItem value = { 'course_type': menu_item.course_type.value, 'course_sub_type': menu_item.course_sub_type.value, 'translation': translatable_to_object(menu_item.translatable), } if menu_item.price_students: value['price_students'] = str(models.MenuItem.format_price(menu_item.price_students)) if menu_item.price_staff: value['price_staff'] = str(models.MenuItem.format_price(menu_item.price_staff)) result.append(value) return jsonify(result) ================================================ FILE: komidabot/blueprint_authentication.py ================================================ import json from typing import Optional, Union from urllib.parse import urlparse, quote, unquote import requests from flask import abort, Blueprint, jsonify, redirect, request, url_for from flask_login import current_user, login_required, login_user, logout_user, UserMixin from oauthlib.oauth2 import InvalidGrantError, OAuth2Error, WebApplicationClient from werkzeug.http import HTTP_STATUS_CODES import komidabot.api_utils as api_utils import komidabot.config as app_config from extensions import db, login from komidabot.app import App, get_app from komidabot.models_users import RegisteredUser blueprint = Blueprint('komidabot authentication', __name__) current_user: 'Union[RegisteredUser, UserMixin]' google_client: Optional[Union[WebApplicationClient, bool]] = None google_provider_config = None def init_google_client(app: App): global google_client client_id = app.config.get('AUTH_GOOGLE_CLIENT_ID') if client_id: google_client = WebApplicationClient(client_id) else: google_client = False def get_google_provider_cfg(): global google_provider_config if google_provider_config is None: google_provider_config = requests.get('https://accounts.google.com/.well-known/openid-configuration').json() return google_provider_config @login.user_loader def user_loader(user_id): return RegisteredUser.get_by_id(user_id) @login.unauthorized_handler def unauthorized_handler(): return api_utils.response_unauthorized() @blueprint.route('/login', methods=['GET']) @api_utils.wrap_exceptions def get_login(): next_url = request.args.get('next', None) return redirect(url_for('.get_login_google', next=next_url)) @blueprint.route('/login/google', methods=['GET']) @api_utils.wrap_exceptions def get_login_google(): app = get_app() if google_client is None: init_google_client(app) if google_client is False: return redirect('/login/not_available') google_provider_cfg = get_google_provider_cfg() authorization_endpoint = google_provider_cfg['authorization_endpoint'] state = {} if 'next' in request.args: next_url = request.args.get('next') parsed_next_url = urlparse(next_url) # Prevent changing the scheme or host if parsed_next_url.scheme != '' or parsed_next_url.netloc != '': return abort(400) state['next'] = parsed_next_url.geturl() request_uri = google_client.prepare_request_uri( authorization_endpoint, redirect_uri=url_for('.get_login_google_callback', _external=True), scope=['openid', 'email', 'profile'], state=quote(json.dumps(state)) ) return redirect(request_uri) @blueprint.route('/login/google/callback', methods=['GET']) @api_utils.wrap_exceptions def get_login_google_callback(): app = get_app() if google_client is None: init_google_client(app) if google_client is False: return redirect('/login/not_available') code = request.args.get('code') state = json.loads(unquote(request.args.get('state'))) next_url = state.get('next', '/') google_provider_cfg = get_google_provider_cfg() token_endpoint = google_provider_cfg['token_endpoint'] token_url, headers, body = google_client.prepare_token_request( token_endpoint, authorization_response=request.url, redirect_url=request.base_url, code=code ) token_response = requests.post( token_url, headers=headers, data=body, auth=(app.config.get('AUTH_GOOGLE_CLIENT_ID'), app.config.get('AUTH_GOOGLE_CLIENT_SECRET')), ) try: google_client.parse_request_body_response(json.dumps(token_response.json())) except InvalidGrantError: # Invalid grant, let's try the login flow again if next_url != '/': return redirect(next_url) return redirect('/login/internal_error') except OAuth2Error: return redirect('/login/internal_error') userinfo_endpoint = google_provider_cfg['userinfo_endpoint'] uri, headers, body = google_client.add_token(userinfo_endpoint) userinfo_response = requests.get(uri, headers=headers, data=body) # You want to make sure their email is verified. # The user authenticated with Google, authorized your # app, and now you've verified their email through Google! if userinfo_response.json().get('email_verified'): unique_id = userinfo_response.json()['sub'] users_email = userinfo_response.json()['email'] picture = userinfo_response.json()['picture'] users_name = userinfo_response.json()['given_name'] else: return redirect('/login/not_verified') user = RegisteredUser.find_by_provider_id('google', unique_id) if not user: if app_config.is_registrations_enabled(): user = RegisteredUser.create('google', unique_id, users_name, users_email, picture) db.session.commit() else: return redirect('/login/login_closed') if not user.is_active: return redirect('/login/not_active') login_user(user) return redirect(next_url) @blueprint.route('/logout', methods=['GET']) @login_required def get_logout(): logout_user() if 'next' in request.args: next_url = request.args.get('next') parsed_next_url = urlparse(next_url) # Prevent changing the scheme or host if parsed_next_url.scheme == '' and parsed_next_url.netloc == '': return redirect(parsed_next_url.geturl()) return redirect('/') @blueprint.route('/authorized', methods=['GET']) @api_utils.wrap_exceptions @api_utils.expects_schema(output_schema='GET_api_authorized.response') @login_required def get_authorized(): roles = [role.name for role in current_user.get_roles()] return jsonify({'status': 200, 'message': HTTP_STATUS_CODES[200], 'roles': roles}), 200 # TODO: Add /users endpoint to manage users as admin ================================================ FILE: komidabot/bot.py ================================================ from komidabot.messages import Trigger class Bot: def trigger_received(self, trigger: Trigger): raise NotImplementedError() # TODO: This should probably be a trigger instead def notify_error(self, error: Exception): raise NotImplementedError() ================================================ FILE: komidabot/config.py ================================================ from komidabot.models import AppSettings def is_registrations_enabled(): return AppSettings.get_value('registrations_enabled') is True ================================================ FILE: komidabot/debug/administration.py ================================================ import copy import json from typing import Any, Callable, NoReturn from pywebpush import webpush, WebPushException import komidabot.messages as messages from komidabot.app import get_app from komidabot.models_users import AdminSubscription, RegisteredUser VAPID_CLAIMS = { 'sub': 'mailto:komidabot@gmail.com' } def notify_admins(message: messages.Message): target: Callable[[AdminSubscription, Any], NoReturn] if isinstance(message, messages.TextMessage): target = _send_text_message elif isinstance(message, messages.ExceptionMessage): target = _send_exception_message else: raise ValueError('Unsupported message type') for user in RegisteredUser.get_all(): for sub in user.get_subscriptions(): message_result = target(sub, message) if message_result == messages.MessageSendResult.GONE: # Gone = User no longer exists, delete from database user.remove_subscription(sub['endpoint']) def _send_notification(subscription: AdminSubscription, data) -> messages.MessageSendResult: app = get_app() try: response = webpush( subscription_info=subscription, data=json.dumps(data), vapid_private_key=app.config['VAPID_PRIVATE_KEY'], vapid_claims=copy.deepcopy(VAPID_CLAIMS) ) if app.config.get('VERBOSE'): print('Received {} for push {}'.format(response.status_code, subscription['endpoint']), flush=True) print(response.content, flush=True) return messages.MessageSendResult.SUCCESS except WebPushException as e: response = e.response if app.config.get('VERBOSE'): print('Received {} for push {}'.format(response.status_code, subscription['endpoint']), flush=True) print(response.content, flush=True) if 500 <= response.status_code < 600: return messages.MessageSendResult.EXTERNAL_ERROR if response.status_code == 429: # Too many requests, rate limited pass # TODO: Handle rate-limiting if response.status_code == 400: # Invalid request return messages.MessageSendResult.ERROR if response.status_code == 404: # Subscription not found return messages.MessageSendResult.GONE if response.status_code == 410: # Subscription has been removed return messages.MessageSendResult.GONE if response.status_code == 413: # Payload too large return messages.MessageSendResult.ERROR return messages.MessageSendResult.ERROR def _send_text_message(subscription: AdminSubscription, message: messages.TextMessage) -> messages.MessageSendResult: data = { 'notification': { # 'lang': 'NL', 'badge': 'https://komidabot.xyz/assets/icons/notification-badge-android-72x72.png', 'title': 'Komidabot message', 'body': message.text, 'vibrate': [], 'renotify': False, 'requireInteraction': False, 'actions': [], 'silent': False, } } return _send_notification(copy.deepcopy(subscription), data) def _send_exception_message(subscription: AdminSubscription, message: messages.ExceptionMessage) -> messages.MessageSendResult: exception_string = str(message.source) if exception_string: body = '{}: {}'.format(type(message.source).__name__, exception_string) else: body = type(message.source).__name__ data = { 'notification': { # 'lang': 'NL', 'badge': 'https://komidabot.xyz/assets/icons/notification-badge-android-72x72.png', 'title': 'Komidabot: Exception', 'body': body, 'vibrate': [], 'renotify': False, 'requireInteraction': False, 'actions': [], 'silent': False, } } return _send_notification(copy.deepcopy(subscription), data) ================================================ FILE: komidabot/debug/state.py ================================================ from logging import Logger from typing import Any, List, Optional class ProgramStateTrace: def __init__(self): self._root: 'ProgramState' = InitialProgramState() self._current: 'ProgramState' = self._root def state(self, state: 'ProgramState'): return WithProgramState(self, state) def push(self, state: 'ProgramState'): assert state is not None state.parent = self._current self._current.children.append(state) self._current = state def pop(self): assert self._current.parent is not None self._current = self._current.parent def prepend(self, parent: 'ProgramStateTrace'): # Add current tree as child of prepended tree parent._current.children.append(self._root) # And update our old root's parent accordingly self._root.parent = parent._current # Then set the new root to the prepended tree's root self._root = parent._root def append(self, child: 'ProgramStateTrace'): # Add child tree as child to current node self._current.children.append(child._root) # And set the child's parent accordingly child._root.parent = self._current def get_state(self) -> 'ProgramState': return self._current def __repr__(self): result = [] current = self._current while current is not None: result.insert(0, '- ' + repr(current)) current = current.parent return '\n'.join(['Program state trace:'] + result) class ProgramState: def __init__(self): self.parent: 'Optional[ProgramState]' = None self.children: 'List[ProgramState]' = [] class InitialProgramState(ProgramState): def __repr__(self): return 'InitialState' class SimpleProgramState(ProgramState): def __init__(self, name: str, data: Any = None): super().__init__() self.name = name self.data = data def __repr__(self): return 'State({}, {})'.format(repr(self.name), repr(self.data)) class DebuggableException(Exception): def __init__(self, message: str, trace: ProgramStateTrace = None): super().__init__(message) self._trace = trace def get_trace(self) -> ProgramStateTrace: return self._trace def get_or_set_trace(self, trace: ProgramStateTrace) -> ProgramStateTrace: if self._trace is None: self._trace = trace return self._trace def get_state(self) -> ProgramState: return self._trace.get_state() def print_info(self, logger: Logger): logger.error('Error trace: {}'.format(self.get_trace())) # Redundant log statement: # logger.error('Error last state: {}'.format(self.get_state())) logger.exception(self) class WithProgramState: def __init__(self, trace: ProgramStateTrace, state: ProgramState): self._trace = trace self._state = state def __enter__(self): self._trace.push(self._state) def __exit__(self, exc_type, exc_val, exc_tb): if exc_val is not None: if isinstance(exc_val, DebuggableException): trace = exc_val.get_or_set_trace(self._trace) if trace is not self._trace: trace.prepend(self._trace) else: raise DebuggableException('Unspecified error', self._trace) from exc_val else: self._trace.pop() ================================================ FILE: komidabot/external_menu.py ================================================ import atexit import datetime import json import re from decimal import Decimal from typing import Any, Dict, Optional, Union import requests import komidabot.models as models from extensions import db from komidabot.debug.state import DebuggableException, ProgramStateTrace, SimpleProgramState from komidabot.rate_limit import Limiter from komidabot.translation import LANGUAGE_DUTCH BASE_ENDPOINT = 'https://restickets.uantwerpen.be/' MENU_API = '{endpoint}api/GetMenuByDate/{campus}/{date}' PRICE_API = '{endpoint}api/getPriceConversion/{price}' ALL_MENU_API = '{endpoint}api/GetMenu/{date}' API_GET_HEADERS = dict() API_GET_HEADERS['Accept'] = 'application/json' COURSE_LOGOS_RAW = [ {"id": 201, "nameNl": "bio", "nameEn": "bio", "logo": "ikoon-bio.gif", "sortorder": 2}, {"id": 202, "nameNl": "gevogelte", "nameEn": "poultry", "logo": "ikoon-gevogelte.gif", "sortorder": 3}, {"id": 203, "nameNl": "grill", "nameEn": "grill", "logo": "ikoon-grill.gif", "sortorder": 1}, {"id": 204, "nameNl": "kaas", "nameEn": "cheese", "logo": "ikoon-kaas.gif", "sortorder": 3}, {"id": 205, "nameNl": "konijn", "nameEn": "rabbit", "logo": "ikoon-konijn.gif", "sortorder": 3}, {"id": 206, "nameNl": "lam", "nameEn": "lamb", "logo": "ikoon-lam.gif", "sortorder": 3}, {"id": 207, "nameNl": "pasta", "nameEn": "pasta", "logo": "ikoon-pasta.gif", "sortorder": 1}, {"id": 208, "nameNl": "rund", "nameEn": "ox", "logo": "ikoon-rund.gif", "sortorder": 3}, {"id": 209, "nameNl": "salade", "nameEn": "salad", "logo": "ikoon-salade.gif", "sortorder": 1}, {"id": 210, "nameNl": "snack", "nameEn": "snack", "logo": "ikoon-snack.gif", "sortorder": 1}, {"id": 211, "nameNl": "soep", "nameEn": "soup", "logo": "ikoon-soep.gif", "sortorder": 1}, {"id": 212, "nameNl": "varken", "nameEn": "pig", "logo": "ikoon-varken.gif", "sortorder": 3}, {"id": 213, "nameNl": "vegan", "nameEn": "vegan", "logo": "ikoon-vegan.gif", "sortorder": 2}, {"id": 214, "nameNl": "veggie", "nameEn": "veggie", "logo": "ikoon-veggie.gif", "sortorder": 2}, {"id": 215, "nameNl": "vis", "nameEn": "fish", "logo": "ikoon-vis.gif", "sortorder": 3}, {"id": 216, "nameNl": "less meat", "nameEn": "less meat", "logo": "ikoon-less.gif", "sortorder": 1}, {"id": 217, "nameNl": "healthify", "nameEn": "healthify", "logo": "healthify.gif", "sortorder": 1}, {"id": 218, "nameNl": "bruin broodje", "nameEn": "brown bread", "logo": "ikoon-bruin-broodje.gif", "sortorder": 1}, {"id": 219, "nameNl": "wit broodje", "nameEn": "white bread", "logo": "ikoon-wit-broodje.gif", "sortorder": 1}, {"id": 220, "nameNl": "conceptbroodje", "nameEn": "concept bread", "logo": "ikoon-concept-broodje.gif", "sortorder": 1} ] COURSE_ALLERGENS_RAW = [ {"id": 200, "nameNl": "Ei", "nameEn": "Egg", "logo": "Ei.gif"}, {"id": 201, "nameNl": "Gluten-tarwe", "nameEn": "Wheat gluten", "logo": "Gluten-tarwe.gif"}, {"id": 202, "nameNl": "Lupine", "nameEn": "Lupine", "logo": "Lupine.gif"}, {"id": 203, "nameNl": "Melk-lactose", "nameEn": "Milk lactose", "logo": "Melk-lactose.gif"}, {"id": 204, "nameNl": "Mosterd", "nameEn": "Mustard", "logo": "Mosterd.gif"}, {"id": 205, "nameNl": "Noten", "nameEn": "nuts", "logo": "Noten.gif"}, {"id": 206, "nameNl": "Pinda", "nameEn": "Peanut", "logo": "Pinda.gif"}, {"id": 207, "nameNl": "Schaaldieren", "nameEn": "shellfish", "logo": "Schaaldieren.gif"}, {"id": 208, "nameNl": "Selderij", "nameEn": "Celery", "logo": "Selderij.gif"}, {"id": 209, "nameNl": "Sesam", "nameEn": "Sesame", "logo": "Sesam.gif"}, {"id": 210, "nameNl": "Soja", "nameEn": "soya", "logo": "Soja.gif"}, {"id": 211, "nameNl": "Sulfiet", "nameEn": "sulfite", "logo": "Sulfiet.gif"}, {"id": 212, "nameNl": "Vis", "nameEn": "Fish", "logo": "Vis.gif"}, {"id": 213, "nameNl": "Weekdieren", "nameEn": "mollusks", "logo": "Weekdieren.gif"}, {"id": 214, "nameNl": "halal", "nameEn": "halal", "logo": "halal.gif"}, ] COURSE_LOGOS: Dict[str, int] = { 'BIO': 201, # Biological course (???) 'CHICKEN': 202, # Contains chicken 'GRILL': 203, # Grill course 'CHEESE': 204, # Contains cheese 'RABBIT': 205, # Contains rabbit 'LAMB': 206, # Contains lamb 'PASTA': 207, # Pasta course / contains pasta??? 'VEAL': 208, # Contains veal 'SALAD': 209, # Salad course 'SNACK': 210, # Sub course 'SOUP': 211, # Soup course 'PIG': 212, # Contains pig 'VEGAN': 213, # Vegan course 'VEGGIE': 214, # Vegetarian course 'FISH': 215, # Contains fish 'LESS_MEAT': 216, # Contains less meat 'HEALTHIFY': 217, # ??? 'BROWN_BREAD': 218, # Brown bread 'WHITE_BREAD': 219, # White bread 'CONCEPT_BREAD': 220, # ??? bread } COURSE_LOGOS_REVERSE: Dict[int, str] = {value: key for key, value in COURSE_LOGOS.items()} COURSE_ALLERGENS = { 'EGG': 200, 'WHEAT_GLUTEN': 201, 'LUPINE': 202, 'MILK_LACTOSE': 203, 'MUSTARD': 204, 'NUTS': 205, 'PEANUTS': 206, 'SHELLFISH': 207, 'CELERY': 208, 'SESAME': 209, 'SOY': 210, 'SULFITES': 211, 'FISH': 212, 'MOLLUSKS': 213, 'HALAL': 214, } COURSE_ALLERGENS_REVERSE: Dict[int, str] = {value: key for key, value in COURSE_ALLERGENS.items()} PASTA_NAMES = ['spaghetti', 'tagliatelle', 'papardelle', 'bucatini', 'cannelloni', 'ravioli', 'tortellini', 'caramelle', 'penne', 'rigatoni', 'orecchiette', 'farfalle', 'caserecce', 'fusilli', 'pasta', ] # Pasta names for those who don't speak Italian BROKEN_ITALIAN_NAMES = ['spagheti', 'tagliatele', 'papardele', 'bucatinni', 'cannellonni', 'canneloni', 'cannellonni', 'raviolli', 'tortellinni', 'tortelini', 'tortelinni', 'caramele', 'pene', 'rigatonni', 'orecchiete', 'orechiette', 'orechiete', 'farfale', 'caserece', 'fusili', ] session_obj = requests.Session() limiter = Limiter(5) # Limit to 5 lookups per second def _cleanup_session(session: requests.Session): session.close() atexit.register(_cleanup_session, session_obj) def _convert_price(price_students: Union[str, Decimal]) -> Decimal: url = PRICE_API.format(endpoint=BASE_ENDPOINT, price=price_students) price_response = session_obj.get(url, headers=API_GET_HEADERS) price_data = json.loads(price_response.text) return round(Decimal(price_data['staffprice']), 2) def _decimal_or_none(value: str) -> Optional[Decimal]: if value is None: return None return Decimal(value) def fetch_raw(campus: models.Campus, date: datetime.date) -> Optional[Any]: debug_state = ProgramStateTrace() with debug_state.state(SimpleProgramState('Lookup menu', {'campus': campus.short_name, 'date': date.isoformat()})): limiter() url = MENU_API.format(endpoint=BASE_ENDPOINT, campus=campus.external_id, date=date.strftime('%Y-%m-%d')) try: response = session_obj.get(url, headers=API_GET_HEADERS) except requests.exceptions.Timeout: return None # If the connection times out, we'll just ignore it if 400 <= response.status_code < 500: raise DebuggableException('Client error on HTTP request') if 500 <= response.status_code < 600: # raise DebuggableException('Server error on HTTP request') return None # Don't raise an exception when the server fails, we'll just ignore it # TODO: Maybe send a notification to admins that we failed requesting data? # No content is returned when there is no menu for a campus on a specific day if response.status_code == 204: return None try: return json.loads(response.text) except json.decoder.JSONDecodeError: # If we fail to decode JSON, this means we got an invalid response back # This can (or used to) happen when we try to look up the menu on a Sunday or Saturday return None def parse_fetched(fetched: Dict): if fetched is None: return None debug_state = ProgramStateTrace() campus = models.Campus.get_by_external_id(fetched['restaurantId']) result = { 'date': datetime.datetime.strptime(fetched['menuDate'], '%Y-%m-%dT%H:%M:%S').date().isoformat(), 'campus': campus.short_name, 'menu': [] } for raw_item in fetched['menuItems']: with debug_state.state(SimpleProgramState('Menu item', raw_item['id'])): if raw_item['enabled'] != 1: # XXX: Spotted in the wild, enabled values of 2! continue parsed_item = { 'external_id': raw_item['id'], 'components': [], 'price': Decimal(0), 'multiple_prices': False, 'sort_order': raw_item['sortorder'] } # Sort components in place # XXX: This makes the items order consistent in the output as well raw_item['menuItemContents'].sort(key=lambda v: (not v['course']['showFirst'], not v['course']['maincourse'], v['sortOrder'])) for raw_item_contents in raw_item['menuItemContents']: with debug_state.state(SimpleProgramState('Menu item component', raw_item_contents['id'])): raw_course = raw_item_contents['course'] if not raw_course['enabled']: pass # XXX: Used to skip not enabled, but the official site shows these items anyway (bug?) if raw_course['deleted']: pass # XXX: Used to skip deleted, but the official site shows these items anyway (bug?) # XXX: Note on names, sometimes these can contain double spaces, so we normalize them. # We also strip any whitespace from the start and end of the names component = { 'name': { 'nl': re.sub(r'\s+', ' ', raw_course['dispNameNl']).strip(), }, 'attributes': [], 'allergens': [] } if raw_course['dispNameEn']: component['name']['en'] = re.sub(r'\s+', ' ', raw_course['dispNameEn']).strip() parsed_item['price'] += round(Decimal(raw_course['price']), 2) if raw_course['calculatedMultiplePrices'] or raw_course['fixedMultiplePrices']: parsed_item['multiple_prices'] = True for raw_allergens in raw_course['course_Allergens']: component['allergens'].append(COURSE_ALLERGENS_REVERSE[raw_allergens['allergenId']]) for raw_logos in raw_course['course_CourseLogos']: component['attributes'].append(COURSE_LOGOS_REVERSE[raw_logos['courseLogoId']]) # Ensure consistent output component['allergens'].sort() component['attributes'].sort() parsed_item['components'].append(component) if parsed_item['price'] == 0: continue # Items with no price are most likely informational messages, not courses parsed_item['price'] = str(parsed_item['price']) # XXX: Only add a menu item if there's actually something in it if len(parsed_item['components']) > 0: result['menu'].append(parsed_item) # Ensure consistent output result['menu'].sort(key=lambda v: v['external_id']) return result def process_parsed(parsed: Dict): if parsed is None: return None debug_state = ProgramStateTrace() result = { 'date': parsed['date'], 'campus': parsed['campus'], 'menu': [], } for parsed_item in parsed['menu']: with debug_state.state(SimpleProgramState('Menu item', parsed_item['external_id'])): processed_item = { 'external_id': parsed_item['external_id'], 'name': { 'nl': [], 'en': [] }, 'course_type': '', 'course_sub_type': '', 'course_attributes': set(), 'course_allergens': set(), 'price_students': parsed_item['price'], 'price_staff': None } for component in parsed_item['components']: component: Dict with debug_state.state(SimpleProgramState('Menu item component', component)): processed_item['course_attributes'].update(component['attributes']) processed_item['course_allergens'].update(component['allergens']) if 'nl' in processed_item['name']: # If not in here, then a component did not support this language piece = component['name'].get('nl', '') if not piece: # Remove if not every component supports this language del processed_item['name']['nl'] else: processed_item['name']['nl'].append(piece) if 'en' in processed_item['name']: # If not in here, then a component did not support this language piece = component['name'].get('en', '') if not piece: # Remove if not every component supports this language del processed_item['name']['en'] else: processed_item['name']['en'].append(piece) for lang in processed_item['name']: name = ', '.join(processed_item['name'][lang]) name = name[0].upper() + name[1:] processed_item['name'][lang] = name processed_item['course_attributes'] = list(processed_item['course_attributes']) processed_item['course_attributes'].sort() processed_item['course_allergens'] = list(processed_item['course_allergens']) processed_item['course_allergens'].sort() if parsed_item['multiple_prices']: processed_item['price_staff'] = str(_convert_price(parsed_item['price'])) has_pasta = 'PASTA' in processed_item['course_attributes'] if not has_pasta: # No pasta in name, let's check to make sure anyway name = processed_item['name']['nl'] for pasta in PASTA_NAMES + BROKEN_ITALIAN_NAMES: if pasta in name.lower(): has_pasta = True break course_type = models.CourseType.DAILY course_sub_type = models.CourseSubType.NORMAL if 'VEGAN' in processed_item['course_attributes']: course_sub_type = models.CourseSubType.VEGAN elif 'VEGGIE' in processed_item['course_attributes']: course_sub_type = models.CourseSubType.VEGETARIAN if 'SOUP' in processed_item['course_attributes']: course_type = models.CourseType.SOUP elif 'PASTA' in processed_item['course_attributes'] or has_pasta: course_type = models.CourseType.PASTA elif 'GRILL' in processed_item['course_attributes']: course_type = models.CourseType.GRILL elif 'SNACK' in processed_item['course_attributes']: # If the item has a low price, it's more likely to be a snack, not a sub (broodje) if Decimal(processed_item['price_students']) < 2.7: course_type = models.CourseType.SNACK else: course_type = models.CourseType.SUB elif 'SALAD' in processed_item['course_attributes']: course_type = models.CourseType.SALAD else: # If the item has a low price and no other specific logo, it's probably a dessert, not a daily course if Decimal(processed_item['price_students']) < 3: course_type = models.CourseType.DESSERT processed_item['course_type'] = course_type.name processed_item['course_sub_type'] = course_sub_type.name result['menu'].append(processed_item) return result def update_menu(processed: Dict): if processed is None: return None debug_state = ProgramStateTrace() with debug_state.state(SimpleProgramState('Campus menu update', {'campus': processed['campus'], 'date': processed['date']})): items = processed['menu'] if len(items) > 0: campus = models.Campus.get_by_short_name(processed['campus']) date = datetime.date.fromisoformat(processed['date']) menu = models.Menu.get_menu(campus, date) if menu is None: menu = models.Menu.create(campus, date) external_ids = [item['external_id'] for item in items] menu_items = {} for menu_item in menu.menu_items: if menu_item.external_id not in external_ids: # Also matches if menu_item.external_id is None if not menu_item.data_frozen: # Old item, remove db.session.delete(menu_item) else: menu_items[menu_item.external_id] = menu_item for item in items: translatable, translation = models.Translatable.get_or_create(item['name'][LANGUAGE_DUTCH], LANGUAGE_DUTCH) for language in set(item['name'].keys()).difference([LANGUAGE_DUTCH]): if translatable.has_translation(language): translation = translatable.get_translation(language) # Don't replace translation if provider is Komida, as this is the official translation # Likewise, if the provider is not defined, this means it is most likely manually added # Otherwise it's done by Google or some other provider, which is sub-optimal if translation.provider not in [None, 'komida', 'manual']: continue # XXX: Only continues for loop over languages # Update translation and provider to new values translation.translation = item['name'][language] translation.provider = 'komida' else: translatable.add_translation(language, item['name'][language], 'komida') attributes = [models.CourseAttributes[attribute] for attribute in item['course_attributes']] allergens = [models.CourseAllergens[allergen] for allergen in item['course_allergens']] if item['external_id'] in menu_items: menu_item = menu_items[item['external_id']] if not menu_item.data_frozen: menu_item.translatable = translatable menu_item.course_type = models.CourseType[item['course_type']] menu_item.course_sub_type = models.CourseSubType[item['course_sub_type']] menu_item.set_attributes(attributes) menu_item.set_allergens(allergens) menu_item.price_students = Decimal(item['price_students']) menu_item.price_staff = _decimal_or_none(item['price_staff']) else: menu_item = menu.add_menu_item(translatable, models.CourseType[item['course_type']], models.CourseSubType[item['course_sub_type']], attributes, allergens, Decimal(item['price_students']), _decimal_or_none(item['price_staff'])) menu_item.external_id = item['external_id'] ================================================ FILE: komidabot/facebook/api_interface.py ================================================ import json import threading import requests from cachetools import cachedmethod, TTLCache import komidabot.messages as messages from komidabot.app import get_app from komidabot.translation import LANGUAGE_DUTCH from komidabot.util import check_exceptions BASE_ENDPOINT = 'https://graph.facebook.com/' API_VERSION = 'v4.0' SEND_API = '/me/messages' PROFILE_API = '/me/messenger_profile' PASS_THREAD_CONTROL_API = '/me/pass_thread_control' class ApiInterface: def __init__(self, page_access_token: str): self.session = requests.Session() self.base_parameters = dict() self.base_parameters['access_token'] = page_access_token self.headers_post = dict() self.headers_post['Content-Type'] = 'application/json' self.locale_parameters = dict() self.locale_parameters['access_token'] = page_access_token self.locale_parameters['fields'] = 'locale' self.locale_cache = TTLCache(maxsize=64, ttl=300) self.locale_lock = threading.Lock() @check_exceptions(messages.MessageSendResult.ERROR) # Handles exceptions raised in this method def post_send_api(self, data: dict) -> messages.MessageSendResult: response = self.session.post(BASE_ENDPOINT + API_VERSION + SEND_API, params=self.base_parameters, headers=self.headers_post, data=json.dumps(data)) data = json.loads(response.content) app = get_app() if app.config.get('VERBOSE'): print('Received {} for request {}'.format(response.status_code, response.request.body), flush=True) print(response.content, flush=True) if response.status_code == 200: return messages.MessageSendResult.SUCCESS if 500 <= response.status_code < 600: return messages.MessageSendResult.EXTERNAL_ERROR if response.status_code == 400: code = data['error']['code'] subcode = data['error']['error_subcode'] # https://developers.facebook.com/docs/messenger-platform/reference/send-api/error-codes if code == 1200: # Temporary send message failure. Please try again later. return messages.MessageSendResult.EXTERNAL_ERROR if code == 100: if subcode == 2018001: # No matching user found return messages.MessageSendResult.GONE if code == 10: if subcode == 2018065: # This message is sent outside of allowed window. return messages.MessageSendResult.UNREACHABLE if subcode == 2018108: # This Person Cannot Receive Messages: This person isn't receiving messages from you right now. return messages.MessageSendResult.UNREACHABLE if subcode == 2018278: # TODO: Get official description from FB once available # Sent after March 4th to indicate the subscription message was denied return messages.MessageSendResult.UNREACHABLE if code == 551: if subcode == 1545041: # This person isn't available right now. return messages.MessageSendResult.UNREACHABLE return messages.MessageSendResult.ERROR # TODO: Further specify @check_exceptions(False) # TODO: Exception checking needs to be done differently def post_profile_api(self, data: dict): response = self.session.post(BASE_ENDPOINT + API_VERSION + PROFILE_API, params=self.base_parameters, headers=self.headers_post, data=json.dumps(data)) app = get_app() if app.config.get('VERBOSE'): print('Received {} for request {}'.format(response.status_code, response.request.body), flush=True) print(response.content, flush=True) # response.raise_for_status() # return True return response.status_code == 200 @check_exceptions(False) # TODO: Exception checking needs to be done differently def post_pass_thread_control(self, data: dict): response = self.session.post(BASE_ENDPOINT + API_VERSION + PASS_THREAD_CONTROL_API, params=self.base_parameters, headers=self.headers_post, data=json.dumps(data)) app = get_app() if app.config.get('VERBOSE'): print('Received {} for request {}'.format(response.status_code, response.request.body), flush=True) print(response.content, flush=True) # response.raise_for_status() # return True return response.status_code == 200 @check_exceptions() # TODO: Exception checking needs to be done differently @cachedmethod(lambda self: self.locale_cache, lock=lambda self: self.locale_lock) def lookup_locale(self, user_id: str) -> str: # TODO: Futures or Promises??? response = self.session.get(BASE_ENDPOINT + API_VERSION + user_id, params=self.locale_parameters) # print('Received {} for user request {}'.format(response.status_code, user_id), flush=True) # print(response.content, flush=True) data = json.loads(response.content) return data.get('locale', LANGUAGE_DUTCH) ================================================ FILE: komidabot/facebook/constants.py ================================================ PROVIDER_ID = 'facebook' ================================================ FILE: komidabot/facebook/messages.py ================================================ import komidabot.facebook.constants as fb_constants import komidabot.menu import komidabot.messages as messages import komidabot.triggers as triggers import komidabot.users as users from komidabot.app import get_app TYPE_REPLY = 'RESPONSE' TYPE_SUBSCRIPTION = 'NON_PROMOTIONAL_SUBSCRIPTION' class MessageHandler(messages.MessageHandler): def send_message(self, user: users.User, message: messages.Message) -> messages.MessageSendResult: if user.id.provider != fb_constants.PROVIDER_ID: raise ValueError('User id is not for Facebook') if isinstance(message, messages.TextMessage): return self._send_text_message(user.id, message) elif isinstance(message, messages.MenuMessage): return self._send_menu_message(user, message) elif isinstance(message, TemplateMessage): return self._send_template_message(user.id, message) else: return messages.MessageSendResult.UNSUPPORTED @staticmethod def _send_text_message(user_id: users.UserId, message: messages.TextMessage) -> messages.MessageSendResult: data = { 'recipient': { 'id': user_id.id }, 'message': { 'text': message.text }, 'messaging_type': TYPE_REPLY if triggers.SenderAspect in message.trigger else TYPE_SUBSCRIPTION, } return get_app().bot_interfaces['facebook']['api_interface'].post_send_api(data) @staticmethod def _send_menu_message(user: users.User, message: messages.MenuMessage) -> messages.MessageSendResult: text = komidabot.menu.get_menu_text(message.menu, message.translator, user.get_locale()) if text is None: return messages.MessageSendResult.ERROR data = { 'recipient': { 'id': user.get_internal_id() }, 'message': { 'text': text }, 'messaging_type': TYPE_REPLY if triggers.SenderAspect in message.trigger else TYPE_SUBSCRIPTION, } return get_app().bot_interfaces['facebook']['api_interface'].post_send_api(data) @staticmethod def _send_template_message(user_id: users.UserId, message: 'TemplateMessage') -> messages.MessageSendResult: data = { 'recipient': { 'id': user_id.id }, 'message': { 'attachment': { 'type': 'template', 'payload': message.payload } }, 'messaging_type': TYPE_REPLY if triggers.SenderAspect in message.trigger else TYPE_SUBSCRIPTION, } return get_app().bot_interfaces['facebook']['api_interface'].post_send_api(data) class TemplateMessage(messages.Message): def __init__(self, trigger: messages.Trigger, payload): super().__init__(trigger) self.payload = payload ================================================ FILE: komidabot/facebook/nlp_dates.py ================================================ from typing import List import dateutil.parser as date_parser import komidabot.triggers as triggers def extract_days(aspects: List[triggers.DatetimeAspect]): dates = [] invalid_date = False for attribute in aspects: grain = attribute.grain value = attribute.value if grain is None or value is None: continue # TODO: Date parsing could be a lot better # Ex. vanmiddag is rejected if grain == 'day': date = date_parser.isoparse(value).date() dates.append(date) else: invalid_date = True return dates, invalid_date ================================================ FILE: komidabot/facebook/postbacks.py ================================================ import json from typing import Callable, Dict, Optional import komidabot.facebook.messages as fb_messages import komidabot.facebook.triggers as triggers import komidabot.localisation as localisation import komidabot.messages as messages import komidabot.models as models from extensions import db from komidabot.translation import LANGUAGE_DUTCH, LANGUAGE_ENGLISH postback_mappings = {} class Postback: def call_postback(self, trigger: triggers.Trigger, *args, **kwargs) -> triggers.Trigger: raise NotImplementedError() def lookup_postback(name: str) -> Postback: return postback_mappings.get(name, None) def postback(name: str = None): class PostbackDecorator(Postback): def __init__(self, func: Callable): nonlocal name if name is None: name = func.__name__ if name in postback_mappings: raise ValueError('Duplicate postback identifier') postback_mappings[name] = self self.func = func self.__name__ = func.__name__ def call_postback(self, trigger: triggers.Trigger, *args, **kwargs) -> Optional[triggers.Trigger]: return self.func(trigger, *args, **kwargs) def __call__(self, *args, **kwargs): return json.dumps({'name': name, 'args': args, 'kwargs': kwargs}) return PostbackDecorator def postback_button(title: str, payload: str): return {'type': 'postback', 'title': title, 'payload': payload} def url_button(title: str, url: str): return { 'type': 'web_url', 'url': url, 'title': title, 'webview_height_ratio': 'full', 'messenger_extensions': 'false', } @postback(name='komidabot:get_started') def get_started(trigger: triggers.Trigger): if triggers.NewUserAspect not in trigger: trigger.add_aspect(triggers.NewUserAspect()) return trigger @postback(name='komidabot:menu_today') def menu_today(trigger: triggers.Trigger): return trigger @postback(name='komidabot:settings_subscriptions') def settings_subscriptions(trigger: triggers.Trigger): if triggers.SenderAspect not in trigger: raise ValueError('Trigger missing SenderAspect') sender = trigger[triggers.SenderAspect].sender db_user = sender.get_db_user() locale = sender.get_locale() if not sender.is_feature_active('menu_subscription'): sender.send_message(messages.TextMessage(trigger, localisation.REPLY_FEATURE_UNAVAILABLE(locale))) return None current_subscriptions = {item.day: (item.campus_id if item.active else None) for item in models.UserDayCampusPreference.get_all_for_user(db_user)} current_subscriptions: Dict[models.Day, Optional[int]] elements_list = [[]] campuses = models.Campus.get_all_active() for day in models.week_days: elements = [] current = current_subscriptions.get(day, None) title = localisation.DAYS[day.value - 1](locale).capitalize() buttons = [] if current is None: buttons.append(postback_button('✔️ ' + localisation.UNSUBSCRIBED(locale), set_subscription(day.value, None))) else: buttons.append(postback_button(localisation.UNSUBSCRIBE(locale), set_subscription(day.value, None))) for campus in campuses: if current == campus.id: buttons.append(postback_button('✔️ ' + campus.name, set_subscription(day.value, campus.id))) else: buttons.append(postback_button(campus.name, set_subscription(day.value, campus.id))) for i in range(0, len(buttons), 3): elements.append({ 'title': title if i == 0 else (title + localisation.CONTINUATION(locale)), # 'image_url': image, 'buttons': buttons[i:i + 3] }) if len(elements_list[-1]) + len(elements) > 10: elements_list.append([]) elements_list[-1].extend(elements) sender.send_message(messages.TextMessage(trigger, localisation.REPLY_EXPERIMENTAL_DISPLAY(locale))) for elements in elements_list: payload = { 'template_type': 'generic', 'elements': elements, } sender.send_message(fb_messages.TemplateMessage(trigger, payload)) return None @postback(name='komidabot:set_subscription') def set_subscription(trigger: triggers.Trigger, day: int, campus: Optional[int]): if triggers.SenderAspect not in trigger: raise ValueError('Trigger missing SenderAspect') sender = trigger[triggers.SenderAspect].sender db_user = sender.get_db_user() locale = sender.get_locale() if not sender.is_feature_active('menu_subscription'): sender.send_message(messages.TextMessage(trigger, localisation.REPLY_FEATURE_UNAVAILABLE(locale))) return None selected_day = models.Day(day) selected_campus = None if campus is None: db_user.set_day_active(selected_day, False) else: selected_campus = models.Campus.get_by_id(campus) db_user.set_campus(selected_day, selected_campus, active=True) db.session.commit() msg = localisation.REPLY_SET_SUBSCRIPTION(locale).format(day=localisation.DAYS[day - 1](locale), campus=localisation.UNSUBSCRIBED(locale) if selected_campus is None else selected_campus.name) sender.send_message(messages.TextMessage(trigger, msg)) return None @postback(name='komidabot:settings_language') def settings_language(trigger: triggers.Trigger): if triggers.SenderAspect not in trigger: raise ValueError('Trigger missing SenderAspect') sender = trigger[triggers.SenderAspect].sender payload = { 'template_type': 'button', 'text': 'Chose your desired language', 'buttons': [ postback_button("Nederlands", set_language(LANGUAGE_DUTCH, 'Nederlands')), postback_button("English", set_language(LANGUAGE_ENGLISH, 'English')), postback_button("From Facebook", set_language('', 'From Facebook')), ], } sender.send_message(fb_messages.TemplateMessage(trigger, payload)) return None @postback(name='komidabot:set_language') def set_language(trigger: triggers.Trigger, language: str, display: str): if triggers.SenderAspect not in trigger: raise ValueError('Trigger missing SenderAspect') sender = trigger[triggers.SenderAspect].sender db_user = sender.get_db_user() locale = sender.get_locale() db_user.set_language(language) db.session.commit() sender.send_message(messages.TextMessage(trigger, localisation.REPLY_SET_LANGUAGE(locale).format(language=display))) return None def generate_postback_data(include_persistent_menu: bool, production: bool): result = dict() result['get_started'] = { 'payload': get_started(), } result['greeting'] = [ { 'locale': 'default', 'text': 'Welcome!', }, { 'locale': 'nl_BE', 'text': 'Welkom!', }, { 'locale': 'nl_NL', 'text': 'Welkom!', }, ] if include_persistent_menu: menu = [ postback_button("Today's menu", menu_today()), postback_button("Change language", settings_language()) ] if not production: menu.append(url_button("Open Komidabot.xyz", 'https://dev.komidabot.xyz/')) # TODO: Once per-user persistent menus are available, use them # https://developers.facebook.com/docs/messenger-platform/send-messages/persistent-menu/ # Followup: What for? result['persistent_menu'] = [ { 'locale': 'default', 'composer_input_disabled': False, 'call_to_actions': menu, }, ] return result ================================================ FILE: komidabot/facebook/triggers.py ================================================ from komidabot.triggers import * class PostbackTrigger(Trigger): def __init__(self, name, d_args, d_kwargs, *args, **kwargs): super().__init__(*args, **kwargs) self.name = name self.args = d_args self.kwargs = d_kwargs def get_repr_text(self): return ['PostbackTrigger', '- Name: ' + repr(self.name), '- args: ' + repr(self.args), '- kwargs: ' + repr(self.kwargs), ] ================================================ FILE: komidabot/facebook/users.py ================================================ from typing import Optional, Union import komidabot.facebook.constants as fb_constants import komidabot.messages as messages import komidabot.models as models import komidabot.users as users from komidabot.app import get_app from komidabot.facebook.messages import MessageHandler as FBMessageHandler __all__ = ['User', 'UserManager'] class UserManager(users.UserManager): def __init__(self): self.message_handler = FBMessageHandler() # def get_subscribed_users(self, day: models.Day) -> 'List[users.User]': # # TODO: Starting March 4th 2020, facebook subscriptions will no longer be available # # https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/ # # return super().get_subscribed_users(day) # return [] def get_user(self, user: 'Union[users.UserId, models.AppUser]', **kwargs) -> 'User': if isinstance(user, models.AppUser): return User(self, user.internal_id) if user.provider != fb_constants.PROVIDER_ID: raise ValueError('User id is not for {}'.format(fb_constants.PROVIDER_ID)) # TODO: This probably could use more checks or something # For example: check if there is a subscription return User(self, user.id) def initialise(self): import komidabot.facebook.postbacks as postbacks app = get_app() if app.config.get('TESTING') or app.config.get('DISABLED'): return data = postbacks.generate_postback_data(True, app.config.get('PRODUCTION')) app.bot_interfaces['facebook']['api_interface'].post_profile_api(data) def get_identifier(self): return fb_constants.PROVIDER_ID class User(users.User): def __init__(self, manager: UserManager, id_str: str): self._manager = manager self._id = id_str def get_locale(self) -> 'Optional[str]': stored_value = super().get_locale() if not stored_value: return get_app().bot_interfaces['facebook']['api_interface'].lookup_locale(self._id) return stored_value def get_provider_name(self) -> 'str': return fb_constants.PROVIDER_ID def get_internal_id(self) -> 'str': return self._id def supports_subscription_channel(self, channel: str) -> bool: # Facebook users cannot receive subscriptions anymore # This used to work by sending a message with "messaging_type" set to "NON_PROMOTIONAL_SUBSCRIPTION" # See https://developers.facebook.com/docs/messenger-platform/policy/policy-overview/ return False def get_manager(self) -> UserManager: return self._manager def get_message_handler(self) -> messages.MessageHandler: return self._manager.message_handler def mark_message_seen(self): return get_app().bot_interfaces['facebook']['api_interface'].post_send_api({ 'recipient': {'id': self._id}, 'sender_action': 'mark_seen' }) ================================================ FILE: komidabot/features.py ================================================ from collections import namedtuple from typing import Dict, Optional import komidabot.models as models from extensions import db from komidabot.users import UserId _feature = namedtuple('_feature', ['string_id', 'description', 'globally_available', 'active_users']) _features = [ _feature('menu_subscription', 'The user can receive a daily menu message automatically', True, [ # Dev user ID UserId('3150885824953769', 'facebook'), # Production user IDs UserId('1441134665935530', 'facebook'), UserId('1532346296833228', 'facebook'), ]), _feature('new_site_notifications', 'The user can receive a notification about the new site', True, [ # Dev user ID UserId('3150885824953769', 'facebook'), # Production user IDs UserId('1441134665935530', 'facebook'), ]), ] class _Feature: def __init__(self, feat: 'Optional[_feature]', obj: 'Optional[models.Feature]'): self.feat = feat self.obj = obj def __repr__(self): return '_Feature({}, {})'.format(repr(self.feat), repr(self.obj)) def update_active_features(): print('Updating active features', flush=True) current_features = models.Feature.get_all() feature_mapping = dict() # type: Dict[str, _Feature] for feature in current_features: feature_mapping[feature.string_id] = _Feature(None, feature) for feature in _features: if feature.string_id not in feature_mapping: feature_mapping[feature.string_id] = _Feature(feature, None) else: feature_mapping[feature.string_id].feat = feature # print('Features mapping: {}'.format(feature_mapping), flush=True) removed_features = [feature.obj for feature in feature_mapping.values() if feature.feat is None] for feature in removed_features: # type: models.Feature print('Removing feature {}: {}'.format(feature.string_id, feature.description or 'no description'), flush=True) db.session.delete(feature) db.session.commit() new_features = [feature.feat for feature in feature_mapping.values() if feature.obj is None] for feature in new_features: # type: _feature print('Adding new feature {}: {}'.format(feature.string_id, feature.description or 'no description'), flush=True) models.Feature.create(feature.string_id, feature.description, feature.globally_available) for user_id in feature.active_users: # type: UserId user = models.AppUser.find_by_id(user_id.provider, user_id.id) if user is None: print('Skipping user {} for feature {}'.format(user_id, feature.string_id), flush=True) continue print('Adding user {} to new feature {}'.format(user_id, feature.string_id), flush=True) models.Feature.set_user_participating(user, feature.string_id, True) db.session.commit() existing_features = [feature for feature in feature_mapping.values() if feature.feat is not None and feature.obj is not None] for feature in existing_features: # type: _Feature if feature.feat.globally_available != feature.obj.globally_available: print('Updating existing feature {}: {}'.format(feature.obj.string_id, feature.obj.description or 'no description'), flush=True) print('Changing general availability to {}'.format(feature.feat.globally_available), flush=True) feature.obj.globally_available = feature.feat.globally_available if feature.feat.description != feature.obj.description: print('Updating existing feature {}: {}'.format(feature.obj.string_id, feature.obj.description or 'no description'), flush=True) print('Changing description to {}'.format(feature.feat.description), flush=True) feature.obj.description = feature.feat.description db.session.commit() print('Done updating active features', flush=True) ================================================ FILE: komidabot/komidabot.py ================================================ import atexit import datetime import threading from typing import List from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.memory import MemoryJobStore from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger import komidabot.external_menu as external_menu import komidabot.facebook.nlp_dates as nlp_dates import komidabot.localisation as localisation import komidabot.messages as messages import komidabot.triggers as triggers from extensions import db from komidabot.app import get_app from komidabot.bot import Bot from komidabot.debug.state import DebuggableException, ProgramStateTrace, SimpleProgramState from komidabot.models import Campus, ClosingDays, Day, Menu from komidabot.models import create_standard_values, import_dump, recreate_db class Komidabot(Bot): def __init__(self, the_app): self.lock = threading.Lock() self.scheduler = BackgroundScheduler( jobstores={'default': MemoryJobStore()}, executors={'default': ThreadPoolExecutor(max_workers=4)}, job_defaults={'misfire_grace_time': 60} ) self._handling_error = False # Scheduled jobs should work with DST @self.scheduler.scheduled_job(CronTrigger(day_of_week='mon-fri', hour=10, minute=0, second=0), args=(the_app.app_context, self), id='daily_menu', name='Daily menu notifications') def daily_menu(context, bot: 'Komidabot'): with context(): if get_app().config.get('DISABLED'): return bot.trigger_received(triggers.SubscriptionTrigger()) @self.scheduler.scheduled_job(CronTrigger(minute=0, second=0), # Run every hour to find changes args=(the_app.app_context, self), id='menu_update', name='Hourly update of the menus') def menu_update(context, bot: 'Komidabot'): with context(): if get_app().config.get('DISABLED'): return try: today = datetime.datetime.today().date() week_start = today + datetime.timedelta(days=-today.weekday()) dates = [week_start + datetime.timedelta(days=i) for i in range(today.weekday(), 5)] if today.weekday() >= 3: dates += [week_start + datetime.timedelta(days=7 + i) for i in range(5)] update_menus(dates=dates) except DebuggableException as e: bot.notify_error(e) e.print_info(get_app().logger) except Exception as e: bot.notify_error(e) get_app().logger.exception(e) def start_scheduler(self): self.scheduler.start() atexit.register(BackgroundScheduler.shutdown, self.scheduler) # Ensure cleanup of resources def trigger_received(self, trigger: triggers.Trigger): with self.lock: # TODO: Maybe only lock on critical sections? app = get_app() verbose = app.config.get('VERBOSE') if verbose: print('Komidabot received a trigger: {}'.format(type(trigger).__name__), flush=True) print(repr(trigger), flush=True) if isinstance(trigger, triggers.SubscriptionTrigger): dispatch_daily_menus(trigger) return if triggers.AtAdminAspect in trigger: return # Don't process messages targeted at the admin locale = None message_handled = False # XXX: Disabled once more because responses aren't reliably in the language the user expects it to be # if triggers.LocaleAspect in trigger and trigger[triggers.LocaleAspect].confidence > 0.9: # locale = trigger[triggers.LocaleAspect].locale if triggers.SenderAspect in trigger: sender = trigger[triggers.SenderAspect].sender campuses = Campus.get_all() # This ensures that when a user is marked as reachable in case they were unreachable at some point # TODO: We no longer mark users as reachable, need to think over the proper course of action # if sender.mark_reachable(): # db.session.commit() if locale is None: locale = sender.get_locale() if triggers.NewUserAspect in trigger: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_NEW_USER(locale))) msg = localisation.REPLY_INSTRUCTIONS(locale).format( campuses=', '.join([campus.short_name.lower() for campus in campuses if campus.active]) ) sender.send_message(messages.TextMessage(trigger, msg)) sender.set_is_notified_new_site(True) db.session.commit() message_handled = True # TODO: Is this really how we want to handle input? # Maybe we can add an IntentAspect, where the intent is the desired action the bot should take # next? Ex. intents: admin message, get help, get menu, set preference (language, subscriptions) if isinstance(trigger, triggers.TextTrigger): text = trigger.text split = text.lower().split(' ') if sender.is_admin(): if split[0] == 'setup': if app.config.get('PRODUCTION'): sender.send_message(messages.TextMessage(trigger, 'Not running setup on production')) return recreate_db() create_standard_values() import_dump(app.config['DUMP_FILE']) sender.send_message(messages.TextMessage(trigger, 'Setup done')) return elif split[0] == 'update': sender.send_message(messages.TextMessage(trigger, 'Updating menus...')) update_menus(*split[1:]) sender.send_message(messages.TextMessage(trigger, 'Done updating menus...')) return elif split[0] == 'psid': # TODO: Deprecated? sender.send_message(messages.TextMessage(trigger, 'Your ID is {}'.format(sender.id.id))) return # TODO: Allow users to send more manual commands # See also the note prefacing the containing block if not message_handled and split[0] == 'help': msg = localisation.REPLY_INSTRUCTIONS(locale).format( campuses=', '.join([campus.short_name.lower() for campus in campuses if campus.active]) ) sender.send_message(messages.TextMessage(trigger, msg)) return if app.config.get('COVID19_DISABLED'): sender.send_message(messages.TextMessage(trigger, localisation.COVID19_UNAVAILABLE(locale))) return requested_dates = [] default_date = False if triggers.DatetimeAspect in trigger: date_times = trigger[triggers.DatetimeAspect] # TODO: Date parsing needs improving requested_dates, invalid_date = nlp_dates.extract_days(date_times) if invalid_date: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_INVALID_DATE(locale))) return if len(requested_dates) > 1: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_TOO_MANY_DAYS(locale))) return elif len(requested_dates) == 1: date = requested_dates[0] else: default_date = True date = datetime.datetime.now().date() # TODO: How about getting the menu for the next day after a certain time of day? # Only if we're returning the default day day = Day(date.isoweekday()) if day == Day.SATURDAY or day == Day.SUNDAY: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_WEEKEND(locale))) return requested_campuses = [] default_campus = False if isinstance(trigger, triggers.TextTrigger): text = trigger.text.lower() for campus in campuses: if not campus.active: continue for kw in campus.get_keywords(): if text.count(kw) > 0: requested_campuses.append(campus) break # Prevent the same campus from being added multiple times if len(requested_campuses) > 1: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_TOO_MANY_CAMPUSES(locale))) return elif len(requested_campuses) == 1: campus = requested_campuses[0] else: default_campus = True campus = sender.get_campus_for_day(date) if campus is None: # User has no campus for the specified day campus = Campus.get_by_short_name('cmi') if not campus.active: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_CAMPUS_INACTIVE(locale) .format(campus=campus.name))) return if message_handled and default_campus and default_date: if isinstance(trigger, triggers.TextTrigger): for word in ['menu', 'lunch', 'eten']: if word in trigger.text: break else: return else: return # if default_date and default_campus: # if isinstance(trigger, triggers.TextTrigger): # sender.send_message(messages.TextMessage(trigger, # localisation.REPLY_NO_DATE_OR_CAMPUS(locale))) # msg = localisation.REPLY_INSTRUCTIONS(locale).format( # campuses=', '.join([campus.short_name for campus in campuses]) # ) # sender.send_message(messages.TextMessage(trigger, msg)) # return # # # User did not send a text message, so we'll continue anyway if not default_campus: sender.set_campus_for_day(campus, date) db.session.commit() if sender.get_is_notified_new_site() is False and sender.is_feature_active('new_site_notifications'): if sender.send_message(messages.TextMessage(trigger, localisation.MESSAGE_NEW_SITE(locale))) \ == messages.MessageSendResult.SUCCESS: sender.set_is_notified_new_site(True) db.session.commit() closed = ClosingDays.find_is_closed(campus, date) if closed: translation = closed.translatable.get_translation(locale, app.translator) sender.send_message(messages.TextMessage(trigger, localisation.REPLY_CAMPUS_CLOSED(locale) .format(campus=campus.name, date=str(date), reason=translation.translation))) return # menu = komidabot.menu.prepare_menu_text(campus, date, app.translator, locale) menu = Menu.get_menu(campus, date) if menu is None: sender.send_message(messages.TextMessage(trigger, localisation.REPLY_NO_MENU(locale) .format(campus=campus.name, date=str(date)))) else: # sender.send_message(messages.TextMessage(trigger, menu)) sender.send_message(messages.MenuMessage(trigger, menu, app.translator)) # XXX: Disabled experiment # if default_date and default_campus and isinstance(trigger, triggers.TextTrigger): # for keyword in ['lunch', 'menu', 'komida']: # if keyword.lower() in trigger.text.lower(): # break # else: # sender.send_message(messages.TextMessage(trigger, localisation.REPLY_USE_AT_ADMIN(locale))) def notify_error(self, error: Exception): if self._handling_error: # Already handling an error, or we failed handling the previous error, so don't try handling more return self._handling_error = True self.message_admins(messages.ExceptionMessage(triggers.Trigger(), error)) self._handling_error = False def message_admins(self, message: messages.Message): from komidabot.debug.administration import notify_admins with self.lock: notify_admins(message) def dispatch_daily_menus(trigger: triggers.SubscriptionTrigger): from komidabot.subscriptions.daily_menu import CHANNEL_ID as DAILY_MENU_ID # limiter = Limiter(20) # Limit to 20 messages per second date = trigger.date or datetime.datetime.now().date() day = Day(date.isoweekday()) app = get_app() verbose = app.config.get('VERBOSE') if verbose: print('Sending out subscription for {} ({})'.format(date, day.name), flush=True) message = messages.SubscriptionMenuMessage(trigger, date, app.translator) app.subscription_manager.deliver_message(DAILY_MENU_ID, message) # user_manager = app.user_manager # changed = False # # subscribed_users = user_manager.get_subscribed_users(day) # subscriptions: Dict[Campus, List[users.User]] = dict() # # for user in subscribed_users: # if app.config.get('DISABLED') and not user.is_admin(): # continue # # if not user.is_feature_active('menu_subscription'): # if verbose: # print('User {} not eligible for subscription'.format(user.id), flush=True) # continue # # subscription = user.get_subscription_for_day(date) # if subscription is None: # continue # if not subscription.active: # continue # # campus = subscription.campus # # if not campus.active: # continue # # if campus not in subscriptions: # subscriptions[campus] = [] # # subscriptions[campus].append(user) # # for campus, sub_users in subscriptions.items(): # if verbose: # print('Preparing menu for {}'.format(campus.short_name), flush=True) # # closed = ClosingDays.find_is_closed(campus, date) # # if closed: # continue # Campus closed, no daily menu # # # TODO: Change menus from TextMessage to a custom message type to support different formatting per platform # menu = Menu.get_menu(campus, date) # if menu is None: # continue # # for user in sub_users: # limiter() # Ensure we don't send too many messages at once # # if verbose: # print('Sending menu for {} to {}'.format(campus.short_name, user.id), flush=True) # message_result = user.send_message(messages.MenuMessage(trigger, menu, app.translator)) # # if message_result == messages.MessageSendResult.UNSUPPORTED: # # Text messages unsupported? Disable subscription then # print('User {} does not support messages, removing from subscription list'.format(user.id), # flush=True) # # user.mark_unreachable() # changed = True # if message_result == messages.MessageSendResult.UNREACHABLE: # # Unreachable = Facebook is blocking us from sending, stop trying to send in the future # print('User {} is unreachable, removing from subscription list'.format(user.id), flush=True) # # user.mark_unreachable() # changed = True # if message_result == messages.MessageSendResult.GONE: # # Gone = User no longer exists, delete from database # print('User {} is gone, removing from database'.format(user.id), flush=True) # # user.delete() # changed = True # # if changed: # db.session.commit() def update_menus(*campuses: str, dates: 'List[datetime.date]' = None): debug_state = ProgramStateTrace() campus_list = Campus.get_all_active() if len(campuses) > 0: campus_list = [campus for campus in campus_list if campus.short_name not in campuses] if not dates: today = datetime.datetime.today().date() dates = [ today, today + datetime.timedelta(days=1), today + datetime.timedelta(days=2), today + datetime.timedelta(days=3), today + datetime.timedelta(days=4), today + datetime.timedelta(days=5), today + datetime.timedelta(days=6), today + datetime.timedelta(days=7), ] for campus in campus_list: for date in dates: if date.isoweekday() in [6, 7]: continue closed = ClosingDays.find_is_closed(campus, date) if closed: continue # Campus closed, don't try to find a menu with debug_state.state(SimpleProgramState('Campus menu update', {'campus': campus.short_name, 'date': str(date)})): data_raw = external_menu.fetch_raw(campus, date) data_parsed = external_menu.parse_fetched(data_raw) data_processed = external_menu.process_parsed(data_parsed) if data_processed is None: continue # No data assert campus.short_name == data_processed['campus'] assert date.isoformat() == data_processed['date'] external_menu.update_menu(data_processed) db.session.commit() ================================================ FILE: komidabot/localisation.py ================================================ import random from typing import Callable def localisation_definition(name, obj, fallback='en') -> Callable[[str], str]: for key, value in obj.copy().items(): if isinstance(key, tuple): del obj[key] for k in key: obj[k] = value def wrapper(locale): if locale is None: result = obj[fallback] else: locale = locale.lower().split('_', 1)[0] result = obj[locale] if locale in obj else obj[fallback] if callable(result): return result() elif isinstance(result, list): weights, strings = zip(*result) return random.choices(strings, weights=weights) else: return result wrapper.__name__ = name return wrapper # Supported locales: # https://developers.facebook.com/docs/messenger-platform/messenger-profile/supported-locales INTERNAL_ERROR = localisation_definition('INTERNAL_ERROR', { 'en': 'An unexpected error occured while trying to perform your request', 'nl': [ (1, 'oepsie woepsie! de bot is stukkie wukkie! we sijn heul hard ' 'aan t werk om dit te make mss kan je beter self kijken owo'), (99, 'Een onverwachte fout gebeurde tijdens het uitvoeren van uw verzoek'), ], }) # INTERNAL_ERROR = localisation_definition('INTERNAL_ERROR', { # 'en': 'An unexpected error occured while trying to perform your request', # 'nl': 'Een onverwachte fout gebeurde tijdens het uitvoeren van uw verzoek', # }) ERROR_TEXT_ONLY = localisation_definition('ERROR_TEXT_ONLY', { 'en': 'Sorry, I only understand text messages', 'nl': 'Sorry, ik begrijp alleen tekstberichten', }) ERROR_NOT_IMPLEMENTED = localisation_definition('ERROR_NOT_IMPLEMENTED', { 'en': 'Sorry, this feature is currently not implemented', 'nl': 'Sorry, deze feature is momenteel niet geïmplementeerd', }) ERROR_POSTBACK = localisation_definition('ERROR_POSTBACK', { 'en': 'Sorry, I cannot handle that message right now. ' 'Please try sending a message using the textbox instead.', 'nl': 'Sorry, ik kan dit bericht momenteel niet begrijpen. ' 'Gelieve het tekstvak te gebruiken voor uw vraag.', }) REPLY_NO_MENU = localisation_definition('REPLY_NO_MENU', { 'en': 'Sorry, no menu is available for {campus} on {date}', 'nl': 'Sorry, er is geen menu beschikbaar voor {campus} op {date}', }) REPLY_CAMPUS_CLOSED = localisation_definition('REPLY_NO_MENU', { 'en': 'Sorry, no menu is available for {campus} on {date}: {reason}', 'nl': 'Sorry, er is geen menu beschikbaar voor {campus} op {date}: {reason}', }) REPLY_CAMPUS_INACTIVE = localisation_definition('REPLY_CAMPUS_INACTIVE', { 'en': 'Sorry, no menus are available for {campus}', 'nl': 'Sorry, er zijn geen menus beschikbaar voor {campus}', }) REPLY_WEEKEND = localisation_definition('REPLY_WEEKEND', { 'en': 'Sorry, there are no menus on Saturdays and Sundays', 'nl': 'Sorry, er zijn geen menus op zon- en zaterdagen', }) REPLY_TOO_MANY_DAYS = localisation_definition('REPLY_TOO_MANY_DAYS', { 'en': 'Sorry, please request only a single day', 'nl': 'Sorry, gelieve een enkele dag te specificeren', }) REPLY_INVALID_DATE = localisation_definition('REPLY_INVALID_DATE', { 'en': 'Sorry, I am unable to understand the requested day. ' 'Please try to specify the day as e.g. "Monday" or "Tomorrow"', 'nl': 'Sorry, ik kan de gevraagde dag niet begrijpen. ' 'Gelieve de dag aan te geven als bvb. "Maandag" of "Morgen"', }) REPLY_TOO_MANY_CAMPUSES = localisation_definition('REPLY_TOO_MANY_CAMPUSES', { 'en': 'Sorry, please only ask for a single campus at a time', 'nl': 'Sorry, gelieve een enkele campus te specificeren', }) REPLY_MENU_START = localisation_definition('REPLY_MENU_START', { 'en': 'Menu at {campus} on {date}', 'nl': 'Menu van {date} in {campus}', }) REPLY_MENU_INCOMPLETE = localisation_definition('REPLY_MENU_START', { 'en': '⚠️ NOTE: This menu may be incomplete', 'nl': '⚠️ LET OP: Dit menu is mogelijks incompleet', }) REPLY_USE_AT_ADMIN = localisation_definition('REPLY_USE_AT_ADMIN', { 'en': "If you would like to talk to the admin instead, use @admin in your message and " "I won't disturb you\n~ 🤖 Komidabot", 'nl': 'Als je met de admin wilt praten, dan kan je @admin gebruiken en ' 'zal ik je niet storen\n~ 🤖 Komidabot', }) REPLY_NEW_USER = localisation_definition('REPLY_NEW_USER', { 'en': 'Welcome to the Komidabot!', 'nl': 'Welkom bij de Komidabot!', }) REPLY_INSTRUCTIONS = localisation_definition('REPLY_INSTRUCTIONS', { 'en': 'You can request the menu by choosing a campus ({campuses}) and/or ' 'asking for a specific day (Monday - Friday, Today, Tomorrow, etc.)\n\n' 'To reach the admin, you can use @admin.\n\n' 'You can also check out the menu at https://komidabot.xyz/', 'nl': 'Je kan het menu opvragen door een campus te kiezen ({campuses}) en/of ' 'een specifieke dag te vragen (maandag - vrijdag, vandaag, morgen, etc.)\n\n' 'Om de admin te bereiken, kan je @admin gebruiken.\n\n' 'Verder kan je het menu ook raadplegen op https://komidabot.xyz/', }) DOWN_FOR_MAINTENANCE = localisation_definition('DOWN_FOR_MAINTENANCE', { 'en': 'I am temporarily down for maintenance, please check back later', 'nl': 'Wegens onderhoud ben ik tijdelijk onbeschikbaar, probeer het later nog eens', }) DAYS = [ localisation_definition('DAYS[0]', { 'en': 'Monday', 'nl': 'maandag', }), localisation_definition('DAYS[1]', { 'en': 'Tuesday', 'nl': 'dinsdag', }), localisation_definition('DAYS[2]', { 'en': 'Wednesday', 'nl': 'woensdag', }), localisation_definition('DAYS[3]', { 'en': 'Thursday', 'nl': 'donderdag', }), localisation_definition('DAYS[4]', { 'en': 'Friday', 'nl': 'vrijdag', }), localisation_definition('DAYS[5]', { 'en': 'Saturday', 'nl': 'zaterdag', }), localisation_definition('DAYS[6]', { 'en': 'Sunday', 'nl': 'zondag', }), ] MONTHS = [ localisation_definition('MONTHS[0]', {'en': 'January', 'nl': 'januari', }), localisation_definition('MONTHS[1]', {'en': 'February', 'nl': 'februari', }), localisation_definition('MONTHS[2]', {'en': 'March', 'nl': 'maart', }), localisation_definition('MONTHS[3]', {'en': 'April', 'nl': 'april', }), localisation_definition('MONTHS[4]', {'en': 'May', 'nl': 'mei', }), localisation_definition('MONTHS[5]', {'en': 'June', 'nl': 'Juni', }), localisation_definition('MONTHS[6]', {'en': 'July', 'nl': 'juli', }), localisation_definition('MONTHS[7]', {'en': 'August', 'nl': 'augustus', }), localisation_definition('MONTHS[8]', {'en': 'September', 'nl': 'september', }), localisation_definition('MONTHS[9]', {'en': 'October', 'nl': 'october', }), localisation_definition('MONTHS[10]', {'en': 'November', 'nl': 'november', }), localisation_definition('MONTHS[11]', {'en': 'December', 'nl': 'december', }), ] CONTINUATION = localisation_definition('CONTINUATION', { 'en': ' (cont.)', 'nl': ' (vervolg)', }) SELECTED = localisation_definition('SELECTED', { 'en': ' (current)', 'nl': ' (geselecteerd)', }) UNSUBSCRIBE = localisation_definition('UNSUBSCRIBE', { 'en': 'Unsubscribe', 'nl': 'Uitschrijven', }) UNSUBSCRIBED = localisation_definition('UNSUBSCRIBED', { 'en': 'Unsubscribed', 'nl': 'Uitgeschreven', }) REPLY_EXPERIMENTAL_DISPLAY = localisation_definition('REPLY_EXPERIMENTAL_DISPLAY', { 'en': 'This feature display is experimental and will change in the future.', 'nl': 'De weergave van deze feature is experimenteel en zal veranderen in de toekomst.', }) REPLY_FEATURE_UNAVAILABLE = localisation_definition('REPLY_FEATURE_UNAVAILABLE', { 'en': 'This feature is currently unavailable.', 'nl': 'Deze feature is momenteel niet beschikbaar.', }) REPLY_SET_SUBSCRIPTION = localisation_definition('REPLY_SET_SUBSCRIPTION', { 'en': 'Preference for {day} set to: {campus}', 'nl': 'Voorkeur voor {day} gezet op: {campus}', }) REPLY_SET_LANGUAGE = localisation_definition('REPLY_SET_SUBSCRIPTION', { 'en': 'Your language is now set to: {language}', 'nl': 'Uw taal staat nu op: {language}', }) MESSAGE_NEW_SITE = localisation_definition('MESSAGE_NEW_SITE', { 'en': "Dear user, a new simplified way of viewing the Komida menus is now available by browsing to " "https://komidabot.xyz/\n\n" "To get an extended overview of the menus, including allergens and ingredients, you can always check the " "official Komida website at https://restickets.uantwerpen.be/calendar\n\n" "Of course the bot will always remain available to get the daily menu as well ;)", 'nl': "Beste gebruiker, vanaf nu kan u de menu's van de Komida op een simpele manier bekijken door naar " "https://komidabot.xyz/ te surfen.\n\n" "Voor een uitgebreider menu met informatie, inclusief allergenen en ingrediënten, kan u altijd de officiële " "website van de Komida raadplegen op https://restickets.uantwerpen.be/kalender\n\n" "Uiteraard blijft de bot hier altijd beschikbaar om de menu's op te vragen ;)", }) COVID19_UNAVAILABLE = localisation_definition('COVID19_UNAVAILABLE', { 'en': 'Dear user, Komidabot is temporarily unable to display the menus for the Komida restaurants.\n' 'For now, you can check out the menus and order online by following this link:' 'https://www.uantwerpen.be/en/life-in-antwerp/catering/about-komida/online-ordering/', 'nl': "Beste gebruiker, de Komidabot kan tijdelijk geen menu's tonen voor de Komida.\n" "U kunt de menu's bekijken en bestellen door op deze link te klikken: " "https://uantwerpen.be/nl/studentenleven/eten/over-komida/online-bestellen/", }) # MESSAGE_NO_SUBSCRIPTIONS = localisation_definition('REPLY_SET_SUBSCRIPTION', { # 'en': 'Dear user, from now on you can once again request the bot to send a daily menu at 10am.\n\n' # 'You can set this up by clicking on the "Manage subscription" button in the menu.\n\n' # 'Your preferences for this are per-day and can be changed at any moment.', # 'nl': 'Beste gebruiker, vanaf nu kan je de bot terug vragen om dagelijks het menu naar je te sturen.\n\n' # 'Je kan dit instellen door in het menu op "Manage subscription" te drukken.\n\n' # 'Uw voorkeuren hiervoor zijn per dag en kunnen op ieder moment aangepast worden.', # }) # # MESSAGE_FIRST_SUBSCRIPTION = localisation_definition('REPLY_SET_SUBSCRIPTION', { # 'en': 'Dear user, from now on the bot will send you the daily menu at 10am once again.\n\n' # 'You can change your preferences by clicking on the "Manage subscription" button in the menu.\n\n' # 'Your preferences are per-day and can be changed at any moment.', # 'nl': 'Beste gebruiker, vanaf nu zal de bot terug automatisch het menu doorsturen om 10 uur.\n\n' # 'Je kan je voorkeuren aanpassen door in het menu op "Manage subscription" te drukken.\n\n' # 'Uw voorkeuren zijn per dag en kunnen op ieder moment aangepast worden.', # }) ================================================ FILE: komidabot/menu.py ================================================ import datetime from typing import Optional import komidabot.localisation as localisation import komidabot.models as models import komidabot.translation as translation import komidabot.util as util def get_menu_line(menu_item: models.MenuItem, translator: translation.TranslationService, locale: str = None) -> str: translation_obj = menu_item.get_translation(locale, translator) if not menu_item.price_staff: price_str = models.MenuItem.format_price(menu_item.price_students) else: price_str = '{} / {}'.format(models.MenuItem.format_price(menu_item.price_students), models.MenuItem.format_price(menu_item.price_staff)) return '{} {} ({})'.format(models.course_icons_matrix[menu_item.course_type][menu_item.course_sub_type], translation_obj.translation, price_str) def prepare_menu_text(campus: models.Campus, date: datetime.date, translator: translation.TranslationService, locale: str) -> 'Optional[str]': return get_menu_text(models.Menu.get_menu(campus, date), translator, locale) def get_menu_text(menu: Optional[models.Menu], translator: translation.TranslationService, locale: str) -> 'Optional[str]': if menu is None: return None date_str = util.date_to_string(locale, menu.menu_day) result = [localisation.REPLY_MENU_START(locale).format(campus=menu.campus.name, date=date_str), ''] # if len(menu.menu_items) < 6: # result.insert(1, localisation.REPLY_MENU_INCOMPLETE(locale)) try: for item in menu.menu_items: item: models.MenuItem result.append(get_menu_line(item, translator, locale)) except Exception: print('Failed translating to {}'.format(locale), flush=True) raise return '\n'.join(result) def get_short_menu_text(menu: Optional[models.Menu], translator: translation.TranslationService, locale: str, *course_types: models.CourseType) -> 'Optional[str]': if menu is None: return None result = [] try: for item in menu.menu_items: item: models.MenuItem if course_types and item.course_type in course_types: result.append(get_menu_line(item, translator, locale)) except Exception: print('Failed translating to {}'.format(locale), flush=True) raise return '\n'.join(result) ================================================ FILE: komidabot/messages.py ================================================ import datetime import enum from typing import Any, Dict, List, Optional, Type, TypeVar, Union import komidabot.models as models import komidabot.translation as translation class Aspect: allows_multiple = False def __repr__(self): return 'Aspect' T = TypeVar('T') class Trigger: def __init__(self, aspects: List[Aspect] = None): self._aspects: Dict[Type[Aspect], Union[List[Aspect], Aspect]] = dict() if aspects: for aspect in aspects: self.add_aspect(aspect) def add_aspect(self, aspect: Aspect, aspect_type: Type[Aspect] = None): aspect_type = aspect_type or type(aspect) if aspect_type in self._aspects: if aspect_type.allows_multiple: self._aspects[aspect_type].append(aspect) else: raise ValueError('Cannot add multiple aspects for ' + aspect_type.__name__) else: if aspect_type.allows_multiple: self._aspects[aspect_type] = [aspect] else: self._aspects[aspect_type] = aspect def __contains__(self, aspect_type: Type[Aspect]) -> bool: return aspect_type in self._aspects def __getitem__(self, aspect_type: Type[T]) -> Union[List[T], T]: return self._aspects[aspect_type] def __delitem__(self, aspect_type: Type[Aspect]): del self._aspects[aspect_type] @classmethod def extend(cls: Type[T], trigger: 'Trigger', *args, aspects: List[Aspect] = None, **kwargs) -> T: new_instance = cls(*args, **kwargs) for aspect_type in trigger._aspects: if not aspect_type.allows_multiple: new_instance.add_aspect(trigger._aspects[aspect_type]) else: for aspect in trigger._aspects[aspect_type]: new_instance.add_aspect(aspect) if aspects: for aspect in aspects: new_instance.add_aspect(aspect) return new_instance def __repr__(self): result = self.get_repr_text() for aspect_type in self._aspects: result.append('- ' + repr(self._aspects[aspect_type])) return '\n'.join(result) def get_repr_text(self): return ['Trigger'] class Message: def __init__(self, trigger: Trigger): self.trigger = trigger class TextMessage(Message): def __init__(self, trigger: Trigger, text: str): super().__init__(trigger) self.text = text class ExceptionMessage(Message): def __init__(self, trigger: Trigger, source: Exception): super().__init__(trigger) self.source = source class MenuMessage(Message): def __init__(self, trigger: Trigger, menu: models.Menu, translator: translation.TranslationService): super().__init__(trigger) self.menu = menu self.translator = translator class SubscriptionMenuMessage(Message): def __init__(self, trigger: Trigger, date: datetime.date, translator: translation.TranslationService): super().__init__(trigger) self.date = date self.translator = translator # campus id -> {language -> {user manager -> prepared message}} self.prepared_cache: Dict[int, Dict[str, Dict[str, Any]]] = dict() def get_prepared(self, campus: models.Campus, lang: str, user_manager: str) -> Optional[Any]: if campus.id in self.prepared_cache: for_campus = self.prepared_cache[campus.id] if lang in for_campus: for_lang = for_campus[lang] if user_manager in for_lang: return for_lang[user_manager] return None def set_prepared(self, campus: models.Campus, lang: str, user_manager: str, prepared: Any): if campus.id not in self.prepared_cache: self.prepared_cache[campus.id] = {} for_campus = self.prepared_cache[campus.id] if lang not in for_campus: for_campus[lang] = {} for_campus[lang][user_manager] = prepared class MessageSendResult(enum.Enum): # Indicates successful message sending SUCCESS = 'Success' # Indicates an internal error when sending ERROR = 'Error' # Indicates an external error when sending EXTERNAL_ERROR = 'External error' # Indicates the message could not be sent because the user does not support receiving it UNSUPPORTED = 'Unsupported' # Indicates the user could not be reached, but could potentially be reached in the future UNREACHABLE = 'Unreachable' # Indicates the user no longer exists, the user should be removed from the database GONE = 'Gone' class MessageHandler: # NOTE: There are some cases where the result of this method is important # For example: When sending subscription messages, we cannot be certain the message will arrive, or the user # may have unsubscribed and we need to remove their entry from the database. # For cases where the message is a direct result of the user sending a message to us, we assume the message # will be delivered without problems. def send_message(self, user, message: 'Message') -> 'MessageSendResult': raise NotImplementedError() ================================================ FILE: komidabot/models.py ================================================ import datetime import enum import json import locale from decimal import Decimal from typing import Any, Collection, Dict, List, Optional, Tuple from sqlalchemy import inspect as sqlalchemy_inspect from sqlalchemy.orm.session import make_transient, make_transient_to_detached from sqlalchemy.sql import expression from extensions import db, ModelBase from komidabot.translation import TranslationService from komidabot.util import expected, expected_or_none make_transient = make_transient make_transient_to_detached = make_transient_to_detached _KEYWORDS_SEPARATOR = ' ' # Main course type class CourseType(enum.Enum): SOUP = 1 DAILY = 2 PASTA = 3 GRILL = 4 SALAD = 5 SUB = 6 DESSERT = 7 SNACK = 8 # Course sub-type class CourseSubType(enum.Enum): NORMAL = 1 VEGETARIAN = 2 VEGAN = 3 # Course attributes from external menu class CourseAttributes(enum.Enum): BIO = 201 CHICKEN = 202 GRILL = 203 CHEESE = 204 RABBIT = 205 LAMB = 206 PASTA = 207 VEAL = 208 SALAD = 209 SNACK = 210 SOUP = 211 PIG = 212 VEGAN = 213 VEGGIE = 214 FISH = 215 LESS_MEAT = 216 HEALTHIFY = 217 BROWN_BREAD = 218 WHITE_BREAD = 219 CONCEPT_BREAD = 220 @classmethod def has_value(cls, value): return value in cls._value2member_map_ # Course attributes from external menu class CourseAllergens(enum.Enum): EGG = 200 WHEAT_GLUTEN = 201 LUPINE = 202 MILK_LACTOSE = 203 MUSTARD = 204 NUTS = 205 PEANUTS = 206 SHELLFISH = 207 CELERY = 208 SESAME = 209 SOY = 210 SULFITES = 211 FISH = 212 MOLLUSKS = 213 HALAL = 214 @classmethod def has_value(cls, value): return value in cls._value2member_map_ course_icons_matrix = { CourseType.SOUP: { CourseSubType.NORMAL: '🍵', CourseSubType.VEGETARIAN: '🍵', CourseSubType.VEGAN: '🍵', }, CourseType.DAILY: { CourseSubType.NORMAL: '🥩', CourseSubType.VEGETARIAN: '🥬', CourseSubType.VEGAN: '🥬', }, CourseType.PASTA: { CourseSubType.NORMAL: '🍝', CourseSubType.VEGETARIAN: '🍝', CourseSubType.VEGAN: '🍝', }, CourseType.GRILL: { CourseSubType.NORMAL: '🍖', CourseSubType.VEGETARIAN: '🍖', CourseSubType.VEGAN: '🍖', }, CourseType.SALAD: { CourseSubType.NORMAL: '🥗', CourseSubType.VEGETARIAN: '🥗', CourseSubType.VEGAN: '🥗', }, CourseType.SUB: { CourseSubType.NORMAL: '🥖', CourseSubType.VEGETARIAN: '🥖', CourseSubType.VEGAN: '🥖', }, CourseType.DESSERT: { CourseSubType.NORMAL: '🍨', CourseSubType.VEGETARIAN: '🍨', CourseSubType.VEGAN: '🍨', }, CourseType.SNACK: { CourseSubType.NORMAL: '🥐', CourseSubType.VEGETARIAN: '🥐', CourseSubType.VEGAN: '🥐', }, } class Day(enum.Enum): MONDAY = 1 TUESDAY = 2 WEDNESDAY = 3 THURSDAY = 4 FRIDAY = 5 # Added for compat with datetime.date SATURDAY = 6 SUNDAY = 7 week_days = [Day.MONDAY, Day.TUESDAY, Day.WEDNESDAY, Day.THURSDAY, Day.FRIDAY] class AppSettings(ModelBase): __tablename__ = 'app_settings' name = db.Column(db.String(), primary_key=True) value = db.Column(db.String(), nullable=False, server_default=json.dumps(None)) def __init__(self, name: str, value: Any = None): if not isinstance(name, str): raise ValueError('name expected {} got {}'.format(type(str), type(name))) self.name = name self.value = json.dumps(value) @staticmethod def create_entries(): AppSettings.set_default('registrations_enabled', False) db.session.commit() @staticmethod def set_default(name: str, default: Any) -> 'AppSettings': setting = AppSettings.query.filter_by(name=name).first() if setting is None: setting = AppSettings(name, default) db.session.add(setting) return setting @staticmethod def get_value(name: str) -> Any: setting = AppSettings.query.filter_by(name=name).first() assert setting is not None return json.loads(setting.value) class Campus(ModelBase): __tablename__ = 'campus' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) name = db.Column(db.String(128), nullable=False) short_name = db.Column(db.String(8), nullable=False) # TODO: Wouldn't it be easier to instead have a new table mapping keywords to campuses, resolving possible conflicts keywords = db.Column(db.Text(), default='', nullable=False) active = db.Column(db.Boolean(), default=True, nullable=False) external_id = db.Column(db.Integer(), nullable=False) menus = db.relationship('Menu', backref='campus', passive_deletes=True) closing_days = db.relationship('ClosingDays', backref='campus', passive_deletes=True) subscriptions = db.relationship('UserDayCampusPreference', backref='campus', passive_deletes=True) def __init__(self, name: str, short_name: str): if not isinstance(name, str): raise expected('name', name, str) if not isinstance(short_name, str): raise expected('short_name', short_name, str) self.name = name self.short_name = short_name.lower() self._set_keywords([short_name, ]) def get_keywords(self) -> List[str]: return self.keywords.split(_KEYWORDS_SEPARATOR) def add_keyword(self, keyword: str): if _KEYWORDS_SEPARATOR in keyword: raise ValueError('Cannot have a space (the separator) in a keyword: {}'.format(repr(keyword))) self._set_keywords(self.get_keywords() + [keyword.lower(), ]) def remove_keyword(self, keyword: str): self._set_keywords([kw for kw in self.get_keywords() if kw != keyword]) def _set_keywords(self, keywords: List[str]): separator = _KEYWORDS_SEPARATOR # XXX: Add separator at the front and end for queries self.keywords = separator + separator.join(set(kw for kw in keywords if kw)) + separator @staticmethod def create(name: str, short_name: str, keywords: List[str], external_id: int, add_to_db=True) -> 'Campus': result = Campus(name, short_name) result.external_id = external_id for keyword in keywords: result.add_keyword(keyword) if add_to_db: db.session.add(result) return result @staticmethod def get_by_id(campus_id: int) -> 'Optional[Campus]': return Campus.query.filter_by(id=campus_id).first() @staticmethod def get_by_external_id(external_id: int) -> 'Optional[Campus]': return Campus.query.filter_by(external_id=external_id).first() @staticmethod def get_by_short_name(short_name: str) -> 'Optional[Campus]': return Campus.query.filter_by(short_name=short_name).first() @staticmethod def find_by_keyword(keyword: str) -> 'List[Campus]': # XXX: Each keyword is prepended and appended with the separator return Campus.query.filter(Campus.keywords.contains(_KEYWORDS_SEPARATOR + keyword.lower() + _KEYWORDS_SEPARATOR, autoescape=True)).all() @staticmethod def get_all() -> 'List[Campus]': return Campus.query.order_by(Campus.id).all() @staticmethod def get_all_active() -> 'List[Campus]': return Campus.query.filter_by(active=True).order_by(Campus.id).all() def __hash__(self): return hash(self.id) class ClosingDays(ModelBase): __tablename__ = 'closing_days' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) campus_id = db.Column(db.Integer(), db.ForeignKey('campus.id'), nullable=False) first_day = db.Column(db.Date(), nullable=False) last_day = db.Column(db.Date(), nullable=True, server_default=None) translatable_id = db.Column(db.Integer(), db.ForeignKey('translatable.id', onupdate='CASCADE', ondelete='RESTRICT'), nullable=False) def __init__(self, campus_id: int, first_day: datetime.date, last_day: datetime.date, translatable_id: int): if not isinstance(campus_id, int): raise expected('campus_id', campus_id, int) if not isinstance(first_day, datetime.date): raise expected('first_day', first_day, datetime.date) if last_day is not None and not isinstance(last_day, datetime.date): raise expected_or_none('last_day', last_day, datetime.date) if not isinstance(translatable_id, int): raise expected('translatable_id', translatable_id, int) self.campus_id = campus_id self.first_day = first_day self.last_day = last_day self.translatable_id = translatable_id @staticmethod def create(campus: Campus, first_day: datetime.date, last_day: Optional[datetime.date], reason: str, language: str, add_to_db=True) -> 'ClosingDays': translatable, translation = Translatable.get_or_create(reason, language) result = ClosingDays(campus.id, first_day, last_day, translatable.id) if add_to_db: db.session.add(result) return result @staticmethod def find_is_closed(campus: Campus, day: datetime.date) -> 'Optional[ClosingDays]': return ClosingDays.query.filter(db.and_(ClosingDays.campus_id == campus.id, ClosingDays.first_day <= day, db.or_( ClosingDays.last_day == None, ClosingDays.last_day >= day ) )).first() @staticmethod def find_closing_days_including(campus: Campus, start_date: datetime.date, end_date: datetime.date) -> 'List[ClosingDays]': return ClosingDays.query.filter(db.and_(ClosingDays.campus_id == campus.id, ClosingDays.first_day <= end_date, db.or_( ClosingDays.last_day == None, ClosingDays.last_day >= start_date ) )).all() class Translatable(ModelBase): __tablename__ = 'translatable' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) original_language = db.Column(db.String(5), nullable=False) original_text = db.Column(db.String(256), nullable=False) _translations = db.relationship('Translation', backref='translatable', passive_deletes=True) menu_items = db.relationship('MenuItem', backref='translatable') closing_days = db.relationship('ClosingDays', backref='translatable') def __init__(self, text: str, language: str): if not isinstance(text, str): raise expected('text', text, str) if not isinstance(language, str): raise expected('language', language, str) self.original_language = language self.original_text = text def add_translation(self, language: str, text: str, provider: str = None) -> 'Translation': if sqlalchemy_inspect(self).transient: raise ValueError('Translatable is transient and cannot have translations') if language == self.original_language: return self._get_dummy_translation() translation = Translation.query.filter_by(translatable_id=self.id, language=language).first() if translation is None: translation = Translation(self.id, language, text, provider) db.session.add(translation) return translation def get_translation(self, language: str, translator: 'TranslationService' = None) -> 'Translation': if not language: raise ValueError('language expected (got {})'.format(language)) if translator is not None and not isinstance(translator, TranslationService): raise expected_or_none('translator', translator, TranslationService) if sqlalchemy_inspect(self).transient: raise ValueError('Translatable is transient and cannot have translations') if language == self.original_language: return self._get_dummy_translation() translation = Translation.query.filter_by(translatable_id=self.id, language=language).first() if translation is None: if translator is None: raise ValueError('Cannot translate without translator function') translation_text = translator.translate(self.original_text, self.original_language, language) translation = self.add_translation(language, translation_text, translator.identifier) return translation def has_translation(self, language: str) -> 'bool': if not language: raise ValueError('language') if sqlalchemy_inspect(self).transient: raise ValueError('Translatable is transient and cannot have translations') if language == self.original_language: return True return db.session.query(Translation.query.filter_by(translatable_id=self.id, language=language).exists()).scalar() @property def translations(self) -> 'Collection[Translation]': return self._get_dummy_translation(), *list(self._translations) def _get_dummy_translation(self) -> 'Translation': translation = getattr(self, '_dummy_translation', None) if translation is None: # Make a fake Translation object translation = Translation(self.id, self.original_language, self.original_text) make_transient_to_detached(translation) setattr(self, '_dummy_translation', translation) return translation @staticmethod def get_or_create(text: str, language) -> 'Tuple[Translatable, Translation]': translatable = Translatable.query.filter_by(original_language=language, original_text=text).first() if translatable is None: translatable = Translatable(text, language) db.session.add(translatable) db.session.flush() return translatable, translatable.get_translation(language, None) @staticmethod def get_by_id(translatable_id) -> 'Optional[Translatable]': return Translatable.query.filter_by(id=translatable_id).first() def __hash__(self): return hash(self.id) class Translation(ModelBase): __tablename__ = 'translation' translatable_id = db.Column(db.Integer(), db.ForeignKey('translatable.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) language = db.Column(db.String(5), primary_key=True) translation = db.Column(db.String(256), nullable=False) provider = db.Column(db.String(16)) def __init__(self, translatable_id: int, language: str, translation: str, provider: str = None): if not isinstance(translatable_id, int): raise expected('translatable_id', translatable_id, int) if not isinstance(language, str): raise expected('language', language, str) if not isinstance(translation, str): raise expected('translation', translation, str) if provider is not None and not isinstance(provider, str): raise expected_or_none('provider', provider, str) self.translatable_id = translatable_id self.language = language self.translation = translation self.provider = provider def __eq__(self, other: 'Translation'): if self.translatable_id != other.translatable_id: return False if self.language != other.language: return False if self.translation != other.translation: return False return True def __hash__(self): return hash((self.translatable_id, self.language)) class Menu(ModelBase): __tablename__ = 'menu' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) campus_id = db.Column(db.Integer(), db.ForeignKey('campus.id'), nullable=False) menu_day = db.Column(db.Date(), nullable=False) menu_items: 'Collection[MenuItem]' = db.relationship('MenuItem', backref='menu', passive_deletes=True, order_by='[MenuItem.course_type, MenuItem.course_sub_type]') def __init__(self, campus_id: int, day: datetime.date): if not isinstance(campus_id, int): raise expected('campus_id', campus_id, int) if not isinstance(day, datetime.date): raise expected('day', day, datetime.date) self.campus_id = campus_id self.menu_day = day def delete(self): db.session.delete(self) def add_menu_item(self, translatable: Translatable, course_type: CourseType, course_sub_type: CourseSubType, course_attributes: List[CourseAttributes], course_allergens: List[CourseAllergens], price_students: Decimal, price_staff: Optional[Decimal]) -> 'MenuItem': menu_item = MenuItem(self, translatable.id, course_type, course_sub_type, price_students, price_staff) menu_item.set_attributes(course_attributes) menu_item.set_allergens(course_allergens) # FIXME: Is this safe? self.menu_items.append(menu_item) return menu_item @staticmethod def create(campus: Campus, day: datetime.date, add_to_db=True) -> 'Menu': menu = Menu(campus.id, day) if add_to_db: db.session.add(menu) return menu @staticmethod def get_menu(campus: Campus, day: datetime.date) -> 'Optional[Menu]': return Menu.query.filter_by(campus_id=campus.id, menu_day=day).first() @staticmethod def remove_menus_on_closing_days(): rows = Menu.query.filter( ClosingDays.query.filter( Menu.campus_id == ClosingDays.campus_id, Menu.menu_day >= ClosingDays.first_day, Menu.menu_day <= ClosingDays.last_day ).exists() ).all() for row in rows: db.session.delete(row) def __hash__(self): return hash(self.id) class MenuItem(ModelBase): __tablename__ = 'menu_item' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) menu_id = db.Column(db.Integer(), db.ForeignKey('menu.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) translatable_id = db.Column(db.Integer(), db.ForeignKey('translatable.id', onupdate='CASCADE', ondelete='RESTRICT'), nullable=False) external_id = db.Column(db.Integer(), unique=True, nullable=True, server_default=expression.null()) course_type = db.Column(db.Enum(CourseType), nullable=False) course_sub_type = db.Column(db.Enum(CourseSubType), nullable=False) course_attributes = db.Column(db.Text(), nullable=False, default='[]', server_default='[]') course_allergens = db.Column(db.Text(), nullable=False, default='[]', server_default='[]') price_students = db.Column(db.Numeric(4, 2), nullable=False) price_staff = db.Column(db.Numeric(4, 2), nullable=True) data_frozen = db.Column(db.Boolean(), nullable=False, server_default=expression.false()) def __init__(self, menu: Menu, translatable_id: int, course_type: CourseType, course_sub_type: CourseSubType, price_students: Decimal, price_staff: Optional[Decimal]): if not isinstance(menu, Menu): raise expected('menu', menu, Menu) if not isinstance(translatable_id, int): raise expected('translatable_id', translatable_id, int) if not isinstance(course_type, CourseType): raise expected('course_type', course_type, CourseType) if not isinstance(course_sub_type, CourseSubType): raise expected('course_sub_type', course_sub_type, CourseSubType) if not isinstance(price_students, Decimal): raise expected('price_students', price_students, Decimal) if price_staff is not None and not isinstance(price_staff, Decimal): raise expected_or_none('price_staff', price_staff, Decimal) self.menu = menu self.translatable_id = translatable_id self.course_type = course_type self.course_sub_type = course_sub_type self.price_students = price_students self.price_staff = price_staff def get_translation(self, language: str, translator: 'TranslationService') -> 'Translation': return self.translatable.get_translation(language, translator) @staticmethod def format_price(price: Decimal) -> str: if price == 0.0: return '' return locale.currency(price).replace(' ', '') def get_attributes(self) -> List[CourseAttributes]: # Stored as a list of strings or a list of ints (backwards compat) return [CourseAttributes(v) if isinstance(v, int) else CourseAttributes[v] for v in json.loads(self.course_attributes)] def set_attributes(self, attributes: List[CourseAttributes]): self.course_attributes = json.dumps([v.name for v in attributes]) def get_allergens(self) -> List[CourseAllergens]: # Stored as a list of strings return [CourseAllergens[v] for v in json.loads(self.course_allergens)] def set_allergens(self, allergens: List[CourseAllergens]): self.course_allergens = json.dumps([v.name for v in allergens]) def __hash__(self): return hash(self.id) class UserDayCampusPreference(ModelBase): __tablename__ = 'user_day_campus_preference' user_id = db.Column(db.Integer(), db.ForeignKey('app_user.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) day = db.Column(db.Enum(Day), primary_key=True) campus_id = db.Column(db.Integer(), db.ForeignKey('campus.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) # FIXME: Move this out of this table and instead store this in some subscription info table for daily_menu channel active = db.Column(db.Boolean(), default=True, nullable=False) def __init__(self, user_id: int, day: Day, campus_id: int, active=True) -> None: if not isinstance(user_id, int): raise expected('user_id', user_id, int) if not isinstance(day, Day): raise expected('day', day, Day) if not isinstance(campus_id, int): raise expected('campus_id', campus_id, int) if not isinstance(active, bool): raise expected('active', active, bool) self.user_id = user_id self.day = day self.campus_id = campus_id self.active = active @staticmethod def get_all_for_user(user: 'AppUser') -> 'List[UserDayCampusPreference]': return UserDayCampusPreference.query.filter_by(user_id=user.id).all() @staticmethod def get_for_user(user: 'AppUser', day: Day) -> 'Optional[UserDayCampusPreference]': return UserDayCampusPreference.query.filter_by(user_id=user.id, day=day).first() @staticmethod def create(user: 'AppUser', day: Day, campus: Campus, active=True) -> 'Optional[UserDayCampusPreference]': if day in [Day.SATURDAY, Day.SUNDAY]: raise ValueError('Day cannot be SATURDAY or SUNDAY') subscription = UserDayCampusPreference(user.id, day, campus.id, active) db.session.add(subscription) return subscription def __hash__(self): return hash((self.user_id, self.day)) class AppUser(ModelBase): __tablename__ = 'app_user' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) provider = db.Column(db.String(32), nullable=False) # String ID of the provider internal_id = db.Column(db.String(), nullable=False) # ID that is specific to the provider language = db.Column(db.String(5), nullable=False) # Flag indicating whether a user has been informed about the new site or not notified_new_site = db.Column(db.Boolean(), nullable=False, default=False, server_default=expression.false()) enabled = db.Column(db.Boolean(), nullable=False, default=True, server_default=expression.true()) data = db.Column(db.Text(), nullable=True) # Stores data specific to the provider __table_args__ = ( db.UniqueConstraint('provider', 'internal_id'), ) subscriptions = db.relationship('UserDayCampusPreference', backref='user', passive_deletes=True) feature_participations = db.relationship('FeatureParticipation', backref='user', passive_deletes=True) def __init__(self, provider: str, internal_id: str, language: str): if not isinstance(provider, str): raise expected('provider', provider, str) if not isinstance(internal_id, str): raise expected('internal_id', internal_id, str) if not isinstance(language, str): raise expected('language', language, str) self.provider = provider self.internal_id = internal_id self.language = language def set_campus(self, day: Day, campus: Campus, active=None): sub = UserDayCampusPreference.get_for_user(self, day) if sub is None: UserDayCampusPreference.create(self, day, campus, active=True if active is None else active) else: sub.campus = campus if active is not None: sub.active = active def set_day_active(self, day: Day, active: bool): sub = UserDayCampusPreference.get_for_user(self, day) if sub is None: if active: raise ValueError('Cannot set subscription active if there is no campus set') else: sub.active = active def get_campus(self, day: Day) -> 'Optional[Campus]': sub = UserDayCampusPreference.get_for_user(self, day) if sub is not None: return sub.campus else: return None def get_subscription(self, day: Day) -> 'Optional[UserDayCampusPreference]': return UserDayCampusPreference.get_for_user(self, day) def set_language(self, language: str): self.language = language def set_active(self, day: Day, active: bool): sub = UserDayCampusPreference.get_for_user(self, day) if sub is None: raise ValueError('User does not have a subscription on day {}'.format(day.name)) sub.active = active @staticmethod def create(provider: str, internal_id: str, language: str) -> 'AppUser': user = AppUser(provider, internal_id, language) db.session.add(user) return user def delete(self): db.session.delete(self) @staticmethod def find_subscribed_users_by_day(day: Day, provider=None) -> 'List[AppUser]': q = AppUser.query if provider: q = q.filter_by(provider=provider) return q.join(AppUser.subscriptions).filter(db.and_(UserDayCampusPreference.day == day, UserDayCampusPreference.active == expression.true(), AppUser.enabled == expression.true() )).order_by(AppUser.provider, AppUser.internal_id).all() @staticmethod def find_by_id(provider: str, internal_id: str) -> 'Optional[AppUser]': return AppUser.query.filter_by(provider=provider, internal_id=internal_id).first() @staticmethod def find_by_provider(provider: str) -> 'List[AppUser]': return AppUser.query.filter_by(provider=provider).order_by(AppUser.internal_id).all() def __hash__(self): return hash(self.id) class Feature(ModelBase): __tablename__ = 'feature' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) string_id = db.Column(db.String(256), nullable=False, unique=True) description = db.Column(db.Text()) globally_available = db.Column(db.Boolean(), default=False, nullable=False) participations = db.relationship('FeatureParticipation', backref='feature', passive_deletes=True) def __init__(self, string_id: str, description: str = None, globally_available=False): if not isinstance(string_id, str): raise expected('string_id', string_id, str) if description is not None and not isinstance(description, str): raise expected_or_none('description', description, str) if globally_available is not None and not isinstance(globally_available, bool): raise expected_or_none('globally_available', globally_available, bool) self.string_id = string_id self.description = description self.globally_available = globally_available @staticmethod def create(string_id: str, description: str = None, globally_available=False) -> 'Optional[Feature]': feature = Feature(string_id, description, globally_available) db.session.add(feature) return feature @staticmethod def find_by_id(string_id: str) -> 'Optional[Feature]': return Feature.query.filter_by(string_id=string_id).first() @staticmethod def get_all() -> 'List[Feature]': return Feature.query.all() @staticmethod def is_user_participating(user: Optional[AppUser], string_id: str) -> bool: feature = Feature.find_by_id(string_id) if feature is None: return False if feature.globally_available: return True if user is None: return False return FeatureParticipation.get_for_user(user, feature) is not None @staticmethod def set_user_participating(user: AppUser, string_id: str, participating: bool): feature = Feature.find_by_id(string_id) participation = FeatureParticipation.get_for_user(user, feature) if participating: if not participation: FeatureParticipation.create(user, feature) else: if participation: db.session.delete(feature) def __hash__(self): return hash(self.id) class FeatureParticipation(ModelBase): __tablename__ = 'feature_participation' user_id = db.Column(db.Integer(), db.ForeignKey('app_user.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) feature_id = db.Column(db.Integer(), db.ForeignKey('feature.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) def __init__(self, user_id: int, feature_id: int): if not isinstance(user_id, int): raise expected('user_id', user_id, int) if not isinstance(feature_id, int): raise expected('feature_id', feature_id, int) self.user_id = user_id self.feature_id = feature_id @staticmethod def create(user: AppUser, feature: Feature) -> 'Optional[FeatureParticipation]': participation = FeatureParticipation(user.id, feature.id) db.session.add(participation) return participation @staticmethod def get_for_user(user: AppUser, feature: Feature) -> 'Optional[FeatureParticipation]': return FeatureParticipation.query.filter_by(user_id=user.id, feature_id=feature.id).first() def __hash__(self): return hash((self.user_id, self.feature_id)) def recreate_db(): db.drop_all() db.create_all() db.session.commit() # noinspection PyUnusedLocal def create_standard_values(): cst = Campus.create('Stadscampus', 'cst', ['stad', 'stadscampus'], 1) cde = Campus.create('Campus Drie Eiken', 'cde', ['drie', 'eiken'], 2) cmi = Campus.create('Campus Middelheim', 'cmi', ['middelheim'], 3) cgb = Campus.create('Campus Groenenborger', 'cgb', ['groenenborger'], 4) cmu = Campus.create('Campus Mutsaard', 'cmu', ['mutsaard'], 5) hzs = Campus.create('Hogere Zeevaartschool', 'hzs', ['hogere', 'zeevaartschool'], 6) hzs.active = False db.session.commit() def import_dump(dump_file): campus_dict: Dict[str, Campus] = dict() def get_campus(short_name) -> Campus: if short_name not in campus_dict: campus_dict[short_name] = Campus.get_by_short_name(short_name) return campus_dict[short_name] with open(dump_file) as file: _ = file.readline() # Skip header line = file.readline() while line: line = line.strip() split = list(line.split('\t')) if len(split) == 8: split[1] = split[1] == 'True' if split[7] == '0': split[7] = '' # Query locale user = AppUser.create('facebook', split[0], split[7]) user.set_campus(Day.MONDAY, get_campus(split[2]), active=split[1]) user.set_campus(Day.TUESDAY, get_campus(split[3]), active=split[1]) user.set_campus(Day.WEDNESDAY, get_campus(split[4]), active=split[1]) user.set_campus(Day.THURSDAY, get_campus(split[5]), active=split[1]) user.set_campus(Day.FRIDAY, get_campus(split[6]), active=split[1]) db.session.add(user) line = file.readline() db.session.commit() ================================================ FILE: komidabot/models_training.py ================================================ import datetime import enum import json from typing import Any, List, NamedTuple, Optional, TypedDict, Union from sqlalchemy.sql import expression from extensions import db, ModelBase from komidabot.models_users import RegisteredUser from komidabot.util import expected # ChoiceSchemaType = NamedTuple('ChoiceType', (('display', str), ('value', Any),)) # # # class SchemaElementType(enum.Enum): # STATIC_TEXT = 1 # Value type: str; always readonly # STATIC_IMAGE = 2 # Value type: str (base64 encoded data); always readonly # DIVIDER = 3 # Value type: nothing; always readonly # BOOLEAN = 4 # Value type: nothing # CHOICE = 5 # Value type: List[ChoiceType] # MULTIPLE_CHOICE = 6 # Value type: List[ChoiceType] # TEXT = 7 # Value type: nothing # NUMBER = 8 # Value type: nothing # # # class SchemaElement(TypedDict): # type: int # SchemaElementType # description: Optional[str] # readonly: Optional[bool] # # # DataElement = Union[str, List[ChoiceSchemaType], None] # # # class TrainingSchema(ModelBase): # __tablename__ = 'training_schema' # # id = db.Column(db.Integer(), primary_key=True, autoincrement=True) # name = db.Column(db.String(), nullable=False) # schema = db.Column(db.String(), nullable=False) # # def __init__(self, name: str, schema: str): # if not isinstance(name, str): # raise expected('name', name, str) # if not isinstance(schema, str): # raise expected('schema', schema, str) # # self.name = name # self.schema = schema # # @staticmethod # def create(name: str, schema: 'List[SchemaElement]', add_to_db=True) -> 'TrainingSchema': # if not isinstance(schema, list): # raise expected('schema', schema, list) # for element in schema: # if not isinstance(element, dict): # raise expected('schema[]', element, dict) # if 'type' not in element: # raise ValueError('Missing type in SchemaElement') # # # FIXME: Verify schema # result = TrainingSchema(name, json.dumps(schema)) # # if add_to_db: # db.session.add(result) # # return result # # @staticmethod # def find_by_id(schema_id: int) -> 'Optional[TrainingSchema]': # return TrainingSchema.query.filter_by(id=schema_id).first() # # def get_schema(self) -> 'List[SchemaElement]': # return json.loads(self.schema) # # def add_input(self, data: 'List[DataElement]', # add_to_db=True) -> 'Optional[TrainingInput]': # # FIXME: Verify data # result = TrainingInput(self.id, json.dumps(data)) # # if add_to_db: # db.session.add(result) # # return result # # # class TrainingInput(ModelBase): # __tablename__ = 'training_input' # # id = db.Column(db.Integer(), primary_key=True, autoincrement=True) # schema_id = db.Column(db.Integer(), db.ForeignKey('training_schema.id'), nullable=False) # data = db.Column(db.String(), nullable=False) # # def __init__(self, schema_id: int, data: str): # if not isinstance(schema_id, int): # raise expected('schema_id', schema_id, int) # if not isinstance(data, str): # raise expected('data', data, str) # # self.schema_id = schema_id # self.data = data # # @staticmethod # def find_by_id(input_id: int) -> 'Optional[TrainingInput]': # return TrainingInput.query.filter_by(id=input_id).first() # # @staticmethod # def get_random(user: 'RegisteredUser') -> 'Optional[TrainingInput]': # return TrainingInput.query.order_by(expression.func.random()).filter( # expression.not_( # TrainingResponse.query.filter( # TrainingInput.id == TrainingResponse.input_id, # TrainingResponse.user_id == user.id # ).exists() # ) # ).first() # # def add_response(self, user: 'RegisteredUser', data: Any, add_to_db=True): # # FIXME: Verify data # result = TrainingResponse(self.id, user.id, json.dumps(data)) # # if add_to_db: # db.session.add(result) # # return result # # # class TrainingResponse(ModelBase): # __tablename__ = 'training_response' # # id = db.Column(db.Integer(), primary_key=True, autoincrement=True) # input_id = db.Column(db.Integer(), db.ForeignKey('training_input.id'), nullable=False) # user_id = db.Column(db.Integer(), db.ForeignKey('registered_users.id', onupdate='CASCADE', ondelete='CASCADE'), # nullable=False) # data = db.Column(db.String(), nullable=False) # # def __init__(self, input_id: int, user_id: int, data: str): # if not isinstance(input_id, int): # raise expected('input_id', input_id, int) # if not isinstance(user_id, int): # raise expected('user_id', user_id, int) # if not isinstance(data, str): # raise expected('data', data, str) # # self.input_id = input_id # self.user_id = user_id # self.data = data class LearningDatapoint(ModelBase): __tablename__ = 'learning_datapoint' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) campus_id = db.Column(db.Integer(), db.ForeignKey('campus.id'), nullable=False) menu_day = db.Column(db.Date(), nullable=False) screenshot = db.Column(db.Text(), nullable=False) processed_data = db.Column(db.Text(), nullable=False) submissions = db.relationship('LearningDatapointSubmission', backref='datapoint', passive_deletes=True) def __init__(self, campus_id: int, menu_day: datetime.date, screenshot: str, processed_data: Any): if not isinstance(campus_id, int): raise expected('campus_id', campus_id, int) if not isinstance(menu_day, datetime.date): raise expected('menu_day', menu_day, datetime.date) if screenshot is None: raise ValueError('screenshot expected not None') if processed_data is None: raise ValueError('processed_data expected not None') self.campus_id = campus_id self.menu_day = menu_day self.screenshot = screenshot self.processed_data = json.dumps(processed_data) @staticmethod def create(campus: 'Campus', menu_day: datetime.date, screenshot: str, processed_data: Any) -> 'Optional[LearningDatapoint]': datapoint = LearningDatapoint(campus.id, menu_day, screenshot, processed_data) db.session.add(datapoint) return datapoint @staticmethod def find_by_id(datapoint_id: int) -> 'Optional[LearningDatapoint]': return LearningDatapoint.query.filter_by(id=datapoint_id).first() @staticmethod def get_all() -> 'List[LearningDatapoint]': return LearningDatapoint.query.all() @staticmethod def get_random(user: 'RegisteredUser') -> 'Optional[LearningDatapoint]': return LearningDatapoint.query.order_by(expression.func.random()).filter( expression.not_( LearningDatapointSubmission.query.filter( LearningDatapoint.id == LearningDatapointSubmission.datapoint_id, LearningDatapointSubmission.user_id == user.id ).exists() ) ).first() def user_submit(self, user: 'RegisteredUser', submission_data: Any): LearningDatapointSubmission.create(self, user, submission_data) def __hash__(self): return hash(self.id) class LearningDatapointSubmission(ModelBase): __tablename__ = 'learning_datapoint_submission' user_id = db.Column(db.Integer(), db.ForeignKey('registered_users.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) datapoint_id = db.Column(db.Integer(), db.ForeignKey('learning_datapoint.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) submission_data = db.Column(db.Text(), nullable=False) def __init__(self, user_id: int, datapoint_id: int, submission_data: Any): if not isinstance(user_id, int): raise expected('user_id', user_id, int) if not isinstance(datapoint_id, int): raise expected('datapoint_id', datapoint_id, int) if submission_data is None: raise ValueError('submission_data expected not None') self.user_id = user_id self.datapoint_id = datapoint_id self.submission_data = json.dumps(submission_data) @staticmethod def create(datapoint: LearningDatapoint, user: 'RegisteredUser', submission_data: Any) -> 'Optional[LearningDatapointSubmission]': submission = LearningDatapointSubmission(user.id, datapoint.id, submission_data) db.session.add(submission) return submission def __hash__(self): return hash((self.user_id, self.datapoint_id)) ================================================ FILE: komidabot/models_users.py ================================================ import json from typing import Dict, List, Optional, TypedDict, Union from flask_login import UserMixin from sqlalchemy.sql import functions from extensions import db, ModelBase, Table from komidabot.util import expected class AdminSubscription(TypedDict): endpoint: str # XXX: This is a globally unique identifier for the client keys: Dict[str, str] user_roles_table = Table( 'user_roles', ModelBase.metadata, db.Column('user_id', db.Integer(), db.ForeignKey('registered_users.id', ondelete='CASCADE'), primary_key=True), db.Column('role_id', db.Integer(), db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True) ) class RegisteredUser(ModelBase, UserMixin): __tablename__ = 'registered_users' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) provider = db.Column(db.String(16), nullable=False) subject = db.Column(db.String(), nullable=False) name = db.Column(db.String(), nullable=False) email = db.Column(db.String(), nullable=False, unique=True) profile_picture = db.Column(db.String(), nullable=False) registered_on = db.Column(db.DateTime(), nullable=False, server_default=functions.now()) activated_on = db.Column(db.DateTime(), nullable=True) web_subscriptions = db.Column(db.String(), nullable=False, server_default='[]') roles: 'List[Role]' = db.relationship('Role', secondary=user_roles_table, back_populates='users') submissions = db.relationship('LearningDatapointSubmission', backref='registered_user', passive_deletes=True) __table_args__ = ( db.UniqueConstraint('provider', 'subject'), ) def __init__(self, provider: str, subject: str, name: str, email: str, profile_picture: str): if not isinstance(provider, str): raise expected('provider', provider, str) if not isinstance(subject, str): raise expected('subject', subject, str) if not isinstance(name, str): raise expected('name', name, str) if not isinstance(email, str): raise expected('email', email, str) if not isinstance(profile_picture, str): raise expected('profile_picture', profile_picture, str) self.provider = provider self.subject = subject self.name = name self.email = email self.profile_picture = profile_picture @staticmethod def create(provider: str, subject: str, name: str, email: str, profile_picture: str, add_to_db=True) -> 'RegisteredUser': user = RegisteredUser(provider, subject, name, email, profile_picture) if add_to_db: db.session.add(user) return user def delete(self): db.session.delete(self) # Overrides UserMixin.is_active @property def is_active(self): return self.activated_on is not None # Query methods @staticmethod def get_by_id(user_id: int) -> 'Optional[RegisteredUser]': return RegisteredUser.query.filter_by(id=user_id).first() @staticmethod def find_by_provider_id(provider: str, subject: str) -> 'Optional[RegisteredUser]': return RegisteredUser.query.filter_by(provider=provider, subject=subject).first() @staticmethod def find_by_email(email: str) -> 'Optional[RegisteredUser]': return RegisteredUser.query.filter_by(email=email).first() @staticmethod def get_all() -> 'List[RegisteredUser]': return RegisteredUser.query.all() @staticmethod def get_all_active() -> 'List[RegisteredUser]': return RegisteredUser.query.filter(RegisteredUser.activated_on != None).all() @staticmethod def get_all_by_role(role: 'Role') -> 'List[RegisteredUser]': return role.users # return RegisteredUser.query.filter( # UserRoles.user_id == RegisteredUser.id, # UserRoles.role_id == role.id # ).all() # Roles functions def get_roles(self) -> 'List[Role]': return self.roles def add_role(self, role: 'Role'): self.roles.append(role) def remove_role(self, role: 'Role'): self.roles.remove(role) def is_role(self, role: 'Union[str, Role]') -> bool: if isinstance(role, str): role = Role.find_by_name(role) return role is not None and role in self.roles elif isinstance(role, Role): return role in self.roles else: raise ValueError('role') # Subscriptions functions def get_subscriptions(self) -> 'List[AdminSubscription]': return json.loads(self.web_subscriptions) def set_subscriptions(self, subscriptions: 'List[AdminSubscription]'): self.web_subscriptions = json.dumps(subscriptions) def add_subscription(self, endpoint: str, keys: Dict[str, str]): subscriptions: 'List[AdminSubscription]' = [] found = False for sub in self.get_subscriptions(): subscriptions.append(sub) if sub['endpoint'] == endpoint: found = True if not found: subscriptions.append({'endpoint': endpoint, 'keys': keys}) self.set_subscriptions(subscriptions) def remove_subscription(self, endpoint: str): self.set_subscriptions([sub for sub in self.get_subscriptions() if sub['endpoint'] != endpoint]) @staticmethod def replace_subscription(old_endpoint: str, endpoint: str, keys: Dict[str, str]): for user in RegisteredUser.get_all(): user.set_subscriptions([sub if sub['endpoint'] != old_endpoint else {'endpoint': endpoint, 'keys': keys} for sub in user.get_subscriptions()]) def __hash__(self): return hash(self.id) class Role(ModelBase): __tablename__ = 'roles' id = db.Column(db.Integer(), primary_key=True, autoincrement=True) name = db.Column(db.String(64), nullable=False, unique=True) users = db.relationship('RegisteredUser', secondary=user_roles_table, back_populates='roles') def __init__(self, name: str): if not isinstance(name, str): raise expected('name', name, str) self.name = name @staticmethod def create(name: str, add_to_db=True) -> 'Role': user = Role(name) if add_to_db: db.session.add(user) return user @staticmethod def find_by_name(name: str) -> 'Optional[Role]': return Role.query.filter_by(name=name).first() ================================================ FILE: komidabot/rate_limit.py ================================================ import time from collections import deque from datetime import datetime class Limiter: def __init__(self, max_rate: int): self.max_rate = max_rate self.last_times = deque() def __call__(self): now = datetime.now() if len(self.last_times) < self.max_rate: self.last_times.append(now) return delta = (now - self.last_times.popleft()).total_seconds() if delta < 1: time.sleep(1.0 - delta) self.last_times.append(now) ================================================ FILE: komidabot/subscriptions/__init__.py ================================================ from typing import Dict, List, Optional, Union from komidabot.messages import Message from komidabot.users import User __all__ = ['SubscriptionChannel', 'SubscriptionManager'] class SubscriptionQuery: pass class SubscriptionData: pass class SubscriptionChannel: def user_supported(self, user: 'User') -> bool: return user.supports_subscription_channel(self.get_name()) and user.is_reachable() def get_subscribed_users(self, /, query: Union[SubscriptionQuery, Dict] = None) -> 'List[User]': raise NotImplementedError() def get_query_from(self, query: Dict = None) -> Optional[SubscriptionQuery]: raise NotImplementedError() def deliver_message(self, message: Message): raise NotImplementedError() def get_name(self) -> str: raise NotImplementedError() def user_subscribe(self, user: 'User', /, data: SubscriptionData = None) -> bool: raise NotImplementedError() def user_unsubscribe(self, user: 'User') -> bool: raise NotImplementedError() def user_subscription_data(self, user: 'User') -> Optional[SubscriptionData]: raise NotImplementedError() class SubscriptionManager: def __init__(self): self._channels: 'Dict[str, SubscriptionChannel]' = dict() def register_channel(self, channel: 'SubscriptionChannel'): if channel.get_name() in self._channels: raise ValueError('Duplicate channel name registered') self._channels[channel.get_name()] = channel def get_channel(self, channel: str) -> 'Optional[SubscriptionChannel]': return self._channels.get(channel, None) def get_subscribed_users(self, channel: str, /, query: Union[SubscriptionQuery, Dict] = None) -> 'List[User]': if channel not in self._channels: raise ValueError('Unknown channel') channel_obj = self._channels[channel] return channel_obj.get_subscribed_users(query=query) def deliver_message(self, channel: str, message: Message): if channel not in self._channels: raise ValueError('Unknown channel') return self._channels[channel].deliver_message(message) def user_subscribe(self, user: 'User', channel: str, /, data: SubscriptionData = None) -> bool: if channel not in self._channels: raise ValueError('Unknown channel') channel_obj = self._channels[channel] if not channel_obj.user_supported(user): return False return channel_obj.user_subscribe(user, data=data) def user_unsubscribe(self, user: 'User', channel: str) -> bool: if channel not in self._channels: raise ValueError('Unknown channel') channel_obj = self._channels[channel] if not channel_obj.user_supported(user): return False return channel_obj.user_unsubscribe(user) def user_subscription_data(self, user: 'User', channel: str) -> Optional[SubscriptionData]: if channel not in self._channels: raise ValueError('Unknown channel') channel_obj = self._channels[channel] if not channel_obj.user_supported(user): return None return channel_obj.user_subscription_data(user) ================================================ FILE: komidabot/subscriptions/daily_menu.py ================================================ from typing import Dict, List, Optional, Union import komidabot.messages as messages import komidabot.models as models import komidabot.subscriptions as subscriptions from extensions import db from komidabot.app import get_app from komidabot.messages import Message from komidabot.models import Day from komidabot.users import User __all__ = ['CHANNEL_ID', 'Channel'] CHANNEL_ID = 'daily_menu' class Query(subscriptions.SubscriptionQuery): def __init__(self, day: models.Day, campus: models.Campus = None): self.day = day self.campus = campus class Data(subscriptions.SubscriptionData): class Day: def __init__(self): self.campus = None self.active = False def __init__(self): self.monday = Data.Day() self.tuesday = Data.Day() self.wednesday = Data.Day() self.thursday = Data.Day() self.friday = Data.Day() self.days = [self.monday, self.tuesday, self.wednesday, self.thursday, self.friday] class Channel(subscriptions.SubscriptionChannel): def get_subscribed_users(self, /, query: Union[Query, Dict] = None) -> 'List[User]': if not isinstance(query, Query): query = self.get_query_from(query) assert isinstance(query, Query), 'query must be SubscriptionQuery' if query.campus is not None: raise NotImplementedError('Cannot query by (day, campus) right now') app = get_app() user_manager = app.user_manager users = models.AppUser.find_subscribed_users_by_day(query.day) return [user for user in (user_manager.get_user(user) for user in users) if self.user_supported(user)] def get_query_from(self, query: Dict = None) -> Optional[Query]: if query is None: return None return Query(day=query.get('day'), campus=query.get('campus', None)) def deliver_message(self, message: Message): if not isinstance(message, messages.SubscriptionMenuMessage): raise NotImplementedError('Daily menu channel only supports SubscriptionMenuMessage') day = Day(message.date.isoweekday()) changed = False for user in self.get_subscribed_users(query=Query(day)): if user.send_message_or_remove(CHANNEL_ID, message): changed = True if changed: db.session.commit() def get_name(self): return CHANNEL_ID def user_subscribe(self, user: 'User', /, data: Data = None) -> bool: return False def user_unsubscribe(self, user: 'User') -> bool: return False def user_subscription_data(self, user: 'User') -> Optional[Data]: result = Data() return None # This subscription doesn't take data ================================================ FILE: komidabot/translation.py ================================================ from googletrans import Translator Language = str LANGUAGE_DUTCH = 'nl' LANGUAGE_ENGLISH = 'en' LANGUAGE_FRENCH = 'fr' def _fix_language(language: Language): if language == 'zh_CN' or language == 'zh_SG': return 'zh-cn' elif language == 'zh_HK' or language == 'zh_TW': return 'zh-tw' return language class TranslationService: def translate(self, text: str, from_language: Language, to_language: Language): """ Submit a string to be translated. :param text: The string to translate :param from_language: A 2 letter string defining the language to translate from :param to_language: A 2 letter string defining the language to translate to :return: The translated string """ raise NotImplementedError() @property def identifier(self): raise NotImplementedError() @property def pretty_name(self): raise NotImplementedError() class KomidaTranslationService(TranslationService): def translate(self, text: str, from_language: Language, to_language: Language): raise Exception('Komida translator service is a placeholder and cannot translate') @property def identifier(self): return 'komida' @property def pretty_name(self): return 'Komida' class GoogleTranslationService(TranslationService): def __init__(self): self.translator = Translator() def translate(self, text: str, from_language: Language, to_language: Language): return self.translator.translate(text, src=from_language, dest=to_language).text @property def identifier(self): return 'google' @property def pretty_name(self): return 'Google Translate' class BingTranslationService(TranslationService): def translate(self, text: str, from_language: Language, to_language: Language): raise NotImplementedError() @property def identifier(self): return 'bing' @property def pretty_name(self): return 'Bing Translate' ================================================ FILE: komidabot/triggers.py ================================================ import datetime import komidabot.users as users from komidabot.messages import Aspect, Trigger class SubscriptionTrigger(Trigger): def __init__(self, *args, date: datetime.date = None, **kwargs): super().__init__(*args, **kwargs) self.date = date def get_repr_text(self): return ['SubscriptionTrigger', '- Date: ' + repr(self.date)] class TextTrigger(Trigger): def __init__(self, text, *args, **kwargs): super().__init__(*args, **kwargs) self.text = text def get_repr_text(self): return ['TextTrigger', '- Text: ' + repr(self.text)] class NewUserAspect(Aspect): def __repr__(self): return 'NewUserAspect()' class SenderAspect(Aspect): def __init__(self, sender: users.User): super().__init__() self.sender = sender def __repr__(self): return 'SenderAspect({})'.format(repr(self.sender)) class AtAdminAspect(Aspect): def __repr__(self): return 'AtAdminAspect()' class DatetimeAspect(Aspect): allows_multiple = True def __init__(self, value: str, grain: str): super().__init__() self.value = value self.grain = grain def __repr__(self): return 'DatetimeAspect({}, {})'.format(repr(self.value), self.grain) class LocaleAspect(Aspect): def __init__(self, locale: str, confidence: float): super().__init__() self.locale = locale self.confidence = confidence def __repr__(self): return 'LocaleAspect({}, {})'.format(self.locale, self.confidence) ================================================ FILE: komidabot/users.py ================================================ import datetime import functools import json from typing import Dict, List, Optional, Union from typing import NamedTuple import komidabot.messages as messages import komidabot.models as models from komidabot.app import get_app __all__ = ['UnifiedUserManager', 'User', 'UserId', 'UserManager'] class UserId(NamedTuple): id: str provider: str def __repr__(self): return '{}/{}'.format(self.provider, self.id) class UserManager: # TODO: This probably could use more methods def get_user(self, user: 'Union[UserId, models.AppUser]', **kwargs) -> 'User': raise NotImplementedError() def get_administrators(self) -> 'List[User]': identifier = self.get_identifier() return [self.get_user(user) for user in get_app().admin_ids if user.provider == identifier] def initialise(self): raise NotImplementedError() def get_identifier(self) -> str: raise NotImplementedError() class User: @property def id(self) -> UserId: return UserId(self.get_internal_id(), self.get_provider_name()) def get_provider_name(self) -> 'str': raise NotImplementedError() @property def manager(self) -> UserManager: return self.get_manager() def get_manager(self) -> UserManager: raise NotImplementedError() def get_internal_id(self) -> 'str': raise NotImplementedError() def get_db_user(self) -> 'Optional[models.AppUser]': user_id = self.id return models.AppUser.find_by_id(user_id.provider, user_id.id) def add_to_db(self): user_id = self.id models.AppUser.create(user_id.provider, user_id.id, '') def remove_from_db(self): """ Deletes the user from the database. """ user = self.get_db_user() if user is None: return user.delete() def get_locale(self) -> 'Optional[str]': # TODO: Properly look into this user = self.get_db_user() if user is None: return None return user.language def get_is_notified_new_site(self) -> 'Optional[bool]': user = self.get_db_user() if user is None: return None return user.notified_new_site def set_is_notified_new_site(self, value: bool): user = self.get_db_user() if user is None: return user.notified_new_site = value def get_campus_for_day(self, date: Union[models.Day, datetime.date]) -> 'Optional[models.Campus]': user = self.get_db_user() if user is None: return None if isinstance(date, datetime.date): day = models.Day(date.isoweekday()) elif isinstance(date, models.Day): day = date else: raise ValueError('date') return user.get_campus(day) def set_campus_for_day(self, campus: models.Campus, date: Union[models.Day, datetime.date]): user = self.get_db_user() if user is None: return if isinstance(date, datetime.date): day = models.Day(date.isoweekday()) elif isinstance(date, models.Day): day = date else: raise ValueError('date') sub = user.get_subscription(day) if sub is None: # Make new subscription and set it to enabled by default user.set_campus(day, campus, True) else: user.set_campus(day, campus) def disable_subscription_for_day(self, date: Union[models.Day, datetime.date]) -> bool: user = self.get_db_user() if user is None: return False if isinstance(date, datetime.date): day = models.Day(date.isoweekday()) elif isinstance(date, models.Day): day = date else: raise ValueError('date') sub = user.get_subscription(day) if sub is not None and sub.active: sub.active = False return True return False def get_subscription_for_day(self, date: Union[models.Day, datetime.date]) \ -> 'Optional[models.UserDayCampusPreference]': user = self.get_db_user() if user is None: return None if isinstance(date, datetime.date): day = models.Day(date.isoweekday()) elif isinstance(date, models.Day): day = date else: raise ValueError('date') return user.get_subscription(day) def mark_reachable(self) -> bool: """ Ensures the user is marked as being reachable. :return: True if the user was marked unreachable before, False otherwise. """ user = self.get_db_user() if user is None: return False if not user.enabled: user.enabled = True return True return False def mark_unreachable(self): """ Marks the user as being unreachable, effectively disabling subscription messages from going through. """ user = self.get_db_user() if user is None: return user.enabled = False def is_reachable(self) -> bool: """ Checks whether the user is reachable or not. :return: True if the user is reachable, False otherwise. """ user = self.get_db_user() if user is None: return False return user.enabled def supports_subscription_channel(self, channel: str) -> bool: raise NotImplementedError() def is_admin(self): user_id = self.id return user_id in get_app().admin_ids def is_feature_active(self, feature_id: str) -> bool: return models.Feature.is_user_participating(self.get_db_user(), feature_id) def get_data(self) -> Optional[Dict]: user = self.get_db_user() if user is None: return None data = user.data if data is None: return None try: return json.loads(data) except json.JSONDecodeError: return None def set_data(self, data: Optional[Dict]): user = self.get_db_user() if user is None: return if data is None: user.data = None else: user.data = json.dumps(data) def get_message_handler(self) -> messages.MessageHandler: raise NotImplementedError() def send_message(self, message: 'messages.Message') -> 'messages.MessageSendResult': result = self.get_message_handler().send_message(self, message) app = get_app() if app.config.get('VERBOSE'): print('Sending message to user {} got result {}'.format(self.id, result), flush=True) return result def send_message_or_remove(self, channel: str, message: 'messages.Message') -> bool: message_result = self.send_message(message) if message_result == messages.MessageSendResult.UNSUPPORTED: # Messages unsupported? Disable subscription then print('User {} does not support messages, removing from subscription list'.format(self.id), flush=True) # FIXME: For unsupported messages, we should mark the user unreachable for this specific channel instead self.mark_unreachable() return True if message_result == messages.MessageSendResult.UNREACHABLE: # Unreachable = Facebook is blocking us from sending, stop trying to send in the future print('User {} is unreachable, removing from subscription list'.format(self.id), flush=True) self.mark_unreachable() return True if message_result == messages.MessageSendResult.GONE: # Gone = User no longer exists, delete from database print('User {} is gone, removing from database'.format(self.id), flush=True) self.remove_from_db() return True return False def __repr__(self): user_id = self.id return 'User: {}'.format(user_id) class UnifiedUserManager(UserManager): def __init__(self): self._managers: Dict[str, UserManager] = dict() def register_manager(self, manager: UserManager): if manager.get_identifier() in self._managers: raise ValueError('Multiple managers registered for one provider') if isinstance(manager, UnifiedUserManager): raise ValueError('Cannot register the unified user manager') self._managers[manager.get_identifier()] = manager def get_user(self, user: 'Union[UserId, models.AppUser]', **kwargs) -> 'User': if user.provider not in self._managers: raise ValueError('Unknown user provider') return self._managers[user.provider].get_user(user, **kwargs) def get_administrators(self): return functools.reduce(list.__add__, [manager.get_administrators() for manager in self._managers.values()]) def initialise(self): for manager in self._managers.values(): manager.initialise() def get_identifier(self): return None ================================================ FILE: komidabot/util.py ================================================ import traceback from functools import wraps from typing import List, Tuple, TypeVar import komidabot.localisation as localisation import komidabot.translation as translation def check_exceptions(fallback=None): def decorator(func): @wraps(func) def decorated_func(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: print('Exception raised while calling {}: {}'.format(func.__name__, e)) traceback.print_tb(e.__traceback__) return fallback return decorated_func return decorator T = TypeVar('T') def get_list_diff(old_list: List[T], new_list: List[T]) -> Tuple[List[T], List[T], List[T]]: """ Computes the difference between two lists. :param old_list: The old list. :param new_list: The new list. :return: A 3-tuple containing the following lists in order: items still present, items added, items removed """ unchanged = [item for item in old_list if item in new_list] added = [item for item in new_list if item not in unchanged] removed = [item for item in old_list if item not in unchanged] assert len(unchanged) + len(removed) == len(old_list), 'List difference incorrect? {} + {} != {}'.format(unchanged, removed, old_list) assert len(unchanged) + len(added) == len(new_list), 'List difference incorrect? {} + {} != {}'.format(unchanged, added, new_list) return unchanged, added, removed def date_to_string(locale: str, date): if locale == translation.LANGUAGE_ENGLISH: day_number = date.day if day_number == 1 or day_number == 21 or day_number == 31: suffix = 'st' elif day_number == 2 or day_number == 22: suffix = 'nd' elif day_number == 3 or day_number == 23: suffix = 'rd' else: suffix = 'th' return '{weekday} {day}{suffix} of {month}'.format(day=date.day, suffix=suffix, month=localisation.MONTHS[date.month - 1](locale), weekday=localisation.DAYS[date.weekday()](locale)) elif locale == translation.LANGUAGE_DUTCH: return '{weekday} {day} {month}'.format(day=date.day, month=localisation.MONTHS[date.month - 1](locale), weekday=localisation.DAYS[date.weekday()](locale)) else: return str(date) def expected(name, value, *types): types_str = ' or '.join(type_obj.__name__ for type_obj in types) return ValueError('{} expected {} got {}'.format(name, types_str, type(value).__name__)) def expected_or_none(value, *types): return expected(value, *types, type(None)) ================================================ FILE: komidabot/web/constants.py ================================================ PROVIDER_ID = 'web' ================================================ FILE: komidabot/web/messages.py ================================================ import copy import json from pywebpush import webpush, WebPushException import komidabot.localisation as localisation import komidabot.menu import komidabot.messages as messages import komidabot.translation as translation import komidabot.users as users import komidabot.util as util import komidabot.web.constants as web_constants from komidabot.app import get_app from komidabot.models import CourseType, Menu VAPID_CLAIMS = { 'sub': 'mailto:komidabot@gmail.com' } class MessageHandler(messages.MessageHandler): def send_message(self, user: users.User, message: messages.Message) -> messages.MessageSendResult: if user.id.provider != web_constants.PROVIDER_ID: raise ValueError('User id is not for {}'.format(web_constants.PROVIDER_ID)) if isinstance(message, messages.TextMessage): return self._send_text_message(user, message) elif isinstance(message, messages.MenuMessage): return self._send_menu_message(user, message) elif isinstance(message, messages.SubscriptionMenuMessage): return self._send_subscription_menu_message(user, message) else: return messages.MessageSendResult.UNSUPPORTED @staticmethod def _send_notification(subscription_information, data) -> messages.MessageSendResult: app = get_app() try: response = webpush( subscription_info=subscription_information, data=json.dumps(data), vapid_private_key=app.config['VAPID_PRIVATE_KEY'], vapid_claims=copy.deepcopy(VAPID_CLAIMS) ) if app.config.get('VERBOSE'): print('Received {} for push {}'.format(response.status_code, subscription_information['endpoint']), flush=True) print(response.content, flush=True) return messages.MessageSendResult.SUCCESS except WebPushException as e: response = e.response if app.config.get('VERBOSE'): print('Received {} for push {}'.format(response.status_code, subscription_information['endpoint']), flush=True) print(response.content, flush=True) if 500 <= response.status_code < 600: return messages.MessageSendResult.EXTERNAL_ERROR if response.status_code == 429: # Too many requests, rate limited pass # TODO: Handle rate-limiting if response.status_code == 400: # Invalid request return messages.MessageSendResult.ERROR if response.status_code == 404: # Subscription not found return messages.MessageSendResult.GONE if response.status_code == 410: # Subscription has been removed return messages.MessageSendResult.GONE if response.status_code == 413: # Payload too large return messages.MessageSendResult.ERROR return messages.MessageSendResult.ERROR @staticmethod def _send_text_message(user: users.User, message: messages.TextMessage) -> messages.MessageSendResult: subscription_information = copy.deepcopy(user.get_data()) subscription_information['endpoint'] = user.get_internal_id() data = { 'notification': { # 'lang': 'NL', 'badge': 'https://komidabot.xyz/assets/icons/notification-badge-android-72x72.png', 'title': 'Komidabot message', 'body': message.text, 'vibrate': [], 'renotify': False, 'requireInteraction': False, 'actions': [], 'silent': False, } } return MessageHandler._send_notification(subscription_information, data) @staticmethod def _send_menu_message(user: users.User, message: messages.MenuMessage) -> messages.MessageSendResult: locale = user.get_locale() or translation.LANGUAGE_DUTCH menu = message.menu date_str = util.date_to_string(locale, menu.menu_day) title = localisation.REPLY_MENU_START(locale).format(campus=menu.campus.name, date=date_str) text = komidabot.menu.get_short_menu_text(menu, message.translator, locale, CourseType.DAILY, CourseType.PASTA, CourseType.GRILL) if text is None or text == '': return messages.MessageSendResult.ERROR subscription_information = copy.deepcopy(user.get_data()) subscription_information['endpoint'] = user.get_internal_id() data = { 'notification': { 'lang': locale, 'badge': 'https://komidabot.xyz/assets/icons/notification-badge-android-72x72.png', 'title': title, 'body': text, 'renotify': False, 'requireInteraction': False, 'actions': [], 'silent': True, } } return MessageHandler._send_notification(subscription_information, data) @staticmethod def _send_subscription_menu_message(user: users.User, message: messages.SubscriptionMenuMessage) -> messages.MessageSendResult: campus = user.get_campus_for_day(message.date) if campus is None: # If no campus for selected day, just success it return messages.MessageSendResult.SUCCESS locale = user.get_locale() or translation.LANGUAGE_DUTCH data = message.get_prepared(campus, locale, user.get_provider_name()) if data is None: menu = Menu.get_menu(campus, message.date) date_str = util.date_to_string(locale, menu.menu_day) title = localisation.REPLY_MENU_START(locale).format(campus=campus.name, date=date_str) text = komidabot.menu.get_short_menu_text(menu, message.translator, locale, CourseType.DAILY, CourseType.PASTA, CourseType.GRILL) if text is None or text == '': return messages.MessageSendResult.ERROR data = { 'notification': { 'lang': locale, 'badge': 'https://komidabot.xyz/assets/icons/notification-badge-android-72x72.png', 'title': title, 'body': text, 'renotify': False, 'requireInteraction': False, 'actions': [], 'silent': True, } } message.set_prepared(campus, locale, user.get_provider_name(), data) subscription_information = copy.deepcopy(user.get_data()) subscription_information['endpoint'] = user.get_internal_id() return MessageHandler._send_notification(subscription_information, copy.deepcopy(data)) ================================================ FILE: komidabot/web/users.py ================================================ from typing import Dict, Optional, TypedDict, Union import komidabot.messages as messages import komidabot.models as models import komidabot.users as users import komidabot.web.constants as web_constants from komidabot.web.messages import MessageHandler as WebMessageHandler __all__ = ['User', 'UserData', 'UserManager'] class UserManager(users.UserManager): def __init__(self): self.message_handler = WebMessageHandler() def get_user(self, user: 'Union[users.UserId, models.AppUser]', **kwargs) -> 'User': if isinstance(user, models.AppUser): return User(self, user.internal_id) if user.provider != web_constants.PROVIDER_ID: raise ValueError('User id is not for {}'.format(web_constants.PROVIDER_ID)) # TODO: This probably could use more checks or something # For example: check if there is a subscription return User(self, user.id) def initialise(self): pass def get_identifier(self): return web_constants.PROVIDER_ID class User(users.User): def __init__(self, manager: UserManager, id_str: str): self._manager = manager self._id = id_str def get_provider_name(self) -> 'str': return web_constants.PROVIDER_ID def get_internal_id(self) -> 'str': return self._id def supports_subscription_channel(self, channel: str) -> bool: return channel in [] def get_manager(self) -> UserManager: return self._manager def get_message_handler(self) -> messages.MessageHandler: return self._manager.message_handler def get_data(self) -> 'Optional[UserData]': return super().get_data() def set_data(self, data: 'Optional[UserData]'): return super().set_data(data) class UserData(TypedDict): keys: Dict[str, str] ================================================ FILE: learning-data/.gitignore ================================================ *.yml *.json ================================================ FILE: learning-data/.gitkeep ================================================ ================================================ FILE: manage.py ================================================ import datetime import glob import json import os import signal import sys import traceback import unittest from typing import Optional import click from colour_runner.runner import ColourTextTestRunner from flask import current_app from flask.cli import FlaskGroup import komidabot.models as models from app import create_app from komidabot.models_training import LearningDatapoint cli = FlaskGroup(create_app=create_app) @cli.command('recreate_db') def recreate_db(): models.recreate_db() @cli.command('seed_db') def seed_db(): models.create_standard_values() models.import_dump(current_app.config['DUMP_FILE']) @cli.command('run_subscription') def run_subscription(): raise NotImplementedError() @cli.command('update_menus') def update_menus(): raise NotImplementedError() @cli.command('cleanup') def cleanup(): raise NotImplementedError() @cli.command('synchronize_menus') def synchronize_menus(): raise NotImplementedError() @cli.command('upload_learning_data') def upload_learning_data(): import komidabot.external_menu as external_menu from extensions import db from komidabot.rate_limit import Limiter limiter = Limiter(10) files = glob.glob(os.path.join(os.path.dirname(__file__), 'learning-data', '*.json')) for file in sorted(files): limiter() print(os.path.basename(file)) try: with open(file, 'r') as f: data = json.load(f) except KeyboardInterrupt: raise except json.JSONDecodeError: print('Could not decode:', file) continue campus = models.Campus.get_by_short_name(data['restaurant']) date = datetime.date.fromisoformat(data['date']) try: data_raw = external_menu.fetch_raw(campus, date) data_parsed = external_menu.parse_fetched(data_raw) data_processed = external_menu.process_parsed(data_parsed) except KeyboardInterrupt: raise except Exception as e: print('Failure parsing external menu for:', file) traceback.print_tb(e.__traceback__) print(e, flush=True, file=sys.stderr) continue reference_menu: list = data['menu'] processed_menu: list = data_processed['menu'] if data_processed is not None else [] matched = [] for reference_item in reference_menu: i = 0 for processed_item in processed_menu: if processed_item['name']['nl'].lower() == reference_item['course_name'].lower(): matched.append((reference_item, processed_item)) break i = i + 1 else: print('Could not match reference item', reference_item['course_name'].lower()) continue processed_menu.pop(i) for processed_item in processed_menu: print('Could not match processed item', processed_item['name']['nl'].lower()) try: for reference_item, processed_item in matched: LearningDatapoint.create(campus, date, reference_item['screenshot'], processed_item) except KeyboardInterrupt: raise except Exception: print('Failure adding to database for', file) continue db.session.commit() @cli.command('test', with_appcontext=False) @click.option('--case') def test(case: Optional[str]): """Runs the tests without code coverage""" if case: tests = unittest.TestLoader().loadTestsFromName('tests.' + case) else: tests = unittest.TestLoader().discover('tests', pattern='test_*.py') result = ColourTextTestRunner(verbosity=2).run(tests) if result.wasSuccessful(): return 0 # This makes Flask return an exit code of 1, otherwise it defaults to 0 even if returning 0 raise click.exceptions.Exit(1) def handler(signum: int, _): if signum == signal.SIGTERM: print('Performing shutdown') os.kill(os.getpid(), signal.SIGINT) if __name__ == '__main__': signal.signal(signal.SIGTERM, handler) cli() ================================================ FILE: manual_menu_scraper.py ================================================ import datetime import sys import komidabot.external_menu as external_menu from komidabot.debug.state import DebuggableException from komidabot.models import Campus, course_icons_matrix, CourseType, CourseSubType if __name__ == '__main__': # Setup campuses = { 'cst': Campus.create('Stadscampus', 'cst', [], 1, add_to_db=False), 'cde': Campus.create('Campus Drie Eiken', 'cde', [], 2, add_to_db=False), 'cmi': Campus.create('Campus Middelheim', 'cmi', [], 3, add_to_db=False), 'cgb': Campus.create('Campus Groenenborger', 'cgb', [], 4, add_to_db=False), 'cmu': Campus.create('Campus Mutsaard', 'cmu', [], 5, add_to_db=False), 'hzs': Campus.create('Hogere Zeevaartschool', 'hzs', [], 6, add_to_db=False), } campuses_reverse = {campus.external_id: campus for campus in campuses.values()} def get_by_external_id(campus_id: int): return campuses_reverse.get(campus_id, None) def get_by_short_name(short_name: str): return campuses.get(short_name, None) # Replace these methods because we don't have database access Campus.get_by_external_id = get_by_external_id Campus.get_by_short_name = get_by_short_name # Actual program logic if sys.argv[1] not in campuses: raise ValueError('Unknown campus') campus = campuses[sys.argv[1]] if len(sys.argv) > 2: dates = [datetime.datetime.strptime(arg, '%Y-%m-%d').date() for arg in sys.argv[2:]] else: dates = [datetime.datetime.today().date()] for date in dates: try: data_raw = external_menu.fetch_raw(campus, date) data_parsed = external_menu.parse_fetched(data_raw) data_processed = external_menu.process_parsed(data_parsed) print('{} @ {}'.format(data_processed['campus'], data_processed['date']), flush=True) for item in data_processed['menu']: icon = course_icons_matrix[CourseType[item['course_type']]][CourseSubType[item['course_sub_type']]] print('{external_id} {type} {sub_type} {attributes} {allergens} {icon} {text} ({price1} / {price2})' .format(icon=icon, text=item['name']['nl'], price1=item['price_students'], price2=item['price_staff'], type=item['course_type'], sub_type=item['course_sub_type'], attributes=item['course_attributes'], allergens=item['course_allergens'], external_id=item['external_id']) ) except DebuggableException as e: print(e.get_trace()) ================================================ FILE: migrations/README ================================================ Generic single-database configuration. ================================================ FILE: migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata from flask import current_app config.set_main_option( 'sqlalchemy.url', current_app.config.get( 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = engine_from_config( config.get_section(config.config_ini_section), prefix='sqlalchemy.', poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: migrations/versions/1a2e04608ee9_.py ================================================ """Add web_subscriptions and provider column to registered_user table Revision ID: 1a2e04608ee9 Revises: d225cbda8c77 Create Date: 2020-10-25 18:55:31.881046 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '1a2e04608ee9' down_revision = 'd225cbda8c77' branch_labels = None depends_on = None def upgrade(): op.add_column('registered_user', sa.Column('provider', sa.String(length=16), nullable=False)) op.add_column('registered_user', sa.Column('web_subscriptions', sa.String(), server_default='[]', nullable=False)) def downgrade(): op.drop_column('registered_user', 'web_subscriptions') op.drop_column('registered_user', 'provider') ================================================ FILE: migrations/versions/1dafd2bf730a_.py ================================================ """Add course allergens column to menu item table Revision ID: 1dafd2bf730a Revises: aa31c90dc353 Create Date: 2020-10-28 15:32:49.787976 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '1dafd2bf730a' down_revision = 'aa31c90dc353' branch_labels = None depends_on = None def upgrade(): op.add_column('menu_item', sa.Column('course_allergens', sa.Text(), server_default='[]', nullable=False)) def downgrade(): op.drop_column('menu_item', 'course_allergens') ================================================ FILE: migrations/versions/276ad61a41a5_.py ================================================ """Change food type in menu items to course type and sub type Revision ID: 276ad61a41a5 Revises: ddf5bd871988 Create Date: 2020-03-10 12:23:22.996161 """ import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql as pg # revision identifiers, used by Alembic. revision = '276ad61a41a5' down_revision = 'ddf5bd871988' branch_labels = None depends_on = None course_type = pg.ENUM('SOUP', 'DAILY', 'PASTA', 'GRILL', 'SALAD', 'SUB', name='coursetype') course_sub_type = pg.ENUM('NORMAL', 'VEGAN', name='coursesubtype') def upgrade(): course_type.create(op.get_bind()) course_sub_type.create(op.get_bind()) op.add_column('menu_item', sa.Column('course_type', course_type, nullable=True)) op.add_column('menu_item', sa.Column('course_sub_type', course_sub_type, nullable=True)) op.add_column('menu_item', sa.Column('course_attributes', sa.Text(), nullable=False, default='[]', server_default='[]')) op.execute(""" UPDATE menu_item SET course_type = 'SOUP', course_sub_type = 'NORMAL' WHERE food_type = 'SOUP' """) op.execute(""" UPDATE menu_item SET course_type = 'DAILY', course_sub_type = 'NORMAL' WHERE food_type = 'MEAT' """) op.execute(""" UPDATE menu_item SET course_type = 'DAILY', course_sub_type = 'VEGAN' WHERE food_type = 'VEGAN' """) op.execute(""" UPDATE menu_item SET course_type = 'GRILL', course_sub_type = 'NORMAL' WHERE food_type = 'GRILL' """) op.execute(""" UPDATE menu_item SET course_type = 'PASTA', course_sub_type = 'NORMAL' WHERE food_type = 'PASTA_MEAT' """) op.execute(""" UPDATE menu_item SET course_type = 'PASTA', course_sub_type = 'VEGAN' WHERE food_type = 'PASTA_VEGAN' """) op.execute(""" UPDATE menu_item SET course_type = 'SALAD', course_sub_type = 'NORMAL' WHERE food_type = 'SALAD' """) op.execute(""" UPDATE menu_item SET course_type = 'SUB', course_sub_type = 'NORMAL' WHERE food_type = 'SUB' """) op.alter_column('menu_item', 'course_type', nullable=False) op.alter_column('menu_item', 'course_sub_type', nullable=False) def downgrade(): op.drop_column('menu_item', 'course_attributes') op.drop_column('menu_item', 'course_sub_type') op.drop_column('menu_item', 'course_type') course_sub_type.drop(op.get_bind()) course_type.drop(op.get_bind()) ================================================ FILE: migrations/versions/2887dcc37788_.py ================================================ """Change registered_users table to have an internal id, rather than having a primary key based on 2 columns Revision ID: 2887dcc37788 Revises: eda0c928c279 Create Date: 2020-11-02 22:43:08.274496 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '2887dcc37788' down_revision = 'eda0c928c279' branch_labels = None depends_on = None def upgrade(): # Primary key shuffling op.rename_table('registered_user', 'registered_users') op.alter_column('registered_users', 'id', new_column_name='subject') id_seq = sa.Sequence('registered_users_id_seq') op.execute(sa.schema.CreateSequence(id_seq)) op.add_column('registered_users', sa.Column('id', sa.Integer(), nullable=False, server_default=id_seq.next_value())) op.drop_constraint('learning_datapoint_submission_user_id_fkey', 'learning_datapoint_submission', type_='foreignkey') op.drop_constraint('registered_user_pkey', 'registered_users', type_='primary') op.create_unique_constraint('registered_users_provider_subject_key', 'registered_users', ['provider', 'subject']) # Foreign keys also need updating op.alter_column('learning_datapoint_submission', 'user_id', new_column_name='user_subject') op.add_column('learning_datapoint_submission', sa.Column('user_id', sa.Integer(), autoincrement=False)) op.execute(""" UPDATE learning_datapoint_submission SET user_id = users.id FROM (SELECT id, subject, provider FROM registered_users) AS users WHERE learning_datapoint_submission.user_subject = users.subject AND learning_datapoint_submission.user_provider = users.provider """) op.alter_column('learning_datapoint_submission', 'user_id', nullable=False) op.drop_constraint('learning_datapoint_submission_pkey', 'learning_datapoint_submission', type_='primary') op.create_primary_key('learning_datapoint_submission_pkey', 'learning_datapoint_submission', ['user_id', 'datapoint_id']) op.drop_column('learning_datapoint_submission', 'user_provider') op.drop_column('learning_datapoint_submission', 'user_subject') op.create_primary_key('registered_users_pkey', 'registered_users', ['id']) op.create_foreign_key('learning_datapoint_submission_user_id_fkey', 'learning_datapoint_submission', 'registered_users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') # Replace enabled with more informative version having dates op.add_column('registered_users', sa.Column('activated_on', sa.DateTime(), nullable=True)) op.add_column('registered_users', sa.Column('registered_on', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) op.execute("UPDATE registered_users SET activated_on = NOW() WHERE enabled = TRUE") op.drop_column('registered_users', 'enabled') # Add new tables for roles op.create_table( 'roles', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=64), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table( 'user_roles', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('role_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['registered_users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('user_id', 'role_id') ) def downgrade(): # Remove roles tables op.drop_table('user_roles') op.drop_table('roles') # Bring back enabled column op.add_column('registered_users', sa.Column('enabled', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) op.execute("UPDATE registered_users SET enabled = TRUE WHERE activated_on IS NOT NULL") op.drop_column('registered_users', 'registered_on') op.drop_column('registered_users', 'activated_on') # Undo foreign keys updating after primary key shuffling op.drop_constraint('learning_datapoint_submission_user_id_fkey', 'learning_datapoint_submission', type_='foreignkey') op.drop_constraint('registered_users_pkey', 'registered_users', type_='primary') op.add_column('learning_datapoint_submission', sa.Column('user_subject', sa.String(), autoincrement=False)) op.add_column('learning_datapoint_submission', sa.Column('user_provider', sa.String(16), autoincrement=False)) op.drop_constraint('learning_datapoint_submission_pkey', 'learning_datapoint_submission', type_='primary') op.create_primary_key('learning_datapoint_submission_pkey', 'learning_datapoint_submission', ['user_subject', 'user_provider', 'datapoint_id']) op.execute(""" UPDATE learning_datapoint_submission SET user_subject = users.subject, user_provider = users.provider FROM (SELECT id, subject, provider FROM registered_users) AS users WHERE learning_datapoint_submission.user_id = users.id """) op.alter_column('learning_datapoint_submission', 'user_subject', nullable=False) op.alter_column('learning_datapoint_submission', 'user_provider', nullable=False) op.drop_column('learning_datapoint_submission', 'user_id') op.alter_column('learning_datapoint_submission', 'user_subject', new_column_name='user_id') op.drop_constraint('registered_users_provider_subject_key', 'registered_users', type_='unique') op.create_primary_key('registered_user_pkey', 'registered_users', ['subject', 'provider']) op.create_foreign_key('learning_datapoint_submission_user_id_fkey', 'learning_datapoint_submission', 'registered_users', ['user_id', 'user_provider'], ['subject', 'provider'], onupdate='CASCADE', ondelete='CASCADE') # Undo primary key shuffling op.drop_column('registered_users', 'id') op.execute(sa.schema.DropSequence(sa.Sequence('registered_users_id_seq'))) op.alter_column('registered_users', 'subject', new_column_name='id') op.rename_table('registered_users', 'registered_user') ================================================ FILE: migrations/versions/3806b46f7f00_.py ================================================ """Modify model for external API Revision ID: 3806b46f7f00 Revises: 4fafafd2400f Create Date: 2019-11-03 23:26:02.357848 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '3806b46f7f00' down_revision = '4fafafd2400f' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('campus', sa.Column('external_id', sa.Integer(), nullable=True)) op.alter_column('campus', 'page_url', existing_type=sa.TEXT(), server_default=None, nullable=True) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('campus', 'page_url', existing_type=sa.TEXT(), server_default='', nullable=False) op.drop_column('campus', 'external_id') # ### end Alembic commands ### ================================================ FILE: migrations/versions/4fafafd2400f_.py ================================================ """Add new FoodType enum values Revision ID: 4fafafd2400f Revises: 7751a57b029e Create Date: 2019-10-28 19:54:52.943891 """ from alembic import op # revision identifiers, used by Alembic. revision = '4fafafd2400f' down_revision = '7751a57b029e' branch_labels = None depends_on = None def upgrade(): op.execute(""" COMMIT """) op.execute(""" ALTER TYPE foodtype ADD VALUE IF NOT EXISTS 'SALAD' AFTER 'PASTA_VEGAN' """) op.execute(""" ALTER TYPE foodtype ADD VALUE IF NOT EXISTS 'SUB' AFTER 'SALAD' """) def downgrade(): raise NotImplementedError() ================================================ FILE: migrations/versions/528821121657_.py ================================================ """Rename user_subscription to user_day_campus_preference Revision ID: 528821121657 Revises: bd04cd56036f Create Date: 2020-10-20 17:47:05.866470 """ from alembic import op # revision identifiers, used by Alembic. revision = '528821121657' down_revision = 'bd04cd56036f' branch_labels = None depends_on = None def upgrade(): op.rename_table('user_subscription', 'user_day_campus_preference') def downgrade(): op.rename_table('user_day_campus_preference', 'user_subscription') ================================================ FILE: migrations/versions/55696107a6b9_.py ================================================ """Add columns for feature participation Revision ID: 55696107a6b9 Revises: 79e0c9de90f0 Create Date: 2019-10-14 13:17:49.813805 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '55696107a6b9' down_revision = '79e0c9de90f0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('feature', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('string_id', sa.String(length=256), nullable=False, unique=True), sa.Column('description', sa.Text(), nullable=True), sa.Column('globally_available', sa.Boolean(), default=False, nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_table('feature_participation', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('feature_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['feature_id'], ['feature.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('user_id', 'feature_id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('feature_participation') op.drop_table('feature') # ### end Alembic commands ### ================================================ FILE: migrations/versions/5cd86de4dffe_.py ================================================ """Drop onboarding_done as this will not be necessary anymore, and add an enabled field. Revision ID: 5cd86de4dffe Revises: 93b9de63cd7b Create Date: 2020-02-25 10:59:52.562751 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '5cd86de4dffe' down_revision = '93b9de63cd7b' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('app_user', sa.Column('enabled', sa.Boolean(), nullable=False, default=True, server_default=sa.sql.expression.true())) op.drop_column('app_user', 'onboarding_done') # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('app_user', sa.Column('onboarding_done', sa.BOOLEAN(), autoincrement=False, nullable=False, default=False, server_default=sa.sql.expression.false())) op.drop_column('app_user', 'enabled') # ### end Alembic commands ### ================================================ FILE: migrations/versions/5ee455656a96_.py ================================================ """Rename table subscription -> user Revision ID: 5ee455656a96 Revises: 85b659320f83 Create Date: 2019-10-14 00:49:07.272985 """ from alembic import op # revision identifiers, used by Alembic. revision = '5ee455656a96' down_revision = '85b659320f83' branch_labels = None depends_on = None def upgrade(): op.rename_table('subscription', 'app_user') def downgrade(): op.rename_table('app_user', 'subscription') ================================================ FILE: migrations/versions/7751a57b029e_.py ================================================ """Add a table to indicate restaurant closures Revision ID: 7751a57b029e Revises: 55696107a6b9 Create Date: 2019-10-28 00:19:18.033714 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '7751a57b029e' down_revision = '55696107a6b9' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('closing_days', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('campus_id', sa.Integer(), nullable=False), sa.Column('first_day', sa.Date(), nullable=False), sa.Column('last_day', sa.Date(), nullable=False), sa.Column('translatable_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['campus_id'], ['campus.id'], ), sa.ForeignKeyConstraint(['translatable_id'], ['translatable.id'], onupdate='CASCADE', ondelete='RESTRICT'), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('closing_days') # ### end Alembic commands ### ================================================ FILE: migrations/versions/79e0c9de90f0_.py ================================================ """Split app_user subscription data into a separate table to allow a more fine-grained control over subscriptions Revision ID: 79e0c9de90f0 Revises: 5ee455656a96 Create Date: 2019-10-14 01:05:50.621591 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '79e0c9de90f0' down_revision = '5ee455656a96' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('user_subscription', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('day', sa.Enum('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY', name='day'), nullable=False), sa.Column('campus_id', sa.Integer(), nullable=False), sa.Column('active', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['campus_id'], ['campus.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('user_id', 'day') ) op.execute(""" INSERT INTO user_subscription(user_id, day, campus_id, active) SELECT id, 'MONDAY', campus_mon_id, active FROM app_user """) op.execute(""" INSERT INTO user_subscription(user_id, day, campus_id, active) SELECT id, 'TUESDAY', campus_tue_id, active FROM app_user """) op.execute(""" INSERT INTO user_subscription(user_id, day, campus_id, active) SELECT id, 'WEDNESDAY', campus_wed_id, active FROM app_user """) op.execute(""" INSERT INTO user_subscription(user_id, day, campus_id, active) SELECT id, 'THURSDAY', campus_thu_id, active FROM app_user """) op.execute(""" INSERT INTO user_subscription(user_id, day, campus_id, active) SELECT id, 'FRIDAY', campus_fri_id, active FROM app_user """) op.drop_constraint('Subscription_campus_mon_id_fkey', 'app_user', type_='foreignkey') op.drop_constraint('Subscription_campus_tue_id_fkey', 'app_user', type_='foreignkey') op.drop_constraint('Subscription_campus_wed_id_fkey', 'app_user', type_='foreignkey') op.drop_constraint('Subscription_campus_thu_id_fkey', 'app_user', type_='foreignkey') op.drop_constraint('Subscription_campus_fri_id_fkey', 'app_user', type_='foreignkey') op.drop_column('app_user', 'campus_mon_id') op.drop_column('app_user', 'campus_tue_id') op.drop_column('app_user', 'campus_wed_id') op.drop_column('app_user', 'campus_thu_id') op.drop_column('app_user', 'campus_fri_id') op.drop_column('app_user', 'active') # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('app_user', sa.Column('active', sa.BOOLEAN())) op.add_column('app_user', sa.Column('campus_mon_id', sa.INTEGER(), autoincrement=False)) op.add_column('app_user', sa.Column('campus_tue_id', sa.INTEGER(), autoincrement=False)) op.add_column('app_user', sa.Column('campus_wed_id', sa.INTEGER(), autoincrement=False)) op.add_column('app_user', sa.Column('campus_thu_id', sa.INTEGER(), autoincrement=False)) op.add_column('app_user', sa.Column('campus_fri_id', sa.INTEGER(), autoincrement=False)) op.execute(""" UPDATE app_user SET campus_fri_id = ( SELECT campus_id FROM user_subscription WHERE user_subscription.user_id = app_user.id AND day = 'FRIDAY' ) """) op.execute(""" UPDATE app_user SET campus_thu_id = ( SELECT campus_id FROM user_subscription WHERE user_subscription.user_id = app_user.id AND day = 'THURSDAY' ) """) op.execute(""" UPDATE app_user SET campus_wed_id = ( SELECT campus_id FROM user_subscription WHERE user_subscription.user_id = app_user.id AND day = 'WEDNESDAY' ) """) op.execute(""" UPDATE app_user SET campus_tue_id = ( SELECT campus_id FROM user_subscription WHERE user_subscription.user_id = app_user.id AND day = 'TUESDAY' ) """) op.execute(""" UPDATE app_user SET campus_mon_id = ( SELECT campus_id FROM user_subscription WHERE user_subscription.user_id = app_user.id AND day = 'MONDAY' ) """) # Add some fallback data in case the user was created after migration op.execute(""" UPDATE app_user SET campus_mon_id = ( SELECT id FROM campus WHERE short_name = 'cmi' ) WHERE campus_mon_id IS NULL """) op.execute(""" UPDATE app_user SET campus_tue_id = ( SELECT id FROM campus WHERE short_name = 'cmi' ) WHERE campus_tue_id IS NULL """) op.execute(""" UPDATE app_user SET campus_wed_id = ( SELECT id FROM campus WHERE short_name = 'cmi' ) WHERE campus_wed_id IS NULL """) op.execute(""" UPDATE app_user SET campus_thu_id = ( SELECT id FROM campus WHERE short_name = 'cmi' ) WHERE campus_thu_id IS NULL """) op.execute(""" UPDATE app_user SET campus_fri_id = ( SELECT id FROM campus WHERE short_name = 'cmi' ) WHERE campus_fri_id IS NULL """) op.execute(""" UPDATE app_user SET active = 'f' WHERE active IS NULL """) op.alter_column('app_user', 'active', server_default='t', nullable=False) op.alter_column('app_user', 'campus_mon_id', nullable=False) op.alter_column('app_user', 'campus_tue_id', nullable=False) op.alter_column('app_user', 'campus_wed_id', nullable=False) op.alter_column('app_user', 'campus_thu_id', nullable=False) op.alter_column('app_user', 'campus_fri_id', nullable=False) op.create_foreign_key('Subscription_campus_mon_id_fkey', 'app_user', 'campus', ['campus_mon_id'], ['id']) op.create_foreign_key('Subscription_campus_tue_id_fkey', 'app_user', 'campus', ['campus_tue_id'], ['id']) op.create_foreign_key('Subscription_campus_wed_id_fkey', 'app_user', 'campus', ['campus_wed_id'], ['id']) op.create_foreign_key('Subscription_campus_thu_id_fkey', 'app_user', 'campus', ['campus_thu_id'], ['id']) op.create_foreign_key('Subscription_campus_fri_id_fkey', 'app_user', 'campus', ['campus_fri_id'], ['id']) op.drop_table('user_subscription') # Day is generated in this migration script, so we delete it here op.execute("""DROP TYPE day""") # ### end Alembic commands ### ================================================ FILE: migrations/versions/85b659320f83_.py ================================================ """Make tables be lowercase to please Postgres Revision ID: 85b659320f83 Revises: fe4aca6853a2 Create Date: 2019-10-13 23:32:53.122630 """ from alembic import op # revision identifiers, used by Alembic. revision = '85b659320f83' down_revision = 'fe4aca6853a2' branch_labels = None depends_on = None def upgrade(): op.rename_table('Campus', 'campus') op.rename_table('Translatable', 'translatable') op.rename_table('Translation', 'translation') op.rename_table('Menu', 'menu') op.rename_table('MenuItem', 'menu_item') op.rename_table('Subscription', 'subscription') def downgrade(): op.rename_table('campus', 'Campus') op.rename_table('translatable', 'Translatable') op.rename_table('translation', 'Translation') op.rename_table('menu', 'Menu') op.rename_table('menu_item', 'MenuItem') op.rename_table('subscription', 'Subscription') ================================================ FILE: migrations/versions/92e4e9f8ff64_.py ================================================ """Remove fields relating to the old menu parsing Revision ID: 92e4e9f8ff64 Revises: e18b14ed6b98 Create Date: 2019-11-27 10:57:33.180423 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '92e4e9f8ff64' down_revision = 'e18b14ed6b98' branch_labels = None depends_on = None def upgrade(): op.alter_column('campus', 'external_id', existing_type=sa.INTEGER(), nullable=False) op.drop_column('campus', 'page_url') def downgrade(): op.add_column('campus', sa.Column('page_url', sa.TEXT(), autoincrement=False, nullable=True)) op.alter_column('campus', 'external_id', existing_type=sa.INTEGER(), nullable=True) ================================================ FILE: migrations/versions/93b9de63cd7b_.py ================================================ """Add field to users to indicate whether they've received an introduction to the bot yet Revision ID: 93b9de63cd7b Revises: 92e4e9f8ff64 Create Date: 2019-11-27 16:14:21.089378 """ from alembic import op # revision identifiers, used by Alembic. revision = '93b9de63cd7b' down_revision = '92e4e9f8ff64' branch_labels = None depends_on = None def upgrade(): # Manual query because apparently relying on alembic doesn't work here op.execute(""" ALTER TABLE app_user ADD COLUMN onboarding_done BOOLEAN NOT NULL DEFAULT FALSE """) def downgrade(): op.drop_column('app_user', 'onboarding_done') ================================================ FILE: migrations/versions/9b9afdcf4e4e_.py ================================================ """Add column for storing whether a user has been informed about our new site at https://komidabot.xyz/ Revision ID: 9b9afdcf4e4e Revises: 276ad61a41a5 Create Date: 2020-09-19 20:40:11.471923 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '9b9afdcf4e4e' down_revision = '276ad61a41a5' branch_labels = None depends_on = None def upgrade(): op.add_column('app_user', sa.Column('notified_new_site', sa.Boolean(), server_default=sa.text('false'), nullable=False)) def downgrade(): op.drop_column('app_user', 'notified_new_site') ================================================ FILE: migrations/versions/a223b578f7b0_.py ================================================ """Initial imported database structure Revision ID: a223b578f7b0 Revises: Create Date: 2019-10-13 22:02:33.540908 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'a223b578f7b0' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('Campus', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=128), nullable=False), sa.Column('short_name', sa.String(length=8), nullable=False), sa.Column('keywords', sa.Text(), nullable=False), sa.Column('active', sa.Boolean(), nullable=False), sa.Column('page_url', sa.Text(), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_table('Translatable', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('original_language', sa.String(length=5), nullable=False), sa.Column('original_text', sa.String(length=256), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_table('Menu', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('campus_id', sa.Integer(), nullable=False), sa.Column('menu_day', sa.Date(), nullable=False), sa.ForeignKeyConstraint(['campus_id'], ['Campus.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('Subscription', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('facebook_id', sa.String(length=32), nullable=False), sa.Column('active', sa.Boolean(), nullable=False), sa.Column('language', sa.String(length=5), nullable=False), sa.Column('campus_mon_id', sa.Integer(), nullable=False), sa.Column('campus_tue_id', sa.Integer(), nullable=False), sa.Column('campus_wed_id', sa.Integer(), nullable=False), sa.Column('campus_thu_id', sa.Integer(), nullable=False), sa.Column('campus_fri_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['campus_fri_id'], ['Campus.id'], ), sa.ForeignKeyConstraint(['campus_mon_id'], ['Campus.id'], ), sa.ForeignKeyConstraint(['campus_thu_id'], ['Campus.id'], ), sa.ForeignKeyConstraint(['campus_tue_id'], ['Campus.id'], ), sa.ForeignKeyConstraint(['campus_wed_id'], ['Campus.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('facebook_id') ) op.create_table('Translation', sa.Column('translatable_id', sa.Integer(), nullable=False), sa.Column('language', sa.String(length=5), nullable=False), sa.Column('translation', sa.String(length=256), nullable=False), sa.ForeignKeyConstraint(['translatable_id'], ['Translatable.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('translatable_id', 'language') ) op.create_table('MenuItem', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('menu_id', sa.Integer(), nullable=False), sa.Column('translatable_id', sa.Integer(), nullable=False), sa.Column('food_type', sa.Enum('SOUP', 'MEAT', 'VEGAN', 'GRILL', 'PASTA_MEAT', 'PASTA_VEGAN', name='foodtype'), nullable=False), sa.Column('price_students', sa.String(length=8), nullable=False), sa.Column('price_staff', sa.String(length=8), nullable=False), sa.ForeignKeyConstraint(['menu_id'], ['Menu.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.ForeignKeyConstraint(['translatable_id'], ['Translatable.id'], onupdate='CASCADE', ondelete='RESTRICT'), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('MenuItem') op.drop_table('Translation') op.drop_table('Subscription') op.drop_table('Menu') op.drop_table('Translatable') op.drop_table('Campus') # ### end Alembic commands ### ================================================ FILE: migrations/versions/aa31c90dc353_.py ================================================ """Add dessert type to coursetype enum Revision ID: aa31c90dc353 Revises: daf22dcadb8d Create Date: 2020-10-28 02:34:17.867680 """ from alembic import op # revision identifiers, used by Alembic. revision = 'aa31c90dc353' down_revision = 'daf22dcadb8d' branch_labels = None depends_on = None def upgrade(): op.execute(""" COMMIT """) op.execute(""" ALTER TYPE coursetype ADD VALUE IF NOT EXISTS 'DESSERT' AFTER 'SUB' """) def downgrade(): raise NotImplementedError() ================================================ FILE: migrations/versions/b384f281e755_.py ================================================ """Add column to AppUser to store data that some providers may need to store. Revision ID: b384f281e755 Revises: ee24af8d3121 Create Date: 2020-02-27 12:52:40.163394 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'b384f281e755' down_revision = 'ee24af8d3121' branch_labels = None depends_on = None def upgrade(): op.add_column('app_user', sa.Column('data', sa.Text(), nullable=True)) def downgrade(): op.drop_column('app_user', 'data') ================================================ FILE: migrations/versions/bc1ef0083bb4_.py ================================================ """Allow closing days to not have an end date Revision ID: bc1ef0083bb4 Revises: 9b9afdcf4e4e Create Date: 2020-09-22 18:29:49.798217 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'bc1ef0083bb4' down_revision = '9b9afdcf4e4e' branch_labels = None depends_on = None def upgrade(): op.alter_column('closing_days', 'last_day', existing_type=sa.DATE(), nullable=True) def downgrade(): op.alter_column('closing_days', 'last_day', existing_type=sa.DATE(), nullable=False) ================================================ FILE: migrations/versions/bd04cd56036f_.py ================================================ """Rename VEGAN to VEGETARIAN and add a real VEGAN enum option Revision ID: bd04cd56036f Revises: bc1ef0083bb4 Create Date: 2020-10-08 01:52:43.143274 """ from alembic import op # revision identifiers, used by Alembic. revision = 'bd04cd56036f' down_revision = 'bc1ef0083bb4' branch_labels = None depends_on = None def upgrade(): op.execute(""" COMMIT """) op.execute(""" ALTER TYPE coursesubtype RENAME VALUE 'VEGAN' TO 'VEGETARIAN' """) op.execute(""" ALTER TYPE coursesubtype ADD VALUE IF NOT EXISTS 'VEGAN' AFTER 'VEGETARIAN' """) def downgrade(): raise NotImplementedError() ================================================ FILE: migrations/versions/d225cbda8c77_.py ================================================ """Added registered_user and app_settings table Revision ID: d225cbda8c77 Revises: 528821121657 Create Date: 2020-10-24 22:29:31.746859 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'd225cbda8c77' down_revision = '528821121657' branch_labels = None depends_on = None def upgrade(): op.create_table('registered_user', sa.Column('id', sa.String(), nullable=False), sa.Column('name', sa.String(), nullable=False), sa.Column('email', sa.String(), nullable=False), sa.Column('profile_picture', sa.String(), nullable=False), sa.Column('enabled', sa.Boolean(), server_default=sa.text('false'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email') ) op.create_table('app_settings', sa.Column('name', sa.String(), nullable=False), sa.Column('value', sa.String(), server_default='null', nullable=False), sa.PrimaryKeyConstraint('name') ) def downgrade(): op.drop_table('app_settings') op.drop_table('registered_user') ================================================ FILE: migrations/versions/daf22dcadb8d_.py ================================================ """Drop food_type type Revision ID: daf22dcadb8d Revises: fe7bda58c5a4 Create Date: 2020-10-28 01:52:05.428570 """ from alembic import op # revision identifiers, used by Alembic. revision = 'daf22dcadb8d' down_revision = 'fe7bda58c5a4' branch_labels = None depends_on = None def upgrade(): op.drop_column('menu_item', 'food_type') op.execute("DROP TYPE foodtype") def downgrade(): raise NotImplementedError() ================================================ FILE: migrations/versions/ddf5bd871988_.py ================================================ """Add field to Translation indicating the provider of the translation. e.g. google, bing, komida, ... Also truncates language fields Revision ID: ddf5bd871988 Revises: b384f281e755 Create Date: 2020-03-04 14:52:00.074936 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'ddf5bd871988' down_revision = 'b384f281e755' branch_labels = None depends_on = None def upgrade(): op.add_column('translation', sa.Column('provider', sa.String(length=16), nullable=True)) op.execute(""" UPDATE translation SET provider = 'google', language = LEFT(language, 2) """) op.execute(""" UPDATE translatable SET original_language = LEFT(original_language, 2) """) op.execute(""" UPDATE app_user SET language = LEFT(language, 2) """) def downgrade(): op.drop_column('translation', 'provider') op.execute(""" UPDATE translation SET language = 'nl_NL' WHERE language = 'nl' """) op.execute(""" UPDATE translatable SET original_language = 'nl_NL' WHERE original_language = 'nl' """) op.execute(""" UPDATE app_user SET language = 'nl_NL' WHERE language = 'nl' """) op.execute(""" UPDATE translation SET language = 'en_GB' WHERE language = 'en' """) op.execute(""" UPDATE translatable SET original_language = 'en_GB' WHERE original_language = 'en' """) op.execute(""" UPDATE app_user SET language = 'en_GB' WHERE language = 'en' """) op.execute(""" UPDATE translation SET language = 'hi_IN' WHERE language = 'hi' """) op.execute(""" UPDATE translatable SET original_language = 'hi_IN' WHERE original_language = 'hi' """) op.execute(""" UPDATE app_user SET language = 'hi_IN' WHERE language = 'hi' """) op.execute(""" UPDATE translation SET language = 'de_DE' WHERE language = 'de' """) op.execute(""" UPDATE translatable SET original_language = 'de_DE' WHERE original_language = 'de' """) op.execute(""" UPDATE app_user SET language = 'de_DE' WHERE language = 'de' """) op.execute(""" UPDATE translation SET language = 'ko_KR' WHERE language = 'ko' """) op.execute(""" UPDATE translatable SET original_language = 'ko_KR' WHERE original_language = 'ko' """) op.execute(""" UPDATE app_user SET language = 'ko_KR' WHERE language = 'ko' """) op.execute(""" UPDATE translation SET language = 'es_ES' WHERE language = 'es' """) op.execute(""" UPDATE translatable SET original_language = 'es_ES' WHERE original_language = 'es' """) op.execute(""" UPDATE app_user SET language = 'es_ES' WHERE language = 'es' """) ================================================ FILE: migrations/versions/e18b14ed6b98_.py ================================================ """Change price columns to store as numerics instead of strings Revision ID: e18b14ed6b98 Revises: 3806b46f7f00 Create Date: 2019-11-07 00:53:28.115755 """ from alembic import op # revision identifiers, used by Alembic. revision = 'e18b14ed6b98' down_revision = '3806b46f7f00' branch_labels = None depends_on = None def upgrade(): op.execute(""" ALTER TABLE menu_item ALTER COLUMN price_students TYPE NUMERIC(4, 2) USING CASE WHEN price_students = '' THEN 0.0 ELSE substring(REPLACE(price_students, ',', '.') FROM 2)::numeric(4,2) END, ALTER COLUMN price_staff TYPE NUMERIC(4, 2) USING CASE WHEN price_staff = '' THEN 0.0 ELSE substring(REPLACE(price_staff, ',', '.') FROM 2)::numeric(4,2) END, ALTER COLUMN price_staff DROP NOT NULL """) op.execute(""" UPDATE menu_item SET price_staff = NULL WHERE price_staff = '0.0' """) def downgrade(): raise NotImplementedError() ================================================ FILE: migrations/versions/ea6e1f581a7b_.py ================================================ """Add external id column to menu items Revision ID: ea6e1f581a7b Revises: 1a2e04608ee9 Create Date: 2020-10-25 21:42:34.054774 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'ea6e1f581a7b' down_revision = '1a2e04608ee9' branch_labels = None depends_on = None def upgrade(): op.add_column('menu_item', sa.Column('external_id', sa.Integer(), server_default=sa.text('NULL'), nullable=True)) op.create_unique_constraint(None, 'menu_item', ['external_id']) def downgrade(): op.drop_constraint(None, 'menu_item', type_='unique') op.drop_column('menu_item', 'external_id') ================================================ FILE: migrations/versions/ecce0e669d8c_.py ================================================ """Add snack type to coursetype enum Revision ID: ecce0e669d8c Revises: 2887dcc37788 Create Date: 2020-11-03 17:35:48.126589 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'ecce0e669d8c' down_revision = '2887dcc37788' branch_labels = None depends_on = None def upgrade(): op.execute(""" COMMIT """) op.execute(""" ALTER TYPE coursetype ADD VALUE IF NOT EXISTS 'SNACK' AFTER 'DESSERT' """) def downgrade(): raise NotImplementedError() ================================================ FILE: migrations/versions/eda0c928c279_.py ================================================ """Add learning datapoints table for gathering data to train a classifier Revision ID: eda0c928c279 Revises: 1dafd2bf730a Create Date: 2020-10-29 02:38:04.925464 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'eda0c928c279' down_revision = '1dafd2bf730a' branch_labels = None depends_on = None def upgrade(): # This fixes a problem where the primary key didn't get updated in a previous migration op.execute("ALTER TABLE registered_user DROP CONSTRAINT registered_user_pkey") op.execute("ALTER TABLE registered_user ADD PRIMARY KEY (id, provider)") op.create_table('learning_datapoint', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('campus_id', sa.Integer(), nullable=False), sa.Column('menu_day', sa.Date(), nullable=False), sa.Column('screenshot', sa.Text(), nullable=False), sa.Column('processed_data', sa.Text(), nullable=False), sa.ForeignKeyConstraint(('campus_id',), ['campus.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('learning_datapoint_submission', sa.Column('user_id', sa.String(), nullable=False), sa.Column('user_provider', sa.String(length=16), nullable=False), sa.Column('datapoint_id', sa.Integer(), nullable=False), sa.Column('submission_data', sa.Text(), nullable=False), sa.ForeignKeyConstraint(('datapoint_id',), ['learning_datapoint.id'], onupdate='CASCADE', ondelete='CASCADE'), sa.ForeignKeyConstraint(('user_id', 'user_provider'), ['registered_user.id', 'registered_user.provider'], onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('user_id', 'user_provider', 'datapoint_id') ) def downgrade(): op.drop_table('learning_datapoint_submission') op.drop_table('learning_datapoint') ================================================ FILE: migrations/versions/ee24af8d3121_.py ================================================ """Remove size constraint on AppUser internal ID. Revision ID: ee24af8d3121 Revises: 5cd86de4dffe Create Date: 2020-02-27 12:43:51.806644 """ from alembic import op # revision identifiers, used by Alembic. revision = 'ee24af8d3121' down_revision = '5cd86de4dffe' branch_labels = None depends_on = None def upgrade(): op.execute(""" ALTER TABLE app_user ALTER COLUMN internal_id TYPE VARCHAR; """) def downgrade(): op.execute(""" ALTER TABLE app_user ALTER COLUMN internal_id TYPE VARCHAR(32); """) ================================================ FILE: migrations/versions/fe4aca6853a2_.py ================================================ """Change subscriptions storage from facebook_id to (provider, internal_id) Revision ID: fe4aca6853a2 Revises: a223b578f7b0 Create Date: 2019-10-13 22:37:16.548775 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'fe4aca6853a2' down_revision = 'a223b578f7b0' branch_labels = None depends_on = None def upgrade(): op.drop_constraint('Subscription_facebook_id_key', 'Subscription', type_='unique') op.add_column('Subscription', sa.Column('provider', sa.String(length=32), server_default='facebook')) op.alter_column('Subscription', 'provider', server_default=None, nullable=False) # Define default then remove it op.alter_column('Subscription', 'facebook_id', new_column_name='internal_id') op.create_unique_constraint('Subscription_provider_internal_id_key', 'Subscription', ['provider', 'internal_id']) def downgrade(): op.drop_constraint('Subscription_provider_internal_id_key', 'Subscription', type_='unique') op.drop_column('Subscription', 'provider') op.alter_column('Subscription', 'internal_id', new_column_name='facebook_id') op.create_unique_constraint('Subscription_facebook_id_key', 'Subscription', ['facebook_id']) ================================================ FILE: migrations/versions/fe7bda58c5a4_.py ================================================ """Add data_frozen column to menu_item table Revision ID: fe7bda58c5a4 Revises: ea6e1f581a7b Create Date: 2020-10-25 23:21:51.975403 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'fe7bda58c5a4' down_revision = 'ea6e1f581a7b' branch_labels = None depends_on = None def upgrade(): op.add_column('menu_item', sa.Column('data_frozen', sa.Boolean(), server_default=sa.text('false'), nullable=False)) def downgrade(): op.drop_column('menu_item', 'data_frozen') ================================================ FILE: requirements.txt ================================================ wheel==0.37.1 setuptools==65.3.0 Flask==2.2.2 Flask-Login==0.6.2 Flask-Migrate==3.1.0 Flask-Session==0.4.0 Flask-SQLAlchemy==2.5.1 Flask-Testing==0.8.1 gunicorn==20.1.0 jsonschema==3.2.0 psycopg2-binary==2.9.3 requests==2.28.1 python-dateutil==2.8.2 googletrans==3.0.0 cachetools==5.2.0 APScheduler==3.9.1 httpretty==1.1.4 cryptography==38.0.1 pywebpush==1.14.0 py-vapid==1.8.2 Werkzeug==2.2.2 boto3==1.17.* SQLAlchemy==1.4.41 sqlalchemy2-stubs==0.0.2a27 colour-runner==0.1.1 oauthlib==3.2.1 PyYAML==6.0.1 Jinja2==3.0.3 itsdangerous==2.0.1 ================================================ FILE: schemas/DELETE_api_subscribe.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "DeleteSubscriptionMessage", "type": "object", "properties": { "endpoint": { "type": "string" }, "channel": { "type": "string" } }, "required": [ "endpoint", "channel" ], "additionalProperties": false } ================================================ FILE: schemas/GET_api_authorized.response.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "api_response_strict.json", "title": "AuthorizedApiResponse", "properties": { "roles": { "type": "array", "items": { "type": "string" } } }, "required": [ "roles" ] } ================================================ FILE: schemas/GET_api_learning.response.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "api_response_strict.json", "title": "LearningApiResponse", "properties": { "data": { "oneOf": [ { "type": "null" }, { "type": "object", "properties": { "id": { "type": "string" }, "screenshot": { "type": "string" }, "course_name": { "type": "string" }, "course_type": { "type": "number", "minimum": 1, "maximum": 8 }, "course_sub_type": { "type": "number", "minimum": 1, "maximum": 3 }, "price_students": { "type": "string" }, "price_staff": { "type": "string" } }, "required": [ "id", "screenshot", "course_name", "course_type", "course_sub_type", "price_students", "price_staff" ] } ] } }, "required": [ "data" ] } ================================================ FILE: schemas/POST_api_learning.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LearningPostMessage", "type": "object", "properties": { "id": { "type": "string" }, "course_name_correct": { "type": "boolean" }, "course_type": { "type": "integer", "oneOf": [ { "minimum": 1, "maximum": 8 }, { "minimum": -2, "maximum": -1 } ] }, "course_sub_type": { "type": "integer", "minimum": 1, "maximum": 3 }, "price_students_correct": { "type": "boolean" }, "price_staff_correct": { "type": "boolean" } }, "required": [ "id", "course_name_correct", "course_type", "course_sub_type", "price_students_correct", "price_staff_correct" ], "additionalProperties": false } ================================================ FILE: schemas/POST_api_login.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LoginMessage", "type": "object", "properties": { "username": { "type": "string" }, "password": { "type": "string" } }, "required": [ "username", "password" ], "additionalProperties": false } ================================================ FILE: schemas/POST_api_subscribe.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "AddSubscriptionMessage", "type": "object", "properties": { "endpoint": { "type": "string" }, "keys": { "type": "object" }, "channel": { "type": "string" }, "data": true }, "required": [ "endpoint", "keys", "channel" ], "additionalProperties": false } ================================================ FILE: schemas/POST_api_trigger.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LoginMessage", "type": "object", "properties": { "trigger": { "type": "string", "enum": [ "menu_update", "notification_test_error", "notification_test_text" ] } }, "required": [ "username" ], "additionalProperties": false } ================================================ FILE: schemas/PUT_api_subscribe.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ReplaceSubscriptionMessage", "type": "object", "properties": { "old_endpoint": { "type": "string" }, "endpoint": { "type": "string" }, "keys": { "type": "object" } }, "required": [ "old_endpoint", "endpoint", "keys" ], "additionalProperties": false } ================================================ FILE: schemas/api_response_base.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ApiResponse", "type": "object", "properties": { "status": { "type": "integer" }, "message": { "type": "string" } }, "required": [ "status", "message" ] } ================================================ FILE: schemas/api_response_strict.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "api_response_base.json", "title": "StrictApiResponse", "additionalProperties": false } ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/base.py ================================================ import datetime from decimal import Decimal from functools import partial, wraps from typing import Dict, List, NamedTuple, Tuple import httpretty from flask.cli import ScriptInfo from flask_testing import TestCase import komidabot.models as models import komidabot.users as users from app import create_app, db from komidabot.app import App from tests.utils import StubTranslator menu_item = NamedTuple('menu_item', [('type', models.CourseType), ('sub_type', models.CourseSubType), ('attributes', List[models.CourseAttributes]), ('allergens', List[models.CourseAllergens]), ('text', str), ('language', str), ('price_students', Decimal), ('price_staff', Decimal)]) def with_context(func): @wraps(func) def decorated_func(self, *args, **kwargs): if getattr(with_context, 'active', False): return func(self, *args, **kwargs) if 'has_context' in kwargs: has_context = kwargs.pop('has_context') if has_context: try: setattr(with_context, 'active', True) return func(self, *args, **kwargs) finally: setattr(with_context, 'active', False) with self.app.app_context(): try: setattr(with_context, 'active', True) return func(self, *args, **kwargs) finally: setattr(with_context, 'active', False) return decorated_func class BaseTestCase(TestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # noinspection PyTypeChecker self.app: App = None def create_app(self): script_info = ScriptInfo(create_app=partial(create_app, app_settings='config.TestingConfig')) return script_info.load_app() def setUp(self): super().setUp() self.app.translator = self.translator = StubTranslator() with self.app.app_context(): db.create_all() db.session.commit() def tearDown(self): with self.app.app_context(): db.session.remove() db.drop_all() super().tearDown() def assertEqualCommutative(self, first, second, msg=None): self.assertEqual(first, second, msg=msg) self.assertEqual(second, first, msg=msg) def assertNotEqualCommutative(self, first, second, msg=None): self.assertNotEqual(first, second, msg=msg) self.assertNotEqual(second, first, msg=msg) @with_context def create_translation(self, data: Dict[str, str], default_language: str) -> Tuple[models.Translatable, Dict[str, models.Translation]]: if default_language not in data: raise ValueError() result = dict() translatable, translation = models.Translatable.get_or_create(data[default_language], default_language) result[default_language] = translation for language, text in data.items(): if language == default_language: continue translation = translatable.add_translation(language, text) result[language] = translation db.session.commit() return translatable, result @with_context def create_test_campuses(self) -> List[models.Campus]: campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 0) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 0) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 0) campus3.active = False db.session.commit() self.campuses = [campus1, campus2, campus3] return self.campuses @with_context def activate_feature(self, feature_id: str, user_list: 'List[users.UserId]' = None, available=None) -> models.Feature: feature = models.Feature.create(feature_id) if user_list: for user in user_list: user_obj = models.AppUser.find_by_id(user.provider, user.id) if user_obj is None: raise ValueError() models.Feature.set_user_participating(user_obj, feature.string_id, True) if available is not None: feature.globally_available = available db.session.commit() return feature @with_context def create_menu(self, campus: models.Campus, day: datetime.date, items: 'List[menu_item]') -> models.Menu: menu = models.Menu.create(campus, day) for item in items: translatable, _ = models.Translatable.get_or_create(item.text, item.language) menu.add_menu_item(translatable, item.type, item.sub_type, item.attributes, item.allergens, item.price_students, item.price_staff) db.session.commit() return menu class HttpCapture: GET = httpretty.GET PUT = httpretty.PUT POST = httpretty.POST DELETE = httpretty.DELETE HEAD = httpretty.HEAD PATCH = httpretty.PATCH OPTIONS = httpretty.OPTIONS CONNECT = httpretty.CONNECT def __init__(self, allow_net_connect=False): self.allow_net_connect = allow_net_connect def __enter__(self): httpretty.enable(allow_net_connect=self.allow_net_connect) return self def __exit__(self, exc_type, exc_val, exc_tb): httpretty.disable() httpretty.reset() # noinspection PyMethodMayBeStatic def register_uri(self, method, uri, body, status=200): httpretty.register_uri(method, uri, body, status=status) ================================================ FILE: tests/external_menus/.gitignore ================================================ *.parsed.yaml *.processed.yaml ================================================ FILE: tests/external_menus/2019-11-25_cde.parsed.expected.yaml ================================================ $test_case: course_of_interest: 1353 reason: | This response originally broke an assumption that only one component in a menu item can have a price attached to it. However, the price is based on the sum of all components, some of which can have a fixed price, but others may require "calculating" a staff price. If one or more requires this to be calculated this is done on the summed price. campus: cde date: '2019-11-25' menu: - components: - allergens: - CELERY - MILK_LACTOSE - WHEAT_GLUTEN attributes: - SOUP - VEGGIE name: en: Celeriac soup nl: Knolseldersoep external_id: 1351 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - EGG - SULFITES - WHEAT_GLUTEN attributes: - VEGGIE name: en: Quorn and bell pepper goulash dd nl: Goulash met quorn en paprika dd - allergens: [ ] attributes: [ ] name: en: rice nl: rijst external_id: 1352 multiple_prices: true price: '4.60' sort_order: 1 - components: - allergens: - MILK_LACTOSE - WHEAT_GLUTEN attributes: - CHICKEN name: en: Chicken roulade with sun-dried tomatoes nl: Kiprollade met zongedroogde tomaat $test_case: comment: This component has a price of 4.6 in the raw data - allergens: - MUSTARD - WHEAT_GLUTEN attributes: [ ] name: en: couscous nl: couscous $test_case: comment: This component has a price of 0.2 in the raw data - allergens: - CELERY attributes: [ ] name: en: couscous vegetables nl: couscousgroenten external_id: 1353 multiple_prices: true price: '4.80' $test_case: comment: The price of this course is based on the sum of its components sort_order: 2 - components: - allergens: - WHEAT_GLUTEN attributes: - PASTA - VEGAN name: en: Penne with mushrooms and a creamy cauliflower sauce nl: Penne met paddenstoelen en romige bloemkoolsaus external_id: 1427 multiple_prices: true price: '3.80' sort_order: 3 - components: - allergens: - EGG - WHEAT_GLUTEN attributes: [ ] name: en: pasta nl: pasta - allergens: - EGG - MILK_LACTOSE attributes: - PASTA - VEGGIE name: en: African sunshine sauce nl: African sunshinesaus external_id: 1430 multiple_prices: true price: '3.80' sort_order: 4 - components: - allergens: [ ] attributes: - CHICKEN - GRILL name: en: Grilled chicken breast nl: Kipfilet op de grill - allergens: - WHEAT_GLUTEN attributes: [ ] name: en: fries nl: frieten - allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN attributes: [ ] name: en: saladbar nl: Saladbar external_id: 1431 multiple_prices: true price: '4.80' sort_order: 5 - components: - allergens: - SESAME attributes: - SALAD - VEGAN name: en: Buddha bow hummuslicious nl: Buddha bowl humuslicious external_id: 1432 multiple_prices: true price: '3.80' sort_order: 11 ================================================ FILE: tests/external_menus/2019-11-25_cde.processed.expected.yaml ================================================ campus: cde date: '2019-11-25' menu: - course_allergens: - CELERY - MILK_LACTOSE - WHEAT_GLUTEN course_attributes: - SOUP - VEGGIE course_sub_type: VEGETARIAN course_type: SOUP external_id: 1351 name: en: Celeriac soup nl: Knolseldersoep price_staff: null price_students: '0.90' - course_allergens: - EGG - SULFITES - WHEAT_GLUTEN course_attributes: - VEGGIE course_sub_type: VEGETARIAN course_type: DAILY external_id: 1352 name: en: Quorn and bell pepper goulash dd, rice nl: Goulash met quorn en paprika dd, rijst price_staff: '5.70' price_students: '4.60' - course_allergens: - CELERY - MILK_LACTOSE - MUSTARD - WHEAT_GLUTEN course_attributes: - CHICKEN course_sub_type: NORMAL course_type: DAILY external_id: 1353 name: en: Chicken roulade with sun-dried tomatoes, couscous, couscous vegetables nl: Kiprollade met zongedroogde tomaat, couscous, couscousgroenten price_staff: '6.00' price_students: '4.80' - course_allergens: - WHEAT_GLUTEN course_attributes: - PASTA - VEGAN course_sub_type: VEGAN course_type: PASTA external_id: 1427 name: en: Penne with mushrooms and a creamy cauliflower sauce nl: Penne met paddenstoelen en romige bloemkoolsaus price_staff: '4.70' price_students: '3.80' - course_allergens: - EGG - MILK_LACTOSE - WHEAT_GLUTEN course_attributes: - PASTA - VEGGIE course_sub_type: VEGETARIAN course_type: PASTA external_id: 1430 name: en: Pasta, African sunshine sauce nl: Pasta, African sunshinesaus price_staff: '4.70' price_students: '3.80' - course_allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN course_attributes: - CHICKEN - GRILL course_sub_type: NORMAL course_type: GRILL external_id: 1431 name: en: Grilled chicken breast, fries, saladbar nl: Kipfilet op de grill, frieten, Saladbar price_staff: '6.00' price_students: '4.80' - course_allergens: - SESAME course_attributes: - SALAD - VEGAN course_sub_type: VEGAN course_type: SALAD external_id: 1432 name: en: Buddha bow hummuslicious nl: Buddha bowl humuslicious price_staff: '4.70' price_students: '3.80' ================================================ FILE: tests/external_menus/2019-11-25_cde.raw.json ================================================ { "id": 208, "menuDate": "2019-11-25T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1351, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 0, "menuItemContents": [ { "id": 1967, "menuItemId": 1351, "courseId": 859, "sortOrder": 0, "course": { "id": 859, "dispNameNl": "Knolseldersoep", "dispNameEn": "Celeriac soup", "nameNl": "knolseldersoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "bouillon, ajuinblokjes, soepprei,knolselder en eventueel aardappelen samen gaar koken. - mixen. - op smaak brengen met peper en zout. - melk en room toevoegen.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 859, "allergenId": 201 }, { "courseId": 859, "allergenId": 203 }, { "courseId": 859, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 859, "courseLogoId": 211 }, { "courseId": 859, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1352, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 1, "menuItemContents": [ { "id": 1969, "menuItemId": 1352, "courseId": 999, "sortOrder": 0, "course": { "id": 999, "dispNameNl": "rijst", "dispNameEn": "rice", "nameNl": "rijst", "nameEn": "", "weight": "150g", "extra": null, "preparation": "kook de rijst gaar in licht gezouten water -", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1968, "menuItemId": 1352, "courseId": 1318, "sortOrder": 0, "course": { "id": 1318, "dispNameNl": "Goulash met quorn en paprika dd", "dispNameEn": "Quorn and bell pepper goulash dd", "nameNl": "goulash met quorn en paprika dd", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "ajuin aanbakken in vetstof, quornblokjes toevoegen - tomatenpuree en paprikapoeder toevoegen en laten uitdrogen - bevochtigen met water en tomatenblokjes - kruiden met pezo, look, tijm en laurier - bouillon toevoegen champignons en paprika toevoegen. - binden met blanke roux en afwerken met peterselie.", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1318, "allergenId": 200 }, { "courseId": 1318, "allergenId": 201 }, { "courseId": 1318, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1318, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1353, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 2, "menuItemContents": [ { "id": 1971, "menuItemId": 1353, "courseId": 977, "sortOrder": 0, "course": { "id": 977, "dispNameNl": "couscous", "dispNameEn": "couscous", "nameNl": "couscous", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "doe de couscous in een diepe gastronorm. - kook het water met de bouillon - meng de overige groenten met de couscous - giet het water over de couscous en laat het geheel wellen - regelmatig losroeren", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 977, "allergenId": 201 }, { "courseId": 977, "allergenId": 204 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1972, "menuItemId": 1353, "courseId": 1032, "sortOrder": 0, "course": { "id": 1032, "dispNameNl": "couscousgroenten", "dispNameEn": "couscous vegetables", "nameNl": "couscousgroenten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "couscousgroenten beetgaar steamen. stoven in margarine, op smaak brengen met peper en zout. in gastronorm doen en bestrooien met koriander.", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1032, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1970, "menuItemId": 1353, "courseId": 1864, "sortOrder": 0, "course": { "id": 1864, "dispNameNl": "Kiprollade met zongedroogde tomaat ", "dispNameEn": "Chicken roulade with sun-dried tomatoes ", "nameNl": "kiprollade zongedroogde tomaat dd", "nameEn": "", "weight": "150g", "extra": null, "preparation": "zie receptuur portioneren, schikken op geoliede gastro en verwarmen combi op 140\u00b0 met vochtimpuls", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1864, "allergenId": 201 }, { "courseId": 1864, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1864, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1427, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 3, "menuItemContents": [ { "id": 2087, "menuItemId": 1427, "courseId": 5177, "sortOrder": 0, "course": { "id": 5177, "dispNameNl": "Penne met paddenstoelen en romige bloemkoolsaus", "dispNameEn": "Penne with mushrooms and a creamy cauliflower sauce", "nameNl": "00 penne met paddenstoelen en romige bloemkoolsaus (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de bloemkoolsaus klaar volgens de receptuur (zie fiche). - maak de volkoren penne klaar volgens de receptuur. fruit de ui en bak hierbij de champignons, breng op smaak met look, peper en zout.- meng de pasta met de bloemkoolsaus, de champignons en werk af met platte peterselie & gebakken uitjes.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5177, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5177, "courseLogoId": 207 }, { "courseId": 5177, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1430, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 4, "menuItemContents": [ { "id": 2089, "menuItemId": 1430, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2088, "menuItemId": 1430, "courseId": 1416, "sortOrder": 0, "course": { "id": 1416, "dispNameNl": "African sunshinesaus", "dispNameEn": "African sunshine sauce", "nameNl": "african sunshine saus, zvv, dd", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "courgettes en paprika aanstoven en kruiden. - mengen met de pasta en de african sunshine saus toevoegen.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1416, "allergenId": 200 }, { "courseId": 1416, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1416, "courseLogoId": 207 }, { "courseId": 1416, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1431, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 5, "menuItemContents": [ { "id": 2092, "menuItemId": 1431, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2090, "menuItemId": 1431, "courseId": 3264, "sortOrder": 0, "course": { "id": 3264, "dispNameNl": "Kipfilet op de grill ", "dispNameEn": "Grilled chicken breast ", "nameNl": "kipfilet op de grill 1 (kippenkruiden), dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": null, "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3264, "courseLogoId": 202 }, { "courseId": 3264, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2091, "menuItemId": 1431, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1432, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 208, "sortorder": 11, "menuItemContents": [ { "id": 2093, "menuItemId": 1432, "courseId": 5224, "sortOrder": 0, "course": { "id": 5224, "dispNameNl": "Buddha bowl humuslicious", "dispNameEn": "Buddha bow hummuslicious", "nameNl": "00 buddha bowl humuslicious,z (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de quinoa klaar volgens de receptuur & laat afkoelen.- rooster de kikkererwten in de oven: besprenkel met olijfolie en kruid af met kruid mole oaxaca en zeezout & laat afkoelen - rooster de groene asperges - maak de bowl: doe de quinoa in een kom, vervolgens de gegrilde asperges, spinazie en schijfjes avocado (besprenkel met citroensap).- werk af met kerstomaten,granaatappelpitjes en de humus in het midden en geroosterde kikkererwten.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5224, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5224, "courseLogoId": 209 }, { "courseId": 5224, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "raw.schema.json" } ================================================ FILE: tests/external_menus/2019-11-25_cmi.parsed.expected.yaml ================================================ $test_case: course_of_interest: 1390 reason: | This response originally broke an assumption that "maincourse" has precedence over "showFirst". As far as I know, course component ordering is based on: 1. "showFirst": true has precedence over false, otherwise: 2. "maincourse": true has precedence over false, otherwise: 3. (?) "sortOrder": lower values have precedence over higher values, otherwise: 4: The order in which items are returned by the API However, I have not yet encountered a sortOrder other than 0 in the wild, and I'd need to check the official website implementation to see if this is actually used. campus: cmi date: '2019-11-25' menu: - components: - allergens: - WHEAT_GLUTEN attributes: - VEGAN name: en: Falafel nl: Falafel - allergens: - CELERY - WHEAT_GLUTEN attributes: [ ] name: en: sauce Marengo nl: marengosaus - allergens: [ ] attributes: [ ] name: en: parsley potatoes nl: aardappelen met peterselie external_id: 838 multiple_prices: true price: '4.00' sort_order: 1 - components: - allergens: - CELERY - WHEAT_GLUTEN attributes: - SOUP - VEGAN name: en: Leek soup nl: Preisoep external_id: 839 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - SOY - WHEAT_GLUTEN attributes: - CHICKEN name: en: "Turkey pav\xE9" nl: "Kalkoenpav\xE9" - allergens: - CELERY - WHEAT_GLUTEN attributes: [ ] name: en: sauce Marengo nl: marengosaus - allergens: [ ] attributes: [ ] name: en: parsley potatoes nl: aardappelen met peterselie external_id: 840 multiple_prices: true price: '4.20' sort_order: 2 - components: - allergens: - EGG - WHEAT_GLUTEN attributes: [ ] name: en: penne nl: Penne $test_case: showFirst: true maincourse: false - allergens: - EGG - MILK_LACTOSE attributes: - PASTA - VEGGIE name: en: African sunshine sauce nl: African sunshinesaus $test_case: showFirst: false maincourse: true external_id: 1390 multiple_prices: true price: '3.80' sort_order: 4 - components: - allergens: - WHEAT_GLUTEN attributes: - PASTA - VEGAN name: en: Penne with mushrooms and a creamy cauliflower sauce nl: Penne met paddenstoelen en romige bloemkoolsaus external_id: 1391 multiple_prices: true price: '3.80' sort_order: 3 - components: - allergens: [ ] attributes: - CHICKEN - GRILL name: en: Marinated chicken skewer nl: Gemarineerde kippenbrochette - allergens: - SULFITES - WHEAT_GLUTEN attributes: [ ] name: en: "Proven\xE7al sauce" nl: "Proven\xE7aalse saus" - allergens: - WHEAT_GLUTEN attributes: [ ] name: en: fries nl: frieten - allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN attributes: [ ] name: en: saladbar nl: Saladbar external_id: 1392 multiple_prices: true price: '5.20' sort_order: 5 - components: - allergens: - NUTS - PEANUTS - SESAME - SULFITES - WHEAT_GLUTEN attributes: - CHICKEN - SALAD name: en: Mango and chicken salad nl: Mango-kip-salade external_id: 1393 multiple_prices: true price: '4.40' sort_order: 6 - components: - allergens: - EGG - FISH - MUSTARD - WHEAT_GLUTEN attributes: - FISH - SALAD name: en: Salad with peaches and salmon salad nl: Salade met perziken en zalmsalade external_id: 1394 multiple_prices: true price: '4.80' sort_order: 7 - components: - allergens: - EGG - FISH - MUSTARD - WHEAT_GLUTEN attributes: - FISH - SNACK name: en: Trout and citrus sandwich nl: Broodje forel-citrus external_id: 1396 multiple_prices: false price: '3.10' sort_order: 8 ================================================ FILE: tests/external_menus/2019-11-25_cmi.raw.json ================================================ { "id": 177, "menuDate": "2019-11-25T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 839, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 0, "menuItemContents": [ { "id": 1254, "menuItemId": 839, "courseId": 869, "sortOrder": 0, "course": { "id": 869, "dispNameNl": "Preisoep", "dispNameEn": "Leek soup", "nameNl": "preisoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 869, "allergenId": 201 }, { "courseId": 869, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 869, "courseLogoId": 211 }, { "courseId": 869, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "groot: \u20ac 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 838, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 1, "menuItemContents": [ { "id": 1252, "menuItemId": 838, "courseId": 924, "sortOrder": 0, "course": { "id": 924, "dispNameNl": "marengosaus ", "dispNameEn": "sauce Marengo ", "nameNl": "marengo saus dd", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux. - afsmaken met pezo en proven\u00e7aalse kruiden en sambal. - garnituur van gestoofde ajuin, olijven, erwtjes en peterselie toevoegen opm je kan ook de erwtjes apart geven als volwaardige groenten (meer rekenen dan)", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 924, "allergenId": 201 }, { "courseId": 924, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1253, "menuItemId": 838, "courseId": 991, "sortOrder": 0, "course": { "id": 991, "dispNameNl": "aardappelen met peterselie", "dispNameEn": "parsley potatoes", "nameNl": "aardappelen met peterselie", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "stoom de aardappelen gaar in 18 min. - voeg de gehakte peterselie toe. -je kan er optioneel geklaarde boter aan toevoegen", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1251, "menuItemId": 838, "courseId": 1269, "sortOrder": 0, "course": { "id": 1269, "dispNameNl": "Falafel", "dispNameEn": "Falafel", "nameNl": "falafel dd", "nameEn": "", "weight": "6x14g", "extra": null, "preparation": "frituur voorverwarmen op 170\u00b0. - falafel afbakken tot een temperatuur van minstens 65\u00b0c bereikt is . - in bain marie schikken", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1269, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 1269, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 840, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 2, "menuItemContents": [ { "id": 1256, "menuItemId": 840, "courseId": 924, "sortOrder": 0, "course": { "id": 924, "dispNameNl": "marengosaus ", "dispNameEn": "sauce Marengo ", "nameNl": "marengo saus dd", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux. - afsmaken met pezo en proven\u00e7aalse kruiden en sambal. - garnituur van gestoofde ajuin, olijven, erwtjes en peterselie toevoegen opm je kan ook de erwtjes apart geven als volwaardige groenten (meer rekenen dan)", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 924, "allergenId": 201 }, { "courseId": 924, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1257, "menuItemId": 840, "courseId": 991, "sortOrder": 0, "course": { "id": 991, "dispNameNl": "aardappelen met peterselie", "dispNameEn": "parsley potatoes", "nameNl": "aardappelen met peterselie", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "stoom de aardappelen gaar in 18 min. - voeg de gehakte peterselie toe. -je kan er optioneel geklaarde boter aan toevoegen", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1255, "menuItemId": 840, "courseId": 1841, "sortOrder": 0, "course": { "id": 1841, "dispNameNl": "Kalkoenpav\u00e9", "dispNameEn": "Turkey pav\u00e9", "nameNl": "kalkoenpav\u00e9, dd", "nameEn": "", "weight": "120g", "extra": null, "preparation": "op te warmen in steamer. - vacu\u00fcm verpakt", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1841, "allergenId": 200 }, { "courseId": 1841, "allergenId": 201 }, { "courseId": 1841, "allergenId": 203 }, { "courseId": 1841, "allergenId": 204 }, { "courseId": 1841, "allergenId": 208 }, { "courseId": 1841, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1841, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1391, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 3, "menuItemContents": [ { "id": 2029, "menuItemId": 1391, "courseId": 5177, "sortOrder": 0, "course": { "id": 5177, "dispNameNl": "Penne met paddenstoelen en romige bloemkoolsaus", "dispNameEn": "Penne with mushrooms and a creamy cauliflower sauce", "nameNl": "00 penne met paddenstoelen en romige bloemkoolsaus (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de bloemkoolsaus klaar volgens de receptuur (zie fiche). - maak de volkoren penne klaar volgens de receptuur. fruit de ui en bak hierbij de champignons, breng op smaak met look, peper en zout.- meng de pasta met de bloemkoolsaus, de champignons en werk af met platte peterselie & gebakken uitjes.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5177, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5177, "courseLogoId": 207 }, { "courseId": 5177, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1390, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 4, "menuItemContents": [ { "id": 2027, "menuItemId": 1390, "courseId": 1416, "sortOrder": 0, "course": { "id": 1416, "dispNameNl": "African sunshinesaus", "dispNameEn": "African sunshine sauce", "nameNl": "african sunshine saus, zvv, dd", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "courgettes en paprika aanstoven en kruiden. - mengen met de pasta en de african sunshine saus toevoegen.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1416, "allergenId": 200 }, { "courseId": 1416, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1416, "courseLogoId": 207 }, { "courseId": 1416, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2028, "menuItemId": 1390, "courseId": 5480, "sortOrder": 0, "course": { "id": 5480, "dispNameNl": "Penne", "dispNameEn": "penne", "nameNl": "Penne,kookvast", "nameEn": "", "weight": "150 - 200 g pp", "extra": "", "preparation": "kook de pasta gaar in licht gezouten water, olie toevoegen. De kooktijd is afhankelijk van de soort pasta.", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5480, "allergenId": 200 }, { "courseId": 5480, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1392, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 5, "menuItemContents": [ { "id": 2031, "menuItemId": 1392, "courseId": 932, "sortOrder": 0, "course": { "id": 932, "dispNameNl": "Proven\u00e7aalse saus", "dispNameEn": "Proven\u00e7al sauce", "nameNl": "proven\u00e7aalse saus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux - afsmaken met pezo en proven\u00e7aalse kruiden. ajuin en champignons aanstoven en kleuren, courgetten (schijven nog eventueel halveren) en paprika toevoegen warme tomatensaus toevoegen en afwerken met proven\u00e7aalse kruiden en peterselie. - eventueel verdunnen met heet water", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 932, "allergenId": 201 }, { "courseId": 932, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2033, "menuItemId": 1392, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2030, "menuItemId": 1392, "courseId": 1374, "sortOrder": 0, "course": { "id": 1374, "dispNameNl": "Gemarineerde kippenbrochette ", "dispNameEn": "Marinated chicken skewer ", "nameNl": "kippenbrochette gemarineerd, dd", "nameEn": "", "weight": "150g", "extra": null, "preparation": "marineren en afbakken op de grill", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1374, "courseLogoId": 202 }, { "courseId": 1374, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2032, "menuItemId": 1392, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1393, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 6, "menuItemContents": [ { "id": 2034, "menuItemId": 1393, "courseId": 3871, "sortOrder": 0, "course": { "id": 3871, "dispNameNl": "Mango-kip-salade", "dispNameEn": "Mango and chicken salad", "nameNl": "salade mango-kip, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": null, "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3871, "allergenId": 201 }, { "courseId": 3871, "allergenId": 205 }, { "courseId": 3871, "allergenId": 206 }, { "courseId": 3871, "allergenId": 209 }, { "courseId": 3871, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3871, "courseLogoId": 202 }, { "courseId": 3871, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1394, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 7, "menuItemContents": [ { "id": 2035, "menuItemId": 1394, "courseId": 2071, "sortOrder": 0, "course": { "id": 2071, "dispNameNl": "Salade met perziken en zalmsalade", "dispNameEn": "Salad with peaches and salmon salad", "nameNl": "salade met perziken en zalmsalade, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "zalm stomen en laten afkoelen (slechts max. 100g per persoon gebruiken) - zalm prakken, mengen met een kleine hoeveelheid mayonaise (voeg enkel extra mayo toe als de zalmsalade nog te droog is, laat de zalm in geen geval \u2018zwemmen\u2019 in de mayonaise), met geplette hardgekookte eieren en peterselie - perziken in partjes snijden - veldsla en rammenas mengen. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton gebruiken volgorde voor vullen van onder naar boven: gesneden perziken (2 -3 stuks) + zalmsalade (+/- 80-100 gr) + gemengde veldsla \u2013 rammenas", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2071, "allergenId": 200 }, { "courseId": 2071, "allergenId": 201 }, { "courseId": 2071, "allergenId": 204 }, { "courseId": 2071, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2071, "courseLogoId": 209 }, { "courseId": 2071, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1396, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 177, "sortorder": 8, "menuItemContents": [ { "id": 2037, "menuItemId": 1396, "courseId": 3846, "sortOrder": 0, "course": { "id": 3846, "dispNameNl": "Broodje forel-citrus", "dispNameEn": "Trout and citrus sandwich", "nameNl": "broodje forel-citrus, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": "citrus vinaigrette mengen met mayo, dille, peterselie, veldsla en forel - op bun broodje beleggen", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3846, "allergenId": 200 }, { "courseId": 3846, "allergenId": 201 }, { "courseId": 3846, "allergenId": 204 }, { "courseId": 3846, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 3846, "courseLogoId": 210 }, { "courseId": 3846, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-11-25_cmu.parsed.expected.yaml ================================================ campus: cmu date: '2019-11-25' menu: - components: - allergens: - NUTS - SESAME - SOY - WHEAT_GLUTEN attributes: - SALAD - VEGAN name: en: Oriental bulghur salad with chilli nl: Oosterse bulgursalade met chili external_id: 1321 multiple_prices: true price: '3.80' sort_order: 1 - components: - allergens: - MILK_LACTOSE - MUSTARD - NUTS - PEANUTS - SESAME - WHEAT_GLUTEN attributes: - CHEESE - SALAD - VEGGIE name: en: Crunchy salad nl: Krokante salade external_id: 1322 multiple_prices: true price: '5.00' sort_order: 2 - components: - allergens: [] attributes: - BIO - SOUP - VEGAN name: en: Organic broccoli soup nl: Bio-broccolisoep external_id: 1323 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - MILK_LACTOSE - WHEAT_GLUTEN attributes: - CHEESE - SNACK - VEGGIE name: en: Grilled goat cheese sandwich with honey nl: Croque geitenkaas-honing external_id: 1324 multiple_prices: false price: '1.60' sort_order: 11 - components: - allergens: - NUTS - SESAME - WHEAT_GLUTEN attributes: - SNACK - VEGAN name: en: Panini California nl: Panini California external_id: 1325 multiple_prices: false price: '2.90' sort_order: 3 ================================================ FILE: tests/external_menus/2019-11-25_cmu.raw.json ================================================ { "id": 242, "menuDate": "2019-11-25T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1323, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 242, "sortorder": 0, "menuItemContents": [ { "id": 1943, "menuItemId": 1323, "courseId": 2524, "sortOrder": 0, "course": { "id": 2524, "dispNameNl": "Bio-broccolisoep", "dispNameEn": "Organic broccoli soup", "nameNl": "bio-broccolisoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2524, "courseLogoId": 201 }, { "courseId": 2524, "courseLogoId": 211 }, { "courseId": 2524, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1321, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 242, "sortorder": 1, "menuItemContents": [ { "id": 1941, "menuItemId": 1321, "courseId": 4980, "sortOrder": 0, "course": { "id": 4980, "dispNameNl": "Oosterse bulgursalade met chili", "dispNameEn": "Oriental bulghur salad with chilli", "nameNl": "00 oosterse bulgursalade met chili,w", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptsalade winter", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4980, "allergenId": 201 }, { "courseId": 4980, "allergenId": 205 }, { "courseId": 4980, "allergenId": 209 }, { "courseId": 4980, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4980, "courseLogoId": 209 }, { "courseId": 4980, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1322, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 242, "sortorder": 2, "menuItemContents": [ { "id": 1942, "menuItemId": 1322, "courseId": 3142, "sortOrder": 0, "course": { "id": 3142, "dispNameNl": "Krokante salade", "dispNameEn": "Crunchy salad", "nameNl": "krokante salade (veggie), w", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "laagjes van onder naar boven: noten (mengeling) rammenas appel met schil (in schijfjes), besprenkelen met citroensap witloofblaren blauwe kaas veldsla croutons potje dressing", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3142, "allergenId": 201 }, { "courseId": 3142, "allergenId": 203 }, { "courseId": 3142, "allergenId": 204 }, { "courseId": 3142, "allergenId": 205 }, { "courseId": 3142, "allergenId": 206 }, { "courseId": 3142, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3142, "courseLogoId": 204 }, { "courseId": 3142, "courseLogoId": 209 }, { "courseId": 3142, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1325, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 242, "sortorder": 3, "menuItemContents": [ { "id": 1945, "menuItemId": 1325, "courseId": 4972, "sortOrder": 0, "course": { "id": 4972, "dispNameNl": "Panini California ", "dispNameEn": "Panini California ", "nameNl": "00 california panini,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "snack winter olie van de tomaatjes maakt het smeuig", "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4972, "allergenId": 201 }, { "courseId": 4972, "allergenId": 205 }, { "courseId": 4972, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4972, "courseLogoId": 210 }, { "courseId": 4972, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1324, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 242, "sortorder": 11, "menuItemContents": [ { "id": 1944, "menuItemId": 1324, "courseId": 1082, "sortOrder": 0, "course": { "id": 1082, "dispNameNl": "Croque geitenkaas-honing", "dispNameEn": "Grilled goat cheese sandwich with honey", "nameNl": "croque geitenkaas-honing, dd, z & w", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "keuze uit wit brood of bruin brood om de croque te maken 1 potje saus is in de prijs inbegrepen", "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1082, "allergenId": 201 }, { "courseId": 1082, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1082, "courseLogoId": 204 }, { "courseId": 1082, "courseLogoId": 210 }, { "courseId": 1082, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-11-25_cst.parsed.expected.yaml ================================================ campus: cst date: '2019-11-25' menu: - components: - allergens: - CELERY - MILK_LACTOSE - WHEAT_GLUTEN attributes: - SOUP - VEGGIE name: en: Parsnip soup nl: Pastinaaksoep external_id: 982 multiple_prices: false price: '1.50' sort_order: 0 - components: - allergens: [] attributes: - VEGGIE name: en: Pea kofte nl: "Erwtenk\xF6fteballetjes" - allergens: - MILK_LACTOSE - WHEAT_GLUTEN attributes: [] name: en: white cabbage in white sauce nl: witte kool in witte saus - allergens: [] attributes: [] name: en: fried potato slices nl: gebakken aardappelschijfjes external_id: 983 multiple_prices: true price: '4.40' sort_order: 1 - components: - allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - NUTS - SESAME - SOY - WHEAT_GLUTEN attributes: - LESS_MEAT - PIG name: en: Less Meat Loaf nl: Vleesbrood met weinig vlees - allergens: - MILK_LACTOSE - WHEAT_GLUTEN attributes: [] name: en: white cabbage in white sauce nl: witte kool in witte saus - allergens: [] attributes: [] name: en: fried potato slices nl: gebakken aardappelschijfjes external_id: 984 multiple_prices: true price: '3.80' sort_order: 2 - components: - allergens: - WHEAT_GLUTEN attributes: - PASTA - VEGAN name: en: Penne with mushrooms and a creamy cauliflower sauce nl: Penne met paddenstoelen en romige bloemkoolsaus external_id: 1412 multiple_prices: true price: '3.80' sort_order: 3 - components: - allergens: - WHEAT_GLUTEN attributes: [] name: en: spelt penne nl: Speltpenne - allergens: - CELERY - NUTS - PEANUTS - SESAME - SULFITES - WHEAT_GLUTEN attributes: - PASTA - VEGAN name: en: bolnienaise veganlicious nl: bolnienaise veganlicious external_id: 1413 multiple_prices: true price: '3.80' sort_order: 4 - components: - allergens: [] attributes: - CHICKEN - GRILL name: en: Grilled chicken breast nl: Kipfilet op de grill - allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - SOY attributes: [] name: en: choron sauce nl: choronsaus - allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN attributes: [] name: en: saladbar nl: Saladbar external_id: 1414 multiple_prices: true price: '5.00' sort_order: 5 - components: - allergens: - CELERY - NUTS - SESAME - SOY - WHEAT_GLUTEN attributes: - SALAD - VEGAN name: en: "P\xE9pites bowl" nl: "P\xE9pites bowl" external_id: 1415 multiple_prices: true price: '3.80' sort_order: 11 - components: - allergens: - EGG - MILK_LACTOSE - NUTS - PEANUTS - SESAME - WHEAT_GLUTEN attributes: - CHEESE - SALAD - VEGGIE name: en: Cottage cheese salad nl: "Salade h\xFCttekase" external_id: 1416 multiple_prices: true price: '3.80' sort_order: 11 - components: - allergens: - CELERY - SOY attributes: - SALAD - VEGAN name: en: Thai Bombai salad nl: Thai bombai salade external_id: 1417 multiple_prices: true price: '3.80' sort_order: 11 ================================================ FILE: tests/external_menus/2019-11-25_cst.raw.json ================================================ { "id": 194, "menuDate": "2019-11-25T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 982, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 0, "menuItemContents": [ { "id": 1543, "menuItemId": 982, "courseId": 867, "sortOrder": 0, "course": { "id": 867, "dispNameNl": "Pastinaaksoep", "dispNameEn": "Parsnip soup", "nameNl": "pastinaaksoep, w", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 867, "allergenId": 201 }, { "courseId": 867, "allergenId": 203 }, { "courseId": 867, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 867, "courseLogoId": 211 }, { "courseId": 867, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 983, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 1, "menuItemContents": [ { "id": 1510, "menuItemId": 983, "courseId": 1068, "sortOrder": 0, "course": { "id": 1068, "dispNameNl": "witte kool in witte saus", "dispNameEn": "white cabbage in white sauce", "nameNl": "witte kool in witte saus", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "witte kool steamen, witte basissaus maken (zie receptuur) - mengen - kruiden met pezono.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1068, "allergenId": 201 }, { "courseId": 1068, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1509, "menuItemId": 983, "courseId": 1277, "sortOrder": 0, "course": { "id": 1277, "dispNameNl": "Erwtenk\u00f6fteballetjes", "dispNameEn": "Pea kofte", "nameNl": "erwtenk\u00f6fte balletjes dd", "nameEn": "", "weight": "6 x 22g", "extra": null, "preparation": "friteuse verhitten tot 160\u00b0c. - kroketjes afbakken tot een temperatuur van minstens 65\u00b0c bereikt is . - in bain-marie schikken.", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1277, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1511, "menuItemId": 983, "courseId": 1985, "sortOrder": 0, "course": { "id": 1985, "dispNameNl": "gebakken aardappelschijfjes", "dispNameEn": "fried potato slices", "nameNl": "gebakken aardappel schijfjes", "nameEn": "", "weight": "200g", "extra": null, "preparation": "afbakken in margarine en olijfolie", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 984, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 2, "menuItemContents": [ { "id": 1513, "menuItemId": 984, "courseId": 1068, "sortOrder": 0, "course": { "id": 1068, "dispNameNl": "witte kool in witte saus", "dispNameEn": "white cabbage in white sauce", "nameNl": "witte kool in witte saus", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "witte kool steamen, witte basissaus maken (zie receptuur) - mengen - kruiden met pezono.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1068, "allergenId": 201 }, { "courseId": 1068, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1514, "menuItemId": 984, "courseId": 1985, "sortOrder": 0, "course": { "id": 1985, "dispNameNl": "gebakken aardappelschijfjes", "dispNameEn": "fried potato slices", "nameNl": "gebakken aardappel schijfjes", "nameEn": "", "weight": "200g", "extra": null, "preparation": "afbakken in margarine en olijfolie", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1512, "menuItemId": 984, "courseId": 2062, "sortOrder": 0, "course": { "id": 2062, "dispNameNl": "Vleesbrood met weinig vlees", "dispNameEn": "Less Meat Loaf", "nameNl": "vleesbrood met weinig vleesch, dd, z & w - MINDER VLEES", "nameEn": "", "weight": "150g pp", "extra": "", "preparation": "bonen spoelen en laten uitlekken - bonen fijn cutteren - het gehakte vleesch :-) mengen met de gecutterde bonen, chapelure en ei en kruiden met pezono naar smaak. - maak er een gehaktbrood van (of balletjes indien haalbaar in jouw keuken of deze periode) - garen in oven . - gehaktbrood in sneetjes snijden", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2062, "allergenId": 200 }, { "courseId": 2062, "allergenId": 201 }, { "courseId": 2062, "allergenId": 203 }, { "courseId": 2062, "allergenId": 204 }, { "courseId": 2062, "allergenId": 205 }, { "courseId": 2062, "allergenId": 208 }, { "courseId": 2062, "allergenId": 209 }, { "courseId": 2062, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 2062, "courseLogoId": 212 }, { "courseId": 2062, "courseLogoId": 216 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1412, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 3, "menuItemContents": [ { "id": 2063, "menuItemId": 1412, "courseId": 5177, "sortOrder": 0, "course": { "id": 5177, "dispNameNl": "Penne met paddenstoelen en romige bloemkoolsaus", "dispNameEn": "Penne with mushrooms and a creamy cauliflower sauce", "nameNl": "00 penne met paddenstoelen en romige bloemkoolsaus (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de bloemkoolsaus klaar volgens de receptuur (zie fiche). - maak de volkoren penne klaar volgens de receptuur. fruit de ui en bak hierbij de champignons, breng op smaak met look, peper en zout.- meng de pasta met de bloemkoolsaus, de champignons en werk af met platte peterselie & gebakken uitjes.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5177, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5177, "courseLogoId": 207 }, { "courseId": 5177, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1413, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 4, "menuItemContents": [ { "id": 2064, "menuItemId": 1413, "courseId": 5053, "sortOrder": 0, "course": { "id": 5053, "dispNameNl": "bolnienaise veganlicious", "dispNameEn": "bolnienaise veganlicious", "nameNl": "00 bolnienaise veganlicious z&w (vegan)", "nameEn": "", "weight": "200 gr", "extra": "vegan", "preparation": "pasta - kook de linzen apart - stoof de ui aan samen met de paprika, wortel en selder - voeg de tomatenpulp, puree, kruiden, bouillon toe en laat koken - voeg de linzen toe en laat nog even meekoken.- breng op smaak en bind met de roux. - werk af met platte peterselie en cashewnoten", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5053, "allergenId": 201 }, { "courseId": 5053, "allergenId": 205 }, { "courseId": 5053, "allergenId": 206 }, { "courseId": 5053, "allergenId": 208 }, { "courseId": 5053, "allergenId": 209 }, { "courseId": 5053, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 5053, "courseLogoId": 207 }, { "courseId": 5053, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2065, "menuItemId": 1413, "courseId": 5487, "sortOrder": 0, "course": { "id": 5487, "dispNameNl": "Speltpenne", "dispNameEn": "spelt penne", "nameNl": "Speltpenne (vegan)", "nameEn": "", "weight": "", "extra": "", "preparation": "Kook de pasta gaar in licht gezouten water, olie toevoegen. de kooktijd is afhankelijk van de soort pasta.", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5487, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1414, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 5, "menuItemContents": [ { "id": 2067, "menuItemId": 1414, "courseId": 908, "sortOrder": 0, "course": { "id": 908, "dispNameNl": "choronsaus", "dispNameEn": "choron sauce", "nameNl": "choron saus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "tomatino onder warme b\u00e9arnaisesaus mengen", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 908, "allergenId": 200 }, { "courseId": 908, "allergenId": 203 }, { "courseId": 908, "allergenId": 204 }, { "courseId": 908, "allergenId": 208 }, { "courseId": 908, "allergenId": 210 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2066, "menuItemId": 1414, "courseId": 3264, "sortOrder": 0, "course": { "id": 3264, "dispNameNl": "Kipfilet op de grill ", "dispNameEn": "Grilled chicken breast ", "nameNl": "kipfilet op de grill 1 (kippenkruiden), dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": null, "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3264, "courseLogoId": 202 }, { "courseId": 3264, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2068, "menuItemId": 1414, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1415, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 11, "menuItemContents": [ { "id": 2069, "menuItemId": 1415, "courseId": 5529, "sortOrder": 0, "course": { "id": 5529, "dispNameNl": "P\u00e9pites bowl", "dispNameEn": "P\u00e9pites bowl", "nameNl": "P\u00e9pites bowl,w, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de p\u00e9pites klaar volgens de bereidingswijze op de verpakking & laat afkoelen . - Maak de aziatische tuinerwtenspread volgens de receptuur. Maak de budhabowl als volgt: vul het kommetje met de p\u00e9pites, vervolgens de wortelstaafjes, edamame, witte & gele raapjes..Werk de budha bowl af met een toefje tuinerwtenspread in het midden, waterkers, kimchi & sesamzaad. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5529, "allergenId": 201 }, { "courseId": 5529, "allergenId": 205 }, { "courseId": 5529, "allergenId": 208 }, { "courseId": 5529, "allergenId": 209 }, { "courseId": 5529, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5529, "courseLogoId": 209 }, { "courseId": 5529, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1416, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 11, "menuItemContents": [ { "id": 2070, "menuItemId": 1416, "courseId": 4167, "sortOrder": 0, "course": { "id": 4167, "dispNameNl": "Salade h\u00fcttekase", "dispNameEn": "Cottage cheese salad", "nameNl": "salade h\u00fcttekase, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "van onder naar boven: cottage cheese - dan laagje piquillo- boontjes, zoete aardappelen, peper, rode ui ringen, ei, waterkers, cashewnoot, croutons.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4167, "allergenId": 200 }, { "courseId": 4167, "allergenId": 201 }, { "courseId": 4167, "allergenId": 203 }, { "courseId": 4167, "allergenId": 205 }, { "courseId": 4167, "allergenId": 206 }, { "courseId": 4167, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4167, "courseLogoId": 204 }, { "courseId": 4167, "courseLogoId": 209 }, { "courseId": 4167, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1417, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 194, "sortorder": 11, "menuItemContents": [ { "id": 2071, "menuItemId": 1417, "courseId": 3490, "sortOrder": 0, "course": { "id": 3490, "dispNameNl": "Thai bombai salade", "dispNameEn": "Thai Bombai salad", "nameNl": "thai bombai salade, w (vegan)", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "conceptsalade winter miehoen koken - mie mengen met de world grill saus - pastinaak grillen en mengen met de wortelen en sojascheuten en kokosschilfers - opbouw: miehoen, groentjes en platte peterselie", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3490, "allergenId": 208 }, { "courseId": 3490, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3490, "courseLogoId": 209 }, { "courseId": 3490, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-11-25_hzs.parsed.expected.yaml ================================================ campus: hzs date: '2019-11-25' menu: - components: - allergens: - CELERY - WHEAT_GLUTEN attributes: - SOUP - VEGAN name: en: Bell pepper soup nl: Paprikasoep external_id: 1163 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - MILK_LACTOSE - WHEAT_GLUTEN attributes: - SNACK - VEGGIE name: en: Sweet 'n cheesy nl: Sweet 'n cheesy external_id: 1164 multiple_prices: false price: '3.10' sort_order: 1 - components: - allergens: - EGG - FISH - MUSTARD - SESAME - SHELLFISH - SOY - WHEAT_GLUTEN attributes: - FISH - SNACK name: en: Multigrain roll with langoustine salad nl: Meergranenbroodje met langoustinesalade external_id: 1165 multiple_prices: false price: '3.10' sort_order: 2 - components: - allergens: - CELERY - MILK_LACTOSE - MUSTARD - WHEAT_GLUTEN attributes: - SALAD - VEGGIE name: en: Marrakesh salad nl: Salade Marrakech external_id: 1166 multiple_prices: true price: '4.40' sort_order: 3 - components: - allergens: - NUTS - SESAME - WHEAT_GLUTEN attributes: - SALAD - VEGAN name: en: Avocado and quinoa salad nl: Avocado-quinoasalade external_id: 1167 multiple_prices: true price: '3.80' sort_order: 10 - components: - allergens: - WHEAT_GLUTEN attributes: - SNACK - VEGAN name: en: Mexican wrap nl: Mexicaanse wrap external_id: 1168 multiple_prices: false price: '2.00' sort_order: 10 - components: - allergens: - NUTS - SESAME - WHEAT_GLUTEN attributes: - SNACK - VEGAN name: en: Humus and avocado panini nl: Humus-avocado-panini external_id: 1169 multiple_prices: false price: '3.60' sort_order: 10 - components: - allergens: [] attributes: - PASTA - VEGGIE name: en: Pasta pesto with sun-dried tomatoes nl: Pasta pesto met zongedroogde tomaatjes external_id: 1170 multiple_prices: true price: '3.80' sort_order: 10 ================================================ FILE: tests/external_menus/2019-11-25_hzs.raw.json ================================================ { "id": 227, "menuDate": "2019-11-25T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1163, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 0, "menuItemContents": [ { "id": 1738, "menuItemId": 1163, "courseId": 864, "sortOrder": 0, "course": { "id": 864, "dispNameNl": "Paprikasoep", "dispNameEn": "Bell pepper soup", "nameNl": "paprikasoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 864, "allergenId": 201 }, { "courseId": 864, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 864, "courseLogoId": 211 }, { "courseId": 864, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1164, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 1, "menuItemContents": [ { "id": 1739, "menuItemId": 1164, "courseId": 403, "sortOrder": 0, "course": { "id": 403, "dispNameNl": "Sweet 'n cheesy ", "dispNameEn": "Sweet 'n cheesy ", "nameNl": "sweet 'n cheesy (smeerkaas, tapenade paprika) dd, w", "nameEn": "", "weight": "250 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 403, "allergenId": 201 }, { "courseId": 403, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 403, "courseLogoId": 210 }, { "courseId": 403, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1165, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 2, "menuItemContents": [ { "id": 1740, "menuItemId": 1165, "courseId": 1884, "sortOrder": 0, "course": { "id": 1884, "dispNameNl": "Meergranenbroodje met langoustinesalade ", "dispNameEn": "Multigrain roll with langoustine salad ", "nameNl": "langoustinesalade (smos), w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "zie fiche langoustinesalade bij belegde broodjes (eenvoudig) en met zomer- en wintergroentjes scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1884, "allergenId": 200 }, { "courseId": 1884, "allergenId": 201 }, { "courseId": 1884, "allergenId": 204 }, { "courseId": 1884, "allergenId": 207 }, { "courseId": 1884, "allergenId": 209 }, { "courseId": 1884, "allergenId": 210 }, { "courseId": 1884, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1884, "courseLogoId": 210 }, { "courseId": 1884, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1166, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 3, "menuItemContents": [ { "id": 1741, "menuItemId": 1166, "courseId": 281, "sortOrder": 0, "course": { "id": 281, "dispNameNl": "Salade Marrakech ", "dispNameEn": "Marrakesh salad ", "nameNl": "salade marrakech (couscous, mozzarella), dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 281, "allergenId": 201 }, { "courseId": 281, "allergenId": 203 }, { "courseId": 281, "allergenId": 204 }, { "courseId": 281, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 281, "courseLogoId": 209 }, { "courseId": 281, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1167, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 10, "menuItemContents": [ { "id": 1742, "menuItemId": 1167, "courseId": 4978, "sortOrder": 0, "course": { "id": 4978, "dispNameNl": "Avocado-quinoasalade", "dispNameEn": "Avocado and quinoa salad", "nameNl": "avocado-quinoasalade, w (vegan)", "nameEn": "", "weight": "350 gr", "extra": null, "preparation": "conceptsalade winter kook de quinoa in de groentebouillon - steam de pompoenblokjes en laat afkoelen - snij de avocado in mooie blokjes en snipper de koriander- laat de rode bonen uitlekken - vermeng de groenten, de ajuin,de mais, de rode bonen, de noten, de koriander met ma\u00efsolie, sap van limoen en pezo - en meng dit met de quinoa. - vul de saladebox met de quinoasalade.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4978, "allergenId": 201 }, { "courseId": 4978, "allergenId": 205 }, { "courseId": 4978, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4978, "courseLogoId": 209 }, { "courseId": 4978, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1168, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 10, "menuItemContents": [ { "id": 1743, "menuItemId": 1168, "courseId": 5075, "sortOrder": 0, "course": { "id": 5075, "dispNameNl": "Mexicaanse wrap ", "dispNameEn": "Mexican wrap ", "nameNl": "00 mexicaanse wrap z&w (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "meng de salsa mexicana mix met de ma\u00efs - beleg de wrap in het midden met de ontdooide guacamole, vervolgens met de mexicaanse ma\u00efs, gesneden pijpajuin, koriander, beetje sambal en de gepelde rode paprika.- leg de wrap tussen de panini machine voor circa 5 minuten.", "price": 2.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5075, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5075, "courseLogoId": 210 }, { "courseId": 5075, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1169, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 10, "menuItemContents": [ { "id": 1744, "menuItemId": 1169, "courseId": 4973, "sortOrder": 0, "course": { "id": 4973, "dispNameNl": "Humus-avocado-panini ", "dispNameEn": "Humus and avocado panini", "nameNl": "00 panini humus avocado, w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "snack winter", "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4973, "allergenId": 201 }, { "courseId": 4973, "allergenId": 205 }, { "courseId": 4973, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4973, "courseLogoId": 210 }, { "courseId": 4973, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1170, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 227, "sortorder": 10, "menuItemContents": [ { "id": 1745, "menuItemId": 1170, "courseId": 4144, "sortOrder": 0, "course": { "id": 4144, "dispNameNl": "Pasta pesto met zongedroogde tomaatjes", "dispNameEn": "Pasta pesto with sun-dried tomatoes", "nameNl": "pasta pesto met zongedroogde tomaatjes, hzs", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 4144, "courseLogoId": 207 }, { "courseId": 4144, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-12_cde.parsed.expected.yaml ================================================ campus: cde date: '2019-12-12' menu: - components: - allergens: - CELERY - EGG - FISH - MOLLUSKS - SESAME - SOY - SULFITES - WHEAT_GLUTEN attributes: - CHICKEN - FISH name: en: Chicken and seafood paella nl: "Pa\xEBlla met kip en zeevruchten" external_id: 1778 multiple_prices: true price: '4.60' sort_order: 2 - components: - allergens: - NUTS - PEANUTS - SESAME attributes: - CHICKEN - SALAD name: en: Sweet potato bowl nl: Sweet potato bowl external_id: 1794 multiple_prices: true price: '4.00' sort_order: 11 - components: - allergens: - CELERY - EGG - MILK_LACTOSE - NUTS - PEANUTS - SESAME - SULFITES - WHEAT_GLUTEN attributes: - CHEESE - SALAD - VEGGIE name: en: New York salad nl: Salade New York external_id: 1795 multiple_prices: true price: '4.00' sort_order: 11 - components: - allergens: [] attributes: - BIO - SOUP - VEGAN name: en: Organic spinach soup nl: Bio-spinaziesoep external_id: 1843 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - WHEAT_GLUTEN attributes: [] name: en: wholegrain spaghetti nl: Volkoren spaghetti - allergens: - CELERY - PEANUTS - SOY - SULFITES - WHEAT_GLUTEN attributes: - PASTA - VEGAN name: en: soy bolognese sauce nl: soja-bolognaisesaus external_id: 1976 multiple_prices: true price: '3.80' sort_order: 3 - components: - allergens: - EGG - WHEAT_GLUTEN attributes: [] name: en: fusili nl: Fusili - allergens: - MILK_LACTOSE - NUTS - SESAME - SOY - WHEAT_GLUTEN attributes: - CHICKEN name: en: Oriental turkey nl: Oriental turkey external_id: 1977 multiple_prices: true price: '3.80' sort_order: 4 - components: - allergens: - MUSTARD attributes: - GRILL - PIG name: en: Marinated pork rib nl: Varkensrib gemarineerd - allergens: - WHEAT_GLUTEN attributes: [] name: en: fries nl: frieten - allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN attributes: [] name: en: saladbar nl: Saladbar external_id: 1978 multiple_prices: true price: '4.80' sort_order: 5 - components: - allergens: - MILK_LACTOSE attributes: - CHEESE - PASTA - VEGGIE name: en: Ravioli verdura nl: Ravioli verdura external_id: 2325 multiple_prices: true price: '5.00' sort_order: 1 ================================================ FILE: tests/external_menus/2019-12-12_cde.processed.expected.yaml ================================================ campus: cde date: '2019-12-12' menu: - course_allergens: - CELERY - EGG - FISH - MOLLUSKS - SESAME - SOY - SULFITES - WHEAT_GLUTEN course_attributes: - CHICKEN - FISH course_sub_type: NORMAL course_type: DAILY external_id: 1778 name: en: Chicken and seafood paella nl: "Pa\xEBlla met kip en zeevruchten" price_staff: '5.70' price_students: '4.60' - course_allergens: - NUTS - PEANUTS - SESAME course_attributes: - CHICKEN - SALAD course_sub_type: NORMAL course_type: SALAD external_id: 1794 name: en: Sweet potato bowl nl: Sweet potato bowl price_staff: '5.00' price_students: '4.00' - course_allergens: - CELERY - EGG - MILK_LACTOSE - NUTS - PEANUTS - SESAME - SULFITES - WHEAT_GLUTEN course_attributes: - CHEESE - SALAD - VEGGIE course_sub_type: VEGETARIAN course_type: SALAD external_id: 1795 name: en: New York salad nl: Salade New York price_staff: '5.00' price_students: '4.00' - course_allergens: [] course_attributes: - BIO - SOUP - VEGAN course_sub_type: VEGAN course_type: SOUP external_id: 1843 name: en: Organic spinach soup nl: Bio-spinaziesoep price_staff: null price_students: '0.90' - course_allergens: - CELERY - PEANUTS - SOY - SULFITES - WHEAT_GLUTEN course_attributes: - PASTA - VEGAN course_sub_type: VEGAN course_type: PASTA external_id: 1976 name: en: Wholegrain spaghetti, soy bolognese sauce nl: Volkoren spaghetti, soja-bolognaisesaus price_staff: '4.70' price_students: '3.80' - course_allergens: - EGG - MILK_LACTOSE - NUTS - SESAME - SOY - WHEAT_GLUTEN course_attributes: - CHICKEN course_sub_type: NORMAL course_type: PASTA external_id: 1977 name: en: Fusili, Oriental turkey nl: Fusili, Oriental turkey price_staff: '4.70' price_students: '3.80' - course_allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN course_attributes: - GRILL - PIG course_sub_type: NORMAL course_type: GRILL external_id: 1978 name: en: Marinated pork rib, fries, saladbar nl: Varkensrib gemarineerd, frieten, Saladbar price_staff: '6.00' price_students: '4.80' - course_allergens: - MILK_LACTOSE course_attributes: - CHEESE - PASTA - VEGGIE course_sub_type: VEGETARIAN course_type: PASTA external_id: 2325 name: en: Ravioli verdura nl: Ravioli verdura price_staff: '6.20' price_students: '5.00' ================================================ FILE: tests/external_menus/2019-12-12_cde.raw.json ================================================ { "id": 286, "menuDate": "2019-12-12T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1843, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 0, "menuItemContents": [ { "id": 2696, "menuItemId": 1843, "courseId": 2529, "sortOrder": 0, "course": { "id": 2529, "dispNameNl": "Bio-spinaziesoep", "dispNameEn": "Organic spinach soup", "nameNl": "bio-spinaziesoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2529, "courseLogoId": 201 }, { "courseId": 2529, "courseLogoId": 211 }, { "courseId": 2529, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 2325, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 1, "menuItemContents": [ { "id": 3362, "menuItemId": 2325, "courseId": 3568, "sortOrder": 0, "course": { "id": 3568, "dispNameNl": "Ravioli verdura", "dispNameEn": "Ravioli verdura", "nameNl": "ravioli verdura, zvv, dd", "nameEn": "", "weight": "400g", "extra": "veggie", "preparation": null, "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3568, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 3568, "courseLogoId": 204 }, { "courseId": 3568, "courseLogoId": 207 }, { "courseId": 3568, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1778, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 2, "menuItemContents": [ { "id": 2588, "menuItemId": 1778, "courseId": 1455, "sortOrder": 0, "course": { "id": 1455, "dispNameNl": "Pa\u00eblla met kip en zeevruchten", "dispNameEn": "Chicken and seafood paella", "nameNl": "paella met kip en zeevruchten, dd, z & w", "nameEn": "", "weight": "400g", "extra": null, "preparation": "rijst half gaar koken in water met curcuma - erwtjes en paprika beetgaar voorsteamen. - kip en visbouillon oplossen in witte wijn samen met pezo en lookpasta. - rijst, kip, groenten, mosselvlees, surimiflakes, bouillonmix samen in pan . - mengen en even opbakken - in gastro's van 6cm scheppen en in combi steam (150\u00b0)opwarmen tot gewenste temp. - uitscheppen en afwerken met 2 ringetjes gefrituurde calamares, citroentje en peterselie en de vooraf gebakken kippenonderboutjes,", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1455, "allergenId": 200 }, { "courseId": 1455, "allergenId": 201 }, { "courseId": 1455, "allergenId": 208 }, { "courseId": 1455, "allergenId": 209 }, { "courseId": 1455, "allergenId": 210 }, { "courseId": 1455, "allergenId": 211 }, { "courseId": 1455, "allergenId": 212 }, { "courseId": 1455, "allergenId": 213 } ], "course_CourseLogos": [ { "courseId": 1455, "courseLogoId": 202 }, { "courseId": 1455, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1976, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 3, "menuItemContents": [ { "id": 2876, "menuItemId": 1976, "courseId": 1121, "sortOrder": 0, "course": { "id": 1121, "dispNameNl": "soja-bolognaisesaus ", "dispNameEn": "soy bolognese sauce ", "nameNl": "soja - bolognaise saus dd (vegan)", "nameEn": "", "weight": "200g", "extra": null, "preparation": "pastasaus vege gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux . alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1121, "allergenId": 201 }, { "courseId": 1121, "allergenId": 206 }, { "courseId": 1121, "allergenId": 208 }, { "courseId": 1121, "allergenId": 210 }, { "courseId": 1121, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1121, "courseLogoId": 207 }, { "courseId": 1121, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2877, "menuItemId": 1976, "courseId": 5574, "sortOrder": 0, "course": { "id": 5574, "dispNameNl": "Volkoren spaghetti ", "dispNameEn": "wholegrain spaghetti", "nameNl": "Volkoren spaghetti, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5574, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": false, "menuInfoEn": null } } ] }, { "id": 1977, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 4, "menuItemContents": [ { "id": 2878, "menuItemId": 1977, "courseId": 3106, "sortOrder": 0, "course": { "id": 3106, "dispNameNl": "Oriental turkey", "dispNameEn": "Oriental turkey", "nameNl": "pastasaus oriental turkey, z & w", "nameEn": "", "weight": "300g", "extra": null, "preparation": "kebab ontdooien in de oven - en toevoegen bij de voorgekookte pasta - de courgetteschijven op smaak brengen met peper en zout en olijfolie, op een hete temperatuur in de oven bakken - olie, indian mystery, gebakken courgette - en paprika blokjes toevoegen - alles mengen en regenereren op 140 graden . overstrooien met zonnebloempitten en gesnipperde peterselie", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3106, "allergenId": 201 }, { "courseId": 3106, "allergenId": 203 }, { "courseId": 3106, "allergenId": 205 }, { "courseId": 3106, "allergenId": 209 }, { "courseId": 3106, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3106, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2879, "menuItemId": 1977, "courseId": 5476, "sortOrder": 0, "course": { "id": 5476, "dispNameNl": "Fusili", "dispNameEn": "fusili", "nameNl": "Fusili", "nameEn": "", "weight": "150 - 200g pp", "extra": "", "preparation": "kook de pasta gaar in licht gezouten water, olie toevoegen. de kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5476, "allergenId": 200 }, { "courseId": 5476, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1978, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 5, "menuItemContents": [ { "id": 2882, "menuItemId": 1978, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2880, "menuItemId": 1978, "courseId": 1080, "sortOrder": 0, "course": { "id": 1080, "dispNameNl": "Varkensrib gemarineerd", "dispNameEn": "Marinated pork rib", "nameNl": "varkensrib gemarineerd", "nameEn": "", "weight": "180g", "extra": null, "preparation": "marineren en grillen", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1080, "allergenId": 204 } ], "course_CourseLogos": [ { "courseId": 1080, "courseLogoId": 203 }, { "courseId": 1080, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2881, "menuItemId": 1978, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1794, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 11, "menuItemContents": [ { "id": 2607, "menuItemId": 1794, "courseId": 5531, "sortOrder": 0, "course": { "id": 5531, "dispNameNl": "Sweet potato bowl", "dispNameEn": "Sweet potato bowl", "nameNl": "Sweet potato bowl, w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de quinoa klaar volgens de bereidingswijze van de fiche, laat de quinoa afkoelen. Maak de zoete aardappel klaar volgens de fiche en breng op smaak met kaneel. Maak de bowl: doe de quinoa in een kommetje, vervolgens de bonen, zoete aardappel, curly kale & raapjes. Werk af met kipreepjes, cashewnoten, platte peterselie & furikake. Serveer hierbij de vinaigrette.Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5531, "allergenId": 205 }, { "courseId": 5531, "allergenId": 206 }, { "courseId": 5531, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5531, "courseLogoId": 202 }, { "courseId": 5531, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1795, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 286, "sortorder": 11, "menuItemContents": [ { "id": 2608, "menuItemId": 1795, "courseId": 274, "sortOrder": 0, "course": { "id": 274, "dispNameNl": "Salade New York", "dispNameEn": "New York salad", "nameNl": "salade new york (appel, noten, brie), dd, w", "nameEn": "", "weight": "350 g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton citroensap besprenkelen over de appelstukjes", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 274, "allergenId": 200 }, { "courseId": 274, "allergenId": 201 }, { "courseId": 274, "allergenId": 203 }, { "courseId": 274, "allergenId": 205 }, { "courseId": 274, "allergenId": 206 }, { "courseId": 274, "allergenId": 208 }, { "courseId": 274, "allergenId": 209 }, { "courseId": 274, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 274, "courseLogoId": 204 }, { "courseId": 274, "courseLogoId": 209 }, { "courseId": 274, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-12_cgb.raw.json ================================================ { "id": 317, "menuDate": "2019-12-12T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 2081, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 317, "sortorder": 0, "menuItemContents": [ { "id": 3012, "menuItemId": 2081, "courseId": 2525, "sortOrder": 0, "course": { "id": 2525, "dispNameNl": "Bio-champignonsoep", "dispNameEn": "Organic mushroom soup", "nameNl": "bio-champignonsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2525, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 2525, "courseLogoId": 201 }, { "courseId": 2525, "courseLogoId": 211 }, { "courseId": 2525, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2083, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 317, "sortorder": 1, "menuItemContents": [ { "id": 3014, "menuItemId": 2083, "courseId": 4988, "sortOrder": 0, "course": { "id": 4988, "dispNameNl": "Groene pasta met pistachenoten", "dispNameEn": "Green paste with pistachio nuts", "nameNl": "00 groene pasta met pistachenoten,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan (geen andere pasta, anders niet meer vegan)", "preparation": "pasta kook de erwten in de bouillon gedurende 15 min. en giet ze af. steam de spinazie. (hou nog een deel van de spinazie en de erwtjes opzij voor de afwerking).voeg de erwtjes, spinazie en room samen.- pureer alles tot een gladde saus.- breng op smaak met kerrie en pezo.- meng de groene saus met de pasta en voeg er ook nog enkele erwtjes en spinazie onder. crush de pistachenoten - werk af met platte peterselie en gecrushte pistachenoten.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4988, "allergenId": 201 }, { "courseId": 4988, "allergenId": 205 }, { "courseId": 4988, "allergenId": 206 }, { "courseId": 4988, "allergenId": 208 }, { "courseId": 4988, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4988, "courseLogoId": 207 }, { "courseId": 4988, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2082, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 317, "sortorder": 2, "menuItemContents": [ { "id": 3013, "menuItemId": 2082, "courseId": 274, "sortOrder": 0, "course": { "id": 274, "dispNameNl": "Salade New York", "dispNameEn": "New York salad", "nameNl": "salade new york (appel, noten, brie), dd, w", "nameEn": "", "weight": "350 g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton citroensap besprenkelen over de appelstukjes", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 274, "allergenId": 200 }, { "courseId": 274, "allergenId": 201 }, { "courseId": 274, "allergenId": 203 }, { "courseId": 274, "allergenId": 205 }, { "courseId": 274, "allergenId": 206 }, { "courseId": 274, "allergenId": 208 }, { "courseId": 274, "allergenId": 209 }, { "courseId": 274, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 274, "courseLogoId": 204 }, { "courseId": 274, "courseLogoId": 209 }, { "courseId": 274, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2084, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 317, "sortorder": 3, "menuItemContents": [ { "id": 3015, "menuItemId": 2084, "courseId": 2424, "sortOrder": 0, "course": { "id": 2424, "dispNameNl": "Panini 'kalkoen-pesto'", "dispNameEn": "Turkey pesto panini", "nameNl": "panini 'kalkoen-pesto', z & w", "nameEn": "", "weight": "250g", "extra": null, "preparation": "vanaf nu pestospread gran'olivia gebruiken - sandwich spread opwerken", "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2424, "allergenId": 200 }, { "courseId": 2424, "allergenId": 201 }, { "courseId": 2424, "allergenId": 203 }, { "courseId": 2424, "allergenId": 205 }, { "courseId": 2424, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 2424, "courseLogoId": 202 }, { "courseId": 2424, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2085, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 317, "sortorder": 4, "menuItemContents": [ { "id": 3016, "menuItemId": 2085, "courseId": 159, "sortOrder": 0, "course": { "id": 159, "dispNameNl": "Abdijbroodje ", "dispNameEn": "Abbey roll ", "nameNl": "abdijbroodje (baguelino broodje, smeerkaas, rammenas) dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 159, "allergenId": 201 }, { "courseId": 159, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 159, "courseLogoId": 204 }, { "courseId": 159, "courseLogoId": 210 }, { "courseId": 159, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2326, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 317, "sortorder": 10, "menuItemContents": [] }, { "id": 2330, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 317, "sortorder": 10, "menuItemContents": [] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-12_cmi.raw.json ================================================ { "id": 240, "menuDate": "2019-12-12T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1306, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 0, "menuItemContents": [ { "id": 1918, "menuItemId": 1306, "courseId": 2525, "sortOrder": 0, "course": { "id": 2525, "dispNameNl": "Bio-champignonsoep", "dispNameEn": "Organic mushroom soup", "nameNl": "bio-champignonsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2525, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 2525, "courseLogoId": 201 }, { "courseId": 2525, "courseLogoId": 211 }, { "courseId": 2525, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1308, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 1, "menuItemContents": [ { "id": 1927, "menuItemId": 1308, "courseId": 974, "sortOrder": 0, "course": { "id": 974, "dispNameNl": "aardappelen met bieslook", "dispNameEn": "potatoes and chives", "nameNl": "aardappelen met bieslook", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "de aardappelen gaar stomen (ongeveer 20 min). - bieslook versnipperen en mengen met de aardappelen. - de margarine klaren en mengen met de aardappelen (optioneel)", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1923, "menuItemId": 1308, "courseId": 1471, "sortOrder": 0, "course": { "id": 1471, "dispNameNl": "Gegratineerd witloofpannetje", "dispNameEn": "Belgian endive au gratin", "nameNl": "gegratineerd witloofpannetje (+ puree met tuinkers), zvv, dd, w", "nameEn": "", "weight": "400g", "extra": null, "preparation": "kroon witloof verwijderen - witloof in geperforeerde gastronormen leggen en kortsteamen tot 3/4 gaarheid. laten afkoelen en uitlekken. witloof op smaak brengen met peper en zout. de courgetteblokjes steamen tot 3/4 gaarheid, laten afkoelen en op smaak brengen met peper en zout - 20 liter water aan de kook brengen. basissaus roerend oplossen in kokend water room toevoegen. -afsmaken met peper zout en citroensap en eventueel verder binden met roux. - gesloten 6 cm gastro bodem bedekken met laagje witte saus. hierop witloof en courgetteblokjes op schikken. als witloof geschikt is in gastroplaten verder oversausen.afwerken met gemalen kaas paneermeel en boterblokjes gratineren verwarmen in voorverwarmde oven 180\u00b0 tot gewenste kern bereikt is. - afwerken met zonnebloempitjes en gehakte peterselie - serveren met tuinkerspuree zelfbereid (zie receptuur).", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1471, "allergenId": 200 }, { "courseId": 1471, "allergenId": 201 }, { "courseId": 1471, "allergenId": 203 }, { "courseId": 1471, "allergenId": 204 }, { "courseId": 1471, "allergenId": 205 }, { "courseId": 1471, "allergenId": 208 }, { "courseId": 1471, "allergenId": 209 }, { "courseId": 1471, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1471, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1313, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 2, "menuItemContents": [ { "id": 1926, "menuItemId": 1313, "courseId": 971, "sortOrder": 0, "course": { "id": 971, "dispNameNl": "aardappelgratin", "dispNameEn": "potato gratin", "nameNl": "gratin van aardappel", "nameEn": "", "weight": "200g pp", "extra": "gratin dauphinois is als alternatief te gebruiken", "preparation": "de aardappelen 3/4 gaarstomen. - saus maken met pezono- 4 gastronorm inboteren - aardappelen overgieten met witte saus - bestrooien met geraspte kaas - afbakken op 165 graden gedurende 30 min. - gratin dauphinois is als alternatief te gebruiken", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 971, "allergenId": 201 }, { "courseId": 971, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 1925, "menuItemId": 1313, "courseId": 2044, "sortOrder": 0, "course": { "id": 2044, "dispNameNl": "Fazantenballetjes in portosaus en gebakken witloof", "dispNameEn": "Pheasant meatballs in port sauce with braised Belgian endive", "nameNl": "fazantenballetjes in portosaus (+ gebakken witloof), dd, w", "nameEn": "", "weight": null, "extra": null, "preparation": "balletjes aanbakken en de ajuin toevoegen en mooi bruin laten bakken - de porto toevoegen en laten inkoken - de balletjes eruit halen en water toevoegen samen met de perensiroop - als alles kookt binden met de wildfond(poeder) - te serveren met gebakken witloof", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2044, "allergenId": 200 }, { "courseId": 2044, "allergenId": 203 }, { "courseId": 2044, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 2044, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1960, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 3, "menuItemContents": [ { "id": 2844, "menuItemId": 1960, "courseId": 4988, "sortOrder": 0, "course": { "id": 4988, "dispNameNl": "Groene pasta met pistachenoten", "dispNameEn": "Green paste with pistachio nuts", "nameNl": "00 groene pasta met pistachenoten,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan (geen andere pasta, anders niet meer vegan)", "preparation": "pasta kook de erwten in de bouillon gedurende 15 min. en giet ze af. steam de spinazie. (hou nog een deel van de spinazie en de erwtjes opzij voor de afwerking).voeg de erwtjes, spinazie en room samen.- pureer alles tot een gladde saus.- breng op smaak met kerrie en pezo.- meng de groene saus met de pasta en voeg er ook nog enkele erwtjes en spinazie onder. crush de pistachenoten - werk af met platte peterselie en gecrushte pistachenoten.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4988, "allergenId": 201 }, { "courseId": 4988, "allergenId": 205 }, { "courseId": 4988, "allergenId": 206 }, { "courseId": 4988, "allergenId": 208 }, { "courseId": 4988, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4988, "courseLogoId": 207 }, { "courseId": 4988, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1961, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 4, "menuItemContents": [ { "id": 2845, "menuItemId": 1961, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1962, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 5, "menuItemContents": [ { "id": 2847, "menuItemId": 1962, "courseId": 908, "sortOrder": 0, "course": { "id": 908, "dispNameNl": "choronsaus", "dispNameEn": "choron sauce", "nameNl": "choron saus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "tomatino onder warme b\u00e9arnaisesaus mengen", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 908, "allergenId": 200 }, { "courseId": 908, "allergenId": 203 }, { "courseId": 908, "allergenId": 204 }, { "courseId": 908, "allergenId": 208 }, { "courseId": 908, "allergenId": 210 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2849, "menuItemId": 1962, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2846, "menuItemId": 1962, "courseId": 3264, "sortOrder": 0, "course": { "id": 3264, "dispNameNl": "Kipfilet op de grill ", "dispNameEn": "Grilled chicken breast ", "nameNl": "kipfilet op de grill 1 (kippenkruiden), dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": null, "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3264, "courseLogoId": 202 }, { "courseId": 3264, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2848, "menuItemId": 1962, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1958, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 11, "menuItemContents": [ { "id": 2842, "menuItemId": 1958, "courseId": 274, "sortOrder": 0, "course": { "id": 274, "dispNameNl": "Salade New York", "dispNameEn": "New York salad", "nameNl": "salade new york (appel, noten, brie), dd, w", "nameEn": "", "weight": "350 g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton citroensap besprenkelen over de appelstukjes", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 274, "allergenId": 200 }, { "courseId": 274, "allergenId": 201 }, { "courseId": 274, "allergenId": 203 }, { "courseId": 274, "allergenId": 205 }, { "courseId": 274, "allergenId": 206 }, { "courseId": 274, "allergenId": 208 }, { "courseId": 274, "allergenId": 209 }, { "courseId": 274, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 274, "courseLogoId": 204 }, { "courseId": 274, "courseLogoId": 209 }, { "courseId": 274, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1959, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 240, "sortorder": 11, "menuItemContents": [ { "id": 2843, "menuItemId": 1959, "courseId": 159, "sortOrder": 0, "course": { "id": 159, "dispNameNl": "Abdijbroodje ", "dispNameEn": "Abbey roll ", "nameNl": "abdijbroodje (baguelino broodje, smeerkaas, rammenas) dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 159, "allergenId": 201 }, { "courseId": 159, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 159, "courseLogoId": 204 }, { "courseId": 159, "courseLogoId": 210 }, { "courseId": 159, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-12_cmu.raw.json ================================================ { "id": 312, "menuDate": "2019-12-12T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 2047, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2980, "menuItemId": 2047, "courseId": 1091, "sortOrder": 0, "course": { "id": 1091, "dispNameNl": "Groenteloempia", "dispNameEn": "Vegetable spring roll", "nameNl": "groenteloempia, dd, z & w", "nameEn": "", "weight": "125g", "extra": "veggie", "preparation": "1 stuk per persoon", "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1091, "allergenId": 200 }, { "courseId": 1091, "allergenId": 201 }, { "courseId": 1091, "allergenId": 203 }, { "courseId": 1091, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1091, "courseLogoId": 210 }, { "courseId": 1091, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2048, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2981, "menuItemId": 2048, "courseId": 5033, "sortOrder": 0, "course": { "id": 5033, "dispNameNl": "Quesadilla's veganlicious", "dispNameEn": "Veganlicious quesadillas", "nameNl": "00 quesadilla's veganlicious, w (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "snack winter bak de diepgevroren quinoa-groenekoolburger op een bakplaat of in de oven.- strooi de mozzarisella over de bodem van de wraps en voeg de lente-ui toe.- voeg een laagje geroosterde ma\u00efs toe. leg de burger in de wrap en vouw toe tot een quesadilla.- voeg de quesadilla\"s toe aan een warme braadpan en bak elke kant een paar minuten op een laag tot middelhoog vuur tot ze licht geroosterd zijn of je kan ze ook in een paninitoestel plaatsen.- snijd in hapklare stukken.", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5033, "allergenId": 201 }, { "courseId": 5033, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5033, "courseLogoId": 210 }, { "courseId": 5033, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2051, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2984, "menuItemId": 2051, "courseId": 5023, "sortOrder": 0, "course": { "id": 5023, "dispNameNl": "Quinoasalade met bietjes", "dispNameEn": "Quinoa salad with beets", "nameNl": "00 quinoasalade met bietjes,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptsalade winter bereid de quinoa volgens de instructies.- meng de rode biet doorheen de quinoa.- meng ook de boerenkool; peterselie en munt erdoorheen samen met de appelazijn, limoen en ahornsiroop. voeg als laatste de edamame boontjes & avocado toe.- rooster ondertussen de pistachenootjes in een koekenpan met wat olijfolie en strooi deze over de salade heen.-", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5023, "allergenId": 205 }, { "courseId": 5023, "allergenId": 206 }, { "courseId": 5023, "allergenId": 210 }, { "courseId": 5023, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 5023, "courseLogoId": 209 }, { "courseId": 5023, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2052, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2985, "menuItemId": 2052, "courseId": 1934, "sortOrder": 0, "course": { "id": 1934, "dispNameNl": "Bagel met rosbief, veldsla, witloof en rode biet", "dispNameEn": "Roast beef, lamb\u2019s lettuce, Belgian endive and beetroot bagel", "nameNl": "bagel rosbief (veldsla, witloof, rode biet), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1934, "allergenId": 200 }, { "courseId": 1934, "allergenId": 201 }, { "courseId": 1934, "allergenId": 203 }, { "courseId": 1934, "allergenId": 204 }, { "courseId": 1934, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1934, "courseLogoId": 204 }, { "courseId": 1934, "courseLogoId": 208 }, { "courseId": 1934, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2053, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2986, "menuItemId": 2053, "courseId": 4950, "sortOrder": 0, "course": { "id": 4950, "dispNameNl": "Meergranenbroodje met humus en wintergroenten", "dispNameEn": "Multigrain roll with hummus and winter vegetables", "nameNl": "00 broodje fit met humus en wintergroenten (vegan)", "nameEn": "", "weight": "135 g/275g", "extra": "vegan", "preparation": "broodje winter", "price": 3.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4950, "allergenId": 201 }, { "courseId": 4950, "allergenId": 209 }, { "courseId": 4950, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4950, "courseLogoId": 210 }, { "courseId": 4950, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2054, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2987, "menuItemId": 2054, "courseId": 3852, "sortOrder": 0, "course": { "id": 3852, "dispNameNl": "Honey wrap", "dispNameEn": "Honey wrap", "nameNl": "honey wrap, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": "avocado in plakjes snijden en besprenkelen met citroensap", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3852, "allergenId": 203 }, { "courseId": 3852, "allergenId": 204 }, { "courseId": 3852, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3852, "courseLogoId": 202 }, { "courseId": 3852, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2055, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 2988, "menuItemId": 2055, "courseId": 4952, "sortOrder": 0, "course": { "id": 4952, "dispNameNl": "Meergranenbroodje met camembert en wintergroenten", "dispNameEn": "Multigrain roll with camembert and winter vegetables", "nameNl": "00 broodje fit met camembert en wintergroenten", "nameEn": "", "weight": "135 g/275g", "extra": "veggie", "preparation": null, "price": 3.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4952, "allergenId": 201 }, { "courseId": 4952, "allergenId": 203 }, { "courseId": 4952, "allergenId": 209 }, { "courseId": 4952, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4952, "courseLogoId": 210 }, { "courseId": 4952, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2324, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 312, "sortorder": 10, "menuItemContents": [ { "id": 3361, "menuItemId": 2324, "courseId": 877, "sortOrder": 0, "course": { "id": 877, "dispNameNl": "Venkelsoep", "dispNameEn": "Fennel soup", "nameNl": "venkelsoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 877, "allergenId": 201 }, { "courseId": 877, "allergenId": 203 }, { "courseId": 877, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 877, "courseLogoId": 211 }, { "courseId": 877, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-12_cst.raw.json ================================================ { "id": 296, "menuDate": "2019-12-12T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1847, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 0, "menuItemContents": [ { "id": 2705, "menuItemId": 1847, "courseId": 3624, "sortOrder": 0, "course": { "id": 3624, "dispNameNl": "Bio-knolseldersoep", "dispNameEn": "Organic celeriac soup", "nameNl": "bio-knolseldersoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3624, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 3624, "courseLogoId": 201 }, { "courseId": 3624, "courseLogoId": 211 }, { "courseId": 3624, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 1848, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 1, "menuItemContents": [ { "id": 2708, "menuItemId": 1848, "courseId": 1037, "sortOrder": 0, "course": { "id": 1037, "dispNameNl": "Indonesische wokgroenten", "dispNameEn": "Indonesian-style stir-fried vegetables", "nameNl": "indonesische wokgroenten, z & w", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1037, "allergenId": 201 }, { "courseId": 1037, "allergenId": 205 }, { "courseId": 1037, "allergenId": 210 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2707, "menuItemId": 1848, "courseId": 4765, "sortOrder": 0, "course": { "id": 4765, "dispNameNl": "miehoen", "dispNameEn": "mihoen", "nameNl": "mie - miehoen (dunnen noedel obv rijstebloem) (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "miehoen gaar maken in de bio bouillon.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2706, "menuItemId": 1848, "courseId": 5172, "sortOrder": 0, "course": { "id": 5172, "dispNameNl": "bloemkool-broccoli tots", "dispNameEn": "cauliflower and broccoli tots", "nameNl": "00 bloemkool-broccoli tots (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de tots af in de friteuse of in de oven", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5172, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5172, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1860, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 2, "menuItemContents": [ { "id": 2732, "menuItemId": 1860, "courseId": 1021, "sortOrder": 0, "course": { "id": 1021, "dispNameNl": "appelmoes", "dispNameEn": "apple sauce", "nameNl": "appelmoes, w&z", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1021, "allergenId": 204 }, { "courseId": 1021, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2731, "menuItemId": 1860, "courseId": 1374, "sortOrder": 0, "course": { "id": 1374, "dispNameNl": "Gemarineerde kippenbrochette ", "dispNameEn": "Marinated chicken skewer ", "nameNl": "kippenbrochette gemarineerd, dd", "nameEn": "", "weight": "150g", "extra": null, "preparation": "marineren en afbakken op de grill", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1374, "courseLogoId": 202 }, { "courseId": 1374, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2733, "menuItemId": 1860, "courseId": 1921, "sortOrder": 0, "course": { "id": 1921, "dispNameNl": "aardappelwafeltjes", "dispNameEn": "potato waffles", "nameNl": "aardappelwafeltjes", "nameEn": "", "weight": "250g pp", "extra": null, "preparation": "olie verwarmen tot 170\u00b0 en wafeltjes afbakken.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1921, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2011, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 3, "menuItemContents": [ { "id": 2946, "menuItemId": 2011, "courseId": 1425, "sortOrder": 0, "course": { "id": 1425, "dispNameNl": "pasta met paprikaroom & waterkers", "dispNameEn": "paprika cream with garden cress", "nameNl": "00 paprikaroom met waterkers, zvv, dd, z & w (vegan)", "nameEn": "", "weight": "200g", "extra": "vegan", "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux afsmaken met pezo en proven\u00e7aalse kruiden. - rode paprika gaar steamen en samen met room bij tomatensaus doen - mixen en eventueel verdunnen/aandikken serveer met een vegan pasta ( volkoren spaghetti speciality anco/ fusili volkoren bio)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1425, "allergenId": 201 }, { "courseId": 1425, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1425, "courseLogoId": 207 }, { "courseId": 1425, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2003, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 4, "menuItemContents": [ { "id": 2931, "menuItemId": 2003, "courseId": 1414, "sortOrder": 0, "course": { "id": 1414, "dispNameNl": "Spaghetti carbonara", "dispNameEn": "carbonara sauce", "nameNl": "Spaghetti carbonara", "nameEn": "", "weight": "200g", "extra": null, "preparation": "witte saus poeder toevoegen aan kokend water en goed laten doorkoken. - culinaire room toevoegen en de kruiden. - terug laten koken en gebakken spekreepjes toevoegen. + gemalen kaas erbij serveren", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1414, "allergenId": 200 }, { "courseId": 1414, "allergenId": 201 }, { "courseId": 1414, "allergenId": 203 }, { "courseId": 1414, "allergenId": 204 }, { "courseId": 1414, "allergenId": 205 }, { "courseId": 1414, "allergenId": 206 }, { "courseId": 1414, "allergenId": 208 }, { "courseId": 1414, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1414, "courseLogoId": 207 }, { "courseId": 1414, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2004, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 5, "menuItemContents": [ { "id": 2933, "menuItemId": 2004, "courseId": 918, "sortOrder": 0, "course": { "id": 918, "dispNameNl": "Hollandse saus", "dispNameEn": "Dutch sauce", "nameNl": "hollandse saus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "zie verpakking", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 918, "allergenId": 201 }, { "courseId": 918, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2935, "menuItemId": 2004, "courseId": 985, "sortOrder": 0, "course": { "id": 985, "dispNameNl": "kroketten", "dispNameEn": "croquettes", "nameNl": "kroketten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 985, "allergenId": 200 }, { "courseId": 985, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2932, "menuItemId": 2004, "courseId": 3361, "sortOrder": 0, "course": { "id": 3361, "dispNameNl": "Zalmburger (global gap) op de grill", "dispNameEn": "Grilled salmon burger (global gap)", "nameNl": "zalmburger op de grill, global gab, dd", "nameEn": "", "weight": "150g pp", "extra": null, "preparation": "zalmburger kruiden en grillen op de grill", "price": 5.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3361, "allergenId": 201 }, { "courseId": 3361, "allergenId": 208 }, { "courseId": 3361, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 3361, "courseLogoId": 203 }, { "courseId": 3361, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 2934, "menuItemId": 2004, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2000, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 11, "menuItemContents": [ { "id": 2928, "menuItemId": 2000, "courseId": 5562, "sortOrder": 0, "course": { "id": 5562, "dispNameNl": "Chicken Mexicano Bowl", "dispNameEn": "chicken mexicano bowl", "nameNl": "Chicken Mexicano Bowl,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen en meng met de salsa mexicana mix . - Maak de tuinerwtenspread met sjalot volgens de fiche - Bak de kip. Maak de budhabowl als volgt: verdeel de rijst over het kommetje, leg in regenboogvorm: de geroosterde ma\u00efs, veldslag, farmersla en rode bonen. Werk af met de tuinerwtenspread, gebakken ajuintjes, koriander en schijfjes jalapeno. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5562, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5562, "courseLogoId": 202 }, { "courseId": 5562, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2001, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 11, "menuItemContents": [ { "id": 2929, "menuItemId": 2001, "courseId": 4977, "sortOrder": 0, "course": { "id": 4977, "dispNameNl": "Groentesalade", "dispNameEn": "Vegetable salad", "nameNl": "00 groentesalade,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptsalade winter", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4977, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 4977, "courseLogoId": 209 }, { "courseId": 4977, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2002, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 296, "sortorder": 11, "menuItemContents": [ { "id": 2930, "menuItemId": 2002, "courseId": 2071, "sortOrder": 0, "course": { "id": 2071, "dispNameNl": "Salade met perziken en zalmsalade", "dispNameEn": "Salad with peaches and salmon salad", "nameNl": "salade met perziken en zalmsalade, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "zalm stomen en laten afkoelen (slechts max. 100g per persoon gebruiken) - zalm prakken, mengen met een kleine hoeveelheid mayonaise (voeg enkel extra mayo toe als de zalmsalade nog te droog is, laat de zalm in geen geval \u2018zwemmen\u2019 in de mayonaise), met geplette hardgekookte eieren en peterselie - perziken in partjes snijden - veldsla en rammenas mengen. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton gebruiken volgorde voor vullen van onder naar boven: gesneden perziken (2 -3 stuks) + zalmsalade (+/- 80-100 gr) + gemengde veldsla \u2013 rammenas", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2071, "allergenId": 200 }, { "courseId": 2071, "allergenId": 201 }, { "courseId": 2071, "allergenId": 204 }, { "courseId": 2071, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2071, "courseLogoId": 209 }, { "courseId": 2071, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-12_hzs.raw.json ================================================ { "id": 270, "menuDate": "2019-12-12T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1739, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 0, "menuItemContents": [ { "id": 2541, "menuItemId": 1739, "courseId": 3624, "sortOrder": 0, "course": { "id": 3624, "dispNameNl": "Bio-knolseldersoep", "dispNameEn": "Organic celeriac soup", "nameNl": "bio-knolseldersoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3624, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 3624, "courseLogoId": 201 }, { "courseId": 3624, "courseLogoId": 211 }, { "courseId": 3624, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 1743, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 1, "menuItemContents": [ { "id": 2545, "menuItemId": 1743, "courseId": 4978, "sortOrder": 0, "course": { "id": 4978, "dispNameNl": "Avocado-quinoasalade", "dispNameEn": "Avocado and quinoa salad", "nameNl": "avocado-quinoasalade, w (vegan)", "nameEn": "", "weight": "350 gr", "extra": null, "preparation": "conceptsalade winter kook de quinoa in de groentebouillon - steam de pompoenblokjes en laat afkoelen - snij de avocado in mooie blokjes en snipper de koriander- laat de rode bonen uitlekken - vermeng de groenten, de ajuin,de mais, de rode bonen, de noten, de koriander met ma\u00efsolie, sap van limoen en pezo - en meng dit met de quinoa. - vul de saladebox met de quinoasalade.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4978, "allergenId": 201 }, { "courseId": 4978, "allergenId": 205 }, { "courseId": 4978, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4978, "courseLogoId": 209 }, { "courseId": 4978, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1742, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 2, "menuItemContents": [ { "id": 2544, "menuItemId": 1742, "courseId": 283, "sortOrder": 0, "course": { "id": 283, "dispNameNl": "Salade Rhodos", "dispNameEn": "Rhodes salad", "nameNl": "salade rhodos (penne, pesto, geroosterde knolselder), dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren - veggie", "preparation": "geroosterde knolselder: knolselder in grove stukken snijden en roosteren in olijfolie vanaf nu pestospread gran'olivia gebruiken - sandwich spread opwerken - en pesto onder conceptsalade mengen, niet in een dressingpotje feta verkruimelen over de salade", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 283, "allergenId": 200 }, { "courseId": 283, "allergenId": 201 }, { "courseId": 283, "allergenId": 205 }, { "courseId": 283, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 283, "courseLogoId": 209 }, { "courseId": 283, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1741, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 3, "menuItemContents": [ { "id": 2543, "menuItemId": 1741, "courseId": 1872, "sortOrder": 0, "course": { "id": 1872, "dispNameNl": "Italiaanse worteltartaar met parmezaan", "dispNameEn": "Italian carrot tartare with parmesan", "nameNl": "italiaanse worteltartaar met parmezaan, dd, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1872, "allergenId": 200 }, { "courseId": 1872, "allergenId": 201 }, { "courseId": 1872, "allergenId": 203 }, { "courseId": 1872, "allergenId": 204 }, { "courseId": 1872, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1872, "courseLogoId": 204 }, { "courseId": 1872, "courseLogoId": 210 }, { "courseId": 1872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1740, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 4, "menuItemContents": [ { "id": 2542, "menuItemId": 1740, "courseId": 3201, "sortOrder": 0, "course": { "id": 3201, "dispNameNl": "Croissant 'bacon'", "dispNameEn": "\u2018Bacon\u2019 croissant", "nameNl": "croissant bacon (eiersalade en spek), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": "1 reepje bacon per croissant", "price": 2.3, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3201, "allergenId": 200 }, { "courseId": 3201, "allergenId": 201 }, { "courseId": 3201, "allergenId": 203 }, { "courseId": 3201, "allergenId": 204 }, { "courseId": 3201, "allergenId": 205 }, { "courseId": 3201, "allergenId": 207 }, { "courseId": 3201, "allergenId": 208 }, { "courseId": 3201, "allergenId": 209 }, { "courseId": 3201, "allergenId": 210 }, { "courseId": 3201, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 3201, "courseLogoId": 210 }, { "courseId": 3201, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1745, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 5, "menuItemContents": [ { "id": 2547, "menuItemId": 1745, "courseId": 5074, "sortOrder": 0, "course": { "id": 5074, "dispNameNl": "Viking pumpkin", "dispNameEn": "Viking pumpkin", "nameNl": "00 viking pumpkin,w", "nameEn": "", "weight": null, "extra": "veggie", "preparation": "doe de pompoenblokjes in een schaal in de oven, samen met de kruiden & een beetje olijfolie, gedurende 10 minuten op 250 \u00b0c. - beleg het viking brood met de geitenkaas, beetje honing of mango chutney en de pompoenblokjes.- steek het broodje in de panini machine gedurende circa 3 minuten.", "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5074, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5074, "courseLogoId": 204 }, { "courseId": 5074, "courseLogoId": 210 }, { "courseId": 5074, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1744, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 6, "menuItemContents": [ { "id": 2546, "menuItemId": 1744, "courseId": 1105, "sortOrder": 0, "course": { "id": 1105, "dispNameNl": "Toscaanse panini", "dispNameEn": "Tuscan panini", "nameNl": "toscaanse panini (mozzarella,balletjes, paprikatapenade), z", "nameEn": "", "weight": "300g", "extra": null, "preparation": null, "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1105, "allergenId": 201 }, { "courseId": 1105, "allergenId": 203 }, { "courseId": 1105, "allergenId": 205 }, { "courseId": 1105, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 1105, "courseLogoId": 208 }, { "courseId": 1105, "courseLogoId": 210 }, { "courseId": 1105, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1553, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 270, "sortorder": 7, "menuItemContents": [ { "id": 2288, "menuItemId": 1553, "courseId": 3424, "sortOrder": 0, "course": { "id": 3424, "dispNameNl": "Pasta met tomaat-basilicumsaus", "dispNameEn": "Pasta with tomato and basil sauce", "nameNl": "pasta tomaat basilicum saus, hzs (veggie)", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3424, "allergenId": 200 }, { "courseId": 3424, "allergenId": 203 }, { "courseId": 3424, "allergenId": 205 } ], "course_CourseLogos": [ { "courseId": 3424, "courseLogoId": 207 }, { "courseId": 3424, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-19_cde.parsed.expected.yaml ================================================ campus: cde date: '2019-12-19' menu: - components: - allergens: - CELERY - SOY - WHEAT_GLUTEN attributes: - SOUP - VEGAN name: en: silt celeriac soup with salicorn nl: Zilt knolseldersoepje met zeekraal external_id: 1814 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - WHEAT_GLUTEN attributes: - VEGAN name: en: pumpkin surprise nl: Pumpkin surprise external_id: 1815 multiple_prices: true price: '3.80' sort_order: 1 - components: - allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - SOY - WHEAT_GLUTEN attributes: - CHICKEN - GRILL name: en: festive turkey burger with small fries nl: Festive turkey burger met strofrietjes external_id: 1816 multiple_prices: true price: '4.60' sort_order: 2 - components: - allergens: - EGG - FISH - MUSTARD attributes: - FISH - SALAD name: en: Wicca salad nl: Salade Wicca external_id: 1831 multiple_prices: true price: '4.60' sort_order: 11 - components: - allergens: - NUTS - SESAME - SHELLFISH - SOY - WHEAT_GLUTEN attributes: - FISH - SALAD name: en: buddha bowl with scampi nl: Buddha bowl met scampi's external_id: 1832 multiple_prices: true price: '5.00' sort_order: 11 - components: - allergens: - WHEAT_GLUTEN attributes: [ ] name: en: wholegrain penne nl: Volkoren penne - allergens: - CELERY - WHEAT_GLUTEN attributes: - VEGAN name: en: Mediterranean vegetable sauce nl: mediterraanse groentesaus external_id: 2215 multiple_prices: true price: '3.80' sort_order: 3 - components: - allergens: [ ] attributes: [ ] name: en: farfale nl: Farfale - allergens: - SULFITES - WHEAT_GLUTEN attributes: - PIG name: en: Milanese sauce with cubed ham nl: milanese saus met hamblokjes external_id: 2216 multiple_prices: true price: '3.80' sort_order: 4 ================================================ FILE: tests/external_menus/2019-12-19_cde.processed.expected.yaml ================================================ campus: cde date: '2019-12-19' menu: - course_allergens: - CELERY - SOY - WHEAT_GLUTEN course_attributes: - SOUP - VEGAN course_sub_type: VEGAN course_type: SOUP external_id: 1814 name: en: Silt celeriac soup with salicorn nl: Zilt knolseldersoepje met zeekraal price_staff: null price_students: '0.90' - course_allergens: - WHEAT_GLUTEN course_attributes: - VEGAN course_sub_type: VEGAN course_type: DAILY external_id: 1815 name: en: Pumpkin surprise nl: Pumpkin surprise price_staff: '4.70' price_students: '3.80' - course_allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - SOY - WHEAT_GLUTEN course_attributes: - CHICKEN - GRILL course_sub_type: NORMAL course_type: GRILL external_id: 1816 name: en: Festive turkey burger with small fries nl: Festive turkey burger met strofrietjes price_staff: '5.70' price_students: '4.60' - course_allergens: - EGG - FISH - MUSTARD course_attributes: - FISH - SALAD course_sub_type: NORMAL course_type: SALAD external_id: 1831 name: en: Wicca salad nl: Salade Wicca price_staff: '5.70' price_students: '4.60' - course_allergens: - NUTS - SESAME - SHELLFISH - SOY - WHEAT_GLUTEN course_attributes: - FISH - SALAD course_sub_type: NORMAL course_type: SALAD external_id: 1832 name: en: Buddha bowl with scampi nl: Buddha bowl met scampi's price_staff: '6.20' price_students: '5.00' - course_allergens: - CELERY - WHEAT_GLUTEN course_attributes: - VEGAN course_sub_type: VEGAN course_type: PASTA external_id: 2215 name: en: Wholegrain penne, Mediterranean vegetable sauce nl: Volkoren penne, mediterraanse groentesaus price_staff: '4.70' price_students: '3.80' - course_allergens: - SULFITES - WHEAT_GLUTEN course_attributes: - PIG course_sub_type: NORMAL course_type: PASTA external_id: 2216 name: en: Farfale, Milanese sauce with cubed ham nl: Farfale, milanese saus met hamblokjes price_staff: '4.70' price_students: '3.80' ================================================ FILE: tests/external_menus/2019-12-19_cde.raw.json ================================================ { "id": 291, "menuDate": "2019-12-19T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1814, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 0, "menuItemContents": [ { "id": 3123, "menuItemId": 1814, "courseId": 5575, "sortOrder": 0, "course": { "id": 5575, "dispNameNl": "Zilt knolseldersoepje met zeekraal", "dispNameEn": "silt celeriac soup with salicorn", "nameNl": "Zilt knolseldersoepje met zeekraal, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "Stoof de ui aan, samen met de prei en de knolselder in boter. Bevochtig daarna met de bouillon en laat zachtjes koken tot de groenten gaar zijn. Werk af met de kervel en de zeekraal.", "price": 0.9, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5575, "allergenId": 201 }, { "courseId": 5575, "allergenId": 208 }, { "courseId": 5575, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5575, "courseLogoId": 211 }, { "courseId": 5575, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1815, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 1, "menuItemContents": [ { "id": 3124, "menuItemId": 1815, "courseId": 5578, "sortOrder": 0, "course": { "id": 5578, "dispNameNl": "Pumpkin surprise", "dispNameEn": "pumpkin surprise", "nameNl": "Pumpkin surprise", "nameEn": "", "weight": "", "extra": "", "preparation": "Verwam de oven voor op 180 graden en gebruik de grillstand. Snijd de pompoen in de breedte doormidden. Haal het vruchtvlees eruit en snijd in de breedte dunne ringen. Maak de quinoa klaar volgens de bereidingswijze op de verpakking. De groenten in gelijke hoeveelheden verdelen en laten ontdooien. Tijdens de service de groenten kort aanbakken met de garam masala en de gepelde tomaat en een beetje water toevoegen . Bouillion naar smaak toevoegen en kokosmelk laten opkoken. Proef de curry en voeg naar smaak meer kruiden toe. Leg daarna een pompoenring op een bord, vul het binnenste met een beetje quinoa, schep vervolgens de curry erop en garneer met peterselie & een plukje waterkers.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5578, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5578, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1816, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 2, "menuItemContents": [ { "id": 3125, "menuItemId": 1816, "courseId": 5576, "sortOrder": 0, "course": { "id": 5576, "dispNameNl": "Festive turkey burger met strofrietjes", "dispNameEn": "festive turkey burger with small fries ", "nameNl": "Festive turkey burger met strofrietjes & rauwkostslaatje", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 4.6, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5576, "allergenId": 200 }, { "courseId": 5576, "allergenId": 201 }, { "courseId": 5576, "allergenId": 203 }, { "courseId": 5576, "allergenId": 204 }, { "courseId": 5576, "allergenId": 208 }, { "courseId": 5576, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5576, "courseLogoId": 202 }, { "courseId": 5576, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2215, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 3, "menuItemContents": [ { "id": 3238, "menuItemId": 2215, "courseId": 1432, "sortOrder": 0, "course": { "id": 1432, "dispNameNl": "mediterraanse groentesaus", "dispNameEn": "Mediterranean vegetable sauce", "nameNl": "mediterraanse groentesaus, zvv, dd z&w (vegan)", "nameEn": "", "weight": "200g", "extra": null, "preparation": "pastasaus groenten aanstoven en bevochtigen met water. - kruiden toevoegen en een half uurtje laten pruttelen. - afbinden opgelet: vegan pasta voorzien", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1432, "allergenId": 201 }, { "courseId": 1432, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1432, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 3239, "menuItemId": 2215, "courseId": 5488, "sortOrder": 0, "course": { "id": 5488, "dispNameNl": "Volkoren penne", "dispNameEn": "wholegrain penne", "nameNl": "Volkoren penne (vegan)", "nameEn": "", "weight": "150 - 200 gr pp", "extra": "", "preparation": "Kook de pasta gaar in lichtgezouten water, olie toevoegen. de kooktijd is afhankelijk van de soort pasta.", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5488, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2216, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 4, "menuItemContents": [ { "id": 3240, "menuItemId": 2216, "courseId": 3502, "sortOrder": 0, "course": { "id": 3502, "dispNameNl": "milanese saus met hamblokjes", "dispNameEn": "Milanese sauce with cubed ham", "nameNl": "milanese saus met hamblokjes, z & w", "nameEn": "", "weight": "200g", "extra": null, "preparation": "keuze in het gebruik van kappertjes 100ml of kappers 3l - tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water met bouillon toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux indien nodig (optioneel). - afsmaken met pezo en proven\u00e7aalse kruiden. - sjalotjes aanstoven en bevochtigen met witte wijn - tomatenblokjes, kappertjes, lookpuree en kruiden toevoegen . - 15 minuten laten sudderen en bij warme tomatensaus voegen. - hamblokjes opwarmen en op einde toevoegen", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3502, "allergenId": 201 }, { "courseId": 3502, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3502, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": true, "enabled": false, "menuInfoEn": null } }, { "id": 3241, "menuItemId": 2216, "courseId": 5484, "sortOrder": 0, "course": { "id": 5484, "dispNameNl": "Farfale", "dispNameEn": "farfale", "nameNl": "Farfale, kookvast", "nameEn": "", "weight": "150 - 200 g pp", "extra": "", "preparation": "kook de pasta gaar in licht gezouten water, olie toevoegen. de kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1831, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 11, "menuItemContents": [ { "id": 3129, "menuItemId": 1831, "courseId": 1924, "sortOrder": 0, "course": { "id": 1924, "dispNameNl": "Salade Wicca", "dispNameEn": "Wicca salad", "nameNl": "salade wicca (zalm, linzen, aardappelen), w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "pompoenblokjes en aardappelen: niet natuur stomen, maar garen in de oven met olijfolie en rozemarijn. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1924, "allergenId": 200 }, { "courseId": 1924, "allergenId": 204 }, { "courseId": 1924, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1924, "courseLogoId": 209 }, { "courseId": 1924, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1832, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 291, "sortorder": 11, "menuItemContents": [ { "id": 2677, "menuItemId": 1832, "courseId": 5560, "sortOrder": 0, "course": { "id": 5560, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "buddha bowl with scampi", "nameNl": "Buddha bowl met scampi's, w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - Maak de scampi's klaar & laat afkoelen.- Maak de bowl: Doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en raapjes, een rijtje veldsla, schijfjes avocado (besprenkelt met citroensap) en rijtje geraspte rode bieten. Werk af met de scampi's, koriander en gesneden pijpajuin. Serveer hier bij de sojavinaigrette. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 5.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5560, "allergenId": 201 }, { "courseId": 5560, "allergenId": 205 }, { "courseId": 5560, "allergenId": 207 }, { "courseId": 5560, "allergenId": 209 }, { "courseId": 5560, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5560, "courseLogoId": 209 }, { "courseId": 5560, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-19_cgb.raw.json ================================================ { "id": 356, "menuDate": "2019-12-19T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 2340, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 356, "sortorder": 0, "menuItemContents": [ { "id": 3374, "menuItemId": 2340, "courseId": 5575, "sortOrder": 0, "course": { "id": 5575, "dispNameNl": "Zilt knolseldersoepje met zeekraal", "dispNameEn": "silt celeriac soup with salicorn", "nameNl": "Zilt knolseldersoepje met zeekraal, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "Stoof de ui aan, samen met de prei en de knolselder in boter. Bevochtig daarna met de bouillon en laat zachtjes koken tot de groenten gaar zijn. Werk af met de kervel en de zeekraal.", "price": 0.9, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5575, "allergenId": 201 }, { "courseId": 5575, "allergenId": 208 }, { "courseId": 5575, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5575, "courseLogoId": 211 }, { "courseId": 5575, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2431, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 356, "sortorder": 1, "menuItemContents": [ { "id": 3488, "menuItemId": 2431, "courseId": 5586, "sortOrder": 0, "course": { "id": 5586, "dispNameNl": "Pasta met soja-bolognaisesaus", "dispNameEn": "Pasta with soy bolognaise sauce", "nameNl": "Pasta met soja-bolognaisesaus", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5586, "allergenId": 200 }, { "courseId": 5586, "allergenId": 201 }, { "courseId": 5586, "allergenId": 208 }, { "courseId": 5586, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5586, "courseLogoId": 207 }, { "courseId": 5586, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2341, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 356, "sortorder": 2, "menuItemContents": [ { "id": 3375, "menuItemId": 2341, "courseId": 3141, "sortOrder": 0, "course": { "id": 3141, "dispNameNl": "Croissant met brie", "dispNameEn": "Croissant with brie", "nameNl": "croissant brie, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "amandelschilfers roosteren", "price": 2.3, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3141, "allergenId": 200 }, { "courseId": 3141, "allergenId": 201 }, { "courseId": 3141, "allergenId": 203 }, { "courseId": 3141, "allergenId": 205 }, { "courseId": 3141, "allergenId": 206 }, { "courseId": 3141, "allergenId": 209 }, { "courseId": 3141, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3141, "courseLogoId": 204 }, { "courseId": 3141, "courseLogoId": 210 }, { "courseId": 3141, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2401, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 356, "sortorder": 3, "menuItemContents": [ { "id": 3451, "menuItemId": 2401, "courseId": 1890, "sortOrder": 0, "course": { "id": 1890, "dispNameNl": "Salade zonder sla", "dispNameEn": "Salad without lettuce", "nameNl": "salade slaatje zonder sla (aardappel, appel, raapjes), dd, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton appel besprenkelen met citroensap", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1890, "allergenId": 200 }, { "courseId": 1890, "allergenId": 203 }, { "courseId": 1890, "allergenId": 205 }, { "courseId": 1890, "allergenId": 206 }, { "courseId": 1890, "allergenId": 208 }, { "courseId": 1890, "allergenId": 209 }, { "courseId": 1890, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1890, "courseLogoId": 209 }, { "courseId": 1890, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-19_cmi.raw.json ================================================ { "id": 267, "menuDate": "2019-12-19T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 2027, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 0, "menuItemContents": [ { "id": 2959, "menuItemId": 2027, "courseId": 5575, "sortOrder": 0, "course": { "id": 5575, "dispNameNl": "Zilt knolseldersoepje met zeekraal", "dispNameEn": "silt celeriac soup with salicorn", "nameNl": "Zilt knolseldersoepje met zeekraal, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "Stoof de ui aan, samen met de prei en de knolselder in boter. Bevochtig daarna met de bouillon en laat zachtjes koken tot de groenten gaar zijn. Werk af met de kervel en de zeekraal.", "price": 0.9, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5575, "allergenId": 201 }, { "courseId": 5575, "allergenId": 208 }, { "courseId": 5575, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5575, "courseLogoId": 211 }, { "courseId": 5575, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2028, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 1, "menuItemContents": [ { "id": 2961, "menuItemId": 2028, "courseId": 5578, "sortOrder": 0, "course": { "id": 5578, "dispNameNl": "Pumpkin surprise", "dispNameEn": "pumpkin surprise", "nameNl": "Pumpkin surprise", "nameEn": "", "weight": "", "extra": "", "preparation": "Verwam de oven voor op 180 graden en gebruik de grillstand. Snijd de pompoen in de breedte doormidden. Haal het vruchtvlees eruit en snijd in de breedte dunne ringen. Maak de quinoa klaar volgens de bereidingswijze op de verpakking. De groenten in gelijke hoeveelheden verdelen en laten ontdooien. Tijdens de service de groenten kort aanbakken met de garam masala en de gepelde tomaat en een beetje water toevoegen . Bouillion naar smaak toevoegen en kokosmelk laten opkoken. Proef de curry en voeg naar smaak meer kruiden toe. Leg daarna een pompoenring op een bord, vul het binnenste met een beetje quinoa, schep vervolgens de curry erop en garneer met peterselie & een plukje waterkers.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5578, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5578, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2029, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 2, "menuItemContents": [ { "id": 2962, "menuItemId": 2029, "courseId": 5576, "sortOrder": 0, "course": { "id": 5576, "dispNameNl": "Festive turkey burger met strofrietjes", "dispNameEn": "festive turkey burger with small fries ", "nameNl": "Festive turkey burger met strofrietjes & rauwkostslaatje", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 4.6, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5576, "allergenId": 200 }, { "courseId": 5576, "allergenId": 201 }, { "courseId": 5576, "allergenId": 203 }, { "courseId": 5576, "allergenId": 204 }, { "courseId": 5576, "allergenId": 208 }, { "courseId": 5576, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5576, "courseLogoId": 202 }, { "courseId": 5576, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2419, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 3, "menuItemContents": [ { "id": 3477, "menuItemId": 2419, "courseId": 5586, "sortOrder": 0, "course": { "id": 5586, "dispNameNl": "Pasta met soja-bolognaisesaus", "dispNameEn": "Pasta with soy bolognaise sauce", "nameNl": "Pasta met soja-bolognaisesaus", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5586, "allergenId": 200 }, { "courseId": 5586, "allergenId": 201 }, { "courseId": 5586, "allergenId": 208 }, { "courseId": 5586, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5586, "courseLogoId": 207 }, { "courseId": 5586, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2181, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 4, "menuItemContents": [ { "id": 3169, "menuItemId": 2181, "courseId": 1402, "sortOrder": 0, "course": { "id": 1402, "dispNameNl": "Lasagne bolognaise", "dispNameEn": "Lasagne bolognese", "nameNl": "lasagne bolognaise kant-en klaar", "nameEn": "", "weight": "450g", "extra": null, "preparation": "de lasagne in porties snijden. - 20 minuten opwaren in een oven van 200\u00b0c. - eventueel van verpakking ontdoen en in een gastronorm bak opwarmen. - geraspte kaas erbij serveren.", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1402, "allergenId": 200 }, { "courseId": 1402, "allergenId": 201 }, { "courseId": 1402, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1402, "courseLogoId": 207 }, { "courseId": 1402, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2182, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 11, "menuItemContents": [ { "id": 3170, "menuItemId": 2182, "courseId": 3141, "sortOrder": 0, "course": { "id": 3141, "dispNameNl": "Croissant met brie", "dispNameEn": "Croissant with brie", "nameNl": "croissant brie, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "amandelschilfers roosteren", "price": 2.3, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3141, "allergenId": 200 }, { "courseId": 3141, "allergenId": 201 }, { "courseId": 3141, "allergenId": 203 }, { "courseId": 3141, "allergenId": 205 }, { "courseId": 3141, "allergenId": 206 }, { "courseId": 3141, "allergenId": 209 }, { "courseId": 3141, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3141, "courseLogoId": 204 }, { "courseId": 3141, "courseLogoId": 210 }, { "courseId": 3141, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2371, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 267, "sortorder": 11, "menuItemContents": [ { "id": 3403, "menuItemId": 2371, "courseId": 1890, "sortOrder": 0, "course": { "id": 1890, "dispNameNl": "Salade zonder sla", "dispNameEn": "Salad without lettuce", "nameNl": "salade slaatje zonder sla (aardappel, appel, raapjes), dd, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton appel besprenkelen met citroensap", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1890, "allergenId": 200 }, { "courseId": 1890, "allergenId": 203 }, { "courseId": 1890, "allergenId": 205 }, { "courseId": 1890, "allergenId": 206 }, { "courseId": 1890, "allergenId": 208 }, { "courseId": 1890, "allergenId": 209 }, { "courseId": 1890, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1890, "courseLogoId": 209 }, { "courseId": 1890, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-19_cmu.raw.json ================================================ { "id": 326, "menuDate": "2019-12-19T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 2122, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 0, "menuItemContents": [ { "id": 3070, "menuItemId": 2122, "courseId": 5575, "sortOrder": 0, "course": { "id": 5575, "dispNameNl": "Zilt knolseldersoepje met zeekraal", "dispNameEn": "silt celeriac soup with salicorn", "nameNl": "Zilt knolseldersoepje met zeekraal, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "Stoof de ui aan, samen met de prei en de knolselder in boter. Bevochtig daarna met de bouillon en laat zachtjes koken tot de groenten gaar zijn. Werk af met de kervel en de zeekraal.", "price": 0.9, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5575, "allergenId": 201 }, { "courseId": 5575, "allergenId": 208 }, { "courseId": 5575, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5575, "courseLogoId": 211 }, { "courseId": 5575, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2150, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 1, "menuItemContents": [ { "id": 3101, "menuItemId": 2150, "courseId": 5016, "sortOrder": 0, "course": { "id": 5016, "dispNameNl": "Pompoen-falafelwrap", "dispNameEn": "Pumpkin and falafel wrap", "nameNl": "00 wrap pompoen-falafel,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptbroodje winter", "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5016, "allergenId": 201 }, { "courseId": 5016, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5016, "courseLogoId": 210 }, { "courseId": 5016, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2151, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 2, "menuItemContents": [ { "id": 3102, "menuItemId": 2151, "courseId": 4990, "sortOrder": 0, "course": { "id": 4990, "dispNameNl": "Kruidig roggebrood", "dispNameEn": "Herbed rye bread", "nameNl": "00 kruidig roggebrood, w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptbroodje winter werk in 3 lagen met het roggebrood.- besmeer de 3 sneden brood met de vegane mayonaise.- leg op de eerste boterham de vegan kruidenkaas, de schijfjes raap en de waterkers.- leg de 2de boterham hierop en herhaal met het beleg en sluit met het laatste sneetje brood.- snijd de boterham diagonaal doormidden en doe er een cocktailprikker in", "price": 2.7, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4990, "allergenId": 201 }, { "courseId": 4990, "allergenId": 204 }, { "courseId": 4990, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4990, "courseLogoId": 210 }, { "courseId": 4990, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2121, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 3, "menuItemContents": [ { "id": 3071, "menuItemId": 2121, "courseId": 4716, "sortOrder": 0, "course": { "id": 4716, "dispNameNl": "Prinses op de erwt-broodje", "dispNameEn": "Princess and the pea sandwich", "nameNl": "00 prinses op de erwt broodje,z&w", "nameEn": "", "weight": "115 g/235 g", "extra": null, "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4716, "allergenId": 201 }, { "courseId": 4716, "allergenId": 209 }, { "courseId": 4716, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4716, "courseLogoId": 210 }, { "courseId": 4716, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2323, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 4, "menuItemContents": [ { "id": 3360, "menuItemId": 2323, "courseId": 3866, "sortOrder": 0, "course": { "id": 3866, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, w (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter 6 falafels per persoon- pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox gele wortel in fijne reepjes snijden (optioneel toevoegen) hzs: falafel in oven bakken", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3866, "allergenId": 201 }, { "courseId": 3866, "allergenId": 208 }, { "courseId": 3866, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3866, "courseLogoId": 209 }, { "courseId": 3866, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2147, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 5, "menuItemContents": [ { "id": 3098, "menuItemId": 2147, "courseId": 274, "sortOrder": 0, "course": { "id": 274, "dispNameNl": "Salade New York", "dispNameEn": "New York salad", "nameNl": "salade new york (appel, noten, brie), dd, w", "nameEn": "", "weight": "350 g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton citroensap besprenkelen over de appelstukjes", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 274, "allergenId": 200 }, { "courseId": 274, "allergenId": 201 }, { "courseId": 274, "allergenId": 203 }, { "courseId": 274, "allergenId": 205 }, { "courseId": 274, "allergenId": 206 }, { "courseId": 274, "allergenId": 208 }, { "courseId": 274, "allergenId": 209 }, { "courseId": 274, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 274, "courseLogoId": 204 }, { "courseId": 274, "courseLogoId": 209 }, { "courseId": 274, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2148, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 6, "menuItemContents": [ { "id": 3099, "menuItemId": 2148, "courseId": 4989, "sortOrder": 0, "course": { "id": 4989, "dispNameNl": "Vegan burger deluxe", "dispNameEn": "Deluxe vegan burger", "nameNl": "00 vegan burger deluxe,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan (snack)", "preparation": "snack winter bak de burgers.- bestrijk de broodjes met de salsa mexicana, de piri piri saus en ma\u00efs, de burger en de farmersla.", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4989, "allergenId": 201 }, { "courseId": 4989, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4989, "courseLogoId": 210 }, { "courseId": 4989, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2149, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 326, "sortorder": 7, "menuItemContents": [ { "id": 3100, "menuItemId": 2149, "courseId": 5034, "sortOrder": 0, "course": { "id": 5034, "dispNameNl": "Panini green mozzarella", "dispNameEn": "Panini green mozzarella", "nameNl": "00 panini green mozzarella, w", "nameEn": "", "weight": null, "extra": "veggie", "preparation": null, "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5034, "allergenId": 201 }, { "courseId": 5034, "allergenId": 203 }, { "courseId": 5034, "allergenId": 205 }, { "courseId": 5034, "allergenId": 209 }, { "courseId": 5034, "allergenId": 210 }, { "courseId": 5034, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 5034, "courseLogoId": 210 }, { "courseId": 5034, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-19_cst.raw.json ================================================ { "id": 323, "menuDate": "2019-12-19T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 2102, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 323, "sortorder": 0, "menuItemContents": [ { "id": 3066, "menuItemId": 2102, "courseId": 5575, "sortOrder": 0, "course": { "id": 5575, "dispNameNl": "Zilt knolseldersoepje met zeekraal", "dispNameEn": "silt celeriac soup with salicorn", "nameNl": "Zilt knolseldersoepje met zeekraal, vegan", "nameEn": "", "weight": "", "extra": "", "preparation": "Stoof de ui aan, samen met de prei en de knolselder in boter. Bevochtig daarna met de bouillon en laat zachtjes koken tot de groenten gaar zijn. Werk af met de kervel en de zeekraal.", "price": 0.9, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5575, "allergenId": 201 }, { "courseId": 5575, "allergenId": 208 }, { "courseId": 5575, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5575, "courseLogoId": 211 }, { "courseId": 5575, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2118, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 323, "sortorder": 1, "menuItemContents": [ { "id": 3068, "menuItemId": 2118, "courseId": 5578, "sortOrder": 0, "course": { "id": 5578, "dispNameNl": "Pumpkin surprise", "dispNameEn": "pumpkin surprise", "nameNl": "Pumpkin surprise", "nameEn": "", "weight": "", "extra": "", "preparation": "Verwam de oven voor op 180 graden en gebruik de grillstand. Snijd de pompoen in de breedte doormidden. Haal het vruchtvlees eruit en snijd in de breedte dunne ringen. Maak de quinoa klaar volgens de bereidingswijze op de verpakking. De groenten in gelijke hoeveelheden verdelen en laten ontdooien. Tijdens de service de groenten kort aanbakken met de garam masala en de gepelde tomaat en een beetje water toevoegen . Bouillion naar smaak toevoegen en kokosmelk laten opkoken. Proef de curry en voeg naar smaak meer kruiden toe. Leg daarna een pompoenring op een bord, vul het binnenste met een beetje quinoa, schep vervolgens de curry erop en garneer met peterselie & een plukje waterkers.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5578, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5578, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2117, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 323, "sortorder": 2, "menuItemContents": [ { "id": 3067, "menuItemId": 2117, "courseId": 5576, "sortOrder": 0, "course": { "id": 5576, "dispNameNl": "Festive turkey burger met strofrietjes", "dispNameEn": "festive turkey burger with small fries ", "nameNl": "Festive turkey burger met strofrietjes & rauwkostslaatje", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 4.6, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5576, "allergenId": 200 }, { "courseId": 5576, "allergenId": 201 }, { "courseId": 5576, "allergenId": 203 }, { "courseId": 5576, "allergenId": 204 }, { "courseId": 5576, "allergenId": 208 }, { "courseId": 5576, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5576, "courseLogoId": 202 }, { "courseId": 5576, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2119, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 323, "sortorder": 3, "menuItemContents": [ { "id": 3069, "menuItemId": 2119, "courseId": 5037, "sortOrder": 0, "course": { "id": 5037, "dispNameNl": "Salade Orzo ", "dispNameEn": "Orzo salad ", "nameNl": "00 salade orzo (scampi's, griekse deegwaren),w", "nameEn": "", "weight": "350 gr", "extra": "koel bewaren", "preparation": "scampi's: 5 stuks per conceptsalade. meer dan de helft van de salade moet bestaan uit groenten. - afwerken met postelein", "price": 5.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5037, "allergenId": 201 }, { "courseId": 5037, "allergenId": 207 }, { "courseId": 5037, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 5037, "courseLogoId": 209 }, { "courseId": 5037, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2318, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 323, "sortorder": 4, "menuItemContents": [ { "id": 3355, "menuItemId": 2318, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 3354, "menuItemId": 2318, "courseId": 1121, "sortOrder": 0, "course": { "id": 1121, "dispNameNl": "soja-bolognaisesaus ", "dispNameEn": "soy bolognese sauce ", "nameNl": "soja - bolognaise saus dd (vegan)", "nameEn": "", "weight": "200g", "extra": null, "preparation": "pastasaus vege gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux . alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1121, "allergenId": 201 }, { "courseId": 1121, "allergenId": 206 }, { "courseId": 1121, "allergenId": 208 }, { "courseId": 1121, "allergenId": 210 }, { "courseId": 1121, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1121, "courseLogoId": 207 }, { "courseId": 1121, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2319, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 323, "sortorder": 5, "menuItemContents": [ { "id": 3356, "menuItemId": 2319, "courseId": 1433, "sortOrder": 0, "course": { "id": 1433, "dispNameNl": "zalm-broccolisaus", "dispNameEn": "salmon-broccoli sauce", "nameNl": "zalm broccoli saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "ajuin en witte wijn tot de helft laten inkoken en bevochtigen met water. - als het kookt witte wijn saus toevoegen en laten door koken ,daarna mixen. - broccoli stomen en in kleinere stukjes snijden de zalm ook fijner snijden . -broccoli en zalm op het laatste moment toevoegen.", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1433, "allergenId": 201 }, { "courseId": 1433, "allergenId": 203 }, { "courseId": 1433, "allergenId": 208 }, { "courseId": 1433, "allergenId": 211 }, { "courseId": 1433, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1433, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2019-12-19_hzs.raw.json ================================================ { "id": 306, "menuDate": "2019-12-19T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 1924, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 0, "menuItemContents": [ { "id": 2798, "menuItemId": 1924, "courseId": 2530, "sortOrder": 0, "course": { "id": 2530, "dispNameNl": "Bio-wortelsoep", "dispNameEn": "Organic carrot soup", "nameNl": "bio-wortelsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2530, "courseLogoId": 201 }, { "courseId": 2530, "courseLogoId": 211 }, { "courseId": 2530, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1925, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 1, "menuItemContents": [ { "id": 2799, "menuItemId": 1925, "courseId": 161, "sortOrder": 0, "course": { "id": 161, "dispNameNl": "New Delhi ", "dispNameEn": "New Delhi ", "nameNl": "new delhi (kip, smeerkaas, kerrie) dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 161, "allergenId": 200 }, { "courseId": 161, "allergenId": 201 }, { "courseId": 161, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 161, "courseLogoId": 202 }, { "courseId": 161, "courseLogoId": 204 }, { "courseId": 161, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1926, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 2, "menuItemContents": [ { "id": 2800, "menuItemId": 1926, "courseId": 3161, "sortOrder": 0, "course": { "id": 3161, "dispNameNl": "Bagel pumpkin", "dispNameEn": "Pumpkin bagel", "nameNl": "00 bagel pumpkin (cottage cheese,geroosterde pompoen), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "pompoen met olijfolie en pezo roosteren in de oven. bestrijk 1 kant van de bagel met de cottage cheese, beleg met de geroosterde pompoen en werk af met gecrunchte walnoten.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3161, "allergenId": 200 }, { "courseId": 3161, "allergenId": 201 }, { "courseId": 3161, "allergenId": 203 }, { "courseId": 3161, "allergenId": 205 }, { "courseId": 3161, "allergenId": 208 }, { "courseId": 3161, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3161, "courseLogoId": 204 }, { "courseId": 3161, "courseLogoId": 210 }, { "courseId": 3161, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1927, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 3, "menuItemContents": [ { "id": 2801, "menuItemId": 1927, "courseId": 3143, "sortOrder": 0, "course": { "id": 3143, "dispNameNl": "Granaatappel-fetasalade", "dispNameEn": "Pomegranate and feta salad", "nameNl": "granaatappel-fetasalade, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "laagjes van onder naar boven: feta m\u00e9t olie (deels laten uitlekken) veldsla quinoa granaatappelpit waterkers", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3143, "allergenId": 201 }, { "courseId": 3143, "allergenId": 203 }, { "courseId": 3143, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 3143, "courseLogoId": 204 }, { "courseId": 3143, "courseLogoId": 209 }, { "courseId": 3143, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1928, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 4, "menuItemContents": [ { "id": 2802, "menuItemId": 1928, "courseId": 4981, "sortOrder": 0, "course": { "id": 4981, "dispNameNl": "Salade met krieltjes en pompoenpitten", "dispNameEn": "New potato and pumpkin seed salad", "nameNl": "00 salade met krieltjes & pompoenpitten,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptsalade,winter de krieltjes met schil snijden, inwrijven met olie en pezo en grillen in de oven.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4981, "allergenId": 206 }, { "courseId": 4981, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 4981, "courseLogoId": 209 }, { "courseId": 4981, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1929, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 5, "menuItemContents": [ { "id": 2803, "menuItemId": 1929, "courseId": 5026, "sortOrder": 0, "course": { "id": 5026, "dispNameNl": "Pizza-pita vegan delight", "dispNameEn": "Vegan delight pizza-pita", "nameNl": "00 pizza pita vegan delight, w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "snack winter snij het pitabroodje open, smeer langs 1 kant de tomatensaus en langs de andere kant de pesto.- beleg 1 kant met mozzarisella, de geroosterde paprika & postelein.- sluit het broodje en leg tussen de paninimachine.", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5026, "allergenId": 201 }, { "courseId": 5026, "allergenId": 205 }, { "courseId": 5026, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5026, "courseLogoId": 210 }, { "courseId": 5026, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1930, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 6, "menuItemContents": [ { "id": 2804, "menuItemId": 1930, "courseId": 4625, "sortOrder": 0, "course": { "id": 4625, "dispNameNl": "Panini met brie en spek", "dispNameEn": "Brie and bacon panini", "nameNl": "00 panini brie-spek, z & w", "nameEn": "", "weight": null, "extra": null, "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4625, "allergenId": 201 }, { "courseId": 4625, "allergenId": 203 }, { "courseId": 4625, "allergenId": 205 }, { "courseId": 4625, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4625, "courseLogoId": 204 }, { "courseId": 4625, "courseLogoId": 210 }, { "courseId": 4625, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 1931, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 7, "menuItemContents": [ { "id": 2805, "menuItemId": 1931, "courseId": 3422, "sortOrder": 0, "course": { "id": 3422, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese", "nameNl": "pasta bolognaise, hzs", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3422, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 3422, "courseLogoId": 208 }, { "courseId": 3422, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2152, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 8, "menuItemContents": [ { "id": 3103, "menuItemId": 2152, "courseId": 4065, "sortOrder": 0, "course": { "id": 4065, "dispNameNl": "El triangulo", "dispNameEn": "El triangulo", "nameNl": "el triangulo (gerookte zalm, aardappelsla), w", "nameEn": "", "weight": "200g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4065, "allergenId": 200 }, { "courseId": 4065, "allergenId": 201 }, { "courseId": 4065, "allergenId": 204 }, { "courseId": 4065, "allergenId": 205 }, { "courseId": 4065, "allergenId": 210 }, { "courseId": 4065, "allergenId": 211 }, { "courseId": 4065, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4065, "courseLogoId": 210 }, { "courseId": 4065, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 2153, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 306, "sortorder": 9, "menuItemContents": [ { "id": 3104, "menuItemId": 2153, "courseId": 1924, "sortOrder": 0, "course": { "id": 1924, "dispNameNl": "Salade Wicca", "dispNameEn": "Wicca salad", "nameNl": "salade wicca (zalm, linzen, aardappelen), w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "pompoenblokjes en aardappelen: niet natuur stomen, maar garen in de oven met olijfolie en rozemarijn. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1924, "allergenId": 200 }, { "courseId": 1924, "allergenId": 204 }, { "courseId": 1924, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1924, "courseLogoId": 209 }, { "courseId": 1924, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-10_cde.raw.json ================================================ { "id": 544, "menuDate": "2020-02-10T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3751, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 0, "menuItemContents": [ { "id": 5234, "menuItemId": 3751, "courseId": 857, "sortOrder": 0, "course": { "id": 857, "dispNameNl": "Kervelsoep", "dispNameEn": "Chervil soup", "nameNl": "kervelsoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 857, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 857, "courseLogoId": 211 }, { "courseId": 857, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 3752, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 1, "menuItemContents": [ { "id": 5237, "menuItemId": 3752, "courseId": 974, "sortOrder": 0, "course": { "id": 974, "dispNameNl": "aardappelen met bieslook", "dispNameEn": "potatoes and chives", "nameNl": "aardappelen met bieslook", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "de aardappelen gaar stomen (ongeveer 20 min). - bieslook versnipperen en mengen met de aardappelen. - de margarine klaren en mengen met de aardappelen (optioneel)", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5235, "menuItemId": 3752, "courseId": 1273, "sortOrder": 0, "course": { "id": 1273, "dispNameNl": "Groenteballetjes ", "dispNameEn": "Vegetable meatballs ", "nameNl": "groenteballetjes dd", "nameEn": "", "weight": "3x50g", "extra": null, "preparation": "friteuse voorverwarmen op 160\u00b0c. - balletjes kleuren en per 30 stuks op bakplaatjes schikken. - oven voorverwarmen op 180\u00b0c - burger verhitten tot kern van minstens 65 graden bereikt is. - in bain-marie schikken. - 150 gram pp", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1273, "allergenId": 201 }, { "courseId": 1273, "allergenId": 203 }, { "courseId": 1273, "allergenId": 206 }, { "courseId": 1273, "allergenId": 208 }, { "courseId": 1273, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1273, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5236, "menuItemId": 3752, "courseId": 3207, "sortOrder": 0, "course": { "id": 3207, "dispNameNl": "vergeten groenten", "dispNameEn": "forgotten vegetables", "nameNl": "vergeten groenten, z & w ", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3753, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 2, "menuItemContents": [ { "id": 5240, "menuItemId": 3753, "courseId": 974, "sortOrder": 0, "course": { "id": 974, "dispNameNl": "aardappelen met bieslook", "dispNameEn": "potatoes and chives", "nameNl": "aardappelen met bieslook", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "de aardappelen gaar stomen (ongeveer 20 min). - bieslook versnipperen en mengen met de aardappelen. - de margarine klaren en mengen met de aardappelen (optioneel)", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5238, "menuItemId": 3753, "courseId": 1395, "sortOrder": 0, "course": { "id": 1395, "dispNameNl": "Vogelnestje", "dispNameEn": "Scotch egg", "nameNl": "vogelnestje", "nameEn": "", "weight": "150g", "extra": null, "preparation": "vogelnestjes schikken op een ingeboterde lage gastro. - de vogelnestjes bestrijken met geklaarde boter afkruiden. - garen in de oven op 175 graden gedurende ongeveer 12 min tot een temperatuur van minstens 65\u00b0c bereikt is", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1395, "allergenId": 200 }, { "courseId": 1395, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 1395, "courseLogoId": 208 }, { "courseId": 1395, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5239, "menuItemId": 3753, "courseId": 3207, "sortOrder": 0, "course": { "id": 3207, "dispNameNl": "vergeten groenten", "dispNameEn": "forgotten vegetables", "nameNl": "vergeten groenten, z & w ", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4070, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 3, "menuItemContents": [ { "id": 5648, "menuItemId": 4070, "courseId": 1425, "sortOrder": 0, "course": { "id": 1425, "dispNameNl": "pasta met paprikaroom & waterkers", "dispNameEn": "paprika cream with garden cress", "nameNl": "00 paprikaroom met waterkers, zvv, dd, z & w (vegan)", "nameEn": "", "weight": "200g", "extra": "vegan", "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux afsmaken met pezo en proven\u00e7aalse kruiden. - rode paprika gaar steamen en samen met room bij tomatensaus doen - mixen en eventueel verdunnen/aandikken serveer met een vegan pasta ( volkoren spaghetti speciality anco/ fusili volkoren bio)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1425, "allergenId": 201 }, { "courseId": 1425, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1425, "courseLogoId": 207 }, { "courseId": 1425, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4075, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 4, "menuItemContents": [ { "id": 5654, "menuItemId": 4075, "courseId": 925, "sortOrder": 0, "course": { "id": 925, "dispNameNl": "Milanese saus met hamblokjes", "dispNameEn": "Milanese sauce with cubed ham", "nameNl": "milanese saus met hamblokjes", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "keuze in het gebruik van kappertjes 100ml of kappers 3l - tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water met bouillon toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux indien nodig (optioneel). - afsmaken met pezo en proven\u00e7aalse kruiden. - sjalotjes aanstoven en bevochtigen met witte wijn - tomatenblokjes, kappertjes, lookpuree en kruiden toevoegen . - 15 minuten laten sudderen en bij warme tomatensaus voegen. - hamblokjes opwarmen en op einde toevoegen", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 925, "allergenId": 201 }, { "courseId": 925, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 925, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5655, "menuItemId": 4075, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4079, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 5, "menuItemContents": [ { "id": 5664, "menuItemId": 4079, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5665, "menuItemId": 4079, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5663, "menuItemId": 4079, "courseId": 5525, "sortOrder": 0, "course": { "id": 5525, "dispNameNl": "Scampi's", "dispNameEn": "scampi", "nameNl": "Scampi's", "nameEn": "", "weight": "6 - 7 stuks", "extra": "", "preparation": "insmeren met mengeling van saus lemon/green peper en soja olie - grillen", "price": 5.2, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5525, "allergenId": 207 } ], "course_CourseLogos": [ { "courseId": 5525, "courseLogoId": 203 }, { "courseId": 5525, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3886, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 11, "menuItemContents": [ { "id": 5412, "menuItemId": 3886, "courseId": 1892, "sortOrder": 0, "course": { "id": 1892, "dispNameNl": "Boerensalade", "dispNameEn": "Farmer\u2019s salad", "nameNl": "boerensalade (rosbief, aardappelsalade), w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "rosbief: 2 sneetjes van 25g", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1892, "allergenId": 200 }, { "courseId": 1892, "allergenId": 201 }, { "courseId": 1892, "allergenId": 204 }, { "courseId": 1892, "allergenId": 210 }, { "courseId": 1892, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1892, "courseLogoId": 208 }, { "courseId": 1892, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4024, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 544, "sortorder": 11, "menuItemContents": [ { "id": 5595, "menuItemId": 4024, "courseId": 5531, "sortOrder": 0, "course": { "id": 5531, "dispNameNl": "Sweet potato bowl", "dispNameEn": "Sweet potato bowl", "nameNl": "Sweet potato bowl, w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de quinoa klaar volgens de bereidingswijze van de fiche, laat de quinoa afkoelen. Maak de zoete aardappel klaar volgens de fiche en breng op smaak met kaneel. Maak de bowl: doe de quinoa in een kommetje, vervolgens de bonen, zoete aardappel, curly kale & raapjes. Werk af met kipreepjes, cashewnoten, platte peterselie & furikake. Serveer hierbij de vinaigrette.Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5531, "allergenId": 205 }, { "courseId": 5531, "allergenId": 206 }, { "courseId": 5531, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5531, "courseLogoId": 202 }, { "courseId": 5531, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-10_cgb.raw.json ================================================ { "id": 631, "menuDate": "2020-02-10T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3910, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 631, "sortorder": 0, "menuItemContents": [ { "id": 5440, "menuItemId": 3910, "courseId": 1605, "sortOrder": 0, "course": { "id": 1605, "dispNameNl": "Roomsoep van schorseneren", "dispNameEn": "Cream of salsify", "nameNl": "roomsoep van schorseneren", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1605, "allergenId": 201 }, { "courseId": 1605, "allergenId": 203 }, { "courseId": 1605, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1605, "courseLogoId": 211 }, { "courseId": 1605, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3911, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 631, "sortorder": 1, "menuItemContents": [ { "id": 5441, "menuItemId": 3911, "courseId": 4975, "sortOrder": 0, "course": { "id": 4975, "dispNameNl": "Salade Marrakech veganlicious ", "dispNameEn": "Marrakesh veganlicious salad ", "nameNl": "00 salade marrakech veganlicious(couscous,mozzarisella),dd,w (vegan)", "nameEn": "", "weight": "300 gr", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4975, "allergenId": 201 }, { "courseId": 4975, "allergenId": 204 }, { "courseId": 4975, "allergenId": 208 }, { "courseId": 4975, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4975, "courseLogoId": 209 }, { "courseId": 4975, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3916, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 631, "sortorder": 2, "menuItemContents": [ { "id": 5446, "menuItemId": 3916, "courseId": 3161, "sortOrder": 0, "course": { "id": 3161, "dispNameNl": "Bagel pumpkin", "dispNameEn": "Pumpkin bagel", "nameNl": "00 bagel pumpkin (cottage cheese,geroosterde pompoen), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "pompoen met olijfolie en pezo roosteren in de oven. bestrijk 1 kant van de bagel met de cottage cheese, beleg met de geroosterde pompoen en werk af met gecrunchte walnoten.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3161, "allergenId": 200 }, { "courseId": 3161, "allergenId": 201 }, { "courseId": 3161, "allergenId": 203 }, { "courseId": 3161, "allergenId": 205 }, { "courseId": 3161, "allergenId": 208 }, { "courseId": 3161, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3161, "courseLogoId": 204 }, { "courseId": 3161, "courseLogoId": 210 }, { "courseId": 3161, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3921, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 631, "sortorder": 3, "menuItemContents": [ { "id": 5451, "menuItemId": 3921, "courseId": 2424, "sortOrder": 0, "course": { "id": 2424, "dispNameNl": "Panini 'kalkoen-pesto'", "dispNameEn": "Turkey pesto panini", "nameNl": "panini 'kalkoen-pesto', z & w", "nameEn": "", "weight": "250g", "extra": null, "preparation": "vanaf nu pestospread gran'olivia gebruiken - sandwich spread opwerken", "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2424, "allergenId": 200 }, { "courseId": 2424, "allergenId": 201 }, { "courseId": 2424, "allergenId": 203 }, { "courseId": 2424, "allergenId": 205 }, { "courseId": 2424, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 2424, "courseLogoId": 202 }, { "courseId": 2424, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-10_cmi.raw.json ================================================ { "id": 534, "menuDate": "2020-02-10T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3312, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 0, "menuItemContents": [ { "id": 4593, "menuItemId": 3312, "courseId": 1605, "sortOrder": 0, "course": { "id": 1605, "dispNameNl": "Roomsoep van schorseneren", "dispNameEn": "Cream of salsify", "nameNl": "roomsoep van schorseneren", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1605, "allergenId": 201 }, { "courseId": 1605, "allergenId": 203 }, { "courseId": 1605, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1605, "courseLogoId": 211 }, { "courseId": 1605, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3311, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 1, "menuItemContents": [ { "id": 4592, "menuItemId": 3311, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 4589, "menuItemId": 3311, "courseId": 1269, "sortOrder": 0, "course": { "id": 1269, "dispNameNl": "Falafel", "dispNameEn": "Falafel", "nameNl": "falafel dd", "nameEn": "", "weight": "6x14g", "extra": null, "preparation": "frituur voorverwarmen op 170\u00b0. - falafel afbakken tot een temperatuur van minstens 65\u00b0c bereikt is . - in bain marie schikken", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1269, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 1269, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5506, "menuItemId": 3311, "courseId": 5514, "sortOrder": 0, "course": { "id": 5514, "dispNameNl": "Rauwkostslaatje", "dispNameEn": "crudit\u00e9s", "nameNl": "rauwkostslaatje", "nameEn": "", "weight": "", "extra": "Dit is een slaatje om bij een gerecht toe te voegen, waarbij de salade al in de prijs gerekend is.", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5514, "allergenId": 200 }, { "courseId": 5514, "allergenId": 201 }, { "courseId": 5514, "allergenId": 202 }, { "courseId": 5514, "allergenId": 203 }, { "courseId": 5514, "allergenId": 204 }, { "courseId": 5514, "allergenId": 205 }, { "courseId": 5514, "allergenId": 206 }, { "courseId": 5514, "allergenId": 207 }, { "courseId": 5514, "allergenId": 208 }, { "courseId": 5514, "allergenId": 209 }, { "courseId": 5514, "allergenId": 210 }, { "courseId": 5514, "allergenId": 211 }, { "courseId": 5514, "allergenId": 212 }, { "courseId": 5514, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3313, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 2, "menuItemContents": [ { "id": 4595, "menuItemId": 3313, "courseId": 903, "sortOrder": 0, "course": { "id": 903, "dispNameNl": "barbecuesaus", "dispNameEn": "BBQ sauce", "nameNl": "bbq saus dd", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "water aan de kook brengen - poeder oplossen in beetje water en toevoegen tot juiste dikte. - even laten inkoken samen met de ketchup en bbq saus. -gestoofde sjalotjes toevoegen; - keuze om ketchup 3 liter of 1 liter te gebruiken", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 903, "allergenId": 201 }, { "courseId": 903, "allergenId": 204 }, { "courseId": 903, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 4597, "menuItemId": 3313, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 4594, "menuItemId": 3313, "courseId": 1393, "sortOrder": 0, "course": { "id": 1393, "dispNameNl": "Varkenslapje", "dispNameEn": "Pork escalope", "nameNl": "varkenslapje dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": "voorbakken in braadpan - kruiden. - afbakken in oven", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1393, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 4596, "menuItemId": 3313, "courseId": 5514, "sortOrder": 0, "course": { "id": 5514, "dispNameNl": "Rauwkostslaatje", "dispNameEn": "crudit\u00e9s", "nameNl": "rauwkostslaatje", "nameEn": "", "weight": "", "extra": "Dit is een slaatje om bij een gerecht toe te voegen, waarbij de salade al in de prijs gerekend is.", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5514, "allergenId": 200 }, { "courseId": 5514, "allergenId": 201 }, { "courseId": 5514, "allergenId": 202 }, { "courseId": 5514, "allergenId": 203 }, { "courseId": 5514, "allergenId": 204 }, { "courseId": 5514, "allergenId": 205 }, { "courseId": 5514, "allergenId": 206 }, { "courseId": 5514, "allergenId": 207 }, { "courseId": 5514, "allergenId": 208 }, { "courseId": 5514, "allergenId": 209 }, { "courseId": 5514, "allergenId": 210 }, { "courseId": 5514, "allergenId": 211 }, { "courseId": 5514, "allergenId": 212 }, { "courseId": 5514, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4102, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 3, "menuItemContents": [ { "id": 5705, "menuItemId": 4102, "courseId": 3423, "sortOrder": 0, "course": { "id": 3423, "dispNameNl": "Pasta met vegetarische bolognaise", "dispNameEn": "Pasta with vegetarian bolognese", "nameNl": "pasta vegetarische bolognaise, hzs (veggie)", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3423, "allergenId": 203 }, { "courseId": 3423, "allergenId": 208 }, { "courseId": 3423, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3423, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4097, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 4, "menuItemContents": [ { "id": 5700, "menuItemId": 4097, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4093, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 5, "menuItemContents": [ { "id": 5686, "menuItemId": 4093, "courseId": 927, "sortOrder": 0, "course": { "id": 927, "dispNameNl": "pepersaus", "dispNameEn": "pepper sauce", "nameNl": "pepersaus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "bruine saus maken (water, saus espagnole, proven\u00e7aalse kruiden) 12 liter maken voor 100 personen. - peperbollen, room en whiskey toevoegen.", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 927, "allergenId": 201 }, { "courseId": 927, "allergenId": 203 }, { "courseId": 927, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5688, "menuItemId": 4093, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5685, "menuItemId": 4093, "courseId": 1388, "sortOrder": 0, "course": { "id": 1388, "dispNameNl": "Steak", "dispNameEn": "Steak", "nameNl": "steak", "nameEn": "", "weight": "150g", "extra": null, "preparation": "steak grillen in olie of margarine", "price": 5.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1388, "courseLogoId": 203 }, { "courseId": 1388, "courseLogoId": 208 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5687, "menuItemId": 4093, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4083, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 11, "menuItemContents": [ { "id": 5675, "menuItemId": 4083, "courseId": 4975, "sortOrder": 0, "course": { "id": 4975, "dispNameNl": "Salade Marrakech veganlicious ", "dispNameEn": "Marrakesh veganlicious salad ", "nameNl": "00 salade marrakech veganlicious(couscous,mozzarisella),dd,w (vegan)", "nameEn": "", "weight": "300 gr", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4975, "allergenId": 201 }, { "courseId": 4975, "allergenId": 204 }, { "courseId": 4975, "allergenId": 208 }, { "courseId": 4975, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4975, "courseLogoId": 209 }, { "courseId": 4975, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4088, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 534, "sortorder": 11, "menuItemContents": [ { "id": 5680, "menuItemId": 4088, "courseId": 3161, "sortOrder": 0, "course": { "id": 3161, "dispNameNl": "Bagel pumpkin", "dispNameEn": "Pumpkin bagel", "nameNl": "00 bagel pumpkin (cottage cheese,geroosterde pompoen), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "pompoen met olijfolie en pezo roosteren in de oven. bestrijk 1 kant van de bagel met de cottage cheese, beleg met de geroosterde pompoen en werk af met gecrunchte walnoten.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3161, "allergenId": 200 }, { "courseId": 3161, "allergenId": 201 }, { "courseId": 3161, "allergenId": 203 }, { "courseId": 3161, "allergenId": 205 }, { "courseId": 3161, "allergenId": 208 }, { "courseId": 3161, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3161, "courseLogoId": 204 }, { "courseId": 3161, "courseLogoId": 210 }, { "courseId": 3161, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-10_cmu.parsed.expected.yaml ================================================ $test_case: course_of_interest: 3155 reason: | This response contains a menu item which on its own has "enabled" set to 1, but has only one component which is has "deleted" set to true and "enabled" set to false. The official site displays these courses and as such Komidabot should as well, even if it sounds counterintuitive. old_reason: | This response contains a menu item which on its own has "enabled" set to 1, but has only one component which is has "deleted" set to true and "enabled" set to false. The course of interest here does not appear in this file as it should be ignored. campus: cmu date: '2020-02-10' menu: - components: - allergens: - MILK_LACTOSE attributes: - BIO - SOUP - VEGGIE name: en: Organic pumpkin soup nl: Bio-pompoensoep external_id: 3151 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - CELERY - SOY attributes: - SALAD - VEGAN name: en: Thai Bombai salad nl: Thai bombai salade external_id: 3152 multiple_prices: true price: '3.80' sort_order: 1 - components: - allergens: - CELERY - MILK_LACTOSE - NUTS - WHEAT_GLUTEN attributes: - CHEESE - SALAD - VEGGIE name: en: Spiced bread and goat cheese nl: Peperkoeken geitenkaasje external_id: 3153 multiple_prices: true price: '4.40' sort_order: 2 - components: - allergens: - SOY - WHEAT_GLUTEN attributes: - SNACK - VEGAN name: en: Deluxe vegan burger nl: Vegan burger deluxe external_id: 3154 multiple_prices: false price: '3.10' sort_order: 7 - components: - allergens: - MILK_LACTOSE - NUTS attributes: - CHEESE - SNACK name: en: Brie grilled cheese sandwich nl: Croque brie external_id: 3155 multiple_prices: false price: '2.00' sort_order: 8 - components: - allergens: - PEANUTS - SOY - WHEAT_GLUTEN attributes: - SNACK - VEGAN name: en: lentilicious nl: Lentilicious external_id: 3156 multiple_prices: false price: '3.40' sort_order: 3 - components: - allergens: - EGG - MILK_LACTOSE - MUSTARD - NUTS - PEANUTS - SESAME - SOY - WHEAT_GLUTEN attributes: - FISH - SNACK name: en: Multigrain roll with MSC tuna salad nl: Meergranenbroodje met MSC-tonijnsalade external_id: 3157 multiple_prices: false price: '3.40' sort_order: 5 - components: - allergens: - CELERY - EGG - FISH - MILK_LACTOSE - MUSTARD - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN attributes: - SNACK - VEAL name: en: Celery and roast beef focaccia nl: Focaccia selder-rosbief external_id: 3158 multiple_prices: false price: '3.40' sort_order: 4 - components: - allergens: - EGG - MILK_LACTOSE - MUSTARD - SESAME - SOY - WHEAT_GLUTEN attributes: - CHEESE - SNACK - VEGGIE name: en: Multigrain roll with cheese and winter vegetables nl: Meergranenbroodje met kaas en wintergroenten external_id: 3159 multiple_prices: false price: '3.00' sort_order: 6 ================================================ FILE: tests/external_menus/2020-02-10_cmu.raw.json ================================================ { "id": 510, "menuDate": "2020-02-10T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3151, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 0, "menuItemContents": [ { "id": 4378, "menuItemId": 3151, "courseId": 5011, "sortOrder": 0, "course": { "id": 5011, "dispNameNl": "Bio-pompoensoep", "dispNameEn": "Organic pumpkin soup", "nameNl": "00 bio-pompoensoep", "nameEn": "", "weight": "500 ml/700 ml", "extra": null, "preparation": "groenten (ajuin,prei, pompoen en aardappelen) samen met bouillon beetgaar koken. - mixen en room toevoegen. op smaak brengen met peper en zout.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5011, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5011, "courseLogoId": 201 }, { "courseId": 5011, "courseLogoId": 211 }, { "courseId": 5011, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3152, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 1, "menuItemContents": [ { "id": 4379, "menuItemId": 3152, "courseId": 3490, "sortOrder": 0, "course": { "id": 3490, "dispNameNl": "Thai bombai salade", "dispNameEn": "Thai Bombai salad", "nameNl": "thai bombai salade, w (vegan)", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "conceptsalade winter miehoen koken - mie mengen met de world grill saus - pastinaak grillen en mengen met de wortelen en sojascheuten en kokosschilfers - opbouw: miehoen, groentjes en platte peterselie", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3490, "allergenId": 208 }, { "courseId": 3490, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3490, "courseLogoId": 209 }, { "courseId": 3490, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3153, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 2, "menuItemContents": [ { "id": 4380, "menuItemId": 3153, "courseId": 3874, "sortOrder": 0, "course": { "id": 3874, "dispNameNl": "Peperkoeken geitenkaasje", "dispNameEn": "Spiced bread and goat cheese", "nameNl": "peperkoeken geitenkaasje, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "citroensap voor de bio-appel - peperkoek in reepjes snijden en bovenaan in de saladebox", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3874, "allergenId": 201 }, { "courseId": 3874, "allergenId": 203 }, { "courseId": 3874, "allergenId": 205 }, { "courseId": 3874, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 3874, "courseLogoId": 204 }, { "courseId": 3874, "courseLogoId": 209 }, { "courseId": 3874, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3156, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 3, "menuItemContents": [ { "id": 4383, "menuItemId": 3156, "courseId": 5523, "sortOrder": 0, "course": { "id": 5523, "dispNameNl": "Lentilicious", "dispNameEn": "lentilicious", "nameNl": "Lentilicious", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5523, "allergenId": 201 }, { "courseId": 5523, "allergenId": 206 }, { "courseId": 5523, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5523, "courseLogoId": 210 }, { "courseId": 5523, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3158, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 4, "menuItemContents": [ { "id": 4385, "menuItemId": 3158, "courseId": 4627, "sortOrder": 0, "course": { "id": 4627, "dispNameNl": "Focaccia selder-rosbief", "dispNameEn": "Celery and roast beef focaccia", "nameNl": "00 focaccia selder-rosbief, z & w", "nameEn": "", "weight": "300g", "extra": null, "preparation": null, "price": 3.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4627, "allergenId": 200 }, { "courseId": 4627, "allergenId": 201 }, { "courseId": 4627, "allergenId": 203 }, { "courseId": 4627, "allergenId": 204 }, { "courseId": 4627, "allergenId": 207 }, { "courseId": 4627, "allergenId": 208 }, { "courseId": 4627, "allergenId": 210 }, { "courseId": 4627, "allergenId": 211 }, { "courseId": 4627, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4627, "courseLogoId": 208 }, { "courseId": 4627, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3157, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 5, "menuItemContents": [ { "id": 4384, "menuItemId": 3157, "courseId": 4966, "sortOrder": 0, "course": { "id": 4966, "dispNameNl": "Meergranenbroodje met MSC-tonijnsalade", "dispNameEn": "Multigrain roll with MSC tuna salad", "nameNl": "00 broodje fit msc-tonijnsalade, w", "nameEn": "", "weight": "160 g/300 g", "extra": "msc", "preparation": null, "price": 3.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4966, "allergenId": 200 }, { "courseId": 4966, "allergenId": 201 }, { "courseId": 4966, "allergenId": 203 }, { "courseId": 4966, "allergenId": 204 }, { "courseId": 4966, "allergenId": 205 }, { "courseId": 4966, "allergenId": 206 }, { "courseId": 4966, "allergenId": 209 }, { "courseId": 4966, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4966, "courseLogoId": 210 }, { "courseId": 4966, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3159, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 6, "menuItemContents": [ { "id": 4386, "menuItemId": 3159, "courseId": 72, "sortOrder": 0, "course": { "id": 72, "dispNameNl": "Meergranenbroodje met kaas en wintergroenten", "dispNameEn": "Multigrain roll with cheese and winter vegetables", "nameNl": "broodje fit met kaas en wintergroentjes", "nameEn": "", "weight": "160g / 300g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 72, "allergenId": 200 }, { "courseId": 72, "allergenId": 201 }, { "courseId": 72, "allergenId": 203 }, { "courseId": 72, "allergenId": 204 }, { "courseId": 72, "allergenId": 209 }, { "courseId": 72, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 72, "courseLogoId": 204 }, { "courseId": 72, "courseLogoId": 210 }, { "courseId": 72, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3154, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 7, "menuItemContents": [ { "id": 4381, "menuItemId": 3154, "courseId": 4989, "sortOrder": 0, "course": { "id": 4989, "dispNameNl": "Vegan burger deluxe", "dispNameEn": "Deluxe vegan burger", "nameNl": "00 vegan burger deluxe,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan (snack)", "preparation": "snack winter bak de burgers.- bestrijk de broodjes met de salsa mexicana, de piri piri saus en ma\u00efs, de burger en de farmersla.", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4989, "allergenId": 201 }, { "courseId": 4989, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4989, "courseLogoId": 210 }, { "courseId": 4989, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3155, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 510, "sortorder": 8, "menuItemContents": [ { "id": 4382, "menuItemId": 3155, "courseId": 5027, "sortOrder": 0, "course": { "id": 5027, "dispNameNl": "Croque brie", "dispNameEn": "Brie grilled cheese sandwich", "nameNl": "00 croque brie, z&w", "nameEn": "", "weight": null, "extra": "veggie", "preparation": "1 potje saus is inbegrepen in de prijs", "price": 2.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5027, "allergenId": 203 }, { "courseId": 5027, "allergenId": 205 } ], "course_CourseLogos": [ { "courseId": 5027, "courseLogoId": 204 }, { "courseId": 5027, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": true, "enabled": false, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-10_cst.raw.json ================================================ { "id": 626, "menuDate": "2020-02-10T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3839, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 0, "menuItemContents": [ { "id": 5343, "menuItemId": 3839, "courseId": 852, "sortOrder": 0, "course": { "id": 852, "dispNameNl": "Groene seldersoep", "dispNameEn": "Green celery soup", "nameNl": "groene seldersoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "water aan de kook brengen.groenten bouillon peper zout toevoegen. - als groenten gaar zijn mixen afbinden met roux en verder afsmaken.+ 5 kg selder afsteamen en toevoegen als garnituur op laatste moment toevoegen.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 852, "allergenId": 201 }, { "courseId": 852, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 852, "courseLogoId": 211 }, { "courseId": 852, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3840, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 1, "menuItemContents": [ { "id": 5345, "menuItemId": 3840, "courseId": 1010, "sortOrder": 0, "course": { "id": 1010, "dispNameNl": "puree met wortel en pijpajuin", "dispNameEn": "mashed potatoes with carrots and spring onions", "nameNl": "puree met wortel en pijpajuin (zelfbereid), z", "nameEn": "", "weight": "200g", "extra": null, "preparation": "\t\t\r\n", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1010, "allergenId": 203 }, { "courseId": 1010, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5344, "menuItemId": 3840, "courseId": 2741, "sortOrder": 0, "course": { "id": 2741, "dispNameNl": "Quornworst", "dispNameEn": "Quorn sausage", "nameNl": "quornworst, dd java code fout 40353257", "nameEn": "", "weight": "120g (2x60g)", "extra": "veggie", "preparation": "worsten laten ontdooien. - pan verwarmen, boter toevoegen. - worsten kleuren en in gastro schikken. - voor service afbakken in oven op 180\u00b0", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2741, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 2741, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3841, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 2, "menuItemContents": [ { "id": 5347, "menuItemId": 3841, "courseId": 1010, "sortOrder": 0, "course": { "id": 1010, "dispNameNl": "puree met wortel en pijpajuin", "dispNameEn": "mashed potatoes with carrots and spring onions", "nameNl": "puree met wortel en pijpajuin (zelfbereid), z", "nameEn": "", "weight": "200g", "extra": null, "preparation": "\t\t\r\n", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1010, "allergenId": 203 }, { "courseId": 1010, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5346, "menuItemId": 3841, "courseId": 1073, "sortOrder": 0, "course": { "id": 1073, "dispNameNl": "Barbecueworst", "dispNameEn": "Barbecue sausage", "nameNl": "bbq worst", "nameEn": "", "weight": "160g pp", "extra": null, "preparation": "marineren en grillen", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1073, "allergenId": 208 }, { "courseId": 1073, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1073, "courseLogoId": 203 }, { "courseId": 1073, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3874, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 3, "menuItemContents": [ { "id": 5388, "menuItemId": 3874, "courseId": 930, "sortOrder": 0, "course": { "id": 930, "dispNameNl": "portosaus", "dispNameEn": "port sauce", "nameNl": "portosaus dd", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "water aan de kook brengen - poeder oplossen in beetje water en toevoegen tot juiste dikte. - even laten inkoken 3 kg ajuin eminceren en aanstoven in margarine - bij saus voegen. - afwerken met peterselie", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 930, "allergenId": 200 }, { "courseId": 930, "allergenId": 201 }, { "courseId": 930, "allergenId": 203 }, { "courseId": 930, "allergenId": 208 }, { "courseId": 930, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5389, "menuItemId": 3874, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5387, "menuItemId": 3874, "courseId": 1388, "sortOrder": 0, "course": { "id": 1388, "dispNameNl": "Steak", "dispNameEn": "Steak", "nameNl": "steak", "nameEn": "", "weight": "150g", "extra": null, "preparation": "steak grillen in olie of margarine", "price": 5.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1388, "courseLogoId": 203 }, { "courseId": 1388, "courseLogoId": 208 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3884, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 4, "menuItemContents": [ { "id": 5410, "menuItemId": 3884, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5409, "menuItemId": 3884, "courseId": 1416, "sortOrder": 0, "course": { "id": 1416, "dispNameNl": "African sunshinesaus", "dispNameEn": "African sunshine sauce", "nameNl": "african sunshine saus, zvv, dd", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "courgettes en paprika aanstoven en kruiden. - mengen met de pasta en de african sunshine saus toevoegen.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1416, "allergenId": 200 }, { "courseId": 1416, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1416, "courseLogoId": 207 }, { "courseId": 1416, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3865, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 5, "menuItemContents": [ { "id": 5379, "menuItemId": 3865, "courseId": 5563, "sortOrder": 0, "course": { "id": 5563, "dispNameNl": "Falafelbowl", "dispNameEn": "falafel bowl", "nameNl": "Falafelbowl, w (vegan)", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5563, "allergenId": 201 }, { "courseId": 5563, "allergenId": 205 }, { "courseId": 5563, "allergenId": 206 }, { "courseId": 5563, "allergenId": 208 }, { "courseId": 5563, "allergenId": 209 }, { "courseId": 5563, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5563, "courseLogoId": 209 }, { "courseId": 5563, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3875, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 5, "menuItemContents": [ { "id": 5606, "menuItemId": 3875, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3908, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 6, "menuItemContents": [ { "id": 5438, "menuItemId": 3908, "courseId": 5561, "sortOrder": 0, "course": { "id": 5561, "dispNameNl": "Buddha bowl met zalm", "dispNameEn": "buddha bowl with salmon", "nameNl": "Buddha bowl met zalm,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - Maak de wasabidressing: meng de yoghurtdressing met de wasabi - Maak de budhabowl als volgt: verdeel de rijst over het kommetje, leg in regenboogvorm: rijtje edamame bonen, sojascheuten, rijtje zalm en rijtje ma\u00efs. Werk de budha bowl af met de zeewiersalade, koriander en zwart sesamzaad en tuinerwtenspread met sjalot. Serveer hierbij de wasabimayonaise. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5561, "allergenId": 200 }, { "courseId": 5561, "allergenId": 201 }, { "courseId": 5561, "allergenId": 203 }, { "courseId": 5561, "allergenId": 209 }, { "courseId": 5561, "allergenId": 210 }, { "courseId": 5561, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 5561, "courseLogoId": 209 }, { "courseId": 5561, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3864, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 626, "sortorder": 7, "menuItemContents": [ { "id": 5378, "menuItemId": 3864, "courseId": 3487, "sortOrder": 0, "course": { "id": 3487, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - pastinaak grillen en mengen met de wortelen en sojascheuten en kokosschilfers - opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3487, "allergenId": 200 }, { "courseId": 3487, "allergenId": 201 }, { "courseId": 3487, "allergenId": 208 }, { "courseId": 3487, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3487, "courseLogoId": 202 }, { "courseId": 3487, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-10_hzs.raw.json ================================================ { "id": 500, "menuDate": "2020-02-10T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3036, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 0, "menuItemContents": [ { "id": 4227, "menuItemId": 3036, "courseId": 856, "sortOrder": 0, "course": { "id": 856, "dispNameNl": "Juliennesoep", "dispNameEn": "Julienne soup", "nameNl": "juliennesoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "juliennegroenten samen met bouillon beetgaar koken. - op smaak brengen met peper en zout. peterselie fijn hakken en op het laatste moment toevoegen.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 856, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 856, "courseLogoId": 211 }, { "courseId": 856, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3041, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 1, "menuItemContents": [ { "id": 4232, "menuItemId": 3041, "courseId": 4629, "sortOrder": 0, "course": { "id": 4629, "dispNameNl": "Bagel tzatziki", "dispNameEn": "Tzatziki bagel", "nameNl": "00 bagel tzatziki, z&w", "nameEn": "", "weight": "250g", "extra": "veggie", "preparation": null, "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4629, "allergenId": 201 }, { "courseId": 4629, "allergenId": 203 }, { "courseId": 4629, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4629, "courseLogoId": 210 }, { "courseId": 4629, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3046, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 2, "menuItemContents": [ { "id": 4237, "menuItemId": 3046, "courseId": 4168, "sortOrder": 0, "course": { "id": 4168, "dispNameNl": "Broodje met cottage cheese en serranoham", "dispNameEn": "Sandwich with cottage cheese and Serrano ham", "nameNl": "broodje cottage cheese-serrano, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4168, "allergenId": 200 }, { "courseId": 4168, "allergenId": 201 }, { "courseId": 4168, "allergenId": 203 }, { "courseId": 4168, "allergenId": 205 }, { "courseId": 4168, "allergenId": 209 }, { "courseId": 4168, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4168, "courseLogoId": 204 }, { "courseId": 4168, "courseLogoId": 210 }, { "courseId": 4168, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3221, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 3, "menuItemContents": [ { "id": 4450, "menuItemId": 3221, "courseId": 3421, "sortOrder": 0, "course": { "id": 3421, "dispNameNl": "Pasta all' arrabiata", "dispNameEn": "Pasta all'arrabbiata", "nameNl": "pasta arrabiata, hzs (veggie)", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3421, "allergenId": 203 }, { "courseId": 3421, "allergenId": 205 } ], "course_CourseLogos": [ { "courseId": 3421, "courseLogoId": 207 }, { "courseId": 3421, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3051, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 4, "menuItemContents": [ { "id": 4242, "menuItemId": 3051, "courseId": 4976, "sortOrder": 0, "course": { "id": 4976, "dispNameNl": "Mexicaanse salade", "dispNameEn": "Mexican salad", "nameNl": "00 mexicaanse salade,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptsalade winter ma\u00efs stomen.- de avocado in kleine blokjes/schijfjes snijden.- limoenen persen (een deel over de avocado doen voor verkleuring tegen te gaan)- quinoa koken (zie fiche).- olijfolie, rode bonen, wortelstaafjes, ma\u00efs en avocado onder de quinoa mengen.- op smaak brengen met limoensap, komijn, look, peper en zout.- afwerken met vers gehakte koriander.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4976, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 4976, "courseLogoId": 209 }, { "courseId": 4976, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3056, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 5, "menuItemContents": [ { "id": 4247, "menuItemId": 3056, "courseId": 2071, "sortOrder": 0, "course": { "id": 2071, "dispNameNl": "Salade met perziken en zalmsalade", "dispNameEn": "Salad with peaches and salmon salad", "nameNl": "salade met perziken en zalmsalade, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "zalm stomen en laten afkoelen (slechts max. 100g per persoon gebruiken) - zalm prakken, mengen met een kleine hoeveelheid mayonaise (voeg enkel extra mayo toe als de zalmsalade nog te droog is, laat de zalm in geen geval \u2018zwemmen\u2019 in de mayonaise), met geplette hardgekookte eieren en peterselie - perziken in partjes snijden - veldsla en rammenas mengen. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton gebruiken volgorde voor vullen van onder naar boven: gesneden perziken (2 -3 stuks) + zalmsalade (+/- 80-100 gr) + gemengde veldsla \u2013 rammenas", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2071, "allergenId": 200 }, { "courseId": 2071, "allergenId": 201 }, { "courseId": 2071, "allergenId": 204 }, { "courseId": 2071, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2071, "courseLogoId": 209 }, { "courseId": 2071, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3061, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 6, "menuItemContents": [ { "id": 4252, "menuItemId": 3061, "courseId": 5309, "sortOrder": 0, "course": { "id": 5309, "dispNameNl": "Groentepizza", "dispNameEn": "Vegetable pizza", "nameNl": "00 groentepizza,dd,w", "nameEn": "", "weight": null, "extra": null, "preparation": "pizzabodems openleggen en op een ingevette gastronorm plaatsen - tomatensaus over pizzabodems verdelen - courgette schijfjes grillen en op de pizza's schikken - pizza bakken in de oven - achteraf grana padano schilfers erover strooien en de curly kale en afkruiden met toegestane allergeenvrije kruiden. pizzabodem in 8 gelijke stukken snijden.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5309, "allergenId": 200 }, { "courseId": 5309, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5309, "courseLogoId": 210 }, { "courseId": 5309, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3066, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 500, "sortorder": 7, "menuItemContents": [ { "id": 4257, "menuItemId": 3066, "courseId": 1093, "sortOrder": 0, "course": { "id": 1093, "dispNameNl": "Pizzabaguette ham-kaas", "dispNameEn": "Ham and cheese pizza baguette", "nameNl": "pizza baguette ham/kaas, z & w", "nameEn": "", "weight": "160g", "extra": null, "preparation": null, "price": 3.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1093, "allergenId": 201 }, { "courseId": 1093, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1093, "courseLogoId": 204 }, { "courseId": 1093, "courseLogoId": 210 }, { "courseId": 1093, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-13_cde.raw.json ================================================ { "id": 614, "menuDate": "2020-02-13T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 4375, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 0, "menuItemContents": [ { "id": 6052, "menuItemId": 4375, "courseId": 3624, "sortOrder": 0, "course": { "id": 3624, "dispNameNl": "Bio-knolseldersoep", "dispNameEn": "Organic celeriac soup", "nameNl": "bio-knolseldersoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3624, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 3624, "courseLogoId": 201 }, { "courseId": 3624, "courseLogoId": 211 }, { "courseId": 3624, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 4292, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 1, "menuItemContents": [ { "id": 5927, "menuItemId": 4292, "courseId": 5046, "sortOrder": 0, "course": { "id": 5046, "dispNameNl": "Pompoenrisotto met een boschampignonkroket", "dispNameEn": "Pumpkin risotto with a forest mushroom croquette", "nameNl": "00 pompoenrisotto met boschampignonkroket,w", "nameEn": "", "weight": "400 g", "extra": "veggie", "preparation": "pompoenrisotto: meng de rijst, bouillon en pompoen in een gastronorm - plaats 40 min in een oven op 200gr afgedekt - haal uit de oven en meng met pezo, olijfolie en parmesan tot een glanzend mengsel. frituur de kroketten. werk de pompoenrisotto af met kervel of postelein en 1 boschampignonkroket (100gr)", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5046, "allergenId": 200 }, { "courseId": 5046, "allergenId": 201 }, { "courseId": 5046, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5046, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4033, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 2, "menuItemContents": [ { "id": 5605, "menuItemId": 4033, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5603, "menuItemId": 4033, "courseId": 2047, "sortOrder": 0, "course": { "id": 2047, "dispNameNl": "Pita gyros", "dispNameEn": "Pita gyros", "nameNl": "pita gyros (+ aardappelbereiding en cocktail of looksaus) , w", "nameEn": "", "weight": "400g", "extra": null, "preparation": "gyros aanbakken in soja olie - salade maken met wortel, rode kool, veldsla en dressing maison - keuze uit aardappelbereidingen: frietjes, wedges garlic/herb of aardappelschijfjes - cocktailsaus of looksaus", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2047, "allergenId": 204 } ], "course_CourseLogos": [ { "courseId": 2047, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5604, "menuItemId": 4033, "courseId": 5514, "sortOrder": 0, "course": { "id": 5514, "dispNameNl": "Rauwkostslaatje", "dispNameEn": "crudit\u00e9s", "nameNl": "rauwkostslaatje", "nameEn": "", "weight": "", "extra": "Dit is een slaatje om bij een gerecht toe te voegen, waarbij de salade al in de prijs gerekend is.", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5514, "allergenId": 200 }, { "courseId": 5514, "allergenId": 201 }, { "courseId": 5514, "allergenId": 202 }, { "courseId": 5514, "allergenId": 203 }, { "courseId": 5514, "allergenId": 204 }, { "courseId": 5514, "allergenId": 205 }, { "courseId": 5514, "allergenId": 206 }, { "courseId": 5514, "allergenId": 207 }, { "courseId": 5514, "allergenId": 208 }, { "courseId": 5514, "allergenId": 209 }, { "courseId": 5514, "allergenId": 210 }, { "courseId": 5514, "allergenId": 211 }, { "courseId": 5514, "allergenId": 212 }, { "courseId": 5514, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4073, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 3, "menuItemContents": [ { "id": 5651, "menuItemId": 4073, "courseId": 1425, "sortOrder": 0, "course": { "id": 1425, "dispNameNl": "pasta met paprikaroom & waterkers", "dispNameEn": "paprika cream with garden cress", "nameNl": "00 paprikaroom met waterkers, zvv, dd, z & w (vegan)", "nameEn": "", "weight": "200g", "extra": "vegan", "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux afsmaken met pezo en proven\u00e7aalse kruiden. - rode paprika gaar steamen en samen met room bij tomatensaus doen - mixen en eventueel verdunnen/aandikken serveer met een vegan pasta ( volkoren spaghetti speciality anco/ fusili volkoren bio)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1425, "allergenId": 201 }, { "courseId": 1425, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1425, "courseLogoId": 207 }, { "courseId": 1425, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4078, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 4, "menuItemContents": [ { "id": 5660, "menuItemId": 4078, "courseId": 925, "sortOrder": 0, "course": { "id": 925, "dispNameNl": "Milanese saus met hamblokjes", "dispNameEn": "Milanese sauce with cubed ham", "nameNl": "milanese saus met hamblokjes", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "keuze in het gebruik van kappertjes 100ml of kappers 3l - tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water met bouillon toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux indien nodig (optioneel). - afsmaken met pezo en proven\u00e7aalse kruiden. - sjalotjes aanstoven en bevochtigen met witte wijn - tomatenblokjes, kappertjes, lookpuree en kruiden toevoegen . - 15 minuten laten sudderen en bij warme tomatensaus voegen. - hamblokjes opwarmen en op einde toevoegen", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 925, "allergenId": 201 }, { "courseId": 925, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 925, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5661, "menuItemId": 4078, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4082, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 5, "menuItemContents": [ { "id": 5674, "menuItemId": 4082, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5673, "menuItemId": 4082, "courseId": 5515, "sortOrder": 0, "course": { "id": 5515, "dispNameNl": "Saladbar", "dispNameEn": "saladbar", "nameNl": "Salade - bar", "nameEn": "", "weight": "", "extra": "Dit is de saladbar om toe te voegen aan een grill-gerecht, is reeds ingecalculeerd in de prijs van het grill-gerecht", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5515, "allergenId": 200 }, { "courseId": 5515, "allergenId": 201 }, { "courseId": 5515, "allergenId": 202 }, { "courseId": 5515, "allergenId": 203 }, { "courseId": 5515, "allergenId": 204 }, { "courseId": 5515, "allergenId": 205 }, { "courseId": 5515, "allergenId": 206 }, { "courseId": 5515, "allergenId": 207 }, { "courseId": 5515, "allergenId": 208 }, { "courseId": 5515, "allergenId": 209 }, { "courseId": 5515, "allergenId": 210 }, { "courseId": 5515, "allergenId": 211 }, { "courseId": 5515, "allergenId": 212 }, { "courseId": 5515, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5672, "menuItemId": 4082, "courseId": 5525, "sortOrder": 0, "course": { "id": 5525, "dispNameNl": "Scampi's", "dispNameEn": "scampi", "nameNl": "Scampi's", "nameEn": "", "weight": "6 - 7 stuks", "extra": "", "preparation": "insmeren met mengeling van saus lemon/green peper en soja olie - grillen", "price": 5.2, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5525, "allergenId": 207 } ], "course_CourseLogos": [ { "courseId": 5525, "courseLogoId": 203 }, { "courseId": 5525, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3895, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 11, "menuItemContents": [ { "id": 5421, "menuItemId": 3895, "courseId": 4188, "sortOrder": 0, "course": { "id": 4188, "dispNameNl": "Salade met haring en appel", "dispNameEn": "Apple and herring salad", "nameNl": "salade haring-appel, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "onderaan in de pot, aardappelsla, hier op de haring in stukken, meng appel en witloof met zure room en gehakte perterselie en beetje citroensap. bestrooi met rode besjes en werk af met veldsla", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4188, "allergenId": 200 }, { "courseId": 4188, "allergenId": 201 }, { "courseId": 4188, "allergenId": 203 }, { "courseId": 4188, "allergenId": 204 }, { "courseId": 4188, "allergenId": 210 }, { "courseId": 4188, "allergenId": 211 }, { "courseId": 4188, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4188, "courseLogoId": 209 }, { "courseId": 4188, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4027, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 614, "sortorder": 11, "menuItemContents": [ { "id": 5598, "menuItemId": 4027, "courseId": 5531, "sortOrder": 0, "course": { "id": 5531, "dispNameNl": "Sweet potato bowl", "dispNameEn": "Sweet potato bowl", "nameNl": "Sweet potato bowl, w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de quinoa klaar volgens de bereidingswijze van de fiche, laat de quinoa afkoelen. Maak de zoete aardappel klaar volgens de fiche en breng op smaak met kaneel. Maak de bowl: doe de quinoa in een kommetje, vervolgens de bonen, zoete aardappel, curly kale & raapjes. Werk af met kipreepjes, cashewnoten, platte peterselie & furikake. Serveer hierbij de vinaigrette.Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5531, "allergenId": 205 }, { "courseId": 5531, "allergenId": 206 }, { "courseId": 5531, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5531, "courseLogoId": 202 }, { "courseId": 5531, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-13_cgb.raw.json ================================================ { "id": 634, "menuDate": "2020-02-13T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3931, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 634, "sortorder": 0, "menuItemContents": [ { "id": 5463, "menuItemId": 3931, "courseId": 2530, "sortOrder": 0, "course": { "id": 2530, "dispNameNl": "Bio-wortelsoep", "dispNameEn": "Organic carrot soup", "nameNl": "bio-wortelsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2530, "courseLogoId": 201 }, { "courseId": 2530, "courseLogoId": 211 }, { "courseId": 2530, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3933, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 634, "sortorder": 1, "menuItemContents": [ { "id": 5466, "menuItemId": 3933, "courseId": 5561, "sortOrder": 0, "course": { "id": 5561, "dispNameNl": "Buddha bowl met zalm", "dispNameEn": "buddha bowl with salmon", "nameNl": "Buddha bowl met zalm,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - Maak de wasabidressing: meng de yoghurtdressing met de wasabi - Maak de budhabowl als volgt: verdeel de rijst over het kommetje, leg in regenboogvorm: rijtje edamame bonen, sojascheuten, rijtje zalm en rijtje ma\u00efs. Werk de budha bowl af met de zeewiersalade, koriander en zwart sesamzaad en tuinerwtenspread met sjalot. Serveer hierbij de wasabimayonaise. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5561, "allergenId": 200 }, { "courseId": 5561, "allergenId": 201 }, { "courseId": 5561, "allergenId": 203 }, { "courseId": 5561, "allergenId": 209 }, { "courseId": 5561, "allergenId": 210 }, { "courseId": 5561, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 5561, "courseLogoId": 209 }, { "courseId": 5561, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3936, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 634, "sortorder": 2, "menuItemContents": [ { "id": 5470, "menuItemId": 3936, "courseId": 3423, "sortOrder": 0, "course": { "id": 3423, "dispNameNl": "Pasta met vegetarische bolognaise", "dispNameEn": "Pasta with vegetarian bolognese", "nameNl": "pasta vegetarische bolognaise, hzs (veggie)", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3423, "allergenId": 203 }, { "courseId": 3423, "allergenId": 208 }, { "courseId": 3423, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3423, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3924, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 634, "sortorder": 3, "menuItemContents": [ { "id": 5455, "menuItemId": 3924, "courseId": 2424, "sortOrder": 0, "course": { "id": 2424, "dispNameNl": "Panini 'kalkoen-pesto'", "dispNameEn": "Turkey pesto panini", "nameNl": "panini 'kalkoen-pesto', z & w", "nameEn": "", "weight": "250g", "extra": null, "preparation": "vanaf nu pestospread gran'olivia gebruiken - sandwich spread opwerken", "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2424, "allergenId": 200 }, { "courseId": 2424, "allergenId": 201 }, { "courseId": 2424, "allergenId": 203 }, { "courseId": 2424, "allergenId": 205 }, { "courseId": 2424, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 2424, "courseLogoId": 202 }, { "courseId": 2424, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3919, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 634, "sortorder": 4, "menuItemContents": [ { "id": 5449, "menuItemId": 3919, "courseId": 3161, "sortOrder": 0, "course": { "id": 3161, "dispNameNl": "Bagel pumpkin", "dispNameEn": "Pumpkin bagel", "nameNl": "00 bagel pumpkin (cottage cheese,geroosterde pompoen), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "pompoen met olijfolie en pezo roosteren in de oven. bestrijk 1 kant van de bagel met de cottage cheese, beleg met de geroosterde pompoen en werk af met gecrunchte walnoten.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3161, "allergenId": 200 }, { "courseId": 3161, "allergenId": 201 }, { "courseId": 3161, "allergenId": 203 }, { "courseId": 3161, "allergenId": 205 }, { "courseId": 3161, "allergenId": 208 }, { "courseId": 3161, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3161, "courseLogoId": 204 }, { "courseId": 3161, "courseLogoId": 210 }, { "courseId": 3161, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3914, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 634, "sortorder": 5, "menuItemContents": [ { "id": 5444, "menuItemId": 3914, "courseId": 4975, "sortOrder": 0, "course": { "id": 4975, "dispNameNl": "Salade Marrakech veganlicious ", "dispNameEn": "Marrakesh veganlicious salad ", "nameNl": "00 salade marrakech veganlicious(couscous,mozzarisella),dd,w (vegan)", "nameEn": "", "weight": "300 gr", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4975, "allergenId": 201 }, { "courseId": 4975, "allergenId": 204 }, { "courseId": 4975, "allergenId": 208 }, { "courseId": 4975, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4975, "courseLogoId": 209 }, { "courseId": 4975, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4410, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 634, "sortorder": 10, "menuItemContents": [] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-13_cmi.raw.json ================================================ { "id": 542, "menuDate": "2020-02-13T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3326, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 0, "menuItemContents": [ { "id": 4617, "menuItemId": 3326, "courseId": 2530, "sortOrder": 0, "course": { "id": 2530, "dispNameNl": "Bio-wortelsoep", "dispNameEn": "Organic carrot soup", "nameNl": "bio-wortelsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2530, "courseLogoId": 201 }, { "courseId": 2530, "courseLogoId": 211 }, { "courseId": 2530, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3354, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 1, "menuItemContents": [ { "id": 4643, "menuItemId": 3354, "courseId": 4267, "sortOrder": 0, "course": { "id": 4267, "dispNameNl": "Bellaroma burger", "dispNameEn": "Bella Roma burger", "nameNl": "bellaroma burger (grill), dd, w", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "vege burger broodje grillen langs beide kanten - vege burger op voorhand in oven garen - vege burger \u00e0 la minute grillen - hamburger op broodje - geraspte mozzarella erover en afwerken met de gegrilde groenten en toefje postelein. serveren met: frietjes - steakhouse frieten - kroketten - aardappelwafeltjes - gebakken krieltjes - parelcouscous met borlotti bonen - wedges - aardappel in de schil", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4267, "allergenId": 200 }, { "courseId": 4267, "allergenId": 201 }, { "courseId": 4267, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 4267, "courseLogoId": 203 }, { "courseId": 4267, "courseLogoId": 204 }, { "courseId": 4267, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3332, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 2, "menuItemContents": [ { "id": 4623, "menuItemId": 3332, "courseId": 985, "sortOrder": 0, "course": { "id": 985, "dispNameNl": "kroketten", "dispNameEn": "croquettes", "nameNl": "kroketten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 985, "allergenId": 200 }, { "courseId": 985, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 4621, "menuItemId": 3332, "courseId": 1033, "sortOrder": 0, "course": { "id": 1033, "dispNameNl": "erwten en wortelen", "dispNameEn": "peas and carrots", "nameNl": "erwten en wortelen", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1033, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 4619, "menuItemId": 3332, "courseId": 1364, "sortOrder": 0, "course": { "id": 1364, "dispNameNl": "Kalkoenlapje", "dispNameEn": "Turkey escalope", "nameNl": "kalkoenlapje dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": "bak de filets in een braadslede, kruiden met kippenkruiden, verder garen in de oven, versnijden.", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1364, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4110, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 3, "menuItemContents": [ { "id": 5712, "menuItemId": 4110, "courseId": 5561, "sortOrder": 0, "course": { "id": 5561, "dispNameNl": "Buddha bowl met zalm", "dispNameEn": "buddha bowl with salmon", "nameNl": "Buddha bowl met zalm,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - Maak de wasabidressing: meng de yoghurtdressing met de wasabi - Maak de budhabowl als volgt: verdeel de rijst over het kommetje, leg in regenboogvorm: rijtje edamame bonen, sojascheuten, rijtje zalm en rijtje ma\u00efs. Werk de budha bowl af met de zeewiersalade, koriander en zwart sesamzaad en tuinerwtenspread met sjalot. Serveer hierbij de wasabimayonaise. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5561, "allergenId": 200 }, { "courseId": 5561, "allergenId": 201 }, { "courseId": 5561, "allergenId": 203 }, { "courseId": 5561, "allergenId": 209 }, { "courseId": 5561, "allergenId": 210 }, { "courseId": 5561, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 5561, "courseLogoId": 209 }, { "courseId": 5561, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4101, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 5, "menuItemContents": [ { "id": 5704, "menuItemId": 4101, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4106, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 6, "menuItemContents": [ { "id": 5708, "menuItemId": 4106, "courseId": 3423, "sortOrder": 0, "course": { "id": 3423, "dispNameNl": "Pasta met vegetarische bolognaise", "dispNameEn": "Pasta with vegetarian bolognese", "nameNl": "pasta vegetarische bolognaise, hzs (veggie)", "nameEn": "", "weight": "300g", "extra": "pasta met losstaand deksel opwarmen in de microgolfoven - gedurende ongeveer 4 minuten op 800 watt. - omroeren en genieten maar! smakelijk!", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3423, "allergenId": 203 }, { "courseId": 3423, "allergenId": 208 }, { "courseId": 3423, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3423, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4086, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 7, "menuItemContents": [ { "id": 5678, "menuItemId": 4086, "courseId": 4975, "sortOrder": 0, "course": { "id": 4975, "dispNameNl": "Salade Marrakech veganlicious ", "dispNameEn": "Marrakesh veganlicious salad ", "nameNl": "00 salade marrakech veganlicious(couscous,mozzarisella),dd,w (vegan)", "nameEn": "", "weight": "300 gr", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4975, "allergenId": 201 }, { "courseId": 4975, "allergenId": 204 }, { "courseId": 4975, "allergenId": 208 }, { "courseId": 4975, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4975, "courseLogoId": 209 }, { "courseId": 4975, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4091, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 8, "menuItemContents": [ { "id": 5683, "menuItemId": 4091, "courseId": 3161, "sortOrder": 0, "course": { "id": 3161, "dispNameNl": "Bagel pumpkin", "dispNameEn": "Pumpkin bagel", "nameNl": "00 bagel pumpkin (cottage cheese,geroosterde pompoen), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "pompoen met olijfolie en pezo roosteren in de oven. bestrijk 1 kant van de bagel met de cottage cheese, beleg met de geroosterde pompoen en werk af met gecrunchte walnoten.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3161, "allergenId": 200 }, { "courseId": 3161, "allergenId": 201 }, { "courseId": 3161, "allergenId": 203 }, { "courseId": 3161, "allergenId": 205 }, { "courseId": 3161, "allergenId": 208 }, { "courseId": 3161, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3161, "courseLogoId": 204 }, { "courseId": 3161, "courseLogoId": 210 }, { "courseId": 3161, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4402, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 542, "sortorder": 9, "menuItemContents": [ { "id": 6082, "menuItemId": 4402, "courseId": 939, "sortOrder": 0, "course": { "id": 939, "dispNameNl": "tartaarsaus", "dispNameEn": "tartar sauce", "nameNl": "tartaarsaus koud", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "keuze in het gebruik van kappertjes 100ml of kappers 3l - mayonaise aanlengen met beetje water tot gladde saus . - peterselie en kappertjes toevoegen.- eieren grof cutteren en ook toevoegen - alles nog eens goed mengen. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton gebruiken", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 939, "allergenId": 200 }, { "courseId": 939, "allergenId": 201 }, { "courseId": 939, "allergenId": 204 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 6083, "menuItemId": 4402, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 6081, "menuItemId": 4402, "courseId": 1325, "sortOrder": 0, "course": { "id": 1325, "dispNameNl": "Calamares", "dispNameEn": "Calamari", "nameNl": "calamares", "nameEn": "", "weight": "150g", "extra": null, "preparation": "afbakken in friteuze op 180 \u00b0 c", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1325, "allergenId": 201 }, { "courseId": 1325, "allergenId": 212 }, { "courseId": 1325, "allergenId": 213 } ], "course_CourseLogos": [ { "courseId": 1325, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 6085, "menuItemId": 4402, "courseId": 5633, "sortOrder": 0, "course": { "id": 5633, "dispNameNl": "saladbar", "dispNameEn": "salad bar", "nameNl": "saladbar met meerprijs", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.2, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5633, "allergenId": 200 }, { "courseId": 5633, "allergenId": 201 }, { "courseId": 5633, "allergenId": 202 }, { "courseId": 5633, "allergenId": 203 }, { "courseId": 5633, "allergenId": 204 }, { "courseId": 5633, "allergenId": 205 }, { "courseId": 5633, "allergenId": 206 }, { "courseId": 5633, "allergenId": 207 }, { "courseId": 5633, "allergenId": 208 }, { "courseId": 5633, "allergenId": 209 }, { "courseId": 5633, "allergenId": 210 }, { "courseId": 5633, "allergenId": 211 }, { "courseId": 5633, "allergenId": 212 }, { "courseId": 5633, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-13_cmu.raw.json ================================================ { "id": 513, "menuDate": "2020-02-13T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3178, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 0, "menuItemContents": [ { "id": 4406, "menuItemId": 3178, "courseId": 5011, "sortOrder": 0, "course": { "id": 5011, "dispNameNl": "Bio-pompoensoep", "dispNameEn": "Organic pumpkin soup", "nameNl": "00 bio-pompoensoep", "nameEn": "", "weight": "500 ml/700 ml", "extra": null, "preparation": "groenten (ajuin,prei, pompoen en aardappelen) samen met bouillon beetgaar koken. - mixen en room toevoegen. op smaak brengen met peper en zout.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5011, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5011, "courseLogoId": 201 }, { "courseId": 5011, "courseLogoId": 211 }, { "courseId": 5011, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3181, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 1, "menuItemContents": [ { "id": 4409, "menuItemId": 3181, "courseId": 5039, "sortOrder": 0, "course": { "id": 5039, "dispNameNl": "Vegetarische lasagne", "dispNameEn": "Vegetarian lasagne", "nameNl": "lasagne vegetarisch kant-en klaar", "nameEn": "", "weight": "400 g", "extra": null, "preparation": "de lasagne in porties snijden. - 20 minuten opwaren in een oven van 200\u00b0c. - eventueel van verpakking ontdoen en in een gastronorm bak opwarmen. - geraspte kaas erbij serveren.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5039, "allergenId": 200 }, { "courseId": 5039, "allergenId": 201 }, { "courseId": 5039, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5039, "courseLogoId": 207 }, { "courseId": 5039, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3179, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 2, "menuItemContents": [ { "id": 4407, "menuItemId": 3179, "courseId": 5552, "sortOrder": 0, "course": { "id": 5552, "dispNameNl": "Cuban Basmati Bowl", "dispNameEn": "cuban basmati bowl", "nameNl": "Cuban Basmati Bowl, w (veggie)", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen. Maak de zoete aardappel klaar volgens de fiche en breng op smaak met kaneel. Maak de bowl: doe de rijst in een kommetje, vervolgens de bonen, zoete aardappel, ma\u00efs & rode kool. Doe in het midden de guacamole, werk af met de cashewnoten en peterselie. Serveer hierbij de vinaigrette.Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5552, "allergenId": 205 }, { "courseId": 5552, "allergenId": 206 }, { "courseId": 5552, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5552, "courseLogoId": 209 }, { "courseId": 5552, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3182, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 3, "menuItemContents": [ { "id": 4410, "menuItemId": 3182, "courseId": 5027, "sortOrder": 0, "course": { "id": 5027, "dispNameNl": "Croque brie", "dispNameEn": "Brie grilled cheese sandwich", "nameNl": "00 croque brie, z&w", "nameEn": "", "weight": null, "extra": "veggie", "preparation": "1 potje saus is inbegrepen in de prijs", "price": 2.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5027, "allergenId": 203 }, { "courseId": 5027, "allergenId": 205 } ], "course_CourseLogos": [ { "courseId": 5027, "courseLogoId": 204 }, { "courseId": 5027, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": true, "enabled": false, "menuInfoEn": null } } ] }, { "id": 3180, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 4, "menuItemContents": [ { "id": 4408, "menuItemId": 3180, "courseId": 3874, "sortOrder": 0, "course": { "id": 3874, "dispNameNl": "Peperkoeken geitenkaasje", "dispNameEn": "Spiced bread and goat cheese", "nameNl": "peperkoeken geitenkaasje, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "citroensap voor de bio-appel - peperkoek in reepjes snijden en bovenaan in de saladebox", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3874, "allergenId": 201 }, { "courseId": 3874, "allergenId": 203 }, { "courseId": 3874, "allergenId": 205 }, { "courseId": 3874, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 3874, "courseLogoId": 204 }, { "courseId": 3874, "courseLogoId": 209 }, { "courseId": 3874, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3183, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 5, "menuItemContents": [ { "id": 4411, "menuItemId": 3183, "courseId": 25, "sortOrder": 0, "course": { "id": 25, "dispNameNl": "Wit broodje met kaas en wintergroenen", "dispNameEn": "White roll with cheese and winter vegetables", "nameNl": "wit broodje met kaas en wintergroentjes", "nameEn": "", "weight": "135g / 275g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 25, "allergenId": 200 }, { "courseId": 25, "allergenId": 201 }, { "courseId": 25, "allergenId": 203 }, { "courseId": 25, "allergenId": 204 } ], "course_CourseLogos": [ { "courseId": 25, "courseLogoId": 204 }, { "courseId": 25, "courseLogoId": 210 }, { "courseId": 25, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3184, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 6, "menuItemContents": [ { "id": 4412, "menuItemId": 3184, "courseId": 4627, "sortOrder": 0, "course": { "id": 4627, "dispNameNl": "Focaccia selder-rosbief", "dispNameEn": "Celery and roast beef focaccia", "nameNl": "00 focaccia selder-rosbief, z & w", "nameEn": "", "weight": "300g", "extra": null, "preparation": null, "price": 3.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4627, "allergenId": 200 }, { "courseId": 4627, "allergenId": 201 }, { "courseId": 4627, "allergenId": 203 }, { "courseId": 4627, "allergenId": 204 }, { "courseId": 4627, "allergenId": 207 }, { "courseId": 4627, "allergenId": 208 }, { "courseId": 4627, "allergenId": 210 }, { "courseId": 4627, "allergenId": 211 }, { "courseId": 4627, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4627, "courseLogoId": 208 }, { "courseId": 4627, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3185, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 513, "sortorder": 7, "menuItemContents": [ { "id": 4413, "menuItemId": 3185, "courseId": 5018, "sortOrder": 0, "course": { "id": 5018, "dispNameNl": "Club pompernikkel-zoete aardappel", "dispNameEn": "Pumpernickel and sweet potato club sandwich", "nameNl": "00 club pompernikkel - zoete aardappel,w (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "conceptbroodje winter werk in 3 lagen met het roggebrood.- besmeer de 3 sneden brood met de wortelspread.- leg op de eerste boterham de schijfjes zoete aardappel, veldsla en farmer sla- leg de 2de boterham hierop en herhaal met het beleg en sluit met het laatste sneetje brood.- snijd de boterham diagonaal doormidden en doe er een cocktailprikker in", "price": 2.7, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5018, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 5018, "courseLogoId": 210 }, { "courseId": 5018, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-13_cst.raw.json ================================================ { "id": 629, "menuDate": "2020-02-13T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3848, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 0, "menuItemContents": [ { "id": 5357, "menuItemId": 3848, "courseId": 2522, "sortOrder": 0, "course": { "id": 2522, "dispNameNl": "Bio-jardini\u00e8re soep", "dispNameEn": "Organic jardini\u00e8re soup", "nameNl": "bio-jardini\u00e8re soep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "garnituur: paprikablokjes", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2522, "courseLogoId": 201 }, { "courseId": 2522, "courseLogoId": 211 }, { "courseId": 2522, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3849, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 1, "menuItemContents": [ { "id": 5359, "menuItemId": 3849, "courseId": 981, "sortOrder": 0, "course": { "id": 981, "dispNameNl": "gebakken krielaardappelen", "dispNameEn": "fried new potatoes", "nameNl": "gebakken (kriel) aardappelen", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "stoom de aard.3/4 gaar en laat afkoelen - afbakken in margarine en olijfolie", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5358, "menuItemId": 3849, "courseId": 4267, "sortOrder": 0, "course": { "id": 4267, "dispNameNl": "Bellaroma burger", "dispNameEn": "Bella Roma burger", "nameNl": "bellaroma burger (grill), dd, w", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "vege burger broodje grillen langs beide kanten - vege burger op voorhand in oven garen - vege burger \u00e0 la minute grillen - hamburger op broodje - geraspte mozzarella erover en afwerken met de gegrilde groenten en toefje postelein. serveren met: frietjes - steakhouse frieten - kroketten - aardappelwafeltjes - gebakken krieltjes - parelcouscous met borlotti bonen - wedges - aardappel in de schil", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4267, "allergenId": 200 }, { "courseId": 4267, "allergenId": 201 }, { "courseId": 4267, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 4267, "courseLogoId": 203 }, { "courseId": 4267, "courseLogoId": 204 }, { "courseId": 4267, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3850, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 2, "menuItemContents": [ { "id": 5362, "menuItemId": 3850, "courseId": 981, "sortOrder": 0, "course": { "id": 981, "dispNameNl": "gebakken krielaardappelen", "dispNameEn": "fried new potatoes", "nameNl": "gebakken (kriel) aardappelen", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "stoom de aard.3/4 gaar en laat afkoelen - afbakken in margarine en olijfolie", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5361, "menuItemId": 3850, "courseId": 3206, "sortOrder": 0, "course": { "id": 3206, "dispNameNl": "romanesco mix", "dispNameEn": "romanesco mix", "nameNl": "romanesco mix, z & w (+ extra 0,40 euro) ", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5360, "menuItemId": 3850, "courseId": 5048, "sortOrder": 0, "course": { "id": 5048, "dispNameNl": "Kalkoengehaktballetjes", "dispNameEn": "Turkey mince balls", "nameNl": "00 kalkoengehaktballetjes", "nameEn": "", "weight": null, "extra": null, "preparation": "5 balletjes pp (20 g per stuk)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5048, "allergenId": 200 }, { "courseId": 5048, "allergenId": 201 }, { "courseId": 5048, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5048, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3880, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 3, "menuItemContents": [ { "id": 5397, "menuItemId": 3880, "courseId": 930, "sortOrder": 0, "course": { "id": 930, "dispNameNl": "portosaus", "dispNameEn": "port sauce", "nameNl": "portosaus dd", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "water aan de kook brengen - poeder oplossen in beetje water en toevoegen tot juiste dikte. - even laten inkoken 3 kg ajuin eminceren en aanstoven in margarine - bij saus voegen. - afwerken met peterselie", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 930, "allergenId": 200 }, { "courseId": 930, "allergenId": 201 }, { "courseId": 930, "allergenId": 203 }, { "courseId": 930, "allergenId": 208 }, { "courseId": 930, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5398, "menuItemId": 3880, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5396, "menuItemId": 3880, "courseId": 3264, "sortOrder": 0, "course": { "id": 3264, "dispNameNl": "Kipfilet op de grill ", "dispNameEn": "Grilled chicken breast ", "nameNl": "kipfilet op de grill 1 (kippenkruiden), dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": null, "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3264, "courseLogoId": 202 }, { "courseId": 3264, "courseLogoId": 203 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3879, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 4, "menuItemContents": [ { "id": 5395, "menuItemId": 3879, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5394, "menuItemId": 3879, "courseId": 1416, "sortOrder": 0, "course": { "id": 1416, "dispNameNl": "African sunshinesaus", "dispNameEn": "African sunshine sauce", "nameNl": "african sunshine saus, zvv, dd", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "courgettes en paprika aanstoven en kruiden. - mengen met de pasta en de african sunshine saus toevoegen.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1416, "allergenId": 200 }, { "courseId": 1416, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1416, "courseLogoId": 207 }, { "courseId": 1416, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3878, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 5, "menuItemContents": [ { "id": 5609, "menuItemId": 3878, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3868, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 6, "menuItemContents": [ { "id": 5382, "menuItemId": 3868, "courseId": 5563, "sortOrder": 0, "course": { "id": 5563, "dispNameNl": "Falafelbowl", "dispNameEn": "falafel bowl", "nameNl": "Falafelbowl, w (vegan)", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5563, "allergenId": 201 }, { "courseId": 5563, "allergenId": 205 }, { "courseId": 5563, "allergenId": 206 }, { "courseId": 5563, "allergenId": 208 }, { "courseId": 5563, "allergenId": 209 }, { "courseId": 5563, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5563, "courseLogoId": 209 }, { "courseId": 5563, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3906, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 7, "menuItemContents": [ { "id": 5436, "menuItemId": 3906, "courseId": 5561, "sortOrder": 0, "course": { "id": 5561, "dispNameNl": "Buddha bowl met zalm", "dispNameEn": "buddha bowl with salmon", "nameNl": "Buddha bowl met zalm,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - Maak de wasabidressing: meng de yoghurtdressing met de wasabi - Maak de budhabowl als volgt: verdeel de rijst over het kommetje, leg in regenboogvorm: rijtje edamame bonen, sojascheuten, rijtje zalm en rijtje ma\u00efs. Werk de budha bowl af met de zeewiersalade, koriander en zwart sesamzaad en tuinerwtenspread met sjalot. Serveer hierbij de wasabimayonaise. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 4.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5561, "allergenId": 200 }, { "courseId": 5561, "allergenId": 201 }, { "courseId": 5561, "allergenId": 203 }, { "courseId": 5561, "allergenId": 209 }, { "courseId": 5561, "allergenId": 210 }, { "courseId": 5561, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 5561, "courseLogoId": 209 }, { "courseId": 5561, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3861, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 629, "sortorder": 8, "menuItemContents": [ { "id": 5375, "menuItemId": 3861, "courseId": 3487, "sortOrder": 0, "course": { "id": 3487, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - pastinaak grillen en mengen met de wortelen en sojascheuten en kokosschilfers - opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3487, "allergenId": 200 }, { "courseId": 3487, "allergenId": 201 }, { "courseId": 3487, "allergenId": 208 }, { "courseId": 3487, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3487, "courseLogoId": 202 }, { "courseId": 3487, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-02-13_hzs.raw.json ================================================ { "id": 503, "menuDate": "2020-02-13T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3039, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 0, "menuItemContents": [ { "id": 4230, "menuItemId": 3039, "courseId": 2526, "sortOrder": 0, "course": { "id": 2526, "dispNameNl": "Bio-erwtensoep", "dispNameEn": "Organic pea soup", "nameNl": "bio-erwtensoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2526, "courseLogoId": 201 }, { "courseId": 2526, "courseLogoId": 211 }, { "courseId": 2526, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3044, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 1, "menuItemContents": [ { "id": 4235, "menuItemId": 3044, "courseId": 4629, "sortOrder": 0, "course": { "id": 4629, "dispNameNl": "Bagel tzatziki", "dispNameEn": "Tzatziki bagel", "nameNl": "00 bagel tzatziki, z&w", "nameEn": "", "weight": "250g", "extra": "veggie", "preparation": null, "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4629, "allergenId": 201 }, { "courseId": 4629, "allergenId": 203 }, { "courseId": 4629, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4629, "courseLogoId": 210 }, { "courseId": 4629, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3049, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 2, "menuItemContents": [ { "id": 4240, "menuItemId": 3049, "courseId": 4168, "sortOrder": 0, "course": { "id": 4168, "dispNameNl": "Broodje met cottage cheese en serranoham", "dispNameEn": "Sandwich with cottage cheese and Serrano ham", "nameNl": "broodje cottage cheese-serrano, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4168, "allergenId": 200 }, { "courseId": 4168, "allergenId": 201 }, { "courseId": 4168, "allergenId": 203 }, { "courseId": 4168, "allergenId": 205 }, { "courseId": 4168, "allergenId": 209 }, { "courseId": 4168, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4168, "courseLogoId": 204 }, { "courseId": 4168, "courseLogoId": 210 }, { "courseId": 4168, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3054, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 3, "menuItemContents": [ { "id": 4245, "menuItemId": 3054, "courseId": 4976, "sortOrder": 0, "course": { "id": 4976, "dispNameNl": "Mexicaanse salade", "dispNameEn": "Mexican salad", "nameNl": "00 mexicaanse salade,w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptsalade winter ma\u00efs stomen.- de avocado in kleine blokjes/schijfjes snijden.- limoenen persen (een deel over de avocado doen voor verkleuring tegen te gaan)- quinoa koken (zie fiche).- olijfolie, rode bonen, wortelstaafjes, ma\u00efs en avocado onder de quinoa mengen.- op smaak brengen met limoensap, komijn, look, peper en zout.- afwerken met vers gehakte koriander.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4976, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 4976, "courseLogoId": 209 }, { "courseId": 4976, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3059, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 4, "menuItemContents": [ { "id": 4250, "menuItemId": 3059, "courseId": 2071, "sortOrder": 0, "course": { "id": 2071, "dispNameNl": "Salade met perziken en zalmsalade", "dispNameEn": "Salad with peaches and salmon salad", "nameNl": "salade met perziken en zalmsalade, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "zalm stomen en laten afkoelen (slechts max. 100g per persoon gebruiken) - zalm prakken, mengen met een kleine hoeveelheid mayonaise (voeg enkel extra mayo toe als de zalmsalade nog te droog is, laat de zalm in geen geval \u2018zwemmen\u2019 in de mayonaise), met geplette hardgekookte eieren en peterselie - perziken in partjes snijden - veldsla en rammenas mengen. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton gebruiken volgorde voor vullen van onder naar boven: gesneden perziken (2 -3 stuks) + zalmsalade (+/- 80-100 gr) + gemengde veldsla \u2013 rammenas", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2071, "allergenId": 200 }, { "courseId": 2071, "allergenId": 201 }, { "courseId": 2071, "allergenId": 204 }, { "courseId": 2071, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2071, "courseLogoId": 209 }, { "courseId": 2071, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3064, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 5, "menuItemContents": [ { "id": 4255, "menuItemId": 3064, "courseId": 5309, "sortOrder": 0, "course": { "id": 5309, "dispNameNl": "Groentepizza", "dispNameEn": "Vegetable pizza", "nameNl": "00 groentepizza,dd,w", "nameEn": "", "weight": null, "extra": null, "preparation": "pizzabodems openleggen en op een ingevette gastronorm plaatsen - tomatensaus over pizzabodems verdelen - courgette schijfjes grillen en op de pizza's schikken - pizza bakken in de oven - achteraf grana padano schilfers erover strooien en de curly kale en afkruiden met toegestane allergeenvrije kruiden. pizzabodem in 8 gelijke stukken snijden.", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5309, "allergenId": 200 }, { "courseId": 5309, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5309, "courseLogoId": 210 }, { "courseId": 5309, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3069, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 503, "sortorder": 6, "menuItemContents": [ { "id": 4260, "menuItemId": 3069, "courseId": 1093, "sortOrder": 0, "course": { "id": 1093, "dispNameNl": "Pizzabaguette ham-kaas", "dispNameEn": "Ham and cheese pizza baguette", "nameNl": "pizza baguette ham/kaas, z & w", "nameEn": "", "weight": "160g", "extra": null, "preparation": null, "price": 3.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1093, "allergenId": 201 }, { "courseId": 1093, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1093, "courseLogoId": 204 }, { "courseId": 1093, "courseLogoId": 210 }, { "courseId": 1093, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-12_cde.raw.json ================================================ { "id": 797, "menuDate": "2020-03-12T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5350, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 0, "menuItemContents": [ { "id": 7297, "menuItemId": 5350, "courseId": 3622, "sortOrder": 0, "course": { "id": 3622, "dispNameNl": "Bio-minestrone", "dispNameEn": "Organic minestrone", "nameNl": "bio-minestrone", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3622, "allergenId": 201 }, { "courseId": 3622, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 3622, "courseLogoId": 201 }, { "courseId": 3622, "courseLogoId": 211 }, { "courseId": 3622, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "groot: 1.20", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 5313, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 1, "menuItemContents": [ { "id": 7234, "menuItemId": 5313, "courseId": 975, "sortOrder": 0, "course": { "id": 975, "dispNameNl": "rijst met munt", "dispNameEn": "rice with mint", "nameNl": "rijst met munt", "nameEn": "", "weight": "150g pp", "extra": null, "preparation": "kook de rijst gaar in licht gezouten water. - witte basmati ongeveer 10 min - volkoren basmati ongeveer 20 min - voeg er de verse munt aan toe,", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7232, "menuItemId": 5313, "courseId": 1304, "sortOrder": 0, "course": { "id": 1304, "dispNameNl": "Quornburger 'southern style' ", "dispNameEn": "'Southern style\u2019 quorn burger ", "nameNl": "quornburger southern style dd", "nameEn": "", "weight": "2x63g", "extra": null, "preparation": "op platte plaatjes met bakpapier afbakken tot een temperatuur van minstens 65\u00b0c bereikt is", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1304, "allergenId": 200 }, { "courseId": 1304, "allergenId": 201 }, { "courseId": 1304, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1304, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7233, "menuItemId": 5313, "courseId": 2008, "sortOrder": 0, "course": { "id": 2008, "dispNameNl": "wokgroenten", "dispNameEn": "stir-fried vegetables", "nameNl": "wokgroenten, w", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2008, "allergenId": 201 }, { "courseId": 2008, "allergenId": 208 }, { "courseId": 2008, "allergenId": 210 }, { "courseId": 2008, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5314, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 2, "menuItemContents": [ { "id": 7236, "menuItemId": 5314, "courseId": 1052, "sortOrder": 0, "course": { "id": 1052, "dispNameNl": "ratatouille", "dispNameEn": "ratatouille", "nameNl": "ratatouille", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1052, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7235, "menuItemId": 5314, "courseId": 1350, "sortOrder": 0, "course": { "id": 1350, "dispNameNl": "Cordon bleu ", "dispNameEn": "Cordon bleu ", "nameNl": "cordon bleu varken", "nameEn": "", "weight": "160g", "extra": null, "preparation": "voorbakken in pan, kruiden en nadien afbakken in oven", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1350, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1350, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7237, "menuItemId": 5314, "courseId": 3991, "sortOrder": 0, "course": { "id": 3991, "dispNameNl": "parelcouscous", "dispNameEn": "pearl couscous", "nameNl": "parelcouscous", "nameEn": "", "weight": "150g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3991, "allergenId": 204 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5320, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 3, "menuItemContents": [ { "id": 7249, "menuItemId": 5320, "courseId": 1432, "sortOrder": 0, "course": { "id": 1432, "dispNameNl": "mediterraanse groentesaus", "dispNameEn": "Mediterranean vegetable sauce", "nameNl": "mediterraanse groentesaus, zvv, dd z&w (vegan)", "nameEn": "", "weight": "200g", "extra": null, "preparation": "pastasaus groenten aanstoven en bevochtigen met water. - kruiden toevoegen en een half uurtje laten pruttelen. - afbinden opgelet: vegan pasta voorzien", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1432, "allergenId": 201 }, { "courseId": 1432, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1432, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7256, "menuItemId": 5320, "courseId": 5478, "sortOrder": 0, "course": { "id": 5478, "dispNameNl": "Volkoren fusili", "dispNameEn": "wholegrain fusili", "nameNl": "Volkoren fusili, vegan", "nameEn": "", "weight": "150 - 200g pp", "extra": "", "preparation": "kook de pasta gaar in licht gezouten water, olie toevoegen - de kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5478, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5325, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 4, "menuItemContents": [ { "id": 7263, "menuItemId": 5325, "courseId": 1414, "sortOrder": 0, "course": { "id": 1414, "dispNameNl": "Spaghetti carbonara", "dispNameEn": "carbonara sauce", "nameNl": "Spaghetti carbonara", "nameEn": "", "weight": "200g", "extra": null, "preparation": "witte saus poeder toevoegen aan kokend water en goed laten doorkoken. - culinaire room toevoegen en de kruiden. - terug laten koken en gebakken spekreepjes toevoegen. + gemalen kaas erbij serveren", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1414, "allergenId": 200 }, { "courseId": 1414, "allergenId": 201 }, { "courseId": 1414, "allergenId": 203 }, { "courseId": 1414, "allergenId": 204 }, { "courseId": 1414, "allergenId": 205 }, { "courseId": 1414, "allergenId": 206 }, { "courseId": 1414, "allergenId": 208 }, { "courseId": 1414, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1414, "courseLogoId": 207 }, { "courseId": 1414, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5330, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 5, "menuItemContents": [ { "id": 7273, "menuItemId": 5330, "courseId": 922, "sortOrder": 0, "course": { "id": 922, "dispNameNl": "looksaus ", "dispNameEn": "garlic sauce ", "nameNl": "looksaus koud", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "mayonaise aanlengen met water tot gladde saus. - peterselie + lookpuree toevoegen en wederom goed mengen", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 922, "allergenId": 200 }, { "courseId": 922, "allergenId": 201 }, { "courseId": 922, "allergenId": 204 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7278, "menuItemId": 5330, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7268, "menuItemId": 5330, "courseId": 1078, "sortOrder": 0, "course": { "id": 1078, "dispNameNl": "Scampibrochette", "dispNameEn": "Scampi skewer", "nameNl": "scampi brochette, dd", "nameEn": "", "weight": "100g pp", "extra": null, "preparation": "insmeren met mengeling van saus lemon/green peper en soja olie - grillen", "price": 5.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1078, "allergenId": 207 } ], "course_CourseLogos": [ { "courseId": 1078, "courseLogoId": 203 }, { "courseId": 1078, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5336, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 11, "menuItemContents": [ { "id": 7284, "menuItemId": 5336, "courseId": 5566, "sortOrder": 0, "course": { "id": 5566, "dispNameNl": "Teriyaki Chicken Bowl", "dispNameEn": "teriyaki chicken bowl", "nameNl": "Teriyaki Chicken Bowl, w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . Breng de kip op smaak met de look en de wokpasta - Maakt de tuinerwtenspread volgens de receptuur. Maak de budhabowl als volgt: verdeel de rijst over het kommetje, leg in regenboogvorm: thaise tuinerwtenspread, rijtje edamame bonen, reepjes boerenkool, geraspte knolselder (besprenkel met citroensap), waterkers en kip. Werk af met het zwart sesamzaad.Serveer met de sojasaus vinaigrette.Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5566, "allergenId": 201 }, { "courseId": 5566, "allergenId": 205 }, { "courseId": 5566, "allergenId": 208 }, { "courseId": 5566, "allergenId": 209 }, { "courseId": 5566, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5566, "courseLogoId": 202 }, { "courseId": 5566, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5342, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 797, "sortorder": 11, "menuItemContents": [ { "id": 7290, "menuItemId": 5342, "courseId": 5024, "sortOrder": 0, "course": { "id": 5024, "dispNameNl": "Pittige pastinaak met humus en bulgur", "dispNameEn": "Spicy parsnip with hummus and bulghur", "nameNl": "00 pittige pastinaak met humus en rode quinoa/bulgur w", "nameEn": "", "weight": null, "extra": "veggie", "preparation": "de pastinaak bakken in de oven met komijn, paprikapoeder, honing en olie.- tot deze gaar zijn & gekarameliseerd. meng de bulgur met de pastinaak en pompoenpitten.- hak de koriander fijn en verdeel over de salade samen met de veldsla", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5024, "allergenId": 201 }, { "courseId": 5024, "allergenId": 203 }, { "courseId": 5024, "allergenId": 205 }, { "courseId": 5024, "allergenId": 206 }, { "courseId": 5024, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5024, "courseLogoId": 209 }, { "courseId": 5024, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-12_cgb.raw.json ================================================ { "id": 807, "menuDate": "2020-03-12T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5418, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 807, "sortorder": 0, "menuItemContents": [ { "id": 7404, "menuItemId": 5418, "courseId": 2523, "sortOrder": 0, "course": { "id": 2523, "dispNameNl": "Bio-bloemkoolsoep", "dispNameEn": "Organic cauliflower soup", "nameNl": "bio-bloemkoolsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "water aan de kook brengen. - 20 kg bloemkool, aardappel en ajuin toevoegen. - bouillon peper zout toevoegen en laten koken. - als groenten gaar.+ 5 kg bloemkoolroosjes op laatste moment toevoegen.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2523, "courseLogoId": 201 }, { "courseId": 2523, "courseLogoId": 211 }, { "courseId": 2523, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5428, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 807, "sortorder": 1, "menuItemContents": [ { "id": 7413, "menuItemId": 5428, "courseId": 5650, "sortOrder": 0, "course": { "id": 5650, "dispNameNl": "Pasta met schorseneren in lookboter, met ei, olijven, peultjes & spekjes", "dispNameEn": "Pasta with salsify in garlic butter, with egg, olives, snow peas & cubed bacons", "nameNl": "Pasta met schorseneren in lookboter, met ei, olijven, peultjes & spekjes", "nameEn": "", "weight": "", "extra": "", "preparation": "beetgare pasta voorzichtig mengen met voorgesteamde peultjes en schorseneren, grof gesneden eitjes, look, olijven, peterselie en gesmolten boter - gebakken spekblokjes toevoegen - afkruiden met pezo. - in gn van 6 diep verdelen en opwarmen in combi-steamer. ", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5650, "allergenId": 200 }, { "courseId": 5650, "allergenId": 201 }, { "courseId": 5650, "allergenId": 203 }, { "courseId": 5650, "allergenId": 204 }, { "courseId": 5650, "allergenId": 205 }, { "courseId": 5650, "allergenId": 206 }, { "courseId": 5650, "allergenId": 208 }, { "courseId": 5650, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5650, "courseLogoId": 207 }, { "courseId": 5650, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 5431, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 807, "sortorder": 2, "menuItemContents": [ { "id": 7416, "menuItemId": 5431, "courseId": 5573, "sortOrder": 0, "course": { "id": 5573, "dispNameNl": "Purple bowl", "dispNameEn": "purple bowl", "nameNl": "Purple bowl,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de parelcouscous klaar volgens de bereidingswijze op de verpakking & laat afkoelen . - Maak de budhabowl als volgt: vul het kommetje met de parelcouscous, vervolgens de bonen, rode bietblokjes, geraspte knolselder en een paar stukjes witloof aan de zijkant. Werk de budha bowl af met kippenreepjes, gecrunshte pindanoten, gebakken uitjes & peterselie. Serveer hierbij de vinaigrette sjalot & ui. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5573, "allergenId": 201 }, { "courseId": 5573, "allergenId": 205 }, { "courseId": 5573, "allergenId": 206 }, { "courseId": 5573, "allergenId": 208 }, { "courseId": 5573, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 5573, "courseLogoId": 202 }, { "courseId": 5573, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5413, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 807, "sortorder": 3, "menuItemContents": [ { "id": 7399, "menuItemId": 5413, "courseId": 3104, "sortOrder": 0, "course": { "id": 3104, "dispNameNl": "Broodje sjeik", "dispNameEn": "Sjeik sandwich", "nameNl": "broodje sjeik,w (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptbroodje winter eerst tomatade - dan augurken - mozzarisella - postelein", "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3104, "allergenId": 201 }, { "courseId": 3104, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3104, "courseLogoId": 210 }, { "courseId": 3104, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5407, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 807, "sortorder": 4, "menuItemContents": [ { "id": 7394, "menuItemId": 5407, "courseId": 1890, "sortOrder": 0, "course": { "id": 1890, "dispNameNl": "Salade zonder sla", "dispNameEn": "Salad without lettuce", "nameNl": "salade slaatje zonder sla (aardappel, appel, raapjes), dd, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton appel besprenkelen met citroensap", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1890, "allergenId": 200 }, { "courseId": 1890, "allergenId": 203 }, { "courseId": 1890, "allergenId": 205 }, { "courseId": 1890, "allergenId": 206 }, { "courseId": 1890, "allergenId": 208 }, { "courseId": 1890, "allergenId": 209 }, { "courseId": 1890, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1890, "courseLogoId": 209 }, { "courseId": 1890, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5424, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 807, "sortorder": 5, "menuItemContents": [ { "id": 7409, "menuItemId": 5424, "courseId": 1084, "sortOrder": 0, "course": { "id": 1084, "dispNameNl": "Croque Hawa\u00ef", "dispNameEn": "Croque Hawaii", "nameNl": "croque hawa\u00ef, z & w", "nameEn": "", "weight": "200g", "extra": null, "preparation": "keuze uit wit brood of bruin brood om de croque te maken 1 potje saus is inbegrepen in de prijs", "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1084, "allergenId": 201 }, { "courseId": 1084, "allergenId": 203 }, { "courseId": 1084, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1084, "courseLogoId": 204 }, { "courseId": 1084, "courseLogoId": 210 }, { "courseId": 1084, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-12_cmi.parsed.expected.yaml ================================================ $test_case: course_of_interest: 5398 reason: | On this response, Komidabot differred from the official site by only showing one price for the "Purple bowl" menu item. The reason for this is we only used to look if "calculatedMultiplePrices" is set to true, but "fixedMultiplePrices" set to true should behave the same way (at least to display menu items). campus: cmi date: '2020-03-12' menu: - components: - allergens: [] attributes: - BIO - SOUP - VEGAN name: en: Organic cauliflower soup nl: Bio-bloemkoolsoep external_id: 3657 multiple_prices: false price: '0.90' sort_order: 0 - components: - allergens: - EGG - LUPINE - MILK_LACTOSE - NUTS - SESAME - SULFITES - WHEAT_GLUTEN attributes: - VEGGIE name: en: Vegetarian vol-au-vent nl: Veggie koninginnehapje - allergens: - WHEAT_GLUTEN attributes: [] name: en: fries nl: frieten - allergens: - EGG - MILK_LACTOSE - NUTS attributes: [] name: en: Belgian endive salad with nuts, w nl: witloofsla met noten, w external_id: 3658 multiple_prices: true price: '5.40' sort_order: 1 - components: - allergens: - CELERY - EGG - LUPINE - MILK_LACTOSE - MUSTARD - NUTS - SESAME - SOY - SULFITES - WHEAT_GLUTEN attributes: - CHICKEN name: en: Chicken vol-au-vent nl: Koninginnehapje - allergens: - WHEAT_GLUTEN attributes: [] name: en: fries nl: frieten - allergens: - CELERY - EGG - FISH - LUPINE - MILK_LACTOSE - MOLLUSKS - MUSTARD - NUTS - PEANUTS - SESAME - SHELLFISH - SOY - SULFITES - WHEAT_GLUTEN attributes: [] name: en: "crudit\xE9s" nl: Rauwkostslaatje external_id: 3659 multiple_prices: true price: '5.40' sort_order: 2 - components: - allergens: - CELERY - EGG - FISH - MILK_LACTOSE - NUTS - PEANUTS - SESAME attributes: - SALAD - VEGGIE name: en: Salad without lettuce nl: Salade zonder sla external_id: 5391 multiple_prices: true price: '3.80' sort_order: 6 - components: - allergens: - SOY - WHEAT_GLUTEN attributes: - SNACK - VEGAN name: en: Sjeik sandwich nl: Broodje sjeik external_id: 5396 multiple_prices: false price: '3.60' sort_order: 7 - components: - allergens: - CELERY - NUTS - PEANUTS - SULFITES - WHEAT_GLUTEN attributes: - CHICKEN - SALAD name: en: purple bowl nl: Purple bowl external_id: 5398 multiple_prices: true price: '3.80' sort_order: 5 - components: - allergens: - SOY - WHEAT_GLUTEN attributes: - PASTA - VEGAN name: en: paprika cream with garden cress nl: pasta met paprikaroom & waterkers external_id: 5505 multiple_prices: true price: '3.80' sort_order: 3 - components: - allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - NUTS - PEANUTS - SOY - WHEAT_GLUTEN attributes: - PASTA - PIG name: en: Pasta with salsify in garlic butter, with egg, olives, snow peas & cubed bacons nl: Pasta met schorseneren in lookboter, met ei, olijven, peultjes & spekjes external_id: 5510 multiple_prices: true price: '3.80' sort_order: 4 - components: - allergens: - EGG - MUSTARD - WHEAT_GLUTEN attributes: - GRILL - VEGGIE name: en: New Orleans pepper burger nl: New Orleans pepper burger external_id: 5539 multiple_prices: true price: '5.00' sort_order: 5 ================================================ FILE: tests/external_menus/2020-03-12_cmi.raw.json ================================================ { "id": 599, "menuDate": "2020-03-12T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 3657, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 0, "menuItemContents": [ { "id": 5066, "menuItemId": 3657, "courseId": 2523, "sortOrder": 0, "course": { "id": 2523, "dispNameNl": "Bio-bloemkoolsoep", "dispNameEn": "Organic cauliflower soup", "nameNl": "bio-bloemkoolsoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "water aan de kook brengen. - 20 kg bloemkool, aardappel en ajuin toevoegen. - bouillon peper zout toevoegen en laten koken. - als groenten gaar.+ 5 kg bloemkoolroosjes op laatste moment toevoegen.", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2523, "courseLogoId": 201 }, { "courseId": 2523, "courseLogoId": 211 }, { "courseId": 2523, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3658, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 1, "menuItemContents": [ { "id": 5068, "menuItemId": 3658, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5067, "menuItemId": 3658, "courseId": 1312, "sortOrder": 0, "course": { "id": 1312, "dispNameNl": "Veggie koninginnehapje", "dispNameEn": "Vegetarian vol-au-vent", "nameNl": "koninginnehapje veggie dd", "nameEn": "", "weight": "450g pp", "extra": null, "preparation": "( zo kort mogelijk voor service bereiden ! ) quornsstuks en opwarmen, samen met de gebakken ajuin en champignons mengen in diepe bak. - warme witte saus + groentenbouillon erover gieten en mengen tot juiste consistentie. - volledig afsmaken en afwerken met peterselie - + pasteitjes 5 min. opwarmen in oven", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1312, "allergenId": 200 }, { "courseId": 1312, "allergenId": 201 }, { "courseId": 1312, "allergenId": 202 }, { "courseId": 1312, "allergenId": 203 }, { "courseId": 1312, "allergenId": 205 }, { "courseId": 1312, "allergenId": 209 }, { "courseId": 1312, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1312, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5069, "menuItemId": 3658, "courseId": 1623, "sortOrder": 0, "course": { "id": 1623, "dispNameNl": "witloofsla met noten, w", "dispNameEn": "Belgian endive salad with nuts, w", "nameNl": "witloofsla met noten, w", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1623, "allergenId": 200 }, { "courseId": 1623, "allergenId": 203 }, { "courseId": 1623, "allergenId": 205 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 3659, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 2, "menuItemContents": [ { "id": 5072, "menuItemId": 3659, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5070, "menuItemId": 3659, "courseId": 1378, "sortOrder": 0, "course": { "id": 1378, "dispNameNl": "Koninginnehapje", "dispNameEn": "Chicken vol-au-vent", "nameNl": "koninginnehapje dd", "nameEn": "", "weight": "200g", "extra": null, "preparation": "( zo kort mogelijk voor service bereiden ! ) kippenreepjes en balletjes opwarmen en samen met de gebakken ajuin en champignons mengen in diepe bak warme witte saus + bouillon van kip erover gieten en mengen tot juiste consistentie. - volledig afsmaken en afwerken met peterselie - + pasteitjes 5 min. opwarmen in oven", "price": 5.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1378, "allergenId": 200 }, { "courseId": 1378, "allergenId": 201 }, { "courseId": 1378, "allergenId": 202 }, { "courseId": 1378, "allergenId": 203 }, { "courseId": 1378, "allergenId": 204 }, { "courseId": 1378, "allergenId": 205 }, { "courseId": 1378, "allergenId": 208 }, { "courseId": 1378, "allergenId": 209 }, { "courseId": 1378, "allergenId": 210 }, { "courseId": 1378, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1378, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 5073, "menuItemId": 3659, "courseId": 5514, "sortOrder": 0, "course": { "id": 5514, "dispNameNl": "Rauwkostslaatje", "dispNameEn": "crudit\u00e9s", "nameNl": "rauwkostslaatje", "nameEn": "", "weight": "", "extra": "Dit is een slaatje om bij een gerecht toe te voegen, waarbij de salade al in de prijs gerekend is.", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5514, "allergenId": 200 }, { "courseId": 5514, "allergenId": 201 }, { "courseId": 5514, "allergenId": 202 }, { "courseId": 5514, "allergenId": 203 }, { "courseId": 5514, "allergenId": 204 }, { "courseId": 5514, "allergenId": 205 }, { "courseId": 5514, "allergenId": 206 }, { "courseId": 5514, "allergenId": 207 }, { "courseId": 5514, "allergenId": 208 }, { "courseId": 5514, "allergenId": 209 }, { "courseId": 5514, "allergenId": 210 }, { "courseId": 5514, "allergenId": 211 }, { "courseId": 5514, "allergenId": 212 }, { "courseId": 5514, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5505, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 3, "menuItemContents": [ { "id": 7527, "menuItemId": 5505, "courseId": 1425, "sortOrder": 0, "course": { "id": 1425, "dispNameNl": "pasta met paprikaroom & waterkers", "dispNameEn": "paprika cream with garden cress", "nameNl": "00 paprikaroom met waterkers, zvv, dd, z & w (vegan)", "nameEn": "", "weight": "200g", "extra": "vegan", "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux afsmaken met pezo en proven\u00e7aalse kruiden. - rode paprika gaar steamen en samen met room bij tomatensaus doen - mixen en eventueel verdunnen/aandikken serveer met een vegan pasta ( volkoren spaghetti speciality anco/ fusili volkoren bio)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1425, "allergenId": 201 }, { "courseId": 1425, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1425, "courseLogoId": 207 }, { "courseId": 1425, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5510, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 4, "menuItemContents": [ { "id": 7531, "menuItemId": 5510, "courseId": 5650, "sortOrder": 0, "course": { "id": 5650, "dispNameNl": "Pasta met schorseneren in lookboter, met ei, olijven, peultjes & spekjes", "dispNameEn": "Pasta with salsify in garlic butter, with egg, olives, snow peas & cubed bacons", "nameNl": "Pasta met schorseneren in lookboter, met ei, olijven, peultjes & spekjes", "nameEn": "", "weight": "", "extra": "", "preparation": "beetgare pasta voorzichtig mengen met voorgesteamde peultjes en schorseneren, grof gesneden eitjes, look, olijven, peterselie en gesmolten boter - gebakken spekblokjes toevoegen - afkruiden met pezo. - in gn van 6 diep verdelen en opwarmen in combi-steamer. ", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5650, "allergenId": 200 }, { "courseId": 5650, "allergenId": 201 }, { "courseId": 5650, "allergenId": 203 }, { "courseId": 5650, "allergenId": 204 }, { "courseId": 5650, "allergenId": 205 }, { "courseId": 5650, "allergenId": 206 }, { "courseId": 5650, "allergenId": 208 }, { "courseId": 5650, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5650, "courseLogoId": 207 }, { "courseId": 5650, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 5398, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 5, "menuItemContents": [ { "id": 7383, "menuItemId": 5398, "courseId": 5573, "sortOrder": 0, "course": { "id": 5573, "dispNameNl": "Purple bowl", "dispNameEn": "purple bowl", "nameNl": "Purple bowl,w", "nameEn": "", "weight": "", "extra": "", "preparation": "Maak de parelcouscous klaar volgens de bereidingswijze op de verpakking & laat afkoelen . - Maak de budhabowl als volgt: vul het kommetje met de parelcouscous, vervolgens de bonen, rode bietblokjes, geraspte knolselder en een paar stukjes witloof aan de zijkant. Werk de budha bowl af met kippenreepjes, gecrunshte pindanoten, gebakken uitjes & peterselie. Serveer hierbij de vinaigrette sjalot & ui. Zet het deksel op de weckpot en plak het etiket aan de zijkant.", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5573, "allergenId": 201 }, { "courseId": 5573, "allergenId": 205 }, { "courseId": 5573, "allergenId": 206 }, { "courseId": 5573, "allergenId": 208 }, { "courseId": 5573, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 5573, "courseLogoId": 202 }, { "courseId": 5573, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5539, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 5, "menuItemContents": [ { "id": 7561, "menuItemId": 5539, "courseId": 4269, "sortOrder": 0, "course": { "id": 4269, "dispNameNl": "New Orleans pepper burger", "dispNameEn": "New Orleans pepper burger", "nameNl": "new orleans pepper burger (grill), dd, w", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "saus maken met dressing maison - mayo - mosterd (keuze uit 1 liter of 3 liter) - bbq verstegen saus - groentenmengeling maken met witte kool, geraspte wortel, postelein, bieslook en peterselie. - hamburger broodje grillen langs beide kanten - burger op voorhand in oven garen - burger \u00e0 la minute grillen - saus op broodje doen - burger erop en afwerken met de groentjes en gebakken ajuintjes - serveren met: frietjes - steakhouse frieten - kroketten - aardappelwafeltjes - gebakken krieltjes - parelcouscous met borlotti bonen - wedges - aardappel in de schil", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4269, "allergenId": 200 }, { "courseId": 4269, "allergenId": 201 }, { "courseId": 4269, "allergenId": 204 } ], "course_CourseLogos": [ { "courseId": 4269, "courseLogoId": 203 }, { "courseId": 4269, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5391, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 6, "menuItemContents": [ { "id": 7376, "menuItemId": 5391, "courseId": 1890, "sortOrder": 0, "course": { "id": 1890, "dispNameNl": "Salade zonder sla", "dispNameEn": "Salad without lettuce", "nameNl": "salade slaatje zonder sla (aardappel, appel, raapjes), dd, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton appel besprenkelen met citroensap", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1890, "allergenId": 200 }, { "courseId": 1890, "allergenId": 203 }, { "courseId": 1890, "allergenId": 205 }, { "courseId": 1890, "allergenId": 206 }, { "courseId": 1890, "allergenId": 208 }, { "courseId": 1890, "allergenId": 209 }, { "courseId": 1890, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1890, "courseLogoId": 209 }, { "courseId": 1890, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5396, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 599, "sortorder": 7, "menuItemContents": [ { "id": 7381, "menuItemId": 5396, "courseId": 3104, "sortOrder": 0, "course": { "id": 3104, "dispNameNl": "Broodje sjeik", "dispNameEn": "Sjeik sandwich", "nameNl": "broodje sjeik,w (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptbroodje winter eerst tomatade - dan augurken - mozzarisella - postelein", "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3104, "allergenId": 201 }, { "courseId": 3104, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3104, "courseLogoId": 210 }, { "courseId": 3104, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-12_cmu.raw.json ================================================ { "id": 748, "menuDate": "2020-03-12T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 4793, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 0, "menuItemContents": [ { "id": 6610, "menuItemId": 4793, "courseId": 5533, "sortOrder": 0, "course": { "id": 5533, "dispNameNl": "Winter falafel bowl", "dispNameEn": "Winter falafel bowl", "nameNl": "Winter falafel bowl, w (vegan)", "nameEn": "", "weight": "", "extra": "", "preparation": "Mix de opgelegde rode biet, breng op smaak met het limoensap & pezo. Meng de rode bietspread onder de gekookte parelcouscous. Maak de buddha bowl als volgt: verdeel de rode parelcouscous over de kommetjes, vervolgens de gesneden rode kool, gele & oranje wortelschijfjes, curly kale en werk af met de falafelballetjes, pompoenpitten & een toefje hummus", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5533, "allergenId": 206 }, { "courseId": 5533, "allergenId": 208 }, { "courseId": 5533, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 5533, "courseLogoId": 209 }, { "courseId": 5533, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4801, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 2, "menuItemContents": [ { "id": 6618, "menuItemId": 4801, "courseId": 5033, "sortOrder": 0, "course": { "id": 5033, "dispNameNl": "Quesadilla's veganlicious", "dispNameEn": "Veganlicious quesadillas", "nameNl": "00 quesadilla's veganlicious, w (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "snack winter bak de diepgevroren quinoa-groenekoolburger op een bakplaat of in de oven.- strooi de mozzarisella over de bodem van de wraps en voeg de lente-ui toe.- voeg een laagje geroosterde ma\u00efs toe. leg de burger in de wrap en vouw toe tot een quesadilla.- voeg de quesadilla\"s toe aan een warme braadpan en bak elke kant een paar minuten op een laag tot middelhoog vuur tot ze licht geroosterd zijn of je kan ze ook in een paninitoestel plaatsen.- snijd in hapklare stukken.", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5033, "allergenId": 201 }, { "courseId": 5033, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5033, "courseLogoId": 210 }, { "courseId": 5033, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4805, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 3, "menuItemContents": [ { "id": 6641, "menuItemId": 4805, "courseId": 159, "sortOrder": 0, "course": { "id": 159, "dispNameNl": "Abdijbroodje ", "dispNameEn": "Abbey roll ", "nameNl": "abdijbroodje (baguelino broodje, smeerkaas, rammenas) dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 159, "allergenId": 201 }, { "courseId": 159, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 159, "courseLogoId": 204 }, { "courseId": 159, "courseLogoId": 210 }, { "courseId": 159, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4811, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 4, "menuItemContents": [ { "id": 6627, "menuItemId": 4811, "courseId": 4990, "sortOrder": 0, "course": { "id": 4990, "dispNameNl": "Kruidig roggebrood", "dispNameEn": "Herbed rye bread", "nameNl": "00 kruidig roggebrood, w (vegan)", "nameEn": "", "weight": null, "extra": "vegan", "preparation": "conceptbroodje winter werk in 3 lagen met het roggebrood.- besmeer de 3 sneden brood met de vegane mayonaise.- leg op de eerste boterham de vegan kruidenkaas, de schijfjes raap en de waterkers.- leg de 2de boterham hierop en herhaal met het beleg en sluit met het laatste sneetje brood.- snijd de boterham diagonaal doormidden en doe er een cocktailprikker in", "price": 2.7, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4990, "allergenId": 201 }, { "courseId": 4990, "allergenId": 204 }, { "courseId": 4990, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4990, "courseLogoId": 210 }, { "courseId": 4990, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4816, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 4, "menuItemContents": [ { "id": 6632, "menuItemId": 4816, "courseId": 4953, "sortOrder": 0, "course": { "id": 4953, "dispNameNl": "Meergranenbroodje met basilicum-humus en wintergroenten", "dispNameEn": "Multigrain roll with basil hummus and winter vegetables", "nameNl": "00 broodje fit met humus basilicum en wintergroenten (vegan)", "nameEn": "", "weight": "135 g/275g", "extra": null, "preparation": "broodje winter", "price": 3.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4953, "allergenId": 201 }, { "courseId": 4953, "allergenId": 209 }, { "courseId": 4953, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4953, "courseLogoId": 210 }, { "courseId": 4953, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4821, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 5, "menuItemContents": [ { "id": 6636, "menuItemId": 4821, "courseId": 1872, "sortOrder": 0, "course": { "id": 1872, "dispNameNl": "Italiaanse worteltartaar met parmezaan", "dispNameEn": "Italian carrot tartare with parmesan", "nameNl": "italiaanse worteltartaar met parmezaan, dd, w", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1872, "allergenId": 200 }, { "courseId": 1872, "allergenId": 201 }, { "courseId": 1872, "allergenId": 203 }, { "courseId": 1872, "allergenId": 204 }, { "courseId": 1872, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1872, "courseLogoId": 204 }, { "courseId": 1872, "courseLogoId": 210 }, { "courseId": 1872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4826, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 6, "menuItemContents": [ { "id": 6642, "menuItemId": 4826, "courseId": 159, "sortOrder": 0, "course": { "id": 159, "dispNameNl": "Abdijbroodje ", "dispNameEn": "Abbey roll ", "nameNl": "abdijbroodje (baguelino broodje, smeerkaas, rammenas) dd, w", "nameEn": "", "weight": "300 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 159, "allergenId": 201 }, { "courseId": 159, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 159, "courseLogoId": 204 }, { "courseId": 159, "courseLogoId": 210 }, { "courseId": 159, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4831, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 7, "menuItemContents": [ { "id": 6647, "menuItemId": 4831, "courseId": 1924, "sortOrder": 0, "course": { "id": 1924, "dispNameNl": "Salade Wicca", "dispNameEn": "Wicca salad", "nameNl": "salade wicca (zalm, linzen, aardappelen), w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "pompoenblokjes en aardappelen: niet natuur stomen, maar garen in de oven met olijfolie en rozemarijn. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1924, "allergenId": 200 }, { "courseId": 1924, "allergenId": 204 }, { "courseId": 1924, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1924, "courseLogoId": 209 }, { "courseId": 1924, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4832, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 8, "menuItemContents": [ { "id": 6648, "menuItemId": 4832, "courseId": 864, "sortOrder": 0, "course": { "id": 864, "dispNameNl": "Paprikasoep", "dispNameEn": "Bell pepper soup", "nameNl": "paprikasoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 864, "allergenId": 201 }, { "courseId": 864, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 864, "courseLogoId": 211 }, { "courseId": 864, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4833, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 9, "menuItemContents": [ { "id": 6649, "menuItemId": 4833, "courseId": 5205, "sortOrder": 0, "course": { "id": 5205, "dispNameNl": "Quiche normande", "dispNameEn": "Quiche normande", "nameNl": "00 quiche normande z&w", "nameEn": "", "weight": null, "extra": null, "preparation": "d + 1 indien niet opgewarmd - warm op volgens instructies", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5205, "allergenId": 201 }, { "courseId": 5205, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5205, "courseLogoId": 210 }, { "courseId": 5205, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5546, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 748, "sortorder": 10, "menuItemContents": [ { "id": 7567, "menuItemId": 5546, "courseId": 4404, "sortOrder": 0, "course": { "id": 4404, "dispNameNl": "Chia-banaanpudding", "dispNameEn": "Chia and banana pudding", "nameNl": "chia banaanpudding (vegan)", "nameEn": "", "weight": "160g", "extra": "te maken in kleine cambiopot of in glazen dessertpotje of tumbler (desserten per 25 pers.)", "preparation": "dessert mix de bananen met de kokosmelk (variant amandelmelk is minder vet), vanille, kaneel en ahornsiroop. - meng er daarna het chiazaad onder en verdeel: een laagje pudding, een paar schijfjes banaan, laagje pudding en afwerken met kokosschilfers", "price": 2.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4404, "allergenId": 205 }, { "courseId": 4404, "allergenId": 206 }, { "courseId": 4404, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 4404, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-12_cst.raw.json ================================================ { "id": 792, "menuDate": "2020-03-12T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5259, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 0, "menuItemContents": [ { "id": 7129, "menuItemId": 5259, "courseId": 2526, "sortOrder": 0, "course": { "id": 2526, "dispNameNl": "Bio-erwtensoep", "dispNameEn": "Organic pea soup", "nameNl": "bio-erwtensoep", "nameEn": "", "weight": "500ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": null, "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2526, "courseLogoId": 201 }, { "courseId": 2526, "courseLogoId": 211 }, { "courseId": 2526, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5679, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 1, "menuItemContents": [ { "id": 7705, "menuItemId": 5679, "courseId": 1476, "sortOrder": 0, "course": { "id": 1476, "dispNameNl": "Groentepa\u00eblla", "dispNameEn": "Vegetable paella", "nameNl": "groentepaella, zvv, dd, w (vegan)", "nameEn": "", "weight": "400g", "extra": null, "preparation": "rijst half gaar koken in water met curry en curcuma - erwtjes, paprika, boontjes, wortelen en spruitjes beetgaar voorsteamen. - champignons snijden en aanbakken - bio bouillon oplossen in witte wijn samen met paella mix, pezo, saffraan en lookpasta. - rijst, bouillonmix samen in pan . - mengen en even opbakken - in gastro's van 6cm scheppen, samen met de groentjes en in combi steam (150\u00b0) opwarmen tot gewenste temp. - uitscheppen en afwerken met citroentje en peterselie.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1476, "allergenId": 208 }, { "courseId": 1476, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1476, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5680, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 2, "menuItemContents": [ { "id": 7706, "menuItemId": 5680, "courseId": 1455, "sortOrder": 0, "course": { "id": 1455, "dispNameNl": "Pa\u00eblla met kip en zeevruchten", "dispNameEn": "Chicken and seafood paella", "nameNl": "paella met kip en zeevruchten, dd, z & w", "nameEn": "", "weight": "400g", "extra": null, "preparation": "rijst half gaar koken in water met curcuma - erwtjes en paprika beetgaar voorsteamen. - kip en visbouillon oplossen in witte wijn samen met pezo en lookpasta. - rijst, kip, groenten, mosselvlees, surimiflakes, bouillonmix samen in pan . - mengen en even opbakken - in gastro's van 6cm scheppen en in combi steam (150\u00b0)opwarmen tot gewenste temp. - uitscheppen en afwerken met 2 ringetjes gefrituurde calamares, citroentje en peterselie en de vooraf gebakken kippenonderboutjes,", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1455, "allergenId": 200 }, { "courseId": 1455, "allergenId": 201 }, { "courseId": 1455, "allergenId": 208 }, { "courseId": 1455, "allergenId": 209 }, { "courseId": 1455, "allergenId": 210 }, { "courseId": 1455, "allergenId": 211 }, { "courseId": 1455, "allergenId": 212 }, { "courseId": 1455, "allergenId": 213 } ], "course_CourseLogos": [ { "courseId": 1455, "courseLogoId": 202 }, { "courseId": 1455, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5295, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 3, "menuItemContents": [ { "id": 7214, "menuItemId": 5295, "courseId": 932, "sortOrder": 0, "course": { "id": 932, "dispNameNl": "Proven\u00e7aalse saus", "dispNameEn": "Proven\u00e7al sauce", "nameNl": "proven\u00e7aalse saus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux - afsmaken met pezo en proven\u00e7aalse kruiden. ajuin en champignons aanstoven en kleuren, courgetten (schijven nog eventueel halveren) en paprika toevoegen warme tomatensaus toevoegen en afwerken met proven\u00e7aalse kruiden en peterselie. - eventueel verdunnen met heet water", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 932, "allergenId": 201 }, { "courseId": 932, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7215, "menuItemId": 5295, "courseId": 985, "sortOrder": 0, "course": { "id": 985, "dispNameNl": "kroketten", "dispNameEn": "croquettes", "nameNl": "kroketten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 985, "allergenId": 200 }, { "courseId": 985, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7212, "menuItemId": 5295, "courseId": 1322, "sortOrder": 0, "course": { "id": 1322, "dispNameNl": "Sojasteak", "dispNameEn": "Soy steak", "nameNl": "sojasteak dd", "nameEn": "", "weight": "67g", "extra": null, "preparation": "friteuse voorverwarmen op 180\u00b0c. - steaks kleuren en per 20 stuks op bakplaatjes schikken. - oven voorverwarmen op 180\u00b0c burger verhitten tot kern van 65 graden bereikt is. - in bain-marie schikken. - 1 stuks pp 67 gram pp", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1322, "allergenId": 200 }, { "courseId": 1322, "allergenId": 201 }, { "courseId": 1322, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1322, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5291, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 5, "menuItemContents": [ { "id": 7180, "menuItemId": 5291, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7179, "menuItemId": 5291, "courseId": 3984, "sortOrder": 0, "course": { "id": 3984, "dispNameNl": "gerookte zalm, dille en kruidenkaas", "dispNameEn": "smoked salmon, dill and cream cheese", "nameNl": "pasta met gerookte zalm, dille en kruidenkaas, z", "nameEn": "", "weight": "200g", "extra": null, "preparation": "ajuin snijden en aanstoven - primerba dille toevoegen en room en laten doorkoken - kruidenkaas doorroeren - afsmaken met pezono - broccoliroosjes steamen - kerstomaatjes halveren - groenten toevoegen aan de saus (kerstomaatjes pas op einde toevoegen) - gerookte zalmsnippers toevoegen.", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3984, "allergenId": 203 }, { "courseId": 3984, "allergenId": 208 }, { "courseId": 3984, "allergenId": 211 }, { "courseId": 3984, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 3984, "courseLogoId": 204 }, { "courseId": 3984, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5300, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 6, "menuItemContents": [ { "id": 7197, "menuItemId": 5300, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 7196, "menuItemId": 5300, "courseId": 1425, "sortOrder": 0, "course": { "id": 1425, "dispNameNl": "pasta met paprikaroom & waterkers", "dispNameEn": "paprika cream with garden cress", "nameNl": "00 paprikaroom met waterkers, zvv, dd, z & w (vegan)", "nameEn": "", "weight": "200g", "extra": "vegan", "preparation": "tomatensaus maken: ajuin aanstoven - bestrooien met paprika, look, bloem en tomatenpuree - drogen - water toevoegen + tomatenblokjes - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux afsmaken met pezo en proven\u00e7aalse kruiden. - rode paprika gaar steamen en samen met room bij tomatensaus doen - mixen en eventueel verdunnen/aandikken serveer met een vegan pasta ( volkoren spaghetti speciality anco/ fusili volkoren bio)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1425, "allergenId": 201 }, { "courseId": 1425, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1425, "courseLogoId": 207 }, { "courseId": 1425, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5263, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 11, "menuItemContents": [ { "id": 7133, "menuItemId": 5263, "courseId": 1889, "sortOrder": 0, "course": { "id": 1889, "dispNameNl": "Salade 'Forza'", "dispNameEn": "Forza salad", "nameNl": "salade 'forza' (bulgur, mozzarella), dd, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": null, "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1889, "allergenId": 201 }, { "courseId": 1889, "allergenId": 203 }, { "courseId": 1889, "allergenId": 205 }, { "courseId": 1889, "allergenId": 206 }, { "courseId": 1889, "allergenId": 209 }, { "courseId": 1889, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1889, "courseLogoId": 204 }, { "courseId": 1889, "courseLogoId": 209 }, { "courseId": 1889, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5268, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 11, "menuItemContents": [ { "id": 7138, "menuItemId": 5268, "courseId": 4190, "sortOrder": 0, "course": { "id": 4190, "dispNameNl": "Salade Ankara met groentepat\u00e9 ", "dispNameEn": "Ankara salad with vegetable pat\u00e9 ", "nameNl": "salade ankara met groentepat\u00e9 , w (vegan)", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "conceptsalade winter gril de pompoen. kook de quinoa, met de bouillion, kruid met de yedi baharat kruiden, meng met gesnipperde munt en rode ui en zwarte olijfschijven, leg hier op staafjes van de pat\u00e9. meng de gare bonen en kikkererwten en de gegrilde pompoen. werk af met takjes platte peterselie.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4190, "allergenId": 201 }, { "courseId": 4190, "allergenId": 205 }, { "courseId": 4190, "allergenId": 209 }, { "courseId": 4190, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4190, "courseLogoId": 209 }, { "courseId": 4190, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 5271, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 792, "sortorder": 11, "menuItemContents": [ { "id": 7141, "menuItemId": 5271, "courseId": 4165, "sortOrder": 0, "course": { "id": 4165, "dispNameNl": "Zuiderse pasta met serranoham", "dispNameEn": "Mediterranean pasta with Serrano ham", "nameNl": "salade zuiderse pasta met serrano, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "pasta mengen met cottage sheese, zongedroogde tomaat, gehakte platte peterselie, reepjes gegrilde paprika, olijfolie, peper. hier op de schijfjes olijf, serranoham, veldsla en zonnebloempitten serranoham in hapklare stukken snijden", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4165, "allergenId": 200 }, { "courseId": 4165, "allergenId": 201 }, { "courseId": 4165, "allergenId": 203 }, { "courseId": 4165, "allergenId": 205 }, { "courseId": 4165, "allergenId": 209 }, { "courseId": 4165, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 4165, "courseLogoId": 204 }, { "courseId": 4165, "courseLogoId": 209 }, { "courseId": 4165, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-12_hzs.raw.json ================================================ { "id": 683, "menuDate": "2020-03-12T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 4179, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 0, "menuItemContents": [ { "id": 5803, "menuItemId": 4179, "courseId": 3623, "sortOrder": 0, "course": { "id": 3623, "dispNameNl": "Bio-tomatensoep", "dispNameEn": "Organic tomato soup", "nameNl": "bio-tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "bieslook op het einde toevoegen", "price": 0.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3623, "courseLogoId": 201 }, { "courseId": 3623, "courseLogoId": 211 }, { "courseId": 3623, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4184, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 1, "menuItemContents": [ { "id": 5808, "menuItemId": 4184, "courseId": 403, "sortOrder": 0, "course": { "id": 403, "dispNameNl": "Sweet 'n cheesy ", "dispNameEn": "Sweet 'n cheesy ", "nameNl": "sweet 'n cheesy (smeerkaas, tapenade paprika) dd, w", "nameEn": "", "weight": "250 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 403, "allergenId": 201 }, { "courseId": 403, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 403, "courseLogoId": 210 }, { "courseId": 403, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4189, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 2, "menuItemContents": [ { "id": 5813, "menuItemId": 4189, "courseId": 1867, "sortOrder": 0, "course": { "id": 1867, "dispNameNl": "Pikant chilibroodje", "dispNameEn": "Spicy chilli sandwich", "nameNl": "chilibroodje pikant (rosbief), w", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": "kruiden met piri piri, mag pittig zijn", "price": 3.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1867, "allergenId": 201 }, { "courseId": 1867, "allergenId": 203 }, { "courseId": 1867, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1867, "courseLogoId": 208 }, { "courseId": 1867, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4194, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 3, "menuItemContents": [ { "id": 5818, "menuItemId": 4194, "courseId": 1890, "sortOrder": 0, "course": { "id": 1890, "dispNameNl": "Salade zonder sla", "dispNameEn": "Salad without lettuce", "nameNl": "salade slaatje zonder sla (aardappel, appel, raapjes), dd, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton appel besprenkelen met citroensap", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1890, "allergenId": 200 }, { "courseId": 1890, "allergenId": 203 }, { "courseId": 1890, "allergenId": 205 }, { "courseId": 1890, "allergenId": 206 }, { "courseId": 1890, "allergenId": 208 }, { "courseId": 1890, "allergenId": 209 }, { "courseId": 1890, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1890, "courseLogoId": 209 }, { "courseId": 1890, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4199, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 4, "menuItemContents": [ { "id": 5823, "menuItemId": 4199, "courseId": 3871, "sortOrder": 0, "course": { "id": 3871, "dispNameNl": "Mango-kip-salade", "dispNameEn": "Mango and chicken salad", "nameNl": "salade mango-kip, w", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": null, "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3871, "allergenId": 201 }, { "courseId": 3871, "allergenId": 205 }, { "courseId": 3871, "allergenId": 206 }, { "courseId": 3871, "allergenId": 209 }, { "courseId": 3871, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3871, "courseLogoId": 202 }, { "courseId": 3871, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4204, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 5, "menuItemContents": [ { "id": 5828, "menuItemId": 4204, "courseId": 175, "sortOrder": 0, "course": { "id": 175, "dispNameNl": "Panini Marco Polo", "dispNameEn": "Panini Marco Polo", "nameNl": "panini marco polo (tapenade paprika, cheddar) dd, z & w", "nameEn": "", "weight": "250 g", "extra": "koel bewaren - veggie", "preparation": null, "price": 2.9, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 175, "allergenId": 201 }, { "courseId": 175, "allergenId": 203 }, { "courseId": 175, "allergenId": 205 }, { "courseId": 175, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 175, "courseLogoId": 204 }, { "courseId": 175, "courseLogoId": 210 }, { "courseId": 175, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 4209, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 683, "sortorder": 6, "menuItemContents": [ { "id": 5833, "menuItemId": 4209, "courseId": 1111, "sortOrder": 0, "course": { "id": 1111, "dispNameNl": "Fishburger", "dispNameEn": "Fish burger", "nameNl": "fishburger (met tartaarsaus), z", "nameEn": "", "weight": "300g", "extra": null, "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1111, "allergenId": 200 }, { "courseId": 1111, "allergenId": 201 }, { "courseId": 1111, "allergenId": 203 }, { "courseId": 1111, "allergenId": 204 }, { "courseId": 1111, "allergenId": 208 }, { "courseId": 1111, "allergenId": 210 }, { "courseId": 1111, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1111, "courseLogoId": 210 }, { "courseId": 1111, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-16_cde.raw.json ================================================ { "id": 799, "menuDate": "2020-03-16T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5834, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 799, "sortorder": 10, "menuItemContents": [ { "id": 7910, "menuItemId": 5834, "courseId": 5684, "sortOrder": 0, "course": { "id": 5684, "dispNameNl": "komida is minstens tot en met 3 april gesloten.", "dispNameEn": "From 16/03/2020 till (at least) 3/04/2020 komida will be closed.", "nameNl": "Vanaf 16/03/2020 tot en met 3/04/2020 is komida enkel een shop waar je terecht kan voor belegde broodjes, snoep en drank. Ter plaatse eten kan niet.", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-16_cgb.raw.json ================================================ { "id": 825, "menuDate": "2020-03-16T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5839, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 825, "sortorder": 10, "menuItemContents": [ { "id": 7915, "menuItemId": 5839, "courseId": 5684, "sortOrder": 0, "course": { "id": 5684, "dispNameNl": "komida is minstens tot en met 3 april gesloten.", "dispNameEn": "From 16/03/2020 till (at least) 3/04/2020 komida will be closed.", "nameNl": "Vanaf 16/03/2020 tot en met 3/04/2020 is komida enkel een shop waar je terecht kan voor belegde broodjes, snoep en drank. Ter plaatse eten kan niet.", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-16_cmi.raw.json ================================================ { "id": 602, "menuDate": "2020-03-16T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5846, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 602, "sortorder": 10, "menuItemContents": [ { "id": 7922, "menuItemId": 5846, "courseId": 5684, "sortOrder": 0, "course": { "id": 5684, "dispNameNl": "komida is minstens tot en met 3 april gesloten.", "dispNameEn": "From 16/03/2020 till (at least) 3/04/2020 komida will be closed.", "nameNl": "Vanaf 16/03/2020 tot en met 3/04/2020 is komida enkel een shop waar je terecht kan voor belegde broodjes, snoep en drank. Ter plaatse eten kan niet.", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-16_cmu.raw.json ================================================ { "id": 750, "menuDate": "2020-03-16T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5885, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 750, "sortorder": 10, "menuItemContents": [ { "id": 7960, "menuItemId": 5885, "courseId": 5685, "sortOrder": 0, "course": { "id": 5685, "dispNameNl": "Gesloten van 16/03/2020 tot en met 17/04/2020", "dispNameEn": "Closed from 16/03/2020 till 17/04/2020", "nameNl": "Gesloten van 16/03/2020 tot en met 17/04/2020 ", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-16_cst.raw.json ================================================ { "id": 814, "menuDate": "2020-03-16T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5851, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 814, "sortorder": 10, "menuItemContents": [ { "id": 7927, "menuItemId": 5851, "courseId": 5684, "sortOrder": 0, "course": { "id": 5684, "dispNameNl": "komida is minstens tot en met 3 april gesloten.", "dispNameEn": "From 16/03/2020 till (at least) 3/04/2020 komida will be closed.", "nameNl": "Vanaf 16/03/2020 tot en met 3/04/2020 is komida enkel een shop waar je terecht kan voor belegde broodjes, snoep en drank. Ter plaatse eten kan niet.", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-03-16_hzs.raw.json ================================================ { "id": 685, "menuDate": "2020-03-16T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 5854, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 685, "sortorder": 10, "menuItemContents": [ { "id": 7930, "menuItemId": 5854, "courseId": 5685, "sortOrder": 0, "course": { "id": 5685, "dispNameNl": "Gesloten van 16/03/2020 tot en met 17/04/2020", "dispNameEn": "Closed from 16/03/2020 till 17/04/2020", "nameNl": "Gesloten van 16/03/2020 tot en met 17/04/2020 ", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-25_cde.raw.json ================================================ { "id": 954, "menuDate": "2020-09-25T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7329, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 0, "menuItemContents": [ { "id": 9507, "menuItemId": 7329, "courseId": 997, "sortOrder": 0, "course": { "id": 997, "dispNameNl": "puree", "dispNameEn": "mashed potatoes", "nameNl": "puree zelf bereid", "nameEn": "", "weight": "200g", "extra": null, "preparation": "aardappelen steamen. - in klopper tot puree verwerken met melk en margarine en afsmaken.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 997, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9492, "menuItemId": 7329, "courseId": 1328, "sortOrder": 0, "course": { "id": 1328, "dispNameNl": "Kabeljauwfilet (MSC)", "dispNameEn": "Cod fillet (MSC)", "nameNl": "kabeljauw gestoomd (filet), msc, dd", "nameEn": "", "weight": "150g", "extra": null, "preparation": "kabeljauw dag voordien openleggen en laten ontdooien. - dag zelf stomen op 90 \u00b0c tot kerntemperatuur van 70 \u00b0c - afkruiden", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1328, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1328, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9562, "menuItemId": 7329, "courseId": 3210, "sortOrder": 0, "course": { "id": 3210, "dispNameNl": "fijne groentjes", "dispNameEn": "garden vegetables", "nameNl": "fijne groentjes, z & w (+ extra 0,40 euro)", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7313, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 1, "menuItemContents": [ { "id": 9453, "menuItemId": 7313, "courseId": 5787, "sortOrder": 0, "course": { "id": 5787, "dispNameNl": "Pasta met feta, olijven en paprika ", "dispNameEn": "Pasta with feta, olives and bell pepper ", "nameNl": "Pasta met feta, olijven en paprika ", "nameEn": "", "weight": "200g", "extra": "", "preparation": "groenten aan stoven , tomatenpuree toevoegen , bevochtigen met de gepelde tomaat en water. - als de groenten gaar zijn mixen. - pasta opstomen en mengen met de tomatensaus , overstrooien met de olijven ,paprikareepjes en feta, verkruimel deze over de pasta alternatief: met fijne boontjes en sojaolie", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5787, "allergenId": 200 }, { "courseId": 5787, "allergenId": 201 }, { "courseId": 5787, "allergenId": 203 }, { "courseId": 5787, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 5787, "courseLogoId": 204 }, { "courseId": 5787, "courseLogoId": 207 }, { "courseId": 5787, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 7350, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 2, "menuItemContents": [ { "id": 9520, "menuItemId": 7350, "courseId": 1451, "sortOrder": 0, "course": { "id": 1451, "dispNameNl": "Luikse sla ", "dispNameEn": "Salade li\u00e9geoise ", "nameNl": "luikse sla dd, z - MINDER VLEES", "nameEn": "", "weight": "400g", "extra": "40 gr spek per persoon", "preparation": "boontjes en aardappelen beetgaar steamen . - aardappelen en spek aanbakken in vetstof. - de boontjes toevoegen en alles afkruiden. - gekookte eieren in partjes snijden en ermee onder doen - afwerken met tomatenschijfjes en peterseli. dressing maken met slasaus en mosterd (keuze om mosterd 1 liter of 3 liter te gebruiken)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1451, "allergenId": 200 }, { "courseId": 1451, "allergenId": 201 }, { "courseId": 1451, "allergenId": 203 }, { "courseId": 1451, "allergenId": 204 }, { "courseId": 1451, "allergenId": 205 }, { "courseId": 1451, "allergenId": 206 }, { "courseId": 1451, "allergenId": 208 }, { "courseId": 1451, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1451, "courseLogoId": 212 }, { "courseId": 1451, "courseLogoId": 216 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7483, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 3, "menuItemContents": [ { "id": 9703, "menuItemId": 7483, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7503, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 4, "menuItemContents": [ { "id": 9724, "menuItemId": 7503, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7523, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 5, "menuItemContents": [ { "id": 9744, "menuItemId": 7523, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7543, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 6, "menuItemContents": [ { "id": 9764, "menuItemId": 7543, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7654, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 7, "menuItemContents": [ { "id": 9883, "menuItemId": 7654, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7659, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 8, "menuItemContents": [ { "id": 9888, "menuItemId": 7659, "courseId": 661, "sortOrder": 0, "course": { "id": 661, "dispNameNl": "Brie-appelbagnat ", "dispNameEn": "Pan bagnat with brie and apple ", "nameNl": "brie - appel bagnat (brie, appel, noten) dd, z (veggie)", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "walnoten in stukken hakken indien nodig - citroensap voor bij de appeltjes", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 661, "allergenId": 201 }, { "courseId": 661, "allergenId": 203 }, { "courseId": 661, "allergenId": 204 }, { "courseId": 661, "allergenId": 205 }, { "courseId": 661, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 661, "courseLogoId": 204 }, { "courseId": 661, "courseLogoId": 210 }, { "courseId": 661, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7664, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 9, "menuItemContents": [ { "id": 9893, "menuItemId": 7664, "courseId": 550, "sortOrder": 0, "course": { "id": 550, "dispNameNl": "Kalkoenfinesse met zomergroenten", "dispNameEn": "Turkey finesse with summer vegetables", "nameNl": "kalkoenfinesse met zomergroentjes dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 550, "allergenId": 200 }, { "courseId": 550, "allergenId": 201 }, { "courseId": 550, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 550, "courseLogoId": 202 }, { "courseId": 550, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7669, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 11, "menuItemContents": [ { "id": 9898, "menuItemId": 7669, "courseId": 2341, "sortOrder": 0, "course": { "id": 2341, "dispNameNl": "Worstenbroodje", "dispNameEn": "Sausage roll", "nameNl": "worstenbroodje gevogelte", "nameEn": "", "weight": "160g", "extra": "halal", "preparation": null, "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2341, "courseLogoId": 202 }, { "courseId": 2341, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7674, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 11, "menuItemContents": [ { "id": 9903, "menuItemId": 7674, "courseId": 4884, "sortOrder": 0, "course": { "id": 4884, "dispNameNl": "Rauwkostbox", "dispNameEn": "Crudit\u00e9 box", "nameNl": "00 rauwkostbox z&w (vegan)", "nameEn": "", "weight": "200 gr", "extra": "koel bewaren", "preparation": "rauwkost verwerk de rest van je rauwkost dat in een rauwkostsalade.- voeg geen sauzen toe.- voeg geen opgelegd fruit toe.-", "price": 2.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4884, "allergenId": 200 }, { "courseId": 4884, "allergenId": 201 }, { "courseId": 4884, "allergenId": 202 }, { "courseId": 4884, "allergenId": 203 }, { "courseId": 4884, "allergenId": 204 }, { "courseId": 4884, "allergenId": 205 }, { "courseId": 4884, "allergenId": 206 }, { "courseId": 4884, "allergenId": 207 }, { "courseId": 4884, "allergenId": 208 }, { "courseId": 4884, "allergenId": 209 }, { "courseId": 4884, "allergenId": 210 }, { "courseId": 4884, "allergenId": 211 }, { "courseId": 4884, "allergenId": 212 }, { "courseId": 4884, "allergenId": 213 } ], "course_CourseLogos": [ { "courseId": 4884, "courseLogoId": 209 }, { "courseId": 4884, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7683, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 954, "sortorder": 11, "menuItemContents": [ { "id": 9925, "menuItemId": 7683, "courseId": 5030, "sortOrder": 0, "course": { "id": 5030, "dispNameNl": "Kaasfeuillet\u00e9", "dispNameEn": "Cheese puff pastry", "nameNl": "00 kaasfeuillet\u00e9", "nameEn": "", "weight": null, "extra": null, "preparation": null, "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5030, "allergenId": 201 }, { "courseId": 5030, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5030, "courseLogoId": 204 }, { "courseId": 5030, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-25_cgb.raw.json ================================================ { "id": 983, "menuDate": "2020-09-25T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7575, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 1, "menuItemContents": [ { "id": 9808, "menuItemId": 7575, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7592, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 2, "menuItemContents": [ { "id": 9823, "menuItemId": 7592, "courseId": 3866, "sortOrder": 0, "course": { "id": 3866, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, w (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade winter 6 falafels per persoon- pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox gele wortel in fijne reepjes snijden (optioneel toevoegen) hzs: falafel in oven bakken", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3866, "allergenId": 201 }, { "courseId": 3866, "allergenId": 208 }, { "courseId": 3866, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3866, "courseLogoId": 209 }, { "courseId": 3866, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7581, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 3, "menuItemContents": [ { "id": 9813, "menuItemId": 7581, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7598, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 4, "menuItemContents": [ { "id": 9829, "menuItemId": 7598, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7605, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 5, "menuItemContents": [ { "id": 9835, "menuItemId": 7605, "courseId": 550, "sortOrder": 0, "course": { "id": 550, "dispNameNl": "Kalkoenfinesse met zomergroenten", "dispNameEn": "Turkey finesse with summer vegetables", "nameNl": "kalkoenfinesse met zomergroentjes dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 550, "allergenId": 200 }, { "courseId": 550, "allergenId": 201 }, { "courseId": 550, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 550, "courseLogoId": 202 }, { "courseId": 550, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7611, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 6, "menuItemContents": [ { "id": 9967, "menuItemId": 7611, "courseId": 661, "sortOrder": 0, "course": { "id": 661, "dispNameNl": "Brie-appelbagnat ", "dispNameEn": "Pan bagnat with brie and apple ", "nameNl": "brie - appel bagnat (brie, appel, noten) dd, z (veggie)", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "walnoten in stukken hakken indien nodig - citroensap voor bij de appeltjes", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 661, "allergenId": 201 }, { "courseId": 661, "allergenId": 203 }, { "courseId": 661, "allergenId": 204 }, { "courseId": 661, "allergenId": 205 }, { "courseId": 661, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 661, "courseLogoId": 204 }, { "courseId": 661, "courseLogoId": 210 }, { "courseId": 661, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7617, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 7, "menuItemContents": [ { "id": 9845, "menuItemId": 7617, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7740, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 11, "menuItemContents": [ { "id": 9980, "menuItemId": 7740, "courseId": 1109, "sortOrder": 0, "course": { "id": 1109, "dispNameNl": "Hamburger", "dispNameEn": "Hamburger", "nameNl": "hamburger standaard, z", "nameEn": "", "weight": "300g", "extra": null, "preparation": "keuze om ketchup 3 liter of 1 liter te gebruiken", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1109, "allergenId": 200 }, { "courseId": 1109, "allergenId": 201 }, { "courseId": 1109, "allergenId": 204 }, { "courseId": 1109, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1109, "courseLogoId": 202 }, { "courseId": 1109, "courseLogoId": 208 }, { "courseId": 1109, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7744, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 983, "sortorder": 11, "menuItemContents": [ { "id": 9984, "menuItemId": 7744, "courseId": 1537, "sortOrder": 0, "course": { "id": 1537, "dispNameNl": "Rijstpap", "dispNameEn": "Rice pudding", "nameNl": "rijstpap kant-en klaar", "nameEn": "", "weight": "160g", "extra": "garnituur: bruine suiker", "preparation": "potjes vullen met de kant- en klare rijstpap en bruine suiker erbij serveren", "price": 1.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1537, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1537, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-25_cmi.raw.json ================================================ { "id": 905, "menuDate": "2020-09-25T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7050, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 0, "menuItemContents": [ { "id": 9466, "menuItemId": 7050, "courseId": 997, "sortOrder": 0, "course": { "id": 997, "dispNameNl": "puree", "dispNameEn": "mashed potatoes", "nameNl": "puree zelf bereid", "nameEn": "", "weight": "200g", "extra": null, "preparation": "aardappelen steamen. - in klopper tot puree verwerken met melk en margarine en afsmaken.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 997, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9464, "menuItemId": 7050, "courseId": 1052, "sortOrder": 0, "course": { "id": 1052, "dispNameNl": "ratatouille", "dispNameEn": "ratatouille", "nameNl": "ratatouille", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1052, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9463, "menuItemId": 7050, "courseId": 1274, "sortOrder": 0, "course": { "id": 1274, "dispNameNl": "Groentekrustie ", "dispNameEn": "Vegetarian krustie ", "nameNl": "burger groentekrustie dd", "nameEn": "", "weight": "150g", "extra": null, "preparation": "frituren", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1274, "allergenId": 201 }, { "courseId": 1274, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1274, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "prijs", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "price" } } ] }, { "id": 7049, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 1, "menuItemContents": [ { "id": 9121, "menuItemId": 7049, "courseId": 997, "sortOrder": 0, "course": { "id": 997, "dispNameNl": "puree", "dispNameEn": "mashed potatoes", "nameNl": "puree zelf bereid", "nameEn": "", "weight": "200g", "extra": null, "preparation": "aardappelen steamen. - in klopper tot puree verwerken met melk en margarine en afsmaken.", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 997, "allergenId": 203 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9120, "menuItemId": 7049, "courseId": 1052, "sortOrder": 0, "course": { "id": 1052, "dispNameNl": "ratatouille", "dispNameEn": "ratatouille", "nameNl": "ratatouille", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1052, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9119, "menuItemId": 7049, "courseId": 1741, "sortOrder": 0, "course": { "id": 1741, "dispNameNl": "Visbrochette (MSC)", "dispNameEn": "Fish skewer (MSC)", "nameNl": "visbrochette voorgebakken, msc,dd", "nameEn": "", "weight": "150g", "extra": null, "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1741, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7051, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 2, "menuItemContents": [ { "id": 9126, "menuItemId": 7051, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7693, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 3, "menuItemContents": [ { "id": 9932, "menuItemId": 7693, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7703, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 4, "menuItemContents": [ { "id": 9941, "menuItemId": 7703, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7708, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 5, "menuItemContents": [ { "id": 9946, "menuItemId": 7708, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7713, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 6, "menuItemContents": [ { "id": 9951, "menuItemId": 7713, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7718, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 7, "menuItemContents": [ { "id": 9956, "menuItemId": 7718, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7723, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 8, "menuItemContents": [ { "id": 9961, "menuItemId": 7723, "courseId": 550, "sortOrder": 0, "course": { "id": 550, "dispNameNl": "Kalkoenfinesse met zomergroenten", "dispNameEn": "Turkey finesse with summer vegetables", "nameNl": "kalkoenfinesse met zomergroentjes dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 550, "allergenId": 200 }, { "courseId": 550, "allergenId": 201 }, { "courseId": 550, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 550, "courseLogoId": 202 }, { "courseId": 550, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7729, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 905, "sortorder": 9, "menuItemContents": [ { "id": 9971, "menuItemId": 7729, "courseId": 661, "sortOrder": 0, "course": { "id": 661, "dispNameNl": "Brie-appelbagnat ", "dispNameEn": "Pan bagnat with brie and apple ", "nameNl": "brie - appel bagnat (brie, appel, noten) dd, z (veggie)", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "walnoten in stukken hakken indien nodig - citroensap voor bij de appeltjes", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 661, "allergenId": 201 }, { "courseId": 661, "allergenId": 203 }, { "courseId": 661, "allergenId": 204 }, { "courseId": 661, "allergenId": 205 }, { "courseId": 661, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 661, "courseLogoId": 204 }, { "courseId": 661, "courseLogoId": 210 }, { "courseId": 661, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-25_cmu.raw.json ================================================ { "id": 1007, "menuDate": "2020-09-25T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7840, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1007, "sortorder": 0, "menuItemContents": [ { "id": 10087, "menuItemId": 7840, "courseId": 5820, "sortOrder": 0, "course": { "id": 5820, "dispNameNl": "komida@Mutsaard is voorlopig gesloten, maar u kan wel terecht in komida@Stadscampus", "dispNameEn": "", "nameNl": "komida@Mutsaard is temporarily closed, but you can go to komida@Stadscampus", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-25_cst.raw.json ================================================ { "id": 921, "menuDate": "2020-09-25T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7217, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 0, "menuItemContents": [ { "id": 9307, "menuItemId": 7217, "courseId": 5811, "sortOrder": 0, "course": { "id": 5811, "dispNameNl": "Chili con carne met rijst, tortillachips en zure room ", "dispNameEn": "Chili con carne with rice, tortilla chips and sour cream ", "nameNl": "Chili con carne met rijst, tortillachips en zure room (Iziii)", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 4.2, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5811, "allergenId": 201 }, { "courseId": 5811, "allergenId": 203 }, { "courseId": 5811, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 5811, "courseLogoId": 208 }, { "courseId": 5811, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": false, "menuInfoEn": "" } } ] }, { "id": 7216, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 1, "menuItemContents": [ { "id": 9909, "menuItemId": 7216, "courseId": 1486, "sortOrder": 0, "course": { "id": 1486, "dispNameNl": "Gegratineerde groentemoussaka ", "dispNameEn": "Vegetable moussaka ", "nameNl": "gegratineerde groentemoussaka veggie, zvv, dd, z & w", "nameEn": "", "weight": "400g", "extra": null, "preparation": "stoom de courgetten en aubergines gedurende 15min en laat uitlekken. - stoom de aard.schijfjes 10 min. bereid de witte- en tomatensaus. - kruid de witte saus af met lookpoeder en de tomatensaus met medina, sambal , tijm en paprika gemalen - eminceer de ui en fruit aan en kruiden. - bouw op in een diepe gastronorm als volgt laag aardappelen,courgette,aubergine,ajuin, feta (verkruimel de feta),overgiet met witte saus,bestrooi met gemalen kaas. - bak af op 175 graden gedurende 45 min . - dien op met tomatensaus.", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1486, "allergenId": 201 }, { "courseId": 1486, "allergenId": 203 }, { "courseId": 1486, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 1486, "courseLogoId": 204 }, { "courseId": 1486, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7215, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 2, "menuItemContents": [ { "id": 9305, "menuItemId": 7215, "courseId": 990, "sortOrder": 0, "course": { "id": 990, "dispNameNl": "pasta", "dispNameEn": "pasta", "nameNl": "pasta", "nameEn": "", "weight": "150 - 200g pp", "extra": null, "preparation": "kook de pasta gaar in licht gezouten water, sojaolie toevoegen, - kooktijd is afhankelijk van de soort pasta", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 990, "allergenId": 200 }, { "courseId": 990, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": true, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9304, "menuItemId": 7215, "courseId": 1783, "sortOrder": 0, "course": { "id": 1783, "dispNameNl": "gegrilde kip en chorizo", "dispNameEn": "grilled chicken and chorizo", "nameNl": "pastasaus met gegrilde kip en chorizo, z&w", "nameEn": "", "weight": "200g", "extra": null, "preparation": "pasta koken. kippenreepjes aanbakken in olijfolie met kippenkruiden. chorizo in blokjes snijden. - ajuin aanstoven in olijfolie en look toevoegen. chorizo toevoegen laten stoven. blussen met rode wijn en tomatenblokjes. - afsmaken met peper zout rozemarijn thijm en oregano. - kip aan saus toevoegen. - pasta opwarmen tot een temperatuur boven 65\u00b0c bereikt is. - pasta afwerken met saus, parmezaan en rucola (zomer)/ postelein (winter)", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1783, "allergenId": 200 }, { "courseId": 1783, "allergenId": 203 }, { "courseId": 1783, "allergenId": 208 }, { "courseId": 1783, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1783, "courseLogoId": 202 }, { "courseId": 1783, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7625, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 3, "menuItemContents": [ { "id": 9853, "menuItemId": 7625, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7632, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 4, "menuItemContents": [ { "id": 9910, "menuItemId": 7632, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7639, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 5, "menuItemContents": [ { "id": 9867, "menuItemId": 7639, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7646, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 921, "sortorder": 6, "menuItemContents": [ { "id": 9874, "menuItemId": 7646, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-25_hzs.raw.json ================================================ { "id": 1021, "menuDate": "2020-09-25T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 8034, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 1021, "sortorder": 10, "menuItemContents": [] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-28_cde.raw.json ================================================ { "id": 958, "menuDate": "2020-09-28T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7396, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 0, "menuItemContents": [ { "id": 9576, "menuItemId": 7396, "courseId": 989, "sortOrder": 0, "course": { "id": 989, "dispNameNl": "gekookte aardappelen ", "dispNameEn": "boiled potatoes ", "nameNl": "aardappel natuur", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9575, "menuItemId": 7396, "courseId": 1033, "sortOrder": 0, "course": { "id": 1033, "dispNameNl": "erwten en wortelen", "dispNameEn": "peas and carrots", "nameNl": "erwten en wortelen", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1033, "allergenId": 208 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9574, "menuItemId": 7396, "courseId": 1393, "sortOrder": 0, "course": { "id": 1393, "dispNameNl": "Varkenslapje", "dispNameEn": "Pork escalope", "nameNl": "varkenslapje dd", "nameEn": "", "weight": "140g", "extra": null, "preparation": "voorbakken in braadpan - kruiden. - afbakken in oven", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 1393, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7389, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 1, "menuItemContents": [ { "id": 9565, "menuItemId": 7389, "courseId": 980, "sortOrder": 0, "course": { "id": 980, "dispNameNl": "frieten", "dispNameEn": "fries", "nameNl": "frieten", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": "afbakken in het frituur op 170 graden", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 980, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9564, "menuItemId": 7389, "courseId": 4930, "sortOrder": 0, "course": { "id": 4930, "dispNameNl": "Kibbeling met tartaarsaus", "dispNameEn": "Deep-fried fish with tartar sauce", "nameNl": "kibbeling met tartaarsaus", "nameEn": "", "weight": null, "extra": "foodmarket", "preparation": null, "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4930, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 4930, "courseLogoId": 210 }, { "courseId": 4930, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7383, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 2, "menuItemContents": [ { "id": 9556, "menuItemId": 7383, "courseId": 1562, "sortOrder": 0, "course": { "id": 1562, "dispNameNl": "Pasta met schorseneren in lookboter met ei, olijven en peultjes", "dispNameEn": "salsify in garlic butter, with egg, olives and snow peas", "nameNl": "schorseneren in lookboter, ei, olijven en peultjes, zvv, dd", "nameEn": "", "weight": "200g", "extra": "veggie", "preparation": "beetgare pasta voorzichtig mengen met voorgesteamde peultjes en schorseneren, grof gesneden eitjes, look, olijven, peterselie en gesmolten boter - afkruiden met pezo. - in gn van 6 diep verdelen en opwarmen in combi-steamer. scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton gebruiken", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1562, "allergenId": 200 } ], "course_CourseLogos": [ { "courseId": 1562, "courseLogoId": 207 }, { "courseId": 1562, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7484, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 3, "menuItemContents": [ { "id": 9704, "menuItemId": 7484, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7504, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 4, "menuItemContents": [ { "id": 9725, "menuItemId": 7504, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7524, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 5, "menuItemContents": [ { "id": 9745, "menuItemId": 7524, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7544, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 6, "menuItemContents": [ { "id": 9765, "menuItemId": 7544, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7876, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 11, "menuItemContents": [ { "id": 10115, "menuItemId": 7876, "courseId": 2341, "sortOrder": 0, "course": { "id": 2341, "dispNameNl": "Worstenbroodje", "dispNameEn": "Sausage roll", "nameNl": "worstenbroodje gevogelte", "nameEn": "", "weight": "160g", "extra": "halal", "preparation": null, "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 2341, "courseLogoId": 202 }, { "courseId": 2341, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7881, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 11, "menuItemContents": [ { "id": 10120, "menuItemId": 7881, "courseId": 5030, "sortOrder": 0, "course": { "id": 5030, "dispNameNl": "Kaasfeuillet\u00e9", "dispNameEn": "Cheese puff pastry", "nameNl": "00 kaasfeuillet\u00e9", "nameEn": "", "weight": null, "extra": null, "preparation": null, "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5030, "allergenId": 201 }, { "courseId": 5030, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 5030, "courseLogoId": 204 }, { "courseId": 5030, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7887, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 11, "menuItemContents": [ { "id": 10125, "menuItemId": 7887, "courseId": 1152, "sortOrder": 0, "course": { "id": 1152, "dispNameNl": "Chocolademuffin", "dispNameEn": "Chocolate muffin", "nameNl": "muffin chocolade", "nameEn": "", "weight": null, "extra": null, "preparation": null, "price": 1.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1152, "allergenId": 201 }, { "courseId": 1152, "allergenId": 203 }, { "courseId": 1152, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1152, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7892, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 958, "sortorder": 11, "menuItemContents": [ { "id": 10130, "menuItemId": 7892, "courseId": 1552, "sortOrder": 0, "course": { "id": 1552, "dispNameNl": "Crunchy yoghurt", "dispNameEn": "Crunchy yoghurt", "nameNl": "crunchy yoghurt", "nameEn": "", "weight": "300ml", "extra": null, "preparation": "potjes vullen met krieken en bijvullen met yoghurt - dekseltje vullen met crunchy en op potje zetten keuze om volle of magere yoghurt te gebruiken keuze uit krieken bereid uit blik of krieken uit bokaal (wat je in stock heb)", "price": 1.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1552, "allergenId": 200 }, { "courseId": 1552, "allergenId": 201 }, { "courseId": 1552, "allergenId": 203 }, { "courseId": 1552, "allergenId": 205 }, { "courseId": 1552, "allergenId": 210 }, { "courseId": 1552, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1552, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-28_cgb.raw.json ================================================ { "id": 1011, "menuDate": "2020-09-28T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7915, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 0, "menuItemContents": [ { "id": 10159, "menuItemId": 7915, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7920, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 1, "menuItemContents": [ { "id": 10164, "menuItemId": 7920, "courseId": 661, "sortOrder": 0, "course": { "id": 661, "dispNameNl": "Brie-appelbagnat ", "dispNameEn": "Pan bagnat with brie and apple ", "nameNl": "brie - appel bagnat (brie, appel, noten) dd, z (veggie)", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "walnoten in stukken hakken indien nodig - citroensap voor bij de appeltjes", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 661, "allergenId": 201 }, { "courseId": 661, "allergenId": 203 }, { "courseId": 661, "allergenId": 204 }, { "courseId": 661, "allergenId": 205 }, { "courseId": 661, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 661, "courseLogoId": 204 }, { "courseId": 661, "courseLogoId": 210 }, { "courseId": 661, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7910, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 2, "menuItemContents": [ { "id": 10154, "menuItemId": 7910, "courseId": 550, "sortOrder": 0, "course": { "id": 550, "dispNameNl": "Kalkoenfinesse met zomergroenten", "dispNameEn": "Turkey finesse with summer vegetables", "nameNl": "kalkoenfinesse met zomergroentjes dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 550, "allergenId": 200 }, { "courseId": 550, "allergenId": 201 }, { "courseId": 550, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 550, "courseLogoId": 202 }, { "courseId": 550, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7925, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 3, "menuItemContents": [ { "id": 10169, "menuItemId": 7925, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7930, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 4, "menuItemContents": [ { "id": 10174, "menuItemId": 7930, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7935, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 5, "menuItemContents": [ { "id": 10179, "menuItemId": 7935, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7940, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 6, "menuItemContents": [ { "id": 10184, "menuItemId": 7940, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7946, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 7, "menuItemContents": [ { "id": 10191, "menuItemId": 7946, "courseId": 1081, "sortOrder": 0, "course": { "id": 1081, "dispNameNl": "Croque monsieur ", "dispNameEn": "Croque monsieur ", "nameNl": "croque monsieur standaard, z & w", "nameEn": "", "weight": "200g", "extra": null, "preparation": "keuze uit wit brood of bruin brood om de croque te maken 1 potje saus is in de prijs inbegrepen", "price": 1.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1081, "allergenId": 201 }, { "courseId": 1081, "allergenId": 203 }, { "courseId": 1081, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1081, "courseLogoId": 204 }, { "courseId": 1081, "courseLogoId": 210 }, { "courseId": 1081, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7951, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1011, "sortorder": 8, "menuItemContents": [ { "id": 10196, "menuItemId": 7951, "courseId": 1552, "sortOrder": 0, "course": { "id": 1552, "dispNameNl": "Crunchy yoghurt", "dispNameEn": "Crunchy yoghurt", "nameNl": "crunchy yoghurt", "nameEn": "", "weight": "300ml", "extra": null, "preparation": "potjes vullen met krieken en bijvullen met yoghurt - dekseltje vullen met crunchy en op potje zetten keuze om volle of magere yoghurt te gebruiken keuze uit krieken bereid uit blik of krieken uit bokaal (wat je in stock heb)", "price": 1.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1552, "allergenId": 200 }, { "courseId": 1552, "allergenId": 201 }, { "courseId": 1552, "allergenId": 203 }, { "courseId": 1552, "allergenId": 205 }, { "courseId": 1552, "allergenId": 210 }, { "courseId": 1552, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1552, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-28_cmi.raw.json ================================================ { "id": 906, "menuDate": "2020-09-28T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7324, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 0, "menuItemContents": [ { "id": 9477, "menuItemId": 7324, "courseId": 911, "sortOrder": 0, "course": { "id": 911, "dispNameNl": "currysaus", "dispNameEn": "curry sauce", "nameNl": "currysaus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "ajuin aanstoven-bestrooien met curry, bloem en appelmoes - drogen - water toevoegen + kokosmelk - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux. - afsmaken met pezo", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 911, "allergenId": 201 }, { "courseId": 911, "allergenId": 204 }, { "courseId": 911, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9476, "menuItemId": 7324, "courseId": 999, "sortOrder": 0, "course": { "id": 999, "dispNameNl": "rijst", "dispNameEn": "rice", "nameNl": "rijst", "nameEn": "", "weight": "150g", "extra": null, "preparation": "kook de rijst gaar in licht gezouten water -", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9475, "menuItemId": 7324, "courseId": 1320, "sortOrder": 0, "course": { "id": 1320, "dispNameNl": "Groenteloempia", "dispNameEn": "Vegetable spring roll", "nameNl": "groenteloempia, zvv, dd", "nameEn": "", "weight": "2 stuks per persoon", "extra": null, "preparation": "loemia per 20 op platte gastronorm schikken . - oven voorverwarmen op 180\u00b0. - per 10 stuks op gastroplaten schikken en koelen - loempia verwarmen in de oven tot een temperatuur van minstens 65\u00b0c bereikt is", "price": 4.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1320, "allergenId": 200 }, { "courseId": 1320, "allergenId": 201 }, { "courseId": 1320, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1320, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 10136, "menuItemId": 7324, "courseId": 5827, "sortOrder": 0, "course": { "id": 5827, "dispNameNl": "Gebakken sojascheuten", "dispNameEn": "Fried soy sprouts", "nameNl": "Gebakken sojascheuten", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5827, "allergenId": 210 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 7067, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 1, "menuItemContents": [ { "id": 9474, "menuItemId": 7067, "courseId": 911, "sortOrder": 0, "course": { "id": 911, "dispNameNl": "currysaus", "dispNameEn": "curry sauce", "nameNl": "currysaus", "nameEn": "", "weight": "50 ml", "extra": null, "preparation": "ajuin aanstoven-bestrooien met curry, bloem en appelmoes - drogen - water toevoegen + kokosmelk - 1 uur laten koken op klein vuur - mixen en afbinden met blanke roux. - afsmaken met pezo", "price": 0.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 911, "allergenId": 201 }, { "courseId": 911, "allergenId": 204 }, { "courseId": 911, "allergenId": 211 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9473, "menuItemId": 7067, "courseId": 999, "sortOrder": 0, "course": { "id": 999, "dispNameNl": "rijst", "dispNameEn": "rice", "nameNl": "rijst", "nameEn": "", "weight": "150g", "extra": null, "preparation": "kook de rijst gaar in licht gezouten water -", "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 9472, "menuItemId": 7067, "courseId": 1381, "sortOrder": 0, "course": { "id": 1381, "dispNameNl": "Loempia met kip", "dispNameEn": "Chicken spring roll", "nameNl": "loempia met kip1 dd - MINDER VLEES", "nameEn": "", "weight": "170g", "extra": null, "preparation": "laten ontdooien - 10 minuten voorbakken (op plaatjes met bakpapier) in oven van 180\u00b0c afbakken in frituur", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1381, "allergenId": 200 }, { "courseId": 1381, "allergenId": 201 } ], "course_CourseLogos": [ { "courseId": 1381, "courseLogoId": 202 }, { "courseId": 1381, "courseLogoId": 216 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 10137, "menuItemId": 7067, "courseId": 5827, "sortOrder": 0, "course": { "id": 5827, "dispNameNl": "Gebakken sojascheuten", "dispNameEn": "Fried soy sprouts", "nameNl": "Gebakken sojascheuten", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5827, "allergenId": 210 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 7898, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 2, "menuItemContents": [ { "id": 10138, "menuItemId": 7898, "courseId": 5828, "sortOrder": 0, "course": { "id": 5828, "dispNameNl": "Pasta met kervelroom, jonge spinazie, zongedroogde tomaten en pompoenpitten", "dispNameEn": "Pasta with chervil cream, baby spinach, sun-dried tomatoes and pine nuts", "nameNl": "Pasta met kervelroom, jonge spinazie, zongedroogde tomaten en pompoenpitten", "nameEn": "", "weight": "", "extra": "", "preparation": "voorgegaarde pasta mengen met zongedroogde tomaten, pezo,sojaolie, gember,pesto en sambal oelek. - spinazie en venkel bakken en toevoegen aan de pasta - in gastro's 6 cm scheppen - opwarmen en afwerken met kervel en pompoenpitten.- kervelsaus maken (zie receptuur). - kervelsaus en gemalen kaas apart bijgeven", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5828, "allergenId": 203 }, { "courseId": 5828, "allergenId": 206 } ], "course_CourseLogos": [ { "courseId": 5828, "courseLogoId": 204 }, { "courseId": 5828, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 7899, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 3, "menuItemContents": [ { "id": 10139, "menuItemId": 7899, "courseId": 1561, "sortOrder": 0, "course": { "id": 1561, "dispNameNl": "Pasta met kervel, gerookte zalm en zongedroogde tomaten", "dispNameEn": "chervil, smoked salmon and sun-dried tomatoes", "nameNl": "kervel, gerookte zalm en zongedroogde tomaten", "nameEn": "", "weight": "200g", "extra": null, "preparation": "beetgare pasta mengen met arachideolie, zongedroogde tomaten, lookpasta en zalmsnippers. - afkruiden met pezo en pa\u00eblla mix . in gn van 6 diep verdelen en opwarmen in combi-steamer. - kervel wassen en bovenste gedeelte fijn snijden en over warme pasta strooien samen met de pompoenpitten ; - steeltjes van de kervel met de witte basissaus mengen en mixen en bij gerecht serveren", "price": 4.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1561, "allergenId": 201 }, { "courseId": 1561, "allergenId": 203 }, { "courseId": 1561, "allergenId": 206 }, { "courseId": 1561, "allergenId": 208 }, { "courseId": 1561, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 1561, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7997, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 4, "menuItemContents": [ { "id": 10253, "menuItemId": 7997, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8003, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 5, "menuItemContents": [ { "id": 10258, "menuItemId": 8003, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8009, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 6, "menuItemContents": [ { "id": 10263, "menuItemId": 8009, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8014, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 7, "menuItemContents": [ { "id": 10268, "menuItemId": 8014, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8019, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 8, "menuItemContents": [ { "id": 10273, "menuItemId": 8019, "courseId": 661, "sortOrder": 0, "course": { "id": 661, "dispNameNl": "Brie-appelbagnat ", "dispNameEn": "Pan bagnat with brie and apple ", "nameNl": "brie - appel bagnat (brie, appel, noten) dd, z (veggie)", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": "walnoten in stukken hakken indien nodig - citroensap voor bij de appeltjes", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 661, "allergenId": 201 }, { "courseId": 661, "allergenId": 203 }, { "courseId": 661, "allergenId": 204 }, { "courseId": 661, "allergenId": 205 }, { "courseId": 661, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 661, "courseLogoId": 204 }, { "courseId": 661, "courseLogoId": 210 }, { "courseId": 661, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8024, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 9, "menuItemContents": [ { "id": 10278, "menuItemId": 8024, "courseId": 550, "sortOrder": 0, "course": { "id": 550, "dispNameNl": "Kalkoenfinesse met zomergroenten", "dispNameEn": "Turkey finesse with summer vegetables", "nameNl": "kalkoenfinesse met zomergroentjes dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": "scharrelei gekookt rico 75st of scharrelei gekookt rico 6x12st karton", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 550, "allergenId": 200 }, { "courseId": 550, "allergenId": 201 }, { "courseId": 550, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 550, "courseLogoId": 202 }, { "courseId": 550, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8050, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 906, "sortorder": 11, "menuItemContents": [ { "id": 10304, "menuItemId": 8050, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-28_cmu.raw.json ================================================ { "id": 1027, "menuDate": "2020-09-28T00:00:00", "restaurantId": 5, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 8040, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1027, "sortorder": 0, "menuItemContents": [ { "id": 10294, "menuItemId": 8040, "courseId": 5820, "sortOrder": 0, "course": { "id": 5820, "dispNameNl": "komida@Mutsaard is voorlopig gesloten", "dispNameEn": "", "nameNl": "komida@Mutsaard is temporarily closed", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-28_cst.parsed.expected.yaml ================================================ $test_case: course_of_interest: 7221 reason: | This response originally broke an assumption that a course component that is neither deleted nor enabled should not show. The official site however does show these courses so we should as well. campus: cst date: '2020-09-28' menu: - components: - allergens: - CELERY - MILK_LACTOSE - SOY - SULFITES - WHEAT_GLUTEN attributes: - PASTA - PIG - VEAL name: en: Pasta bolognese sauce nl: Pasta bolognaise external_id: 7219 multiple_prices: true price: '3.80' sort_order: 2 - components: - allergens: - CELERY - MILK_LACTOSE attributes: - CHEESE - VEGGIE name: en: Mexican loaded sweet potato with cheddar cheese and nachos nl: Mexicaans gevulde zoete aardappel met cheddar en nachos external_id: 7220 multiple_prices: true price: '3.80' sort_order: 1 - components: - allergens: - FISH - MILK_LACTOSE - NUTS - PEANUTS - SHELLFISH - SOY attributes: - CHICKEN name: en: Chicken tikka masala with rice and salsa (iziii) nl: Kip tikka masala met grove salsa & rijst (iziii) $test_case: comment: This item has both enabled and deleted set to false external_id: 7221 multiple_prices: true price: '4.80' sort_order: 0 - components: - allergens: - NUTS - SESAME - SHELLFISH - SOY - WHEAT_GLUTEN attributes: - FISH - SALAD name: en: Buddha bowl with scampi nl: Buddha bowl met scampi's external_id: 7626 multiple_prices: true price: '5.00' sort_order: 3 - components: - allergens: - CELERY - SESAME - WHEAT_GLUTEN attributes: - SALAD - VEGAN name: en: Falafel and hummus salad nl: Salade met falafel en humus external_id: 7633 multiple_prices: true price: '3.80' sort_order: 4 - components: - allergens: - MILK_LACTOSE - NUTS - SESAME - SOY - SULFITES - WHEAT_GLUTEN attributes: - CHEESE - SALAD - VEGGIE name: en: "\u2018Classic\u2019 tomato mozzarella" nl: Tomaat-mozzarella 'classic' external_id: 7640 multiple_prices: true price: '4.20' sort_order: 5 - components: - allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - NUTS - PEANUTS - SOY - WHEAT_GLUTEN attributes: - CHICKEN - PIG - SALAD name: en: Summer salad with chicken, bacon strips and avocado nl: Zomerse salade met kip, spekjes en avocado external_id: 7647 multiple_prices: true price: '4.40' sort_order: 6 ================================================ FILE: tests/external_menus/2020-09-28_cst.processed.expected.yaml ================================================ campus: cst date: '2020-09-28' menu: - course_allergens: - CELERY - MILK_LACTOSE - SOY - SULFITES - WHEAT_GLUTEN course_attributes: - PASTA - PIG - VEAL course_sub_type: NORMAL course_type: PASTA external_id: 7219 name: en: Pasta bolognese sauce nl: Pasta bolognaise price_staff: '4.70' price_students: '3.80' - course_allergens: - CELERY - MILK_LACTOSE course_attributes: - CHEESE - VEGGIE course_sub_type: VEGETARIAN course_type: DAILY external_id: 7220 name: en: Mexican loaded sweet potato with cheddar cheese and nachos nl: Mexicaans gevulde zoete aardappel met cheddar en nachos price_staff: '4.70' price_students: '3.80' - course_allergens: - FISH - MILK_LACTOSE - NUTS - PEANUTS - SHELLFISH - SOY course_attributes: - CHICKEN course_sub_type: NORMAL course_type: DAILY external_id: 7221 name: en: Chicken tikka masala with rice and salsa (iziii) nl: Kip tikka masala met grove salsa & rijst (iziii) price_staff: '6.00' price_students: '4.80' - course_allergens: - NUTS - SESAME - SHELLFISH - SOY - WHEAT_GLUTEN course_attributes: - FISH - SALAD course_sub_type: NORMAL course_type: SALAD external_id: 7626 name: en: Buddha bowl with scampi nl: Buddha bowl met scampi's price_staff: '6.20' price_students: '5.00' - course_allergens: - CELERY - SESAME - WHEAT_GLUTEN course_attributes: - SALAD - VEGAN course_sub_type: VEGAN course_type: SALAD external_id: 7633 name: en: Falafel and hummus salad nl: Salade met falafel en humus price_staff: '4.70' price_students: '3.80' - course_allergens: - MILK_LACTOSE - NUTS - SESAME - SOY - SULFITES - WHEAT_GLUTEN course_attributes: - CHEESE - SALAD - VEGGIE course_sub_type: VEGETARIAN course_type: SALAD external_id: 7640 name: en: "\u2018Classic\u2019 tomato mozzarella" nl: Tomaat-mozzarella 'classic' price_staff: '5.20' price_students: '4.20' - course_allergens: - CELERY - EGG - MILK_LACTOSE - MUSTARD - NUTS - PEANUTS - SOY - WHEAT_GLUTEN course_attributes: - CHICKEN - PIG - SALAD course_sub_type: NORMAL course_type: SALAD external_id: 7647 name: en: Summer salad with chicken, bacon strips and avocado nl: Zomerse salade met kip, spekjes en avocado price_staff: '5.50' price_students: '4.40' ================================================ FILE: tests/external_menus/2020-09-28_cst.raw.json ================================================ { "id": 922, "menuDate": "2020-09-28T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 7221, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 0, "menuItemContents": [ { "id": 9312, "menuItemId": 7221, "courseId": 5790, "sortOrder": 0, "course": { "id": 5790, "dispNameNl": "Kip tikka masala met grove salsa & rijst (iziii)", "dispNameEn": "Chicken tikka masala with rice and salsa (iziii)", "nameNl": "Kip tikka masala met grove salsa & rijst (iziii)", "nameEn": "", "weight": "", "extra": "", "preparation": "Stoom de broccoli. - Marineer de kip in de yoghurt tandoorikruiden en garam masala. - ontdoe de kip van de meeste marinade. - verhit de olie en kleur de kip kort. Verwijder de kip en voeg de ui en look toe. - blus met de tomatenblokjes., voeg de currypasta toe en smaak af met pezo,sambal, gember en citroensap. - voeg de kip toe en laat zachtjes garen. - werk af met kokosmelk,koriander en de gestoomde broccoli. Serveer met de rijst en de grove salsa.", "price": 4.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5790, "allergenId": 203 }, { "courseId": 5790, "allergenId": 205 }, { "courseId": 5790, "allergenId": 206 }, { "courseId": 5790, "allergenId": 207 }, { "courseId": 5790, "allergenId": 210 }, { "courseId": 5790, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 5790, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": false, "menuInfoEn": "" } } ] }, { "id": 7220, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 1, "menuItemContents": [ { "id": 9311, "menuItemId": 7220, "courseId": 3461, "sortOrder": 0, "course": { "id": 3461, "dispNameNl": "Mexicaans gevulde zoete aardappel met cheddar en nachos", "dispNameEn": "Mexican loaded sweet potato with cheddar cheese and nachos", "nameNl": "mexicaans gevulde zoete aardappel met cheddar en nachos, dd, zvv, z & w", "nameEn": "", "weight": "400g", "extra": null, "preparation": "1. kuis en spoel de zoete aardappelen grondig en schil ze (indien nodig). stoom deze voor 3/4de gaar. 2. stoof de sjalot, bleekselder, paprikablokjes gaar en voeg de rode bonen en mais toe op het laatste (2.5kg gebruiken van de mais). 3. kruid met pezo, komijn en cayennepeper. 3. halveer de zoete aardappelen / indien dit niet lukt (verschillende groottes) snij dan de zoete aardappelen in plakjes. je mag ze ook deels uithollen, het vulsel mag dan mee vermengd worden met de andere groentjes. 4. stabiliseer de zoete aardappelen in aan gastronorm en doe de groentjes erover. 5. strooi er de cheddar kaas over en laat in oven nog even gratineren. 6. serveer met peterselie en zure room. 7. salade maken (veldsla (winter) /babyleaf (zomer), wortel, rode kool en mais) en afwerken met olijfolie en pezo en tortillachips bijgeven", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3461, "allergenId": 203 }, { "courseId": 3461, "allergenId": 208 } ], "course_CourseLogos": [ { "courseId": 3461, "courseLogoId": 204 }, { "courseId": 3461, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "prijs", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "price" } } ] }, { "id": 7219, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 2, "menuItemContents": [ { "id": 9309, "menuItemId": 7219, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7626, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 3, "menuItemContents": [ { "id": 9854, "menuItemId": 7626, "courseId": 5225, "sortOrder": 0, "course": { "id": 5225, "dispNameNl": "Buddha bowl met scampi's", "dispNameEn": "Buddha bowl with scampi", "nameNl": "00 buddha bowl met scampi's, zomer", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rijst klaar volgens de bereidingswijze op de verpakking, roer de rijstazijn door de rijst als die nog warm is en laat de rijst afkoelen . - stoof de paksoi & breng op smaak met pezo. - maak de scampi's klaar & laat afkoelen.- maak de bowl: doe de rijst in een bowl, maak een rijtje edamame, een rijtje schijfjes wortelen en radijsjes, een rijtje babyleaf, schijfjes avocado (besprenkelt met citroensap) en rijtje paksoi. werk af met de scampi's, koriander en gesneden pijpajuin. serveer hier bij de sojavinaigrette.", "price": 5.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5225, "allergenId": 201 }, { "courseId": 5225, "allergenId": 205 }, { "courseId": 5225, "allergenId": 207 }, { "courseId": 5225, "allergenId": 209 }, { "courseId": 5225, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5225, "courseLogoId": 209 }, { "courseId": 5225, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7633, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 4, "menuItemContents": [ { "id": 9911, "menuItemId": 7633, "courseId": 3865, "sortOrder": 0, "course": { "id": 3865, "dispNameNl": "Salade met falafel en humus", "dispNameEn": "Falafel and hummus salad", "nameNl": "falafel salade met humus, z (vegan)", "nameEn": "", "weight": "300g", "extra": "koel bewaren - vegan", "preparation": "conceptsalade zomer 6 falafels per persoon -pitabroodjes in 4 snijden - 2 stukjes pitabrood mee in saladebox falafel in oven bakken voor hzs", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3865, "allergenId": 201 }, { "courseId": 3865, "allergenId": 208 }, { "courseId": 3865, "allergenId": 209 } ], "course_CourseLogos": [ { "courseId": 3865, "courseLogoId": 209 }, { "courseId": 3865, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7640, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 5, "menuItemContents": [ { "id": 9868, "menuItemId": 7640, "courseId": 3872, "sortOrder": 0, "course": { "id": 3872, "dispNameNl": "Tomaat-mozzarella 'classic'", "dispNameEn": "\u2018Classic\u2019 tomato mozzarella", "nameNl": "tomaat-mozzarella \"classic\", z", "nameEn": "", "weight": "350g", "extra": "koel bewaren - veggie", "preparation": "mozzarella kruiden met pezo - mozzarella onderaan - dan tomaten - dan basilicum - zonnebloempitten en romeinse sla bovenaan - dressing in dressingspotje", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3872, "allergenId": 201 }, { "courseId": 3872, "allergenId": 203 }, { "courseId": 3872, "allergenId": 205 }, { "courseId": 3872, "allergenId": 209 }, { "courseId": 3872, "allergenId": 210 }, { "courseId": 3872, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 3872, "courseLogoId": 204 }, { "courseId": 3872, "courseLogoId": 209 }, { "courseId": 3872, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 7647, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 922, "sortorder": 6, "menuItemContents": [ { "id": 9875, "menuItemId": 7647, "courseId": 5286, "sortOrder": 0, "course": { "id": 5286, "dispNameNl": "Zomerse salade met kip, spekjes en avocado", "dispNameEn": "Summer salad with chicken, bacon strips and avocado", "nameNl": "00 zomerse salade met kip, spekjes & avocado,z", "nameEn": "", "weight": null, "extra": null, "preparation": "bak de kip met de kippenkruiden - bak de spekjes - snij de avocado in partjes en besprenkel ze met citroensap; werk in laagjes: kip, spekjes, avocado, gesneden ijsbergsla en kerstomaten - werk af met hennepzaad, platte peterselie, partjes citroen en een potje dressing", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5286, "allergenId": 200 }, { "courseId": 5286, "allergenId": 201 }, { "courseId": 5286, "allergenId": 203 }, { "courseId": 5286, "allergenId": 204 }, { "courseId": 5286, "allergenId": 205 }, { "courseId": 5286, "allergenId": 206 }, { "courseId": 5286, "allergenId": 208 }, { "courseId": 5286, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5286, "courseLogoId": 202 }, { "courseId": 5286, "courseLogoId": 209 }, { "courseId": 5286, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-09-28_hzs.raw.json ================================================ { "id": 1022, "menuDate": "2020-09-28T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 8035, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1022, "sortorder": 0, "menuItemContents": [ { "id": 10288, "menuItemId": 8035, "courseId": 5829, "sortOrder": 0, "course": { "id": 5829, "dispNameNl": "komida@Hogere Zeevaartschool is momenteel enkel het ophaalpunt voor de online bestellingen: https://nl-20202040ra.iziii.pro/", "dispNameEn": "komida@Hogere Zeevaarschool functions as our pick up point for online orders: https://nl-20202040ra.iziii.pro/", "nameNl": "komida@Hogere Zeevaartschool is momenteel enkel het ophaalpunt voor de online bestellingen: https://nl-20202040ra.iziii.pro/", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-10-26_cde.raw.json ================================================ { "id": 1072, "menuDate": "2020-10-26T00:00:00", "restaurantId": 2, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 8760, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 0, "menuItemContents": [ { "id": 11148, "menuItemId": 8760, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 8410, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 1, "menuItemContents": [ { "id": 10718, "menuItemId": 8410, "courseId": 2323, "sortOrder": 0, "course": { "id": 2323, "dispNameNl": "Bladerdeeg met geitenkaas, rauwkostsalade en frietjes", "dispNameEn": "xx Goat cheese puff pastry with crucit\u00e9s and French fries", "nameNl": "bladerdeeg geitenkaas (rauwkostsalade + frietjes), zvv, dd, z", "nameEn": "", "weight": "400g", "extra": "veggie", "preparation": "bladerdeegjes openleggen op platte plaatjes met boterpapier. - afbakken op 180 \u00b0c. + zomerslaatje erbij serveren (zie lijst zomer rauwkostsalades)", "price": 4.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2323, "allergenId": 200 }, { "courseId": 2323, "allergenId": 201 }, { "courseId": 2323, "allergenId": 202 }, { "courseId": 2323, "allergenId": 203 }, { "courseId": 2323, "allergenId": 204 }, { "courseId": 2323, "allergenId": 205 }, { "courseId": 2323, "allergenId": 206 }, { "courseId": 2323, "allergenId": 207 }, { "courseId": 2323, "allergenId": 208 }, { "courseId": 2323, "allergenId": 209 }, { "courseId": 2323, "allergenId": 210 }, { "courseId": 2323, "allergenId": 211 }, { "courseId": 2323, "allergenId": 212 }, { "courseId": 2323, "allergenId": 213 } ], "course_CourseLogos": [ { "courseId": 2323, "courseLogoId": 204 }, { "courseId": 2323, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9108, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 2, "menuItemContents": [ { "id": 11504, "menuItemId": 9108, "courseId": 1412, "sortOrder": 0, "course": { "id": 1412, "dispNameNl": "Pasta bolognaise", "dispNameEn": "Pasta bolognese sauce", "nameNl": "Pasta bolognaise saus", "nameEn": "", "weight": "200g", "extra": null, "preparation": "gehakt en ajuin aanbakken en alles een kookketel doen met water, groenten gelei bouillon, paprikareepjes ,knolselderblokjes, paprikareepjes, tomatenpuree, lookpasta, laten koken en afkruiden. - voor een half uur en binden met de bruine roux en fond bruin. alternatief: tomatenblokjes dv, sambal en tomatino", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1412, "allergenId": 201 }, { "courseId": 1412, "allergenId": 203 }, { "courseId": 1412, "allergenId": 208 }, { "courseId": 1412, "allergenId": 210 }, { "courseId": 1412, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1412, "courseLogoId": 207 }, { "courseId": 1412, "courseLogoId": 208 }, { "courseId": 1412, "courseLogoId": 212 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8679, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11056, "menuItemId": 8679, "courseId": 5226, "sortOrder": 0, "course": { "id": 5226, "dispNameNl": "Regenboog quinoa bowl", "dispNameEn": "Rainbow quinoa bowl", "nameNl": "00 regenboog quinoa bowl,z (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rode quinoa klaar volgens de bereidingswijze op de verpakking en laat afkoelen.- maak de humusdressing met sojayoghurt en humus, breng op smaak met peper en zout. - maak de bowl: doe de quinoa in een kom, vervolgens baby leaf, rode en groene paprikareepjes, reepjes komkommer, kerstomaten en kikkererwten.- werk af met de prei scheuten, granaatappelpitjes, sesamzaad en de tahini dressing", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5226, "allergenId": 201 }, { "courseId": 5226, "allergenId": 205 }, { "courseId": 5226, "allergenId": 209 }, { "courseId": 5226, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5226, "courseLogoId": 209 }, { "courseId": 5226, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8689, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11066, "menuItemId": 8689, "courseId": 3488, "sortOrder": 0, "course": { "id": 3488, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, z", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - lenteui schuin versnijden, mengen met sojascheuten en paprikablokjes en kokoschilfers- opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3488, "allergenId": 200 }, { "courseId": 3488, "allergenId": 201 }, { "courseId": 3488, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3488, "courseLogoId": 202 }, { "courseId": 3488, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 8709, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11086, "menuItemId": 8709, "courseId": 1865, "sortOrder": 0, "course": { "id": 1865, "dispNameNl": "Salade Dolce Vita", "dispNameEn": "Dolce Vita salad", "nameNl": "salade dolce vita (penne, mozzarella, courgette), dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "meer dan de helft van de salade moet bestaan uit groenten. courgette en aubergine snijden en kort roosteren.pasta koken. - kerstomaat en notensla wassen - olijven laten uitlekken. groenten mengen en afsmaken.- pasta groenten en mozzarella assembleren afwerken met notensla en hennepzaad", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1865, "allergenId": 200 }, { "courseId": 1865, "allergenId": 201 }, { "courseId": 1865, "allergenId": 203 }, { "courseId": 1865, "allergenId": 204 }, { "courseId": 1865, "allergenId": 206 }, { "courseId": 1865, "allergenId": 209 }, { "courseId": 1865, "allergenId": 210 }, { "courseId": 1865, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1865, "courseLogoId": 204 }, { "courseId": 1865, "courseLogoId": 209 }, { "courseId": 1865, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9128, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11538, "menuItemId": 9128, "courseId": 525, "sortOrder": 0, "course": { "id": 525, "dispNameNl": "Caesar salad on a bun", "dispNameEn": "Caesar salad on a bun", "nameNl": "caesar salad on a bun, dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": null, "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 525, "allergenId": 200 }, { "courseId": 525, "allergenId": 203 }, { "courseId": 525, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 525, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9135, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11545, "menuItemId": 9135, "courseId": 4169, "sortOrder": 0, "course": { "id": 4169, "dispNameNl": "Nordic cottage cheese", "dispNameEn": "Nordic cottage cheese", "nameNl": "cottage cheese nordic (ger. zalm)", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4169, "allergenId": 200 }, { "courseId": 4169, "allergenId": 201 }, { "courseId": 4169, "allergenId": 203 }, { "courseId": 4169, "allergenId": 205 }, { "courseId": 4169, "allergenId": 210 }, { "courseId": 4169, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4169, "courseLogoId": 204 }, { "courseId": 4169, "courseLogoId": 210 }, { "courseId": 4169, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9138, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11548, "menuItemId": 9138, "courseId": 1552, "sortOrder": 0, "course": { "id": 1552, "dispNameNl": "Crunchy yoghurt", "dispNameEn": "Crunchy yoghurt", "nameNl": "crunchy yoghurt", "nameEn": "", "weight": "300ml", "extra": null, "preparation": "potjes vullen met krieken en bijvullen met yoghurt - dekseltje vullen met crunchy en op potje zetten keuze om volle of magere yoghurt te gebruiken keuze uit krieken bereid uit blik of krieken uit bokaal (wat je in stock heb)", "price": 1.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1552, "allergenId": 200 }, { "courseId": 1552, "allergenId": 201 }, { "courseId": 1552, "allergenId": 203 }, { "courseId": 1552, "allergenId": 205 }, { "courseId": 1552, "allergenId": 210 }, { "courseId": 1552, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1552, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9141, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11551, "menuItemId": 9141, "courseId": 1532, "sortOrder": 0, "course": { "id": 1532, "dispNameNl": "Panna cotta", "dispNameEn": "Panna cotta", "nameNl": "panna cotta", "nameEn": "", "weight": "160g", "extra": "garnituur: rote grutze/speculoos", "preparation": "zie verpakking - speculoos of rote grutze er bovenop serveren", "price": 1.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1532, "allergenId": 200 }, { "courseId": 1532, "allergenId": 201 }, { "courseId": 1532, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 1532, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9200, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11617, "menuItemId": 9200, "courseId": 3283, "sortOrder": 0, "course": { "id": 3283, "dispNameNl": "New York cheesecake", "dispNameEn": "New York cheese cake", "nameNl": "new york cheesecake", "nameEn": "", "weight": null, "extra": null, "preparation": null, "price": 1.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [], "course_CourseLogos": [ { "courseId": 3283, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9206, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1072, "sortorder": 11, "menuItemContents": [ { "id": 11623, "menuItemId": 9206, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-10-26_cgb.raw.json ================================================ { "id": 1136, "menuDate": "2020-10-26T00:00:00", "restaurantId": 4, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 9182, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 0, "menuItemContents": [ { "id": 11592, "menuItemId": 9182, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 9192, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 10, "menuItemContents": [ { "id": 11603, "menuItemId": 9192, "courseId": 5548, "sortOrder": 0, "course": { "id": 5548, "dispNameNl": "Carrot cake", "dispNameEn": "carrot cake", "nameNl": "Carrot cake", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 2.3, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5548, "allergenId": 200 }, { "courseId": 5548, "allergenId": 201 }, { "courseId": 5548, "allergenId": 203 }, { "courseId": 5548, "allergenId": 205 }, { "courseId": 5548, "allergenId": 210 } ], "course_CourseLogos": [], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9146, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11556, "menuItemId": 9146, "courseId": 530, "sortOrder": 0, "course": { "id": 530, "dispNameNl": "Tuinbroodje ", "dispNameEn": "Spring sandwich ", "nameNl": "tuinbroodje dd, z", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 530, "allergenId": 201 }, { "courseId": 530, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 530, "courseLogoId": 204 }, { "courseId": 530, "courseLogoId": 210 }, { "courseId": 530, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9151, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11561, "menuItemId": 9151, "courseId": 525, "sortOrder": 0, "course": { "id": 525, "dispNameNl": "Caesar salad on a bun", "dispNameEn": "Caesar salad on a bun", "nameNl": "caesar salad on a bun, dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": null, "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 525, "allergenId": 200 }, { "courseId": 525, "allergenId": 203 }, { "courseId": 525, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 525, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9156, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11566, "menuItemId": 9156, "courseId": 4169, "sortOrder": 0, "course": { "id": 4169, "dispNameNl": "Nordic cottage cheese", "dispNameEn": "Nordic cottage cheese", "nameNl": "cottage cheese nordic (ger. zalm)", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4169, "allergenId": 200 }, { "courseId": 4169, "allergenId": 201 }, { "courseId": 4169, "allergenId": 203 }, { "courseId": 4169, "allergenId": 205 }, { "courseId": 4169, "allergenId": 210 }, { "courseId": 4169, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4169, "courseLogoId": 204 }, { "courseId": 4169, "courseLogoId": 210 }, { "courseId": 4169, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9161, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11571, "menuItemId": 9161, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9166, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11576, "menuItemId": 9166, "courseId": 1865, "sortOrder": 0, "course": { "id": 1865, "dispNameNl": "Salade Dolce Vita", "dispNameEn": "Dolce Vita salad", "nameNl": "salade dolce vita (penne, mozzarella, courgette), dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "meer dan de helft van de salade moet bestaan uit groenten. courgette en aubergine snijden en kort roosteren.pasta koken. - kerstomaat en notensla wassen - olijven laten uitlekken. groenten mengen en afsmaken.- pasta groenten en mozzarella assembleren afwerken met notensla en hennepzaad", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1865, "allergenId": 200 }, { "courseId": 1865, "allergenId": 201 }, { "courseId": 1865, "allergenId": 203 }, { "courseId": 1865, "allergenId": 204 }, { "courseId": 1865, "allergenId": 206 }, { "courseId": 1865, "allergenId": 209 }, { "courseId": 1865, "allergenId": 210 }, { "courseId": 1865, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1865, "courseLogoId": 204 }, { "courseId": 1865, "courseLogoId": 209 }, { "courseId": 1865, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9171, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11581, "menuItemId": 9171, "courseId": 5226, "sortOrder": 0, "course": { "id": 5226, "dispNameNl": "Regenboog quinoa bowl", "dispNameEn": "Rainbow quinoa bowl", "nameNl": "00 regenboog quinoa bowl,z (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rode quinoa klaar volgens de bereidingswijze op de verpakking en laat afkoelen.- maak de humusdressing met sojayoghurt en humus, breng op smaak met peper en zout. - maak de bowl: doe de quinoa in een kom, vervolgens baby leaf, rode en groene paprikareepjes, reepjes komkommer, kerstomaten en kikkererwten.- werk af met de prei scheuten, granaatappelpitjes, sesamzaad en de tahini dressing", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5226, "allergenId": 201 }, { "courseId": 5226, "allergenId": 205 }, { "courseId": 5226, "allergenId": 209 }, { "courseId": 5226, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5226, "courseLogoId": 209 }, { "courseId": 5226, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9176, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11586, "menuItemId": 9176, "courseId": 3487, "sortOrder": 0, "course": { "id": 3487, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, w", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - pastinaak grillen en mengen met de wortelen en sojascheuten en kokosschilfers - opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3487, "allergenId": 200 }, { "courseId": 3487, "allergenId": 201 }, { "courseId": 3487, "allergenId": 208 }, { "courseId": 3487, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3487, "courseLogoId": 202 }, { "courseId": 3487, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9187, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1136, "sortorder": 11, "menuItemContents": [ { "id": 11598, "menuItemId": 9187, "courseId": 1113, "sortOrder": 0, "course": { "id": 1113, "dispNameNl": "Mexicano", "dispNameEn": "Mexicano", "nameNl": "mexicano, z", "nameEn": "", "weight": "300g", "extra": null, "preparation": "keuze om ketchup 3 liter of 1 liter te gebruiken", "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1113, "allergenId": 200 }, { "courseId": 1113, "allergenId": 201 }, { "courseId": 1113, "allergenId": 204 }, { "courseId": 1113, "allergenId": 209 }, { "courseId": 1113, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1113, "courseLogoId": 202 }, { "courseId": 1113, "courseLogoId": 208 }, { "courseId": 1113, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-10-26_cmi.raw.json ================================================ { "id": 1104, "menuDate": "2020-10-26T00:00:00", "restaurantId": 3, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 9233, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 0, "menuItemContents": [ { "id": 11652, "menuItemId": 9233, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 9119, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 1, "menuItemContents": [ { "id": 11524, "menuItemId": 9119, "courseId": 1451, "sortOrder": 0, "course": { "id": 1451, "dispNameNl": "Luikse sla ", "dispNameEn": "Salade li\u00e9geoise ", "nameNl": "luikse sla dd, z - MINDER VLEES", "nameEn": "", "weight": "400g", "extra": "40 gr spek per persoon", "preparation": "boontjes en aardappelen beetgaar steamen . - aardappelen en spek aanbakken in vetstof. - de boontjes toevoegen en alles afkruiden. - gekookte eieren in partjes snijden en ermee onder doen - afwerken met tomatenschijfjes en peterseli. dressing maken met slasaus en mosterd (keuze om mosterd 1 liter of 3 liter te gebruiken)", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1451, "allergenId": 200 }, { "courseId": 1451, "allergenId": 201 }, { "courseId": 1451, "allergenId": 203 }, { "courseId": 1451, "allergenId": 204 }, { "courseId": 1451, "allergenId": 205 }, { "courseId": 1451, "allergenId": 206 }, { "courseId": 1451, "allergenId": 208 }, { "courseId": 1451, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 1451, "courseLogoId": 212 }, { "courseId": 1451, "courseLogoId": 216 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9308, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 2, "menuItemContents": [ { "id": 11732, "menuItemId": 9308, "courseId": 5865, "sortOrder": 0, "course": { "id": 5865, "dispNameNl": "Lauw prinsessenslaatje met quorn", "dispNameEn": "Luke-warm green bean salad with quorn pieces ", "nameNl": "Lauw prinsessenslaatje met quorn (tijdelijk COVID)", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.8, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5865, "allergenId": 200 }, { "courseId": 5865, "allergenId": 201 }, { "courseId": 5865, "allergenId": 204 } ], "course_CourseLogos": [ { "courseId": 5865, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": "", "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "" } } ] }, { "id": 9262, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11687, "menuItemId": 9262, "courseId": 3488, "sortOrder": 0, "course": { "id": 3488, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, z", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - lenteui schuin versnijden, mengen met sojascheuten en paprikablokjes en kokoschilfers- opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3488, "allergenId": 200 }, { "courseId": 3488, "allergenId": 201 }, { "courseId": 3488, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3488, "courseLogoId": 202 }, { "courseId": 3488, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9267, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11693, "menuItemId": 9267, "courseId": 1865, "sortOrder": 0, "course": { "id": 1865, "dispNameNl": "Salade Dolce Vita", "dispNameEn": "Dolce Vita salad", "nameNl": "salade dolce vita (penne, mozzarella, courgette), dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "meer dan de helft van de salade moet bestaan uit groenten. courgette en aubergine snijden en kort roosteren.pasta koken. - kerstomaat en notensla wassen - olijven laten uitlekken. groenten mengen en afsmaken.- pasta groenten en mozzarella assembleren afwerken met notensla en hennepzaad", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1865, "allergenId": 200 }, { "courseId": 1865, "allergenId": 201 }, { "courseId": 1865, "allergenId": 203 }, { "courseId": 1865, "allergenId": 204 }, { "courseId": 1865, "allergenId": 206 }, { "courseId": 1865, "allergenId": 209 }, { "courseId": 1865, "allergenId": 210 }, { "courseId": 1865, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1865, "courseLogoId": 204 }, { "courseId": 1865, "courseLogoId": 209 }, { "courseId": 1865, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9272, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11698, "menuItemId": 9272, "courseId": 5226, "sortOrder": 0, "course": { "id": 5226, "dispNameNl": "Regenboog quinoa bowl", "dispNameEn": "Rainbow quinoa bowl", "nameNl": "00 regenboog quinoa bowl,z (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rode quinoa klaar volgens de bereidingswijze op de verpakking en laat afkoelen.- maak de humusdressing met sojayoghurt en humus, breng op smaak met peper en zout. - maak de bowl: doe de quinoa in een kom, vervolgens baby leaf, rode en groene paprikareepjes, reepjes komkommer, kerstomaten en kikkererwten.- werk af met de prei scheuten, granaatappelpitjes, sesamzaad en de tahini dressing", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5226, "allergenId": 201 }, { "courseId": 5226, "allergenId": 205 }, { "courseId": 5226, "allergenId": 209 }, { "courseId": 5226, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5226, "courseLogoId": 209 }, { "courseId": 5226, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9277, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11703, "menuItemId": 9277, "courseId": 5499, "sortOrder": 0, "course": { "id": 5499, "dispNameNl": "Pepper rocket", "dispNameEn": "pepper rocket", "nameNl": "Pepper rocket", "nameEn": "", "weight": "", "extra": "", "preparation": "", "price": 3.4, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5499, "allergenId": 201 }, { "courseId": 5499, "allergenId": 205 }, { "courseId": 5499, "allergenId": 209 }, { "courseId": 5499, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5499, "courseLogoId": 210 }, { "courseId": 5499, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9282, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11708, "menuItemId": 9282, "courseId": 525, "sortOrder": 0, "course": { "id": 525, "dispNameNl": "Caesar salad on a bun", "dispNameEn": "Caesar salad on a bun", "nameNl": "caesar salad on a bun, dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren", "preparation": null, "price": 3.6, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 525, "allergenId": 200 }, { "courseId": 525, "allergenId": 203 }, { "courseId": 525, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 525, "courseLogoId": 210 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9287, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11713, "menuItemId": 9287, "courseId": 4169, "sortOrder": 0, "course": { "id": 4169, "dispNameNl": "Nordic cottage cheese", "dispNameEn": "Nordic cottage cheese", "nameNl": "cottage cheese nordic (ger. zalm)", "nameEn": "", "weight": "250g", "extra": "koel bewaren", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 4169, "allergenId": 200 }, { "courseId": 4169, "allergenId": 201 }, { "courseId": 4169, "allergenId": 203 }, { "courseId": 4169, "allergenId": 205 }, { "courseId": 4169, "allergenId": 210 }, { "courseId": 4169, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 4169, "courseLogoId": 204 }, { "courseId": 4169, "courseLogoId": 210 }, { "courseId": 4169, "courseLogoId": 215 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9293, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 1104, "sortorder": 11, "menuItemContents": [ { "id": 11718, "menuItemId": 9293, "courseId": 530, "sortOrder": 0, "course": { "id": 530, "dispNameNl": "Tuinbroodje ", "dispNameEn": "Spring sandwich ", "nameNl": "tuinbroodje dd, z", "nameEn": "", "weight": "250g", "extra": "koel bewaren - veggie", "preparation": null, "price": 3.1, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 530, "allergenId": 201 }, { "courseId": 530, "allergenId": 203 } ], "course_CourseLogos": [ { "courseId": 530, "courseLogoId": 204 }, { "courseId": 530, "courseLogoId": 210 }, { "courseId": 530, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-10-26_cst.raw.json ================================================ { "id": 932, "menuDate": "2020-10-26T00:00:00", "restaurantId": 1, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 8759, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 932, "sortorder": 0, "menuItemContents": [ { "id": 11147, "menuItemId": 8759, "courseId": 2381, "sortOrder": 0, "course": { "id": 2381, "dispNameNl": "Tomatensoep", "dispNameEn": "Tomato soup", "nameNl": "tomatensoep", "nameEn": "", "weight": "500 ml / 700ml", "extra": "opwarmen in microgolf op 750 watt - roeren en genieten maar! smakelijk!", "preparation": "groenten en aardappelen in water aan de kook brengen met de bio groentebouillon. - daarna de tomatenpuree toevoegen - mixen en afsmaken.", "price": 1.5, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 2381, "allergenId": 201 }, { "courseId": 2381, "allergenId": 208 }, { "courseId": 2381, "allergenId": 209 }, { "courseId": 2381, "allergenId": 210 }, { "courseId": 2381, "allergenId": 211 }, { "courseId": 2381, "allergenId": 212 } ], "course_CourseLogos": [ { "courseId": 2381, "courseLogoId": 211 }, { "courseId": 2381, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": "klein: \u20ac 0.90", "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": true, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": "large: 1.20" } } ] }, { "id": 9197, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 932, "sortorder": 1, "menuItemContents": [ { "id": 11650, "menuItemId": 9197, "courseId": 978, "sortOrder": 0, "course": { "id": 978, "dispNameNl": "pommes Duchesse", "dispNameEn": "pommes duchesse", "nameNl": "aardappelen duchesse", "nameEn": "", "weight": "200g pp", "extra": null, "preparation": null, "price": 0.0, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 978, "allergenId": 200 }, { "courseId": 978, "allergenId": 201 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 11608, "menuItemId": 9197, "courseId": 1378, "sortOrder": 0, "course": { "id": 1378, "dispNameNl": "Koninginnehapje", "dispNameEn": "Chicken vol-au-vent", "nameNl": "koninginnehapje dd", "nameEn": "", "weight": "200g", "extra": null, "preparation": "( zo kort mogelijk voor service bereiden ! ) kippenreepjes en balletjes opwarmen en samen met de gebakken ajuin en champignons mengen in diepe bak warme witte saus + bouillon van kip erover gieten en mengen tot juiste consistentie. - volledig afsmaken en afwerken met peterselie - + pasteitjes 5 min. opwarmen in oven", "price": 5.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1378, "allergenId": 200 }, { "courseId": 1378, "allergenId": 201 }, { "courseId": 1378, "allergenId": 202 }, { "courseId": 1378, "allergenId": 203 }, { "courseId": 1378, "allergenId": 204 }, { "courseId": 1378, "allergenId": 205 }, { "courseId": 1378, "allergenId": 208 }, { "courseId": 1378, "allergenId": 209 }, { "courseId": 1378, "allergenId": 210 }, { "courseId": 1378, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1378, "courseLogoId": 202 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } }, { "id": 11612, "menuItemId": 9197, "courseId": 5514, "sortOrder": 0, "course": { "id": 5514, "dispNameNl": "Rauwkostslaatje", "dispNameEn": "crudit\u00e9s", "nameNl": "rauwkostslaatje", "nameEn": "", "weight": "", "extra": "Dit is een slaatje om bij een gerecht toe te voegen, waarbij de salade al in de prijs gerekend is.", "preparation": "", "price": 0.0, "photo": "", "isCourse": true, "isIngredient": true, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5514, "allergenId": 200 }, { "courseId": 5514, "allergenId": 201 }, { "courseId": 5514, "allergenId": 202 }, { "courseId": 5514, "allergenId": 203 }, { "courseId": 5514, "allergenId": 204 }, { "courseId": 5514, "allergenId": 205 }, { "courseId": 5514, "allergenId": 206 }, { "courseId": 5514, "allergenId": 207 }, { "courseId": 5514, "allergenId": 208 }, { "courseId": 5514, "allergenId": 209 }, { "courseId": 5514, "allergenId": 210 }, { "courseId": 5514, "allergenId": 211 }, { "courseId": 5514, "allergenId": 212 }, { "courseId": 5514, "allergenId": 213 } ], "course_CourseLogos": [], "maincourse": false, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9212, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 932, "sortorder": 11, "menuItemContents": [ { "id": 11629, "menuItemId": 9212, "courseId": 1865, "sortOrder": 0, "course": { "id": 1865, "dispNameNl": "Salade Dolce Vita", "dispNameEn": "Dolce Vita salad", "nameNl": "salade dolce vita (penne, mozzarella, courgette), dd, z", "nameEn": "", "weight": "300g", "extra": "koel bewaren - veggie", "preparation": "meer dan de helft van de salade moet bestaan uit groenten. courgette en aubergine snijden en kort roosteren.pasta koken. - kerstomaat en notensla wassen - olijven laten uitlekken. groenten mengen en afsmaken.- pasta groenten en mozzarella assembleren afwerken met notensla en hennepzaad", "price": 4.2, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 1865, "allergenId": 200 }, { "courseId": 1865, "allergenId": 201 }, { "courseId": 1865, "allergenId": 203 }, { "courseId": 1865, "allergenId": 204 }, { "courseId": 1865, "allergenId": 206 }, { "courseId": 1865, "allergenId": 209 }, { "courseId": 1865, "allergenId": 210 }, { "courseId": 1865, "allergenId": 211 } ], "course_CourseLogos": [ { "courseId": 1865, "courseLogoId": 204 }, { "courseId": 1865, "courseLogoId": 209 }, { "courseId": 1865, "courseLogoId": 214 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9217, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 932, "sortorder": 11, "menuItemContents": [ { "id": 11634, "menuItemId": 9217, "courseId": 3488, "sortOrder": 0, "course": { "id": 3488, "dispNameNl": "Thai bombai salade met kip", "dispNameEn": "Thai Bombai chicken salad", "nameNl": "thai bombai kip salade, z", "nameEn": "", "weight": "350g", "extra": "koel bewaren", "preparation": "kip bakken in olijfolie met pezo - mie koken - mie mengen met de world grill saus - lenteui schuin versnijden, mengen met sojascheuten en paprikablokjes en kokoschilfers- opbouw: mie, kippenreepjes, groentjes en platte peterselie", "price": 4.4, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 3488, "allergenId": 200 }, { "courseId": 3488, "allergenId": 201 }, { "courseId": 3488, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 3488, "courseLogoId": 202 }, { "courseId": 3488, "courseLogoId": 209 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": false, "calculatedMultiplePrices": true, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] }, { "id": 9222, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 1, "remark": null, "menuid": 932, "sortorder": 11, "menuItemContents": [ { "id": 11639, "menuItemId": 9222, "courseId": 5226, "sortOrder": 0, "course": { "id": 5226, "dispNameNl": "Regenboog quinoa bowl", "dispNameEn": "Rainbow quinoa bowl", "nameNl": "00 regenboog quinoa bowl,z (vegan)", "nameEn": "", "weight": null, "extra": null, "preparation": "maak de rode quinoa klaar volgens de bereidingswijze op de verpakking en laat afkoelen.- maak de humusdressing met sojayoghurt en humus, breng op smaak met peper en zout. - maak de bowl: doe de quinoa in een kom, vervolgens baby leaf, rode en groene paprikareepjes, reepjes komkommer, kerstomaten en kikkererwten.- werk af met de prei scheuten, granaatappelpitjes, sesamzaad en de tahini dressing", "price": 3.8, "photo": "", "isCourse": false, "isIngredient": false, "course_CategoryForCourses": null, "course_Allergens": [ { "courseId": 5226, "allergenId": 201 }, { "courseId": 5226, "allergenId": 205 }, { "courseId": 5226, "allergenId": 209 }, { "courseId": 5226, "allergenId": 210 } ], "course_CourseLogos": [ { "courseId": 5226, "courseLogoId": 209 }, { "courseId": 5226, "courseLogoId": 213 } ], "maincourse": true, "menuInfo": null, "fixedMultiplePrices": true, "calculatedMultiplePrices": false, "fixedprice": false, "showFirst": false, "deleted": false, "enabled": true, "menuInfoEn": null } } ] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/2020-10-26_hzs.raw.json ================================================ { "id": 1155, "menuDate": "2020-10-26T00:00:00", "restaurantId": 6, "chefId": 0, "description": null, "approvedById": 0, "approvedDateTime": "0001-01-01T00:00:00", "approved": false, "requestToBeApproved": false, "remark": null, "menuItems": [ { "id": 9261, "nameNl": null, "nameEn": null, "menuTypeId": 0, "chefId": 0, "enabled": 0, "remark": null, "menuid": 1155, "sortorder": 10, "menuItemContents": [] } ], "$schema": "./raw.schema.json" } ================================================ FILE: tests/external_menus/download_external_jsons.py ================================================ import datetime import json import os import sys import time from collections import deque from datetime import datetime, timedelta import requests from jsonschema import ValidationError, Draft7Validator BASE_ENDPOINT = 'https://restickets.uantwerpen.be/' MENU_API = '{endpoint}api/GetMenuByDate/{campus}/{date}' FILE_LOCATION = '{date}_{campus}.raw.json' API_GET_HEADERS = dict() API_GET_HEADERS['Accept'] = 'application/json' campuses = { 'cst': 1, 'cde': 2, 'cmi': 3, 'cgb': 4, 'cmu': 5, 'hzs': 6, } class Limiter: def __init__(self, max_rate: int): self.max_rate = max_rate self.last_times = deque() def __call__(self): now = datetime.now() if len(self.last_times) < self.max_rate: self.last_times.append(now) return delta = (now - self.last_times.popleft()).total_seconds() if delta < 1: time.sleep(1.0 - delta) self.last_times.append(now) if __name__ == '__main__': if len(sys.argv) == 3: do_requests = True elif len(sys.argv) == 4: do_requests = (sys.argv[3] != '0') else: print('Needs 2 parameters: first date and last date. Optionally 3rd parameter 0/1', file=sys.stderr) sys.exit(1) first = datetime.strptime(sys.argv[1], '%Y-%m-%d').date() last = datetime.strptime(sys.argv[2], '%Y-%m-%d').date() session = requests.Session() limiter = Limiter(5) with open('raw.schema.json') as f: schema = json.load(f) Draft7Validator.check_schema(schema) validator = Draft7Validator(schema) violations = [] for date in (first + timedelta(days=x) for x in range(0, (last - first).days + 1)): date: datetime.date if date.isoweekday() > 5: # No weekends continue print('Date:', date, file=sys.stderr) for campus, campus_id in campuses.items(): print('- Campus:', campus, file=sys.stderr) url = MENU_API.format(endpoint=BASE_ENDPOINT, campus=campus_id, date=date.strftime('%Y-%m-%d')) file = FILE_LOCATION.format(campus=campus, date=date.strftime('%Y-%m-%d')) if os.path.isfile(file): print(' Exists', file=sys.stderr) try: with open(file, 'r') as f: data = json.load(f) except json.decoder.JSONDecodeError: print('! Json decode error', file=sys.stderr) continue try: validator.validate(data) except ValidationError as e: print('! Schema validation failed: ', e, file=sys.stderr) violations.append((date, campus)) elif do_requests: limiter() response = session.get(url, headers=API_GET_HEADERS) if 400 <= response.status_code < 500: print(' Client error on HTTP request', file=sys.stderr) continue if 500 <= response.status_code < 600: print(' Server error on HTTP request', file=sys.stderr) continue if response.status_code == 204: print(' No response', file=sys.stderr) continue print(' Response:', response, file=sys.stderr) try: data = json.loads(response.text) except json.decoder.JSONDecodeError: print('! Json decode error: ', response.text, file=sys.stderr) continue data['$schema'] = './raw.schema.json' try: validator.validate(data) except ValidationError as e: print('! Schema validation failed: ', e, file=sys.stderr) violations.append((date, campus)) with open(file, 'w') as f: json.dump(data, f, indent=2) print('Violations:') for violation in violations: print(violation) ================================================ FILE: tests/external_menus/parsed.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ParsedMenu", "type": "object", "properties": { "$schema": true, "date": { "type": "string", "format": "date" }, "campus": { "type": "string", "enum": [ "cst", "cde", "cmi", "cgb", "cmu", "hzs" ] }, "menu": { "type": "array", "items": { "type": "object", "properties": { "external_id": { "type": "integer" }, "components": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "object", "patternProperties": { "^[a-z][a-z]$": { "type": "string" } }, "additionalProperties": false }, "attributes": { "type": "array", "items": { "type": "string", "enum": [ "BIO", "CHICKEN", "GRILL", "CHEESE", "RABBIT", "LAMB", "PASTA", "VEAL", "SALAD", "SNACK", "SOUP", "PIG", "VEGAN", "VEGGIE", "FISH", "LESS_MEAT" ] } }, "allergens": { "type": "array", "items": { "type": "string", "enum": [ "EGG", "WHEAT_GLUTEN", "LUPINE", "MILK_LACTOSE", "MUSTARD", "NUTS", "PEANUTS", "SHELLFISH", "CELERY", "SESAME", "SOY", "SULFITES", "FISH", "MOLLUSKS", "HALAL" ] } } }, "required": [ "name", "attributes", "allergens" ] } }, "price": { "type": "string", "pattern": "([0-9]|[1-9][0-9]+)\\.[0-9][0-9]" }, "multiple_prices": { "type": "boolean" }, "sort_order": { "type": "integer", "minimum": 0, "maximum": 11 } }, "required": [ "external_id", "components", "price", "multiple_prices" ], "additionalProperties": false } } }, "required": [ "date", "campus", "menu" ], "additionalProperties": false } ================================================ FILE: tests/external_menus/processed.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ProcessedMenu", "type": "object", "properties": { "$schema": true, "date": { "type": "string", "format": "date" }, "campus": { "type": "string", "enum": [ "cst", "cde", "cmi", "cgb", "cmu", "hzs" ] }, "menu": { "type": "array", "items": { "type": "object", "properties": { "external_id": { "type": "integer" }, "name": { "type": "object", "patternProperties": { "^[a-z][a-z]$": { "type": "string" } }, "additionalProperties": false }, "course_type": { "type": "string", "enum": [ "SOUP", "DAILY", "PASTA", "GRILL", "SALAD", "SUB", "DESSERT", "SNACK" ] }, "course_sub_type": { "type": "string", "enum": [ "NORMAL", "VEGETARIAN", "VEGAN" ] }, "course_attributes": { "type": "array", "items": { "type": "string", "enum": [ "BIO", "CHICKEN", "GRILL", "CHEESE", "RABBIT", "LAMB", "PASTA", "VEAL", "SALAD", "SNACK", "SOUP", "PIG", "VEGAN", "VEGGIE", "FISH", "LESS_MEAT" ] } }, "course_allergens": { "type": "array", "items": { "type": "string", "enum": [ "EGG", "WHEAT_GLUTEN", "LUPINE", "MILK_LACTOSE", "MUSTARD", "NUTS", "PEANUTS", "SHELLFISH", "CELERY", "SESAME", "SOY", "SULFITES", "FISH", "MOLLUSKS", "HALAL" ] } }, "price_students": { "type": "string", "pattern": "([0-9]|[1-9][0-9]+)\\.[0-9][0-9]" }, "price_staff": { "oneOf": [ { "type": "string", "pattern": "([0-9]|[1-9][0-9]+)\\.[0-9][0-9]" }, { "type": "null" } ] } }, "required": [ "name", "external_id", "course_type", "course_sub_type", "course_attributes", "course_allergens", "price_students", "price_staff" ], "additionalProperties": false } } }, "required": [ "date", "campus", "menu" ], "additionalProperties": false } ================================================ FILE: tests/external_menus/raw.schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "optional_string": { "oneOf": [ { "type": "string" }, { "type": "null" } ] } }, "title": "ExternalMenu", "type": "object", "properties": { "$schema": true, "id": { "type": "integer" }, "menuDate": { "type": "string", "format": "date-time" }, "restaurantId": { "type": "integer" }, "chefId": { "type": "integer", "minimum": 0, "maximum": 0 }, "description": { "type": "null" }, "approvedById": { "type": "integer", "minimum": 0, "maximum": 0 }, "approvedDateTime": { "type": "string", "format": "date-time" }, "approved": { "type": "boolean", "enum": [ false ] }, "requestToBeApproved": { "type": "boolean", "enum": [ false ] }, "remark": { "type": "null" }, "menuItems": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "nameNl": { "type": "null" }, "nameEn": { "type": "null" }, "menuTypeId": { "type": "integer", "minimum": 0, "maximum": 0 }, "chefId": { "type": "integer", "minimum": 0, "maximum": 0 }, "enabled": { "type": "integer", "minimum": 0, "maximum": 2 }, "remark": { "type": "null" }, "menuid": { "type": "integer" }, "sortorder": { "type": "integer", "minimum": 0, "maximum": 11 }, "menuItemContents": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "menuItemId": { "type": "integer" }, "courseId": { "type": "integer" }, "sortOrder": { "type": "integer", "minimum": 0, "maximum": 0 }, "course": { "type": "object", "properties": { "id": { "type": "integer" }, "dispNameNl": { "type": "string" }, "dispNameEn": { "type": "string" }, "nameNl": { "type": "string" }, "nameEn": { "type": "string" }, "weight": { "$ref": "#/definitions/optional_string" }, "extra": { "$ref": "#/definitions/optional_string" }, "preparation": { "$ref": "#/definitions/optional_string" }, "price": { "type": "number", "minimum": 0.0 }, "photo": { "type": "string", "enum": [ "" ] }, "isCourse": { "type": "boolean" }, "isIngredient": { "type": "boolean" }, "course_CategoryForCourses": { "type": "null" }, "course_Allergens": { "type": "array", "items": { "type": "object", "properties": { "courseId": { "type": "integer" }, "allergenId": { "type": "integer" } }, "required": [ "courseId", "allergenId" ], "additionalProperties": false } }, "course_CourseLogos": { "type": "array", "items": { "type": "object", "properties": { "courseId": { "type": "integer" }, "courseLogoId": { "type": "integer" } }, "required": [ "courseId", "courseLogoId" ], "additionalProperties": false } }, "maincourse": { "type": "boolean" }, "menuInfo": { "$ref": "#/definitions/optional_string" }, "fixedMultiplePrices": { "type": "boolean" }, "calculatedMultiplePrices": { "type": "boolean" }, "fixedprice": { "type": "boolean" }, "showFirst": { "type": "boolean" }, "deleted": { "type": "boolean" }, "enabled": { "type": "boolean" }, "menuInfoEn": { "$ref": "#/definitions/optional_string" } }, "required": [ "id", "dispNameNl", "dispNameEn", "nameNl", "nameEn", "weight", "extra", "preparation", "price", "photo", "isCourse", "isIngredient", "course_CategoryForCourses", "course_Allergens", "course_CourseLogos", "maincourse", "menuInfo", "fixedMultiplePrices", "calculatedMultiplePrices", "fixedprice", "showFirst", "deleted", "enabled", "menuInfoEn" ], "additionalProperties": false } }, "required": [ "id", "menuItemId", "courseId", "sortOrder", "course" ] } } }, "required": [ "id", "nameNl", "nameEn", "menuTypeId", "chefId", "enabled", "remark", "menuid", "sortorder", "menuItemContents" ], "additionalProperties": false } } }, "required": [ "id", "menuDate", "restaurantId", "chefId", "description", "approvedById", "approvedDateTime", "approved", "requestToBeApproved", "remark", "menuItems" ], "additionalProperties": false } ================================================ FILE: tests/test_debug_state.py ================================================ import unittest from komidabot.debug.state import DebuggableException, ProgramStateTrace, SimpleProgramState class TestConstants(unittest.TestCase): """ Tests to see if komidabot.debug.state works properly. """ def test_no_raise(self): # Checks that ProgramStateTrace.state does not raise on its self. debug_state = ProgramStateTrace() with debug_state.state(SimpleProgramState('Test state')): pass def test_simple_raise(self): # Checks that ProgramStateTrace.state catches exceptions and rethrows them as DebuggableException. debug_state = ProgramStateTrace() with self.assertRaises(DebuggableException) as caught: with debug_state.state(SimpleProgramState('Test state 1')): raise Exception('Test exception') expected = '\n'.join([ "Program state trace:", "- InitialState", "- State('Test state 1', None)", ]) ex: DebuggableException = caught.exception self.assertEqual(expected, repr(ex.get_trace())) def test_simple_nested(self): # Checks that simple nested states are properly returned debug_state = ProgramStateTrace() with self.assertRaises(DebuggableException) as caught: with debug_state.state(SimpleProgramState('Test state 1')): with debug_state.state(SimpleProgramState('Test state 2')): raise Exception('Test exception') expected = '\n'.join([ "Program state trace:", "- InitialState", "- State('Test state 1', None)", "- State('Test state 2', None)", ]) ex: DebuggableException = caught.exception self.assertEqual(expected, repr(ex.get_trace())) def test_simple_branched(self): # Checks that only the branch where the exception occurred is returned debug_state = ProgramStateTrace() with self.assertRaises(DebuggableException) as caught: with debug_state.state(SimpleProgramState('Test state 1')): with debug_state.state(SimpleProgramState('Test state 1a')): pass with debug_state.state(SimpleProgramState('Test state 1b')): pass with debug_state.state(SimpleProgramState('Test state 1c')): pass with debug_state.state(SimpleProgramState('Test state 2')): with debug_state.state(SimpleProgramState('Test state 2a')): pass with debug_state.state(SimpleProgramState('Test state 2b')): raise Exception('Test exception') with debug_state.state(SimpleProgramState('Test state 2c')): pass with debug_state.state(SimpleProgramState('Test state 3')): with debug_state.state(SimpleProgramState('Test state 3a')): pass with debug_state.state(SimpleProgramState('Test state 3b')): pass with debug_state.state(SimpleProgramState('Test state 3c')): pass expected = '\n'.join([ "Program state trace:", "- InitialState", "- State('Test state 2', None)", "- State('Test state 2b', None)", ]) ex: DebuggableException = caught.exception self.assertEqual(expected, repr(ex.get_trace())) def test_multi_nested(self): # Checks that nested states from different traces are properly returned debug_state1 = ProgramStateTrace() with self.assertRaises(DebuggableException) as caught: with debug_state1.state(SimpleProgramState('Test state 1')): debug_state2 = ProgramStateTrace() with debug_state2.state(SimpleProgramState('Test state 2')): raise Exception('Test exception') expected = '\n'.join([ "Program state trace:", "- InitialState", "- State('Test state 1', None)", "- InitialState", "- State('Test state 2', None)", ]) ex: DebuggableException = caught.exception self.assertEqual(expected, repr(ex.get_trace())) def test_multi_branched(self): # Checks that only the branch where the exception occurred is returned, even if we have different traces debug_state1 = ProgramStateTrace() with self.assertRaises(DebuggableException) as caught: with debug_state1.state(SimpleProgramState('Test state 1')): debug_state2 = ProgramStateTrace() with debug_state2.state(SimpleProgramState('Test state 1a')): pass with debug_state2.state(SimpleProgramState('Test state 1b')): pass with debug_state2.state(SimpleProgramState('Test state 1c')): pass with debug_state1.state(SimpleProgramState('Test state 2')): debug_state2 = ProgramStateTrace() with debug_state2.state(SimpleProgramState('Test state 2a')): pass with debug_state2.state(SimpleProgramState('Test state 2b')): raise Exception('Test exception') with debug_state2.state(SimpleProgramState('Test state 2c')): pass with debug_state1.state(SimpleProgramState('Test state 3')): debug_state2 = ProgramStateTrace() with debug_state2.state(SimpleProgramState('Test state 3a')): pass with debug_state2.state(SimpleProgramState('Test state 3b')): pass with debug_state2.state(SimpleProgramState('Test state 3c')): pass expected = '\n'.join([ "Program state trace:", "- InitialState", "- State('Test state 2', None)", "- InitialState", "- State('Test state 2b', None)", ]) ex: DebuggableException = caught.exception self.assertEqual(expected, repr(ex.get_trace())) ================================================ FILE: tests/test_external_menu.py ================================================ import glob import json import os import re from typing import Any, Dict, List, Union import yaml from jsonschema import Draft7Validator import komidabot.external_menu as external_menu import komidabot.models as models from extensions import db from tests.base import BaseTestCase, HttpCapture def filter_meta(value: Union[List[Any], Dict[str, Any]]): if isinstance(value, dict): for key in list(value.keys()): if key.startswith('$'): del value[key] else: filter_meta(value[key]) elif isinstance(value, list): for item in value: filter_meta(item) class TestExternalMenu(BaseTestCase): def setUp(self): super().setUp() self.campuses = { 'cst': models.Campus.create('Stadscampus', 'cst', ['stad', 'stadscampus'], 1), 'cde': models.Campus.create('Campus Drie Eiken', 'cde', ['drie', 'eiken'], 2), 'cmi': models.Campus.create('Campus Middelheim', 'cmi', ['middelheim'], 3), 'cgb': models.Campus.create('Campus Groenenborger', 'cgb', ['groenenborger'], 4), 'cmu': models.Campus.create('Campus Mutsaard', 'cmu', ['mutsaard'], 5), 'hzs': models.Campus.create('Hogere Zeevaartschool', 'hzs', ['hogere', 'zeevaartschool'], 6), } db.session.commit() self.assertEqual(self.campuses['cst'].id, 1) self.validator_raw = TestExternalMenu.create_validator('raw.schema.json') self.validator_parsed = TestExternalMenu.create_validator('parsed.schema.json') self.validator_processed = TestExternalMenu.create_validator('processed.schema.json') @staticmethod def create_validator(schema): with open(os.path.join(os.path.dirname(__file__), 'external_menus', schema)) as f: schema = json.load(f) Draft7Validator.check_schema(schema) return Draft7Validator(schema) def test_saved_requests(self): saved_files = glob.glob(os.path.join(os.path.dirname(__file__), 'external_menus', '*.raw.json')) self.maxDiff = 5000 old_convert_price = external_menu._convert_price # conversion_table = {} def _convert_price(price_students): price_students = str(price_students) # nonlocal conversion_table # # if price_students in conversion_table: # return conversion_table[price_students] # # conversion_table[price_students] = old_convert_price(price_students) # # return conversion_table[price_students] return { '3.20': '4.00', # external ID 1 '3.40': '4.20', # external ID 2 '3.60': '4.50', # external ID 3 '3.80': '4.70', # external ID 4 '4.00': '5.00', # external ID 5 '4.20': '5.20', # external ID 6 '4.40': '5.50', # external ID 7 '4.60': '5.70', # external ID 8 '4.80': '6.00', # external ID 9 '5.00': '6.20', # external ID 10 '5.20': '6.50', # external ID 11 '5.40': '6.70', # external ID 12 '5.60': '7.00', # external ID 13 }.get(price_students, price_students) external_menu._convert_price = _convert_price for saved_file in sorted(saved_files): with HttpCapture(): # Ensure no requests are made with self.subTest(file=os.path.basename(saved_file)): with self.app.app_context(): parsed_out = re.sub(r'raw\.json$', 'parsed.yaml', saved_file) parsed_expected = re.sub(r'raw\.json$', 'parsed.expected.yaml', saved_file) processed_out = re.sub(r'raw\.json$', 'processed.yaml', saved_file) processed_expected = re.sub(r'raw\.json$', 'processed.expected.yaml', saved_file) with open(saved_file, 'r') as f: data_raw = json.load(f) self.validator_raw.validate(data_raw) data_parsed = external_menu.parse_fetched(data_raw) with open(parsed_out, 'w') as f: yaml.safe_dump(data_parsed, f, indent=2) if os.path.exists(parsed_expected): with open(parsed_expected, 'r') as f: data_parsed_expected = yaml.safe_load(f) filter_meta(data_parsed_expected) self.assertEqual(yaml.safe_dump(data_parsed_expected), yaml.safe_dump(data_parsed)) # If we already know what is expected, this file will contain the same contents and as such # we do not need to keep it around os.remove(parsed_out) self.validator_parsed.validate(data_parsed) data_processed = external_menu.process_parsed(data_parsed) with open(processed_out, 'w') as f: yaml.safe_dump(data_processed, f, indent=2) if os.path.exists(processed_expected): with open(processed_expected, 'r') as f: data_processed_expected = yaml.safe_load(f) filter_meta(data_processed_expected) self.assertEqual(yaml.safe_dump(data_processed_expected), yaml.safe_dump(data_processed)) # If we already know what is expected, this file will contain the same contents and as such # we do not need to keep it around os.remove(processed_out) self.validator_processed.validate(data_processed) # Try and update the menu, this shouldn't cause any issues really. # However we won't check if this was added properly to the database, # different tests should cover this external_menu.update_menu(data_processed) external_menu._convert_price = old_convert_price ================================================ FILE: tests/test_models_campus.py ================================================ from sqlalchemy import inspect import komidabot.models as models from app import db from tests.base import BaseTestCase class TestModelsCampus(BaseTestCase): """ Test models.Campus """ def test_simple_constructors(self): # Test constructor of Campus model with self.app.app_context(): campus1 = models.Campus('Testcampus', 'ctst') campus1.external_id = 900 campus2 = models.Campus('Campus Omega', 'com') campus2.external_id = 800 campus3 = models.Campus('Campus Paardenmarkt', 'cpm') campus3.external_id = 700 # Ensure that the constructor does not add the entities to the database self.assertTrue(inspect(campus1).transient) self.assertTrue(inspect(campus2).transient) self.assertTrue(inspect(campus3).transient) db.session.add(campus1) db.session.add(campus2) db.session.add(campus3) # Flush makes sure default values are actually assigned db.session.flush() self.assertTrue(campus1.active) self.assertTrue(campus2.active) self.assertTrue(campus3.active) db.session.commit() # noinspection PyTypeChecker def test_invalid_constructors(self): # Test constructor of Campus model with self.app.app_context(): with self.assertRaises(ValueError): models.Campus(None, 'ctst') with self.assertRaises(ValueError): models.Campus(13, 'ctst') with self.assertRaises(ValueError): models.Campus('Testcampus', None) with self.assertRaises(ValueError): models.Campus('Testcampus', 42) def test_create(self): # Test usage of Campus.create with add_to_db set to True with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900, add_to_db=True) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800, add_to_db=True) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700, add_to_db=True) # Ensure that the create method adds the entities to the database self.assertFalse(inspect(campus1).transient) self.assertFalse(inspect(campus2).transient) self.assertFalse(inspect(campus3).transient) # Flush makes sure default values are actually assigned db.session.flush() self.assertTrue(campus1.active) self.assertTrue(campus2.active) self.assertTrue(campus3.active) db.session.commit() def test_create_no_add_to_db(self): # Test usage of Campus.create with add_to_db set to False with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900, add_to_db=False) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800, add_to_db=False) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700, add_to_db=False) # Ensure that the create method does not add the entities to the database self.assertTrue(inspect(campus1).transient) self.assertTrue(inspect(campus2).transient) self.assertTrue(inspect(campus3).transient) db.session.add(campus1) db.session.add(campus2) db.session.add(campus3) db.session.commit() # FIXME: Duplicate keywords will not be allowed in the near future def test_keywords(self): # Test keywords methods with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) db.session.commit() kw1 = campus1.get_keywords() kw2 = campus2.get_keywords() kw3 = campus3.get_keywords() # Campus 1 self.assertIn('ctst', kw1) self.assertNotIn('com', kw1) self.assertNotIn('cpm', kw1) self.assertIn('keyword1', kw1) self.assertNotIn('keyword2', kw1) self.assertNotIn('keyword3', kw1) self.assertIn('shared_keyword', kw1) self.assertNotIn('extra_keyword', kw1) # Campus 2 self.assertNotIn('ctst', kw2) self.assertIn('com', kw2) self.assertNotIn('cpm', kw2) self.assertNotIn('keyword1', kw2) self.assertIn('keyword2', kw2) self.assertNotIn('keyword3', kw2) self.assertIn('shared_keyword', kw2) self.assertNotIn('extra_keyword', kw2) # Campus 3 self.assertNotIn('ctst', kw3) self.assertNotIn('com', kw3) self.assertIn('cpm', kw3) self.assertNotIn('keyword1', kw3) self.assertNotIn('keyword2', kw3) self.assertIn('keyword3', kw3) self.assertIn('shared_keyword', kw3) self.assertNotIn('extra_keyword', kw3) campus1.remove_keyword('keyword1') campus3.add_keyword('extra_keyword') db.session.commit() kw1 = campus1.get_keywords() kw2 = campus2.get_keywords() kw3 = campus3.get_keywords() # Campus 1 self.assertIn('ctst', kw1) self.assertNotIn('com', kw1) self.assertNotIn('cpm', kw1) self.assertNotIn('keyword1', kw1) self.assertNotIn('keyword2', kw1) self.assertNotIn('keyword3', kw1) self.assertIn('shared_keyword', kw1) self.assertNotIn('extra_keyword', kw1) # Campus 2 self.assertNotIn('ctst', kw2) self.assertIn('com', kw2) self.assertNotIn('cpm', kw2) self.assertNotIn('keyword1', kw2) self.assertIn('keyword2', kw2) self.assertNotIn('keyword3', kw2) self.assertIn('shared_keyword', kw2) self.assertNotIn('extra_keyword', kw2) # Campus 3 self.assertNotIn('ctst', kw3) self.assertNotIn('com', kw3) self.assertIn('cpm', kw3) self.assertNotIn('keyword1', kw3) self.assertNotIn('keyword2', kw3) self.assertIn('keyword3', kw3) self.assertIn('shared_keyword', kw3) self.assertIn('extra_keyword', kw3) with self.assertRaises(ValueError): campus1.add_keyword('keyword with spaces') def test_get_by_id(self): # Test getting a Campus object by its ID with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) db.session.commit() self.assertEqual(campus1, models.Campus.get_by_id(campus1.id)) self.assertEqual(campus2, models.Campus.get_by_id(campus2.id)) self.assertEqual(campus3, models.Campus.get_by_id(campus3.id)) def test_get_by_external_id(self): # Test getting a Campus object by its ID with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) db.session.commit() self.assertEqual(campus1, models.Campus.get_by_external_id(campus1.external_id)) self.assertEqual(campus2, models.Campus.get_by_external_id(campus2.external_id)) self.assertEqual(campus3, models.Campus.get_by_external_id(campus3.external_id)) def test_get_by_short_name(self): # Test getting a Campus object by its short name with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) db.session.commit() self.assertEqual(campus1, models.Campus.get_by_short_name('ctst')) self.assertEqual(campus2, models.Campus.get_by_short_name('com')) self.assertEqual(campus3, models.Campus.get_by_short_name('cpm')) # FIXME: Duplicate keywords will not be allowed in the near future -> Results will be Optional[Campus] def test_find_by_keyword(self): # Test getting campuses by a keyword with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) campus3.active = False db.session.commit() self.assertEqual(models.Campus.find_by_keyword('ctst'), [campus1]) self.assertEqual(models.Campus.find_by_keyword('com'), [campus2]) self.assertEqual(models.Campus.find_by_keyword('cpm'), [campus3]) self.assertEqual(models.Campus.find_by_keyword('keyword1'), [campus1]) self.assertEqual(models.Campus.find_by_keyword('keyword2'), [campus2]) self.assertEqual(models.Campus.find_by_keyword('keyword3'), [campus3]) campuses = models.Campus.find_by_keyword('shared_keyword') ids = [campus.id for campus in campuses] self.assertEqual(len(campuses), 3) self.assertEqual(len(ids), 3) self.assertIn(campus1.id, ids) self.assertIn(campus2.id, ids) self.assertIn(campus3.id, ids) def test_get_all(self): # Test getting all campuses with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) campus3.active = False db.session.commit() campuses = models.Campus.get_all() ids = [campus.id for campus in campuses] self.assertEqual(len(campuses), 3) self.assertEqual(len(ids), 3) self.assertIn(campus1.id, ids) self.assertIn(campus2.id, ids) self.assertIn(campus3.id, ids) def test_get_all_active(self): # Test getting all campuses marked as active with self.app.app_context(): campus1 = models.Campus.create('Testcampus', 'ctst', ['keyword1', 'shared_keyword'], 900) campus2 = models.Campus.create('Campus Omega', 'com', ['keyword2', 'shared_keyword'], 800) campus3 = models.Campus.create('Campus Paardenmarkt', 'cpm', ['keyword3', 'shared_keyword'], 700) campus3.active = False db.session.commit() campuses = models.Campus.get_all_active() ids = [campus.id for campus in campuses] self.assertEqual(len(campuses), 2) self.assertEqual(len(ids), 2) self.assertIn(campus1.id, ids) self.assertIn(campus2.id, ids) self.assertNotIn(campus3.id, ids) ================================================ FILE: tests/test_models_closing_days.py ================================================ from sqlalchemy import inspect import komidabot.models as models import tests.utils as utils from app import db from tests.base import BaseTestCase class TestModelsClosingDays(BaseTestCase): """ Test models.ClosingDays """ def setUp(self): super().setUp() self.create_test_campuses() def test_simple_constructors(self): # Test constructor of ClosingDays model with self.app.app_context(): db.session.add_all(self.campuses) translatable1, _ = self.create_translation({'en': 'Translation 1: en'}, 'en', has_context=True) translatable2, _ = self.create_translation({'en': 'Translation 2: en'}, 'en', has_context=True) translatable3, _ = self.create_translation({'en': 'Translation 3: en'}, 'en', has_context=True) closed1 = models.ClosingDays(self.campuses[0].id, utils.DAYS['MON'], utils.DAYS['MON'], translatable1.id) closed2 = models.ClosingDays(self.campuses[1].id, utils.DAYS['TUE'], utils.DAYS['FRI'], translatable2.id) closed3 = models.ClosingDays(self.campuses[2].id, utils.DAYS['THU'], utils.DAYS['THU'], translatable3.id) # Ensure that the constructor does not add the entities to the database self.assertTrue(inspect(closed1).transient) self.assertTrue(inspect(closed2).transient) self.assertTrue(inspect(closed3).transient) db.session.add(closed1) db.session.add(closed2) db.session.add(closed3) db.session.commit() # noinspection PyTypeChecker def test_invalid_constructors(self): # Test constructor of Campus model with self.app.app_context(): db.session.add_all(self.campuses) translatable1, _ = self.create_translation({'en': 'Translation 1: en'}, 'en', has_context=True) with self.assertRaises(ValueError): models.ClosingDays(None, utils.DAYS['MON'], utils.DAYS['MON'], translatable1.id) with self.assertRaises(ValueError): models.ClosingDays('id', utils.DAYS['MON'], utils.DAYS['MON'], translatable1.id) with self.assertRaises(ValueError): models.ClosingDays(self.campuses[0].id, None, utils.DAYS['MON'], translatable1.id) with self.assertRaises(ValueError): models.ClosingDays(self.campuses[0].id, '2002-02-20', utils.DAYS['MON'], translatable1.id) with self.assertRaises(ValueError): models.ClosingDays(self.campuses[0].id, utils.DAYS['MON'], '2002-02-20', translatable1.id) with self.assertRaises(ValueError): models.ClosingDays(self.campuses[0].id, utils.DAYS['MON'], utils.DAYS['MON'], None) with self.assertRaises(ValueError): models.ClosingDays(self.campuses[0].id, utils.DAYS['MON'], utils.DAYS['MON'], 'translatable') def test_create(self): # Test usage of ClosingDays.create with add_to_db set to True with self.app.app_context(): db.session.add_all(self.campuses) closed1 = models.ClosingDays.create(self.campuses[0], utils.DAYS['MON'], utils.DAYS['MON'], 'Translation 1: en', 'en', add_to_db=True) closed2 = models.ClosingDays.create(self.campuses[1], utils.DAYS['TUE'], utils.DAYS['FRI'], 'Translation 2: en', 'en', add_to_db=True) closed3 = models.ClosingDays.create(self.campuses[2], utils.DAYS['THU'], utils.DAYS['THU'], 'Translation 3: en', 'en', add_to_db=True) # Ensure that the create method adds the entities to the database self.assertFalse(inspect(closed1).transient) self.assertFalse(inspect(closed2).transient) self.assertFalse(inspect(closed3).transient) db.session.commit() def test_create_no_add_to_db(self): # Test usage of Campus.create with add_to_db set to False with self.app.app_context(): db.session.add_all(self.campuses) closed1 = models.ClosingDays.create(self.campuses[0], utils.DAYS['MON'], utils.DAYS['MON'], 'Translation 1: en', 'en', add_to_db=False) closed2 = models.ClosingDays.create(self.campuses[1], utils.DAYS['TUE'], utils.DAYS['FRI'], 'Translation 2: en', 'en', add_to_db=False) closed3 = models.ClosingDays.create(self.campuses[2], utils.DAYS['THU'], utils.DAYS['THU'], 'Translation 3: en', 'en', add_to_db=False) # Ensure that the create method does not add the entities to the database self.assertTrue(inspect(closed1).transient) self.assertTrue(inspect(closed2).transient) self.assertTrue(inspect(closed3).transient) db.session.add(closed1) db.session.add(closed2) db.session.add(closed3) db.session.commit() def test_find_is_closed(self): # Test finding if a campus is closed on a specific day with self.app.app_context(): db.session.add_all(self.campuses) closed1 = models.ClosingDays.create(self.campuses[0], utils.DAYS['TUE'], utils.DAYS['TUE'], 'Translation 1: en', 'en') closed2 = models.ClosingDays.create(self.campuses[1], utils.DAYS['TUE'], utils.DAYS['THU'], 'Translation 2: en', 'en') closed3 = models.ClosingDays.create(self.campuses[2], utils.DAYS['WED'], None, 'Translation 3: en', 'en') db.session.commit() # Campus 1 self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[0], utils.DAYS['MON'])) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[0], utils.DAYS['TUE']), closed1) self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[0], utils.DAYS['WED'])) self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[0], utils.DAYS['THU'])) self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[0], utils.DAYS['FRI'])) # Campus 2 self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[1], utils.DAYS['MON'])) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[1], utils.DAYS['TUE']), closed2) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[1], utils.DAYS['WED']), closed2) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[1], utils.DAYS['THU']), closed2) self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[1], utils.DAYS['FRI'])) # Campus 3 self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[2], utils.DAYS['MON'])) self.assertIsNone(models.ClosingDays.find_is_closed(self.campuses[2], utils.DAYS['TUE'])) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[2], utils.DAYS['WED']), closed3) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[2], utils.DAYS['THU']), closed3) self.assertEqual(models.ClosingDays.find_is_closed(self.campuses[2], utils.DAYS['FRI']), closed3) def test_find_closing_days_including(self): pass # TODO ================================================ FILE: tests/test_models_menu.py ================================================ from decimal import Decimal import tests.utils as utils from app import db from komidabot.models import Menu, MenuItem, CourseType, CourseSubType, CourseAttributes from tests.base import BaseTestCase class TestModelsMenu(BaseTestCase): """ Test models.Menu """ def setUp(self): super().setUp() self.create_test_campuses() def test_simple_constructors(self): # Test constructor of Menu model with self.app.app_context(): db.session.add_all(self.campuses) # XXX: Use constructor here to test, rather than the appropriate method menu1 = Menu(self.campuses[0].id, utils.DAYS['MON']) menu2 = Menu(self.campuses[1].id, utils.DAYS['TUE']) menu3 = Menu(self.campuses[0].id, utils.DAYS['WED']) menu4 = Menu(self.campuses[1].id, utils.DAYS['THU']) menu5 = Menu(self.campuses[0].id, utils.DAYS['FRI']) db.session.add(menu1) db.session.add(menu2) db.session.add(menu3) db.session.add(menu4) db.session.add(menu5) db.session.commit() # noinspection PyTypeChecker def test_invalid_constructors(self): # Test constructor of Campus model with self.app.app_context(): db.session.add_all(self.campuses) with self.assertRaises(ValueError): Menu(None, utils.DAYS['MON']) with self.assertRaises(ValueError): Menu('id', utils.DAYS['MON']) with self.assertRaises(ValueError): Menu(self.campuses[0].id, None) with self.assertRaises(ValueError): Menu(self.campuses[0].id, '2020-02-20') def test_create(self): # Test usage of Menu.create to check if Menus are constructed the same way as through their constructor with self.app.app_context(): db.session.add_all(self.campuses) Menu.create(self.campuses[0], utils.DAYS['MON']) Menu.create(self.campuses[1], utils.DAYS['TUE']) Menu.create(self.campuses[0], utils.DAYS['WED']) Menu.create(self.campuses[1], utils.DAYS['THU']) Menu.create(self.campuses[0], utils.DAYS['FRI']) db.session.commit() def test_create_no_add_first(self): # Tests usage of Menu.create with add_to_db=False, and manually adding it afterwards with self.app.app_context(): db.session.add_all(self.campuses) translatable1, _ = self.create_translation({'en': 'Translation 1: en'}, 'en', has_context=True) translatable2, _ = self.create_translation({'en': 'Translation 2: en'}, 'en', has_context=True) translatable3, _ = self.create_translation({'en': 'Translation 3: en'}, 'en', has_context=True) menu = Menu.create(self.campuses[0], utils.DAYS['MON'], add_to_db=False) menu_item1 = menu.add_menu_item(translatable1, CourseType.SUB, CourseSubType.NORMAL, [CourseAttributes.SNACK], [], Decimal('1.0'), None) menu_item2 = menu.add_menu_item(translatable2, CourseType.PASTA, CourseSubType.NORMAL, [CourseAttributes.PASTA], [], Decimal('1.0'), Decimal('4.0')) menu_item3 = menu.add_menu_item(translatable3, CourseType.SOUP, CourseSubType.VEGAN, [CourseAttributes.SOUP], [], Decimal('1.0'), Decimal('2.0')) db.session.add(menu) db.session.commit() items = MenuItem.query.filter_by(menu_id=menu.id).order_by(MenuItem.id).all() self.assertIn(menu_item1, items) self.assertIn(menu_item2, items) self.assertIn(menu_item3, items) ================================================ FILE: tests/test_models_menu_item.py ================================================ from decimal import Decimal import tests.utils as utils from app import db from komidabot.models import Menu, MenuItem, CourseType, CourseSubType, CourseAttributes from tests.base import BaseTestCase class TestModelsMenuItem(BaseTestCase): """ Test models.MenuItem """ def setUp(self): super().setUp() self.create_test_campuses() def test_simple_constructors(self): # Test constructor of MenuItem model with self.app.app_context(): db.session.add_all(self.campuses) translatable1, _ = self.create_translation({'en': 'Translation 1: en'}, 'en', has_context=True) translatable2, _ = self.create_translation({'en': 'Translation 2: en'}, 'en', has_context=True) translatable3, _ = self.create_translation({'en': 'Translation 3: en'}, 'en', has_context=True) menu = Menu.create(self.campuses[0], utils.DAYS['MON']) # Required if we need to get menu.id, otherwise it would return None # db.session.flush() # XXX: Use constructor here to test, rather than the appropriate method MenuItem(menu, translatable1.id, CourseType.SUB, CourseSubType.NORMAL, Decimal('1.0'), None) MenuItem(menu, translatable2.id, CourseType.PASTA, CourseSubType.NORMAL, Decimal('1.0'), Decimal('4.0')) MenuItem(menu, translatable3.id, CourseType.SOUP, CourseSubType.VEGAN, Decimal('1.0'), Decimal('2.0')) db.session.commit() def test_add_menu_item(self): # Test usage of Menu.add_menu_item to check if MenuItems are constructed the same way as through their # constructor with self.app.app_context(): db.session.add_all(self.campuses) translatable1, _ = self.create_translation({'en': 'Translation 1: en'}, 'en', has_context=True) translatable2, _ = self.create_translation({'en': 'Translation 2: en'}, 'en', has_context=True) translatable3, _ = self.create_translation({'en': 'Translation 3: en'}, 'en', has_context=True) menu = Menu.create(self.campuses[0], utils.DAYS['MON']) # Required if we need to get menu.id, otherwise it would return None # db.session.flush() menu_item1 = menu.add_menu_item(translatable1, CourseType.SUB, CourseSubType.NORMAL, [CourseAttributes.SNACK], [], Decimal('1.0'), None) menu_item2 = menu.add_menu_item(translatable2, CourseType.PASTA, CourseSubType.NORMAL, [CourseAttributes.PASTA], [], Decimal('1.0'), Decimal('4.0')) menu_item3 = menu.add_menu_item(translatable3, CourseType.SOUP, CourseSubType.VEGAN, [CourseAttributes.SOUP], [], Decimal('1.0'), Decimal('2.0')) db.session.commit() self.assertEqual(len(menu.menu_items), 3) self.assertNotEqual(menu_item1, menu_item2) self.assertNotEqual(menu_item1, menu_item3) self.assertNotEqual(menu_item2, menu_item3) self.assertIn(menu_item1, menu.menu_items) self.assertIn(menu_item2, menu.menu_items) self.assertIn(menu_item3, menu.menu_items) def test_get_translation(self): # Test that translation requests are passed through with self.app.app_context(): trans = self.translator db.session.add_all(self.campuses) translatable1, _ = self.create_translation({'en': 'Translation 1: en', 'nl': 'Translation 1: nl'}, 'en', has_context=True) translatable2, _ = self.create_translation({'en': 'Translation 2: en', 'nl': 'Translation 2: nl'}, 'en', has_context=True) translatable3, _ = self.create_translation({'en': 'Translation 3: en', 'nl': 'Translation 3: nl'}, 'en', has_context=True) menu = Menu.create(self.campuses[0], utils.DAYS['MON']) # Required if we need to get menu.id, otherwise it would return None # db.session.flush() menu_item1 = menu.add_menu_item(translatable1, CourseType.SUB, CourseSubType.NORMAL, [CourseAttributes.SNACK], [], Decimal('1.0'), None) menu_item2 = menu.add_menu_item(translatable2, CourseType.PASTA, CourseSubType.NORMAL, [CourseAttributes.PASTA], [], Decimal('1.0'), Decimal('4.0')) menu_item3 = menu.add_menu_item(translatable3, CourseType.SOUP, CourseSubType.VEGAN, [CourseAttributes.SOUP], [], Decimal('1.0'), Decimal('2.0')) db.session.commit() self.assertEqual(menu_item1.get_translation('en', trans), translatable1.get_translation('en', trans)) self.assertEqual(menu_item1.get_translation('nl', trans), translatable1.get_translation('nl', trans)) self.assertEqual(menu_item1.get_translation('fr', trans), translatable1.get_translation('fr', trans)) self.assertEqual(menu_item2.get_translation('en', trans), translatable2.get_translation('en', trans)) self.assertEqual(menu_item2.get_translation('nl', trans), translatable2.get_translation('nl', trans)) self.assertEqual(menu_item2.get_translation('fr', trans), translatable2.get_translation('fr', trans)) self.assertEqual(menu_item3.get_translation('en', trans), translatable3.get_translation('en', trans)) self.assertEqual(menu_item3.get_translation('nl', trans), translatable3.get_translation('nl', trans)) self.assertEqual(menu_item3.get_translation('fr', trans), translatable3.get_translation('fr', trans)) ================================================ FILE: tests/test_models_registered_user.py ================================================ import datetime from sqlalchemy import inspect from app import db from komidabot.models_users import RegisteredUser, Role from tests.base import BaseTestCase class TestModelsRegisteredUsers(BaseTestCase): """ Test models_users.RegisteredUser """ def test_simple_constructors(self): # Test constructor of RegisteredUser model with self.app.app_context(): user1 = RegisteredUser('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') # Ensure that the constructor does not add the entities to the database self.assertTrue(inspect(user1).transient) self.assertTrue(inspect(user2).transient) self.assertTrue(inspect(user3).transient) db.session.add(user1) db.session.add(user2) db.session.add(user3) db.session.commit() # noinspection PyTypeChecker def test_invalid_constructors(self): # Test constructor of RegisteredUser model with self.app.app_context(): with self.assertRaises(ValueError): RegisteredUser(None, '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser(123, '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', None, 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', 123, 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', '123', None, 'user1@example.com', 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', '123', 123, 'user1@example.com', 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', '123', 'Test User 1', None, 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', '123', 'Test User 1', 123, 'https://example.com/img1.png') with self.assertRaises(ValueError): RegisteredUser('test', '123', 'Test User 1', 'user1@example.com', None) with self.assertRaises(ValueError): RegisteredUser('test', '123', 'Test User 1', 'user1@example.com', 123) def test_create(self): # Test usage of RegisteredUser.create with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') # Ensure that the create method adds the entities to the database self.assertFalse(inspect(user1).transient) self.assertFalse(inspect(user2).transient) self.assertFalse(inspect(user3).transient) db.session.commit() def test_get_by_id(self): # Test getting a RegisteredUser object by its internal ID with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') db.session.commit() self.assertEqual(user1, RegisteredUser.get_by_id(user1.id)) self.assertEqual(user2, RegisteredUser.get_by_id(user2.id)) self.assertEqual(user3, RegisteredUser.get_by_id(user3.id)) self.assertEqual(None, RegisteredUser.get_by_id(user3.id + 1000)) def test_find_by_provider_id(self): # Test getting a RegisteredUser object by its provider id (subject column) with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') db.session.commit() self.assertEqual(user1, RegisteredUser.find_by_provider_id(user1.provider, user1.subject)) self.assertEqual(user2, RegisteredUser.find_by_provider_id(user2.provider, user2.subject)) self.assertEqual(user3, RegisteredUser.find_by_provider_id(user3.provider, user3.subject)) self.assertEqual(None, RegisteredUser.find_by_provider_id('Definitely not used', 'subjectId')) def test_find_by_email(self): # Test getting a RegisteredUser object by its email with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') db.session.commit() self.assertEqual(user1, RegisteredUser.find_by_email('user1@example.com')) self.assertEqual(user2, RegisteredUser.find_by_email('user2@example.com')) self.assertEqual(user3, RegisteredUser.find_by_email('user3@example.com')) def test_get_all(self): # Test getting all RegisteredUser objects with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') db.session.commit() users = RegisteredUser.get_all() ids = [user.id for user in users] self.assertEqual(len(users), 3) self.assertEqual(len(ids), 3) self.assertIn(user1.id, ids) self.assertIn(user2.id, ids) self.assertIn(user3.id, ids) def test_get_all_active(self): # Test getting all active RegisteredUser objects with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') user1.activated_on = datetime.datetime.now() user3.activated_on = user1.activated_on + datetime.timedelta(days=5) db.session.commit() users = RegisteredUser.get_all_active() ids = [user.id for user in users] self.assertEqual(len(users), 2) self.assertEqual(len(ids), 2) self.assertIn(user1.id, ids) self.assertNotIn(user2.id, ids) self.assertIn(user3.id, ids) def test_get_all_by_role(self): # Test getting all active RegisteredUser objects with self.app.app_context(): role1 = Role.create('test_role1') role2 = Role.create('test_role2') user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') user1.add_role(role1) user2.add_role(role1) user2.add_role(role2) db.session.commit() users1 = RegisteredUser.get_all_by_role(role1) ids1 = [user.id for user in users1] self.assertEqual(len(users1), 2) self.assertEqual(len(ids1), 2) self.assertIn(user1.id, ids1) self.assertIn(user2.id, ids1) self.assertNotIn(user3.id, ids1) users2 = RegisteredUser.get_all_by_role(role2) ids2 = [user.id for user in users2] self.assertEqual(len(users2), 1) self.assertEqual(len(ids2), 1) self.assertNotIn(user1.id, ids2) self.assertIn(user2.id, ids2) self.assertNotIn(user3.id, ids2) def test_roles(self): # Test getting all active RegisteredUser objects with self.app.app_context(): role1 = Role.create('test_role1') role2 = Role.create('test_role2') user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') user1.add_role(role1) user2.add_role(role1) user2.add_role(role2) db.session.commit() self.assertTrue(user1.is_role(role1)) self.assertTrue(user2.is_role(role1)) self.assertFalse(user3.is_role(role1)) self.assertFalse(user1.is_role(role2)) self.assertTrue(user2.is_role(role2)) self.assertFalse(user3.is_role(role2)) user2.remove_role(role1) self.assertTrue(user1.is_role(role1.name)) self.assertFalse(user2.is_role(role1.name)) self.assertFalse(user3.is_role(role1.name)) self.assertFalse(user1.is_role(role2.name)) self.assertTrue(user2.is_role(role2.name)) self.assertFalse(user3.is_role(role2.name)) def test_delete(self): # Test getting all RegisteredUser objects with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user3 = RegisteredUser.create('test', '789', 'Test User 3', 'user3@example.com', 'https://example.com/img3.png') db.session.commit() user2.delete() db.session.commit() users = RegisteredUser.get_all() ids = [user.id for user in users] self.assertEqual(len(users), 2) self.assertEqual(len(ids), 2) self.assertIn(user1.id, ids) self.assertNotIn(user2.id, ids) self.assertIn(user3.id, ids) def test_user_mixin(self): # Test getting all RegisteredUser objects with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user2.activated_on = datetime.datetime.now() db.session.commit() self.assertIsNone(user1.activated_on) self.assertFalse(user1.is_active) self.assertFalse(user1.is_anonymous) self.assertIsNotNone(user2.activated_on) self.assertTrue(user2.is_active) self.assertFalse(user2.is_anonymous) def test_subscriptions(self): # Test getting all RegisteredUser objects with self.app.app_context(): user1 = RegisteredUser.create('test', '123', 'Test User 1', 'user1@example.com', 'https://example.com/img1.png') user2 = RegisteredUser.create('test', '456', 'Test User 2', 'user2@example.com', 'https://example.com/img2.png') user1.activated_on = user2.activated_on = datetime.datetime.now() db.session.commit() # Assert that we start with no subscriptions self.assertEqual(len(user1.get_subscriptions()), 0) self.assertEqual(len(user2.get_subscriptions()), 0) # Add a subscription to user 1 user1.add_subscription('https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', {'key1': 'value1'}) # Assert adding a subscription to one user doesn't add one to another self.assertEqual(len(user1.get_subscriptions()), 1) self.assertEqual(len(user2.get_subscriptions()), 0) # Assert that the added subscription is the one we put in self.assertIn({'endpoint': 'https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', 'keys': {'key1': 'value1'}}, user1.get_subscriptions()) # Also check that we're not breaking time I guess self.assertNotIn({'endpoint': 'https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', 'keys': {'key2': 'value2'}}, user1.get_subscriptions()) # Add a 2nd subscription to user 1 user1.add_subscription('https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', {'key2': 'value2'}) # Assert adding a subscription to one user doesn't add one to another once more self.assertEqual(len(user1.get_subscriptions()), 2) self.assertEqual(len(user2.get_subscriptions()), 0) # Assert that all the subscriptions we added are in there self.assertIn({'endpoint': 'https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', 'keys': {'key1': 'value1'}}, user1.get_subscriptions()) self.assertIn({'endpoint': 'https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', 'keys': {'key2': 'value2'}}, user1.get_subscriptions()) # Add the 2nd subscription to user 1 once more user1.add_subscription('https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', {'key2': 'value2'}) # Assert adding a subscription to one user doesn't add one to another once more self.assertEqual(len(user1.get_subscriptions()), 2) # Length unchanged, no duplicates allowed self.assertEqual(len(user2.get_subscriptions()), 0) # Assert that all the subscriptions we added are in there self.assertIn({'endpoint': 'https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', 'keys': {'key1': 'value1'}}, user1.get_subscriptions()) self.assertIn({'endpoint': 'https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', 'keys': {'key2': 'value2'}}, user1.get_subscriptions()) # Try to add the 2nd subscription to user 1, but with different keys user1.add_subscription('https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', {'key10': 'value10'}) # Assert that this did not add a new subscription self.assertEqual(len(user1.get_subscriptions()), 2) self.assertEqual(len(user2.get_subscriptions()), 0) # Assert that the subscriptions are unchanged by this self.assertIn({'endpoint': 'https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', 'keys': {'key1': 'value1'}}, user1.get_subscriptions()) self.assertIn({'endpoint': 'https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', 'keys': {'key2': 'value2'}}, user1.get_subscriptions()) # Try to remove the 1st subscription from user 2 user2.remove_subscription('https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8') # Assert that this did nothing self.assertEqual(len(user1.get_subscriptions()), 2) self.assertEqual(len(user2.get_subscriptions()), 0) # Remove the 1st subscription from user 1 user1.remove_subscription('https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8') # Assert that this time stuff actually happened self.assertEqual(len(user1.get_subscriptions()), 1) self.assertEqual(len(user2.get_subscriptions()), 0) # Assert that only the subscription we removed was actually removed self.assertNotIn({'endpoint': 'https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', 'keys': {'key1': 'value1'}}, user1.get_subscriptions()) self.assertIn({'endpoint': 'https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', 'keys': {'key2': 'value2'}}, user1.get_subscriptions()) # Bring back the 1st subscription user1.add_subscription('https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', {'key1': 'value1'}) # Replace the 2nd subscription user1.replace_subscription('https://example.com/2ee246b0-c8b2-4b6c-a08d-acb26ab392d2', 'https://example.com/4c991903-c193-447b-ac5b-b3b8674cd5f9', {'key3': 'value3'}) # Assert that no additional subscriptions were added, only modified self.assertEqual(len(user1.get_subscriptions()), 2) self.assertEqual(len(user2.get_subscriptions()), 0) # Assert that the 2nd subscription was replaced self.assertIn({'endpoint': 'https://example.com/6cc32b91-6938-4d8b-9f3b-f0fccc015ca8', 'keys': {'key1': 'value1'}}, user1.get_subscriptions()) self.assertIn({'endpoint': 'https://example.com/4c991903-c193-447b-ac5b-b3b8674cd5f9', 'keys': {'key3': 'value3'}}, user1.get_subscriptions()) ================================================ FILE: tests/test_models_translations.py ================================================ from sqlalchemy import inspect import komidabot.models as models from app import db from tests.base import BaseTestCase # TODO: Add provider tests class TestModelsTranslations(BaseTestCase): """ Test models.Translatable and models.Translation """ def test_simple_constructors(self): # Test constructor of Translatable and Translation models with self.app.app_context(): translatable1 = models.Translatable('Translation 1: en', 'en') translatable2 = models.Translatable('Translation 2: en', 'en') translatable3 = models.Translatable('Translation 3: en', 'en') # Ensure that the constructor does not add the entities to the database self.assertTrue(inspect(translatable1).transient) self.assertTrue(inspect(translatable2).transient) self.assertTrue(inspect(translatable3).transient) db.session.add(translatable1) db.session.add(translatable2) db.session.add(translatable3) db.session.flush() translation1a = models.Translation(translatable1.id, 'nl', 'Translation 1: nl') translation1b = models.Translation(translatable1.id, 'fr', 'Translation 1: fr') self.assertTrue(inspect(translation1a).transient) self.assertTrue(inspect(translation1b).transient) db.session.add(translatable1) db.session.add(translatable2) db.session.commit() translation2 = models.Translation(translatable2.id, 'nl', 'Translation 2: nl') self.assertTrue(inspect(translation2).transient) db.session.add(translation2) db.session.commit() def test_get_or_create(self): # Test usage of Translatable.get_or_create with self.app.app_context(): translatable1, translation1 = models.Translatable.get_or_create('Translation 1: en', 'en') translatable2, translation2 = models.Translatable.get_or_create('Translation 2: en', 'en') translatable3, translation3 = models.Translatable.get_or_create('Translation 3: en', 'en') # Ensure that the create method adds the entities to the database self.assertFalse(inspect(translatable1).transient) self.assertFalse(inspect(translation1).transient) self.assertFalse(inspect(translatable2).transient) self.assertFalse(inspect(translation2).transient) self.assertFalse(inspect(translatable3).transient) self.assertFalse(inspect(translation3).transient) db.session.commit() def test_add_translation(self): # Test usage of Translatable.add_translation with self.app.app_context(): translatable1, translation1a = models.Translatable.get_or_create('Translation 1: en', 'en') translatable2, translation2a = models.Translatable.get_or_create('Translation 2: en', 'en') translatable3, translation3a = models.Translatable.get_or_create('Translation 3: en', 'en') translation1b = translatable1.add_translation('nl', 'Translation 1: nl') db.session.flush() translation2b = translatable2.add_translation('nl', 'Translation 2: nl') db.session.commit() translation3b = translatable3.add_translation('nl', 'Translation 3: nl') db.session.commit() translations1 = translatable1.translations translations2 = translatable2.translations translations3 = translatable3.translations self.assertEqual(len(translations1), 2) self.assertEqual(len(translations2), 2) self.assertEqual(len(translations3), 2) self.assertIn(translation1a, translations1) self.assertIn(translation1b, translations1) self.assertIn(translation2a, translations2) self.assertIn(translation2b, translations2) self.assertIn(translation3a, translations3) self.assertIn(translation3b, translations3) def test_has_translation(self): # Test usage of Translatable.add_translation with self.app.app_context(): translatable1, translation1a = models.Translatable.get_or_create('Translation 1: en', 'en') translatable2, translation2a = models.Translatable.get_or_create('Translation 2: nl', 'nl') translatable1.add_translation('nl', 'Translation 1: nl') db.session.flush() translatable2.add_translation('en', 'Translation 2: en') db.session.commit() self.assertTrue(translatable1.has_translation('en')) self.assertTrue(translatable2.has_translation('en')) self.assertTrue(translatable1.has_translation('nl')) self.assertTrue(translatable2.has_translation('nl')) self.assertFalse(translatable1.has_translation('fr')) self.assertFalse(translatable2.has_translation('fr')) # TODO: Test get_translation def test_get_translation(self): # Test usage of Translatable.get_translation with self.app.app_context(): translatable1, translation1a = models.Translatable.get_or_create('Translation 1: en', 'en') translatable2, translation2a = models.Translatable.get_or_create('Translation 2: en', 'en') translatable3, translation3a = models.Translatable.get_or_create('Translation 3: en', 'en') translation1b = translatable1.add_translation('nl', 'Translation 1: nl') translation2b = translatable2.add_translation('nl', 'Translation 2: nl') translation3b = translatable3.add_translation('nl', 'Translation 3: nl') db.session.commit() self.assertEqual(translatable1.get_translation('en', None), translation1a) self.assertEqual(translatable2.get_translation('en', None), translation2a) self.assertEqual(translatable3.get_translation('en', None), translation3a) self.assertEqual(translatable1.get_translation('nl', None), translation1b) self.assertEqual(translatable2.get_translation('nl', None), translation2b) self.assertEqual(translatable3.get_translation('nl', None), translation3b) translation1c = translatable1.get_translation('fr', self.translator) translation2c = translatable2.get_translation('fr', self.translator) translation3c = translatable3.get_translation('fr', self.translator) db.session.commit() self.assertNotEqual(translation1c, translation1a) self.assertNotEqual(translation1c, translation1b) self.assertNotEqual(translation2c, translation2a) self.assertNotEqual(translation2c, translation2b) self.assertNotEqual(translation3c, translation3a) self.assertNotEqual(translation3c, translation3b) for translation in [translation1c, translation2c, translation3c]: translatable: models.Translatable = translation.translatable self.assertEqual(translation.translation, self.translator.translate(translatable.original_text, translatable.original_language, translation.language)) def test_get_by_id(self): # Test usage of Translatable.get_by_id with self.app.app_context(): translatable1, _ = models.Translatable.get_or_create('Translation 1: en', 'en') translatable2, _ = models.Translatable.get_or_create('Translation 2: en', 'en') translatable3, _ = models.Translatable.get_or_create('Translation 3: en', 'en') db.session.commit() self.assertEqual(translatable1, models.Translatable.get_by_id(translatable1.id)) self.assertEqual(translatable2, models.Translatable.get_by_id(translatable2.id)) self.assertEqual(translatable3, models.Translatable.get_by_id(translatable3.id)) ================================================ FILE: tests/test_subscriptions.py ================================================ import datetime from decimal import Decimal from typing import Dict, List, Tuple import komidabot.models as models import komidabot.triggers as triggers import komidabot.users as users import komidabot.util as util import tests.users_stub as users_stub import tests.utils as utils from app import db from komidabot.models import AppUser, Day, CourseType, CourseSubType, UserDayCampusPreference, course_icons_matrix from tests.base import BaseTestCase, HttpCapture, menu_item class BaseSubscriptionsTestCase(BaseTestCase): def setUp(self): super().setUp() self.create_test_campuses() class TestGenericSubscriptions(BaseSubscriptionsTestCase): def setUp(self): super().setUp() with self.app.app_context(): user_manager = users_stub.UserManager() self.message_handler = user_manager.message_handler self.app.user_manager = user_manager # Replace the unified user manager completely to test self.user1 = user_manager.add_user('user1', locale='nl') self.user2 = user_manager.add_user('user2', locale='nl') self.user3 = user_manager.add_user('user3', locale='nl') db.session.commit() def setup_subscriptions(self): def create_subscriptions(user: users.UserId, days: List[Tuple[Day, int, bool]]): for day, campus, active in days: user_obj = AppUser.find_by_id(user.provider, user.id) UserDayCampusPreference.create(user_obj, day, self.campuses[campus], active=active) with self.app.app_context(): db.session.add_all(self.campuses) # First user, subscribed every day create_subscriptions(self.user1.id, [ (Day.MONDAY, 0, True), (Day.TUESDAY, 1, True), (Day.WEDNESDAY, 0, True), (Day.THURSDAY, 1, True), (Day.FRIDAY, 0, True), ]) # Second user, always goes on tuesdays and thursdays, otherwise sporadically create_subscriptions(self.user2.id, [ (Day.MONDAY, 1, False), (Day.TUESDAY, 1, True), (Day.WEDNESDAY, 1, False), (Day.THURSDAY, 0, True), (Day.FRIDAY, 0, False), ]) # Third user, only comes when he wants to create_subscriptions(self.user3.id, [ (Day.MONDAY, 0, False), (Day.TUESDAY, 0, False), (Day.WEDNESDAY, 1, False), (Day.THURSDAY, 1, False), (Day.FRIDAY, 1, False), ]) db.session.commit() def setup_menu(self): self.expected_menus: Dict[Tuple[str, datetime.date], str] = dict() course_types = CourseType sub_types = CourseSubType with self.app.app_context(): db.session.add_all(self.campuses) for campus in self.campuses: for day in utils.DAYS_LIST: day_name = Day(day.isoweekday()).name items = [menu_item(course_type, sub_type, [], [], '{} at {} for {}'.format(course_type.name, campus.short_name, day_name), 'nl', Decimal('1.0'), Decimal('2.0')) for course_type in course_types for sub_type in sub_types] self.create_menu(campus, day, items, has_context=True) result = [ 'Menu van {date} in {campus}'.format(campus=campus.name, date=util.date_to_string('nl', day)), '', ] for item in items: result.append('{} {} ({} / {})'.format(course_icons_matrix[item.type][item.sub_type], item.text, models.MenuItem.format_price(item.price_students), models.MenuItem.format_price(item.price_staff))) self.expected_menus[(campus.short_name, day)] = '\n'.join(result) db.session.commit() def test_active_subscriptions(self): self.setup_subscriptions() self.setup_menu() with self.app.app_context(): self.activate_feature('menu_subscription', available=True, has_context=True) with HttpCapture(): # Ensure no requests are made self.app.bot.trigger_received(triggers.SubscriptionTrigger(date=utils.DAYS['MON'])) self.app.bot.trigger_received(triggers.SubscriptionTrigger(date=utils.DAYS['TUE'])) self.app.bot.trigger_received(triggers.SubscriptionTrigger(date=utils.DAYS['WED'])) self.app.bot.trigger_received(triggers.SubscriptionTrigger(date=utils.DAYS['THU'])) self.app.bot.trigger_received(triggers.SubscriptionTrigger(date=utils.DAYS['FRI'])) db.session.add_all(self.campuses) self.assertIn(self.user1.id, self.message_handler.message_log) self.assertEqual(self.message_handler.message_log[self.user1.id], [ self.expected_menus[(self.campuses[0].short_name, utils.DAYS['MON'])], self.expected_menus[(self.campuses[1].short_name, utils.DAYS['TUE'])], self.expected_menus[(self.campuses[0].short_name, utils.DAYS['WED'])], self.expected_menus[(self.campuses[1].short_name, utils.DAYS['THU'])], self.expected_menus[(self.campuses[0].short_name, utils.DAYS['FRI'])], ]) self.assertIn(self.user2.id, self.message_handler.message_log) self.assertEqual(self.message_handler.message_log[self.user2.id], [ self.expected_menus[(self.campuses[1].short_name, utils.DAYS['TUE'])], self.expected_menus[(self.campuses[0].short_name, utils.DAYS['THU'])], ]) self.assertNotIn(self.user3.id, self.message_handler.message_log) # print(self.message_handler.message_log, flush=True) # class TestFacebookSubscriptions(BaseSubscriptionsTestCase): # def test_http_capture(self): # with self.app.app_context(): # with HttpCapture() as http: # http.register_uri(HttpCapture.GET, 'https://google.be', 'test') # # response = requests.get('https://google.be') # # assert response.text == 'test' ================================================ FILE: tests/test_test_utils.py ================================================ import tests.utils as utils from tests.base import BaseTestCase class TestConstants(BaseTestCase): """ Sanity tests for testing utilities. """ def test_days(self): self.assertEqual(utils.DAYS['MON'].isoweekday(), 1, 'Date is not a Monday') self.assertEqual(utils.DAYS['TUE'].isoweekday(), 2, 'Date is not a Tuesday') self.assertEqual(utils.DAYS['WED'].isoweekday(), 3, 'Date is not a Wednesday') self.assertEqual(utils.DAYS['THU'].isoweekday(), 4, 'Date is not a Thursday') self.assertEqual(utils.DAYS['FRI'].isoweekday(), 5, 'Date is not a Friday') self.assertEqual(utils.DAYS['SAT'].isoweekday(), 6, 'Date is not a Saturday') self.assertEqual(utils.DAYS['SUN'].isoweekday(), 7, 'Date is not a Sunday') def test_days_list(self): self.assertEqual(utils.DAYS_LIST[0].isoweekday(), 1, 'Date is not a Monday') self.assertEqual(utils.DAYS_LIST[1].isoweekday(), 2, 'Date is not a Tuesday') self.assertEqual(utils.DAYS_LIST[2].isoweekday(), 3, 'Date is not a Wednesday') self.assertEqual(utils.DAYS_LIST[3].isoweekday(), 4, 'Date is not a Thursday') self.assertEqual(utils.DAYS_LIST[4].isoweekday(), 5, 'Date is not a Friday') self.assertEqual(utils.DAYS_LIST[5].isoweekday(), 6, 'Date is not a Saturday') self.assertEqual(utils.DAYS_LIST[6].isoweekday(), 7, 'Date is not a Sunday') ================================================ FILE: tests/test_triggers.py ================================================ import datetime import komidabot.triggers as triggers from tests.base import BaseTestCase from tests.users_stub import UserManager as TestUserManager class TestTriggers(BaseTestCase): def setUp(self): super().setUp() self.user_manager = TestUserManager() self.user1 = self.user_manager.add_user('user1') self.triggers = [ (triggers.Trigger, (), {}), (triggers.TextTrigger, ('Hello world!',), {}), (triggers.SubscriptionTrigger, (), {}), (triggers.SubscriptionTrigger, (), {'date': datetime.date(1999, 12, 31)}), ] self.aspects = [ (triggers.SenderAspect, (self.user1,), {}), (triggers.DatetimeAspect, ('1999-12-31T00:00:00.000+01:00', 'day',), {}), (triggers.LocaleAspect, ('nl_XX', 1.0,), {}), ] def test_simple_trigger_constructors(self): # Test constructors of Trigger and classes extending Trigger without any Aspects for TriggerType, args, kwargs in self.triggers: TriggerType(*args, **kwargs) def test_trigger_constructors_with_aspects(self): # Test constructors of Trigger and classes extending Trigger with Aspects pass # for TriggerType, args, kwargs in self.triggers: # TriggerType(*args, **kwargs) def test_aspect_constructors(self): # Test constructors of classes extending Aspect for AspectType, args, kwargs in self.aspects: AspectType(*args, **kwargs) def test_simple_extend(self): # Test the extend method of Trigger and classes extending Trigger without any Aspects trigger = triggers.Trigger() self.assertIsInstance(trigger, triggers.Trigger) for TriggerType, args, kwargs in self.triggers: trigger = TriggerType.extend(trigger, *args, **kwargs) self.assertIsInstance(trigger, TriggerType) def test_extend_with_aspects(self): # Test the extend method of Trigger and classes extending Trigger with Aspects pass # trigger = triggers.Trigger() # self.assertIsInstance(trigger, triggers.Trigger) # # for TriggerType, args, kwargs in self.triggers: # trigger = TriggerType.extend(trigger, *args, **kwargs) # self.assertIsInstance(trigger, TriggerType) def test_no_aspects(self): # Test that the 'in' operator does not falsely report Aspects inside Triggers for TriggerType, args, kwargs in self.triggers: trigger = TriggerType(*args, **kwargs) self.assertNotIn(triggers.Aspect, trigger) self.assertNotIn(triggers.SenderAspect, trigger) self.assertNotIn(triggers.DatetimeAspect, trigger) self.assertNotIn(triggers.LocaleAspect, trigger) def test_single_aspect(self): # Test that the 'in' operator does not falsely report Aspects inside Triggers if there is another type for TriggerType, args, kwargs in self.triggers: for AspectType, args2, kwargs2 in self.aspects: aspect = AspectType(*args2, **kwargs2) trigger = TriggerType(*args, aspects=[aspect, ], **kwargs) self.assertIn(AspectType, trigger) self.assertEqual(trigger[AspectType], [aspect, ] if AspectType.allows_multiple else aspect) for AspectType2, _, _ in self.aspects: if AspectType2 is AspectType: continue # This is the type we're testing in this instance self.assertNotIn(AspectType2, trigger) def test_multiple_aspects(self): # Test that the 'in' operator does not falsely report Aspects not inside Triggers if there are others as well for TriggerType, args, kwargs in self.triggers: for AspectType, args2, kwargs2 in self.aspects: for AspectType2, args3, kwargs3 in self.aspects: if AspectType is AspectType2: continue # This Aspect type is already in the Trigger aspect1 = AspectType(*args2, **kwargs2) aspect2 = AspectType2(*args3, **kwargs3) trigger = TriggerType(*args, aspects=[aspect1, aspect2, ], **kwargs) self.assertIn(AspectType, trigger) self.assertEqual(trigger[AspectType], [aspect1, ] if AspectType.allows_multiple else aspect1) self.assertIn(AspectType2, trigger) self.assertEqual(trigger[AspectType2], [aspect2, ] if AspectType2.allows_multiple else aspect2) for AspectType3, _, _ in self.aspects: if AspectType3 is AspectType3 or AspectType3 is AspectType2: continue # This is the type we're testing in this instance self.assertNotIn(AspectType3, trigger) ================================================ FILE: tests/test_users_base.py ================================================ import tests.users_stub as users_stub from app import db from komidabot.users import UserId from tests.base import BaseTestCase class TestUsersBase(BaseTestCase): """ Base tests for komidabot.users """ def setUp(self): super().setUp() with self.app.app_context(): user_manager = users_stub.UserManager() self.app.user_manager.register_manager(user_manager) self.app.admin_ids = [UserId('admin1', users_stub.PROVIDER_ID), UserId('admin2', users_stub.PROVIDER_ID)] self.user1 = user_manager.add_user('user1', locale='nl') self.user2 = user_manager.add_user('user2', locale='nl') # Defined in TestingConfig self.admin1 = user_manager.add_user('admin1', locale='nl') self.admin2 = user_manager.add_user('admin2', locale='nl') db.session.commit() def test_get_administrators(self): with self.app.app_context(): administrators = self.app.user_manager.get_administrators() self.assertEqual(len(administrators), 2) self.assertNotIn(self.user1, administrators) self.assertNotIn(self.user2, administrators) self.assertIn(self.admin1, administrators) self.assertIn(self.admin2, administrators) ================================================ FILE: tests/users_stub.py ================================================ from typing import Dict, List from typing import Union import komidabot.menu import komidabot.messages as messages import komidabot.users as users from komidabot.models import AppUser, Menu from komidabot.subscriptions.daily_menu import CHANNEL_ID as DAILY_MENU_ID PROVIDER_ID = 'stub' class UserManager(users.UserManager): def __init__(self): self.users: 'Dict[users.UserId, User]' = dict() self.message_handler = MessageHandler() def add_user(self, internal_id: str, locale: str = 'nl') -> 'User': user_id = users.UserId(internal_id, PROVIDER_ID) if user_id in self.users: raise ValueError('Duplicate user ID') user = User(self, user_id.id) self.users[user_id] = user user.add_to_db() user.get_db_user().set_language(locale) return user def get_user(self, user: 'Union[users.UserId, AppUser]', **kwargs) -> 'User': if isinstance(user, AppUser): user = users.UserId(user.internal_id, user.provider) if not isinstance(user, users.UserId): raise ValueError() if user not in self.users: raise ValueError('Invalid user ID: {}'.format(user)) return self.users[user] def initialise(self): assert False # Does not get called def get_identifier(self): return PROVIDER_ID class User(users.User): def __init__(self, manager: UserManager, internal_id: str): self._manager = manager self._id = internal_id def get_provider_name(self) -> 'str': return PROVIDER_ID def get_internal_id(self) -> 'str': return self._id def supports_subscription_channel(self, channel: str) -> bool: return channel in [DAILY_MENU_ID] def get_manager(self) -> UserManager: return self._manager def get_message_handler(self): if self._manager.message_handler is None: raise NotImplementedError() return self._manager.message_handler class MessageHandler(messages.MessageHandler): """Message handler that stores messages in a user->messages dictionary""" def __init__(self): self.message_log: Dict[users.UserId, List[str]] = dict() def reset(self): self.message_log = dict() def send_message(self, user, message: messages.Message) -> messages.MessageSendResult: if user.id.provider != PROVIDER_ID: raise ValueError('User id is not for Stub Provider') if isinstance(message, messages.TextMessage): if user.id not in self.message_log: self.message_log[user.id] = [] text = message.text self.message_log[user.id].append(text) return messages.MessageSendResult.SUCCESS elif isinstance(message, messages.MenuMessage): if user.id not in self.message_log: self.message_log[user.id] = [] text = komidabot.menu.get_menu_text(message.menu, message.translator, user.get_locale()) self.message_log[user.id].append(text) return messages.MessageSendResult.SUCCESS elif isinstance(message, messages.SubscriptionMenuMessage): if user.id not in self.message_log: self.message_log[user.id] = [] campus = user.get_campus_for_day(message.date) menu = Menu.get_menu(campus, message.date) text = komidabot.menu.get_menu_text(menu, message.translator, user.get_locale()) self.message_log[user.id].append(text) return messages.MessageSendResult.SUCCESS else: return messages.MessageSendResult.UNSUPPORTED ================================================ FILE: tests/utils.py ================================================ import datetime import komidabot.translation as translation DAYS = { 'MON': datetime.date(2019, 7, 1), 'TUE': datetime.date(2019, 7, 2), 'WED': datetime.date(2019, 7, 3), 'THU': datetime.date(2019, 7, 4), 'FRI': datetime.date(2019, 7, 5), 'SAT': datetime.date(2019, 7, 6), 'SUN': datetime.date(2019, 7, 7), } DAYS_LIST = list(DAYS.values()) class StubTranslator(translation.TranslationService): def translate(self, text: str, from_language: translation.Language, to_language: translation.Language): return 'No translation {}: {} -> {}'.format(repr(text), from_language, to_language) @property def identifier(self): return 'stub' @property def pretty_name(self): return 'Stub translator implementation' ================================================ FILE: wait-postgres.sh ================================================ #!/usr/bin/env /bin/bash echo "Waiting for postgres..." # Wait for the database in a safe manner while : do trap 'kill -TERM $PID' TERM INT nc -w 2 -z "$POSTGRES_HOST" 5432 & PID=$! wait $PID trap - TERM INT wait $PID EXIT_STATUS=$? if [[ ${EXIT_STATUS} -eq 0 ]] then break fi sleep 0.1 done echo "PostgreSQL started"