Showing preview only (1,489K chars total). Download the full file or copy to clipboard to get everything.
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


================================================
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/<week_str>', methods=['GET'], defaults={'short_name': None})
@blueprint.route('/campus/<short_name>/closing_days/<week_str>', 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/<short_name>/menu/<day_str>', 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:
d
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
SYMBOL INDEX (653 symbols across 86 files)
FILE: app.py
function create_app (line 14) | def create_app(*, app_settings: str = None):
FILE: config.py
function _get_user (line 14) | def _get_user(string: str) -> _UserId:
function _get_postgres_uri (line 22) | def _get_postgres_uri(host, user, password, db):
class ConfigType (line 31) | class ConfigType(TypedDict):
class BaseConfig (line 55) | class BaseConfig:
class ProductionConfig (line 93) | class ProductionConfig(BaseConfig):
class DevelopmentConfig (line 101) | class DevelopmentConfig(BaseConfig):
class TestingConfig (line 112) | class TestingConfig(BaseConfig):
FILE: extensions.py
class _ModelBase (line 14) | class _ModelBase(Model):
FILE: komidabot/api_utils.py
function response_ok (line 18) | def response_ok():
function response_bad_request (line 22) | def response_bad_request():
function response_unauthorized (line 26) | def response_unauthorized():
function wrap_exceptions (line 30) | def wrap_exceptions(func):
function expects_schema (line 59) | def expects_schema(input_schema: str = None, output_schema: str = None):
FILE: komidabot/app.py
function get_app (line 8) | def get_app() -> 'App':
class App (line 12) | class App:
method __init__ (line 13) | def __init__(self, config):
method app_context (line 62) | def app_context(self):
method config (line 66) | def config(self) -> 'ConfigType':
method _get_current_object (line 69) | def _get_current_object(self):
FILE: komidabot/blueprint.py
function handle_facebook_verification (line 32) | def handle_facebook_verification():
function validate_signature (line 44) | def validate_signature(func):
function handle_facebook_webhook (line 74) | def handle_facebook_webhook():
function _do_handle_facebook_webhook (line 121) | def _do_handle_facebook_webhook(event, user: FacebookUser, app):
function handle_web_push_subscription (line 271) | def handle_web_push_subscription():
FILE: komidabot/blueprint_api.py
function translatable_to_object (line 26) | def translatable_to_object(translatable: models.Translatable):
function post_subscribe (line 37) | def post_subscribe():
function delete_subscribe (line 88) | def delete_subscribe():
function put_subscribe (line 129) | def put_subscribe():
function post_trigger (line 159) | def post_trigger():
function get_learning (line 194) | def get_learning():
function post_learning (line 222) | def post_learning():
function get_campus_list (line 254) | def get_campus_list():
function get_active_closing_days (line 278) | def get_active_closing_days(short_name: str, week_str: str):
function get_menu (line 323) | def get_menu(short_name: str, day_str: str):
FILE: komidabot/blueprint_authentication.py
function init_google_client (line 24) | def init_google_client(app: App):
function get_google_provider_cfg (line 33) | def get_google_provider_cfg():
function user_loader (line 41) | def user_loader(user_id):
function unauthorized_handler (line 46) | def unauthorized_handler():
function get_login (line 52) | def get_login():
function get_login_google (line 59) | def get_login_google():
function get_login_google_callback (line 95) | def get_login_google_callback():
function get_logout (line 167) | def get_logout():
function get_authorized (line 185) | def get_authorized():
FILE: komidabot/bot.py
class Bot (line 4) | class Bot:
method trigger_received (line 5) | def trigger_received(self, trigger: Trigger):
method notify_error (line 9) | def notify_error(self, error: Exception):
FILE: komidabot/config.py
function is_registrations_enabled (line 4) | def is_registrations_enabled():
FILE: komidabot/debug/administration.py
function notify_admins (line 16) | def notify_admins(message: messages.Message):
function _send_notification (line 35) | def _send_notification(subscription: AdminSubscription, data) -> message...
function _send_text_message (line 75) | def _send_text_message(subscription: AdminSubscription,
function _send_exception_message (line 94) | def _send_exception_message(subscription: AdminSubscription,
FILE: komidabot/debug/state.py
class ProgramStateTrace (line 5) | class ProgramStateTrace:
method __init__ (line 6) | def __init__(self):
method state (line 10) | def state(self, state: 'ProgramState'):
method push (line 13) | def push(self, state: 'ProgramState'):
method pop (line 20) | def pop(self):
method prepend (line 25) | def prepend(self, parent: 'ProgramStateTrace'):
method append (line 33) | def append(self, child: 'ProgramStateTrace'):
method get_state (line 39) | def get_state(self) -> 'ProgramState':
method __repr__ (line 42) | def __repr__(self):
class ProgramState (line 52) | class ProgramState:
method __init__ (line 53) | def __init__(self):
class InitialProgramState (line 58) | class InitialProgramState(ProgramState):
method __repr__ (line 59) | def __repr__(self):
class SimpleProgramState (line 63) | class SimpleProgramState(ProgramState):
method __init__ (line 64) | def __init__(self, name: str, data: Any = None):
method __repr__ (line 69) | def __repr__(self):
class DebuggableException (line 73) | class DebuggableException(Exception):
method __init__ (line 74) | def __init__(self, message: str, trace: ProgramStateTrace = None):
method get_trace (line 78) | def get_trace(self) -> ProgramStateTrace:
method get_or_set_trace (line 81) | def get_or_set_trace(self, trace: ProgramStateTrace) -> ProgramStateTr...
method get_state (line 86) | def get_state(self) -> ProgramState:
method print_info (line 89) | def print_info(self, logger: Logger):
class WithProgramState (line 96) | class WithProgramState:
method __init__ (line 97) | def __init__(self, trace: ProgramStateTrace, state: ProgramState):
method __enter__ (line 101) | def __enter__(self):
method __exit__ (line 104) | def __exit__(self, exc_type, exc_val, exc_tb):
FILE: komidabot/external_menu.py
function _cleanup_session (line 125) | def _cleanup_session(session: requests.Session):
function _convert_price (line 132) | def _convert_price(price_students: Union[str, Decimal]) -> Decimal:
function _decimal_or_none (line 140) | def _decimal_or_none(value: str) -> Optional[Decimal]:
function fetch_raw (line 146) | def fetch_raw(campus: models.Campus, date: datetime.date) -> Optional[Any]:
function parse_fetched (line 178) | def parse_fetched(fetched: Dict):
function process_parsed (line 266) | def process_parsed(parsed: Dict):
function update_menu (line 379) | def update_menu(processed: Dict):
FILE: komidabot/facebook/api_interface.py
class ApiInterface (line 19) | class ApiInterface:
method __init__ (line 20) | def __init__(self, page_access_token: str):
method post_send_api (line 36) | def post_send_api(self, data: dict) -> messages.MessageSendResult:
method post_profile_api (line 84) | def post_profile_api(self, data: dict):
method post_pass_thread_control (line 101) | def post_pass_thread_control(self, data: dict):
method lookup_locale (line 119) | def lookup_locale(self, user_id: str) -> str:
FILE: komidabot/facebook/messages.py
class MessageHandler (line 12) | class MessageHandler(messages.MessageHandler):
method send_message (line 13) | def send_message(self, user: users.User, message: messages.Message) ->...
method _send_text_message (line 27) | def _send_text_message(user_id: users.UserId, message: messages.TextMe...
method _send_menu_message (line 41) | def _send_menu_message(user: users.User, message: messages.MenuMessage...
method _send_template_message (line 60) | def _send_template_message(user_id: users.UserId, message: 'TemplateMe...
class TemplateMessage (line 77) | class TemplateMessage(messages.Message):
method __init__ (line 78) | def __init__(self, trigger: messages.Trigger, payload):
FILE: komidabot/facebook/nlp_dates.py
function extract_days (line 8) | def extract_days(aspects: List[triggers.DatetimeAspect]):
FILE: komidabot/facebook/postbacks.py
class Postback (line 15) | class Postback:
method call_postback (line 16) | def call_postback(self, trigger: triggers.Trigger, *args, **kwargs) ->...
function lookup_postback (line 20) | def lookup_postback(name: str) -> Postback:
function postback (line 24) | def postback(name: str = None):
function postback_button (line 49) | def postback_button(title: str, payload: str):
function url_button (line 53) | def url_button(title: str, url: str):
function get_started (line 64) | def get_started(trigger: triggers.Trigger):
function menu_today (line 71) | def menu_today(trigger: triggers.Trigger):
function settings_subscriptions (line 76) | def settings_subscriptions(trigger: triggers.Trigger):
function set_subscription (line 141) | def set_subscription(trigger: triggers.Trigger, day: int, campus: Option...
function settings_language (line 172) | def settings_language(trigger: triggers.Trigger):
function set_language (line 192) | def set_language(trigger: triggers.Trigger, language: str, display: str):
function generate_postback_data (line 207) | def generate_postback_data(include_persistent_menu: bool, production: bo...
FILE: komidabot/facebook/triggers.py
class PostbackTrigger (line 4) | class PostbackTrigger(Trigger):
method __init__ (line 5) | def __init__(self, name, d_args, d_kwargs, *args, **kwargs):
method get_repr_text (line 11) | def get_repr_text(self):
FILE: komidabot/facebook/users.py
class UserManager (line 13) | class UserManager(users.UserManager):
method __init__ (line 14) | def __init__(self):
method get_user (line 23) | def get_user(self, user: 'Union[users.UserId, models.AppUser]', **kwar...
method initialise (line 34) | def initialise(self):
method get_identifier (line 44) | def get_identifier(self):
class User (line 48) | class User(users.User):
method __init__ (line 49) | def __init__(self, manager: UserManager, id_str: str):
method get_locale (line 53) | def get_locale(self) -> 'Optional[str]':
method get_provider_name (line 61) | def get_provider_name(self) -> 'str':
method get_internal_id (line 64) | def get_internal_id(self) -> 'str':
method supports_subscription_channel (line 67) | def supports_subscription_channel(self, channel: str) -> bool:
method get_manager (line 73) | def get_manager(self) -> UserManager:
method get_message_handler (line 76) | def get_message_handler(self) -> messages.MessageHandler:
method mark_message_seen (line 79) | def mark_message_seen(self):
FILE: komidabot/features.py
class _Feature (line 26) | class _Feature:
method __init__ (line 27) | def __init__(self, feat: 'Optional[_feature]', obj: 'Optional[models.F...
method __repr__ (line 31) | def __repr__(self):
function update_active_features (line 35) | def update_active_features():
FILE: komidabot/komidabot.py
class Komidabot (line 24) | class Komidabot(Bot):
method __init__ (line 25) | def __init__(self, the_app):
method start_scheduler (line 74) | def start_scheduler(self):
method trigger_received (line 78) | def trigger_received(self, trigger: triggers.Trigger):
method notify_error (line 284) | def notify_error(self, error: Exception):
method message_admins (line 294) | def message_admins(self, message: messages.Message):
function dispatch_daily_menus (line 301) | def dispatch_daily_menus(trigger: triggers.SubscriptionTrigger):
function update_menus (line 395) | def update_menus(*campuses: str, dates: 'List[datetime.date]' = None):
FILE: komidabot/localisation.py
function localisation_definition (line 6) | def localisation_definition(name, obj, fallback='en') -> Callable[[str],...
FILE: komidabot/menu.py
function get_menu_line (line 10) | def get_menu_line(menu_item: models.MenuItem, translator: translation.Tr...
function prepare_menu_text (line 23) | def prepare_menu_text(campus: models.Campus, date: datetime.date, transl...
function get_menu_text (line 28) | def get_menu_text(menu: Optional[models.Menu], translator: translation.T...
function get_short_menu_text (line 51) | def get_short_menu_text(menu: Optional[models.Menu], translator: transla...
FILE: komidabot/messages.py
class Aspect (line 9) | class Aspect:
method __repr__ (line 12) | def __repr__(self):
class Trigger (line 19) | class Trigger:
method __init__ (line 20) | def __init__(self, aspects: List[Aspect] = None):
method add_aspect (line 26) | def add_aspect(self, aspect: Aspect, aspect_type: Type[Aspect] = None):
method __contains__ (line 39) | def __contains__(self, aspect_type: Type[Aspect]) -> bool:
method __getitem__ (line 42) | def __getitem__(self, aspect_type: Type[T]) -> Union[List[T], T]:
method __delitem__ (line 45) | def __delitem__(self, aspect_type: Type[Aspect]):
method extend (line 49) | def extend(cls: Type[T], trigger: 'Trigger', *args, aspects: List[Aspe...
method __repr__ (line 64) | def __repr__(self):
method get_repr_text (line 71) | def get_repr_text(self):
class Message (line 75) | class Message:
method __init__ (line 76) | def __init__(self, trigger: Trigger):
class TextMessage (line 80) | class TextMessage(Message):
method __init__ (line 81) | def __init__(self, trigger: Trigger, text: str):
class ExceptionMessage (line 86) | class ExceptionMessage(Message):
method __init__ (line 87) | def __init__(self, trigger: Trigger, source: Exception):
class MenuMessage (line 92) | class MenuMessage(Message):
method __init__ (line 93) | def __init__(self, trigger: Trigger, menu: models.Menu, translator: tr...
class SubscriptionMenuMessage (line 99) | class SubscriptionMenuMessage(Message):
method __init__ (line 100) | def __init__(self, trigger: Trigger, date: datetime.date, translator: ...
method get_prepared (line 107) | def get_prepared(self, campus: models.Campus, lang: str, user_manager:...
method set_prepared (line 116) | def set_prepared(self, campus: models.Campus, lang: str, user_manager:...
class MessageSendResult (line 127) | class MessageSendResult(enum.Enum):
class MessageHandler (line 142) | class MessageHandler:
method send_message (line 148) | def send_message(self, user, message: 'Message') -> 'MessageSendResult':
FILE: komidabot/models.py
class CourseType (line 23) | class CourseType(enum.Enum):
class CourseSubType (line 35) | class CourseSubType(enum.Enum):
class CourseAttributes (line 42) | class CourseAttributes(enum.Enum):
method has_value (line 65) | def has_value(cls, value):
class CourseAllergens (line 70) | class CourseAllergens(enum.Enum):
method has_value (line 88) | def has_value(cls, value):
class Day (line 136) | class Day(enum.Enum):
class AppSettings (line 150) | class AppSettings(ModelBase):
method __init__ (line 156) | def __init__(self, name: str, value: Any = None):
method create_entries (line 164) | def create_entries():
method set_default (line 170) | def set_default(name: str, default: Any) -> 'AppSettings':
method get_value (line 181) | def get_value(name: str) -> Any:
class Campus (line 189) | class Campus(ModelBase):
method __init__ (line 204) | def __init__(self, name: str, short_name: str):
method get_keywords (line 214) | def get_keywords(self) -> List[str]:
method add_keyword (line 217) | def add_keyword(self, keyword: str):
method remove_keyword (line 223) | def remove_keyword(self, keyword: str):
method _set_keywords (line 226) | def _set_keywords(self, keywords: List[str]):
method create (line 232) | def create(name: str, short_name: str, keywords: List[str], external_i...
method get_by_id (line 245) | def get_by_id(campus_id: int) -> 'Optional[Campus]':
method get_by_external_id (line 249) | def get_by_external_id(external_id: int) -> 'Optional[Campus]':
method get_by_short_name (line 253) | def get_by_short_name(short_name: str) -> 'Optional[Campus]':
method find_by_keyword (line 257) | def find_by_keyword(keyword: str) -> 'List[Campus]':
method get_all (line 263) | def get_all() -> 'List[Campus]':
method get_all_active (line 267) | def get_all_active() -> 'List[Campus]':
method __hash__ (line 270) | def __hash__(self):
class ClosingDays (line 274) | class ClosingDays(ModelBase):
method __init__ (line 284) | def __init__(self, campus_id: int, first_day: datetime.date, last_day:...
method create (line 301) | def create(campus: Campus, first_day: datetime.date, last_day: Optiona...
method find_is_closed (line 313) | def find_is_closed(campus: Campus, day: datetime.date) -> 'Optional[Cl...
method find_closing_days_including (line 323) | def find_closing_days_including(campus: Campus,
class Translatable (line 335) | class Translatable(ModelBase):
method __init__ (line 346) | def __init__(self, text: str, language: str):
method add_translation (line 355) | def add_translation(self, language: str, text: str, provider: str = No...
method get_translation (line 370) | def get_translation(self, language: str, translator: 'TranslationServi...
method has_translation (line 394) | def has_translation(self, language: str) -> 'bool':
method translations (line 408) | def translations(self) -> 'Collection[Translation]':
method _get_dummy_translation (line 411) | def _get_dummy_translation(self) -> 'Translation':
method get_or_create (line 422) | def get_or_create(text: str, language) -> 'Tuple[Translatable, Transla...
method get_by_id (line 433) | def get_by_id(translatable_id) -> 'Optional[Translatable]':
method __hash__ (line 436) | def __hash__(self):
class Translation (line 440) | class Translation(ModelBase):
method __init__ (line 449) | def __init__(self, translatable_id: int, language: str, translation: s...
method __eq__ (line 464) | def __eq__(self, other: 'Translation'):
method __hash__ (line 473) | def __hash__(self):
class Menu (line 477) | class Menu(ModelBase):
method __init__ (line 487) | def __init__(self, campus_id: int, day: datetime.date):
method delete (line 496) | def delete(self):
method add_menu_item (line 499) | def add_menu_item(self, translatable: Translatable, course_type: Cours...
method create (line 512) | def create(campus: Campus, day: datetime.date, add_to_db=True) -> 'Menu':
method get_menu (line 521) | def get_menu(campus: Campus, day: datetime.date) -> 'Optional[Menu]':
method remove_menus_on_closing_days (line 525) | def remove_menus_on_closing_days():
method __hash__ (line 537) | def __hash__(self):
class MenuItem (line 541) | class MenuItem(ModelBase):
method __init__ (line 558) | def __init__(self, menu: Menu, translatable_id: int, course_type: Cour...
method get_translation (line 580) | def get_translation(self, language: str, translator: 'TranslationServi...
method format_price (line 584) | def format_price(price: Decimal) -> str:
method get_attributes (line 589) | def get_attributes(self) -> List[CourseAttributes]:
method set_attributes (line 594) | def set_attributes(self, attributes: List[CourseAttributes]):
method get_allergens (line 597) | def get_allergens(self) -> List[CourseAllergens]:
method set_allergens (line 601) | def set_allergens(self, allergens: List[CourseAllergens]):
method __hash__ (line 604) | def __hash__(self):
class UserDayCampusPreference (line 608) | class UserDayCampusPreference(ModelBase):
method __init__ (line 620) | def __init__(self, user_id: int, day: Day, campus_id: int, active=True...
method get_all_for_user (line 636) | def get_all_for_user(user: 'AppUser') -> 'List[UserDayCampusPreference]':
method get_for_user (line 640) | def get_for_user(user: 'AppUser', day: Day) -> 'Optional[UserDayCampus...
method create (line 644) | def create(user: 'AppUser', day: Day, campus: Campus, active=True) -> ...
method __hash__ (line 654) | def __hash__(self):
class AppUser (line 658) | class AppUser(ModelBase):
method __init__ (line 677) | def __init__(self, provider: str, internal_id: str, language: str):
method set_campus (line 689) | def set_campus(self, day: Day, campus: Campus, active=None):
method set_day_active (line 698) | def set_day_active(self, day: Day, active: bool):
method get_campus (line 706) | def get_campus(self, day: Day) -> 'Optional[Campus]':
method get_subscription (line 713) | def get_subscription(self, day: Day) -> 'Optional[UserDayCampusPrefere...
method set_language (line 716) | def set_language(self, language: str):
method set_active (line 719) | def set_active(self, day: Day, active: bool):
method create (line 727) | def create(provider: str, internal_id: str, language: str) -> 'AppUser':
method delete (line 734) | def delete(self):
method find_subscribed_users_by_day (line 738) | def find_subscribed_users_by_day(day: Day, provider=None) -> 'List[App...
method find_by_id (line 749) | def find_by_id(provider: str, internal_id: str) -> 'Optional[AppUser]':
method find_by_provider (line 753) | def find_by_provider(provider: str) -> 'List[AppUser]':
method __hash__ (line 756) | def __hash__(self):
class Feature (line 760) | class Feature(ModelBase):
method __init__ (line 770) | def __init__(self, string_id: str, description: str = None, globally_a...
method create (line 783) | def create(string_id: str, description: str = None, globally_available...
method find_by_id (line 791) | def find_by_id(string_id: str) -> 'Optional[Feature]':
method get_all (line 795) | def get_all() -> 'List[Feature]':
method is_user_participating (line 799) | def is_user_participating(user: Optional[AppUser], string_id: str) -> ...
method set_user_participating (line 813) | def set_user_participating(user: AppUser, string_id: str, participatin...
method __hash__ (line 824) | def __hash__(self):
class FeatureParticipation (line 828) | class FeatureParticipation(ModelBase):
method __init__ (line 836) | def __init__(self, user_id: int, feature_id: int):
method create (line 846) | def create(user: AppUser, feature: Feature) -> 'Optional[FeaturePartic...
method get_for_user (line 854) | def get_for_user(user: AppUser, feature: Feature) -> 'Optional[Feature...
method __hash__ (line 857) | def __hash__(self):
function recreate_db (line 861) | def recreate_db():
function create_standard_values (line 868) | def create_standard_values():
function import_dump (line 879) | def import_dump(dump_file):
FILE: komidabot/models_training.py
class LearningDatapoint (line 150) | class LearningDatapoint(ModelBase):
method __init__ (line 161) | def __init__(self, campus_id: int, menu_day: datetime.date, screenshot...
method create (line 177) | def create(campus: 'Campus', menu_day: datetime.date, screenshot: str,
method find_by_id (line 186) | def find_by_id(datapoint_id: int) -> 'Optional[LearningDatapoint]':
method get_all (line 190) | def get_all() -> 'List[LearningDatapoint]':
method get_random (line 194) | def get_random(user: 'RegisteredUser') -> 'Optional[LearningDatapoint]':
method user_submit (line 204) | def user_submit(self, user: 'RegisteredUser', submission_data: Any):
method __hash__ (line 207) | def __hash__(self):
class LearningDatapointSubmission (line 211) | class LearningDatapointSubmission(ModelBase):
method __init__ (line 222) | def __init__(self, user_id: int, datapoint_id: int, submission_data: A...
method create (line 235) | def create(datapoint: LearningDatapoint, user: 'RegisteredUser',
method __hash__ (line 243) | def __hash__(self):
FILE: komidabot/models_users.py
class AdminSubscription (line 11) | class AdminSubscription(TypedDict):
class RegisteredUser (line 23) | class RegisteredUser(ModelBase, UserMixin):
method __init__ (line 46) | def __init__(self, provider: str, subject: str, name: str, email: str,...
method create (line 65) | def create(provider: str, subject: str, name: str, email: str, profile...
method delete (line 74) | def delete(self):
method is_active (line 79) | def is_active(self):
method get_by_id (line 84) | def get_by_id(user_id: int) -> 'Optional[RegisteredUser]':
method find_by_provider_id (line 88) | def find_by_provider_id(provider: str, subject: str) -> 'Optional[Regi...
method find_by_email (line 92) | def find_by_email(email: str) -> 'Optional[RegisteredUser]':
method get_all (line 96) | def get_all() -> 'List[RegisteredUser]':
method get_all_active (line 100) | def get_all_active() -> 'List[RegisteredUser]':
method get_all_by_role (line 104) | def get_all_by_role(role: 'Role') -> 'List[RegisteredUser]':
method get_roles (line 112) | def get_roles(self) -> 'List[Role]':
method add_role (line 115) | def add_role(self, role: 'Role'):
method remove_role (line 118) | def remove_role(self, role: 'Role'):
method is_role (line 121) | def is_role(self, role: 'Union[str, Role]') -> bool:
method get_subscriptions (line 131) | def get_subscriptions(self) -> 'List[AdminSubscription]':
method set_subscriptions (line 134) | def set_subscriptions(self, subscriptions: 'List[AdminSubscription]'):
method add_subscription (line 137) | def add_subscription(self, endpoint: str, keys: Dict[str, str]):
method remove_subscription (line 152) | def remove_subscription(self, endpoint: str):
method replace_subscription (line 156) | def replace_subscription(old_endpoint: str, endpoint: str, keys: Dict[...
method __hash__ (line 161) | def __hash__(self):
class Role (line 165) | class Role(ModelBase):
method __init__ (line 173) | def __init__(self, name: str):
method create (line 180) | def create(name: str, add_to_db=True) -> 'Role':
method find_by_name (line 189) | def find_by_name(name: str) -> 'Optional[Role]':
FILE: komidabot/rate_limit.py
class Limiter (line 6) | class Limiter:
method __init__ (line 7) | def __init__(self, max_rate: int):
method __call__ (line 11) | def __call__(self):
FILE: komidabot/subscriptions/__init__.py
class SubscriptionQuery (line 9) | class SubscriptionQuery:
class SubscriptionData (line 13) | class SubscriptionData:
class SubscriptionChannel (line 17) | class SubscriptionChannel:
method user_supported (line 18) | def user_supported(self, user: 'User') -> bool:
method get_subscribed_users (line 21) | def get_subscribed_users(self, /, query: Union[SubscriptionQuery, Dict...
method get_query_from (line 24) | def get_query_from(self, query: Dict = None) -> Optional[SubscriptionQ...
method deliver_message (line 27) | def deliver_message(self, message: Message):
method get_name (line 30) | def get_name(self) -> str:
method user_subscribe (line 33) | def user_subscribe(self, user: 'User', /, data: SubscriptionData = Non...
method user_unsubscribe (line 36) | def user_unsubscribe(self, user: 'User') -> bool:
method user_subscription_data (line 39) | def user_subscription_data(self, user: 'User') -> Optional[Subscriptio...
class SubscriptionManager (line 43) | class SubscriptionManager:
method __init__ (line 44) | def __init__(self):
method register_channel (line 47) | def register_channel(self, channel: 'SubscriptionChannel'):
method get_channel (line 53) | def get_channel(self, channel: str) -> 'Optional[SubscriptionChannel]':
method get_subscribed_users (line 56) | def get_subscribed_users(self, channel: str, /, query: Union[Subscript...
method deliver_message (line 64) | def deliver_message(self, channel: str, message: Message):
method user_subscribe (line 70) | def user_subscribe(self, user: 'User', channel: str, /, data: Subscrip...
method user_unsubscribe (line 81) | def user_unsubscribe(self, user: 'User', channel: str) -> bool:
method user_subscription_data (line 92) | def user_subscription_data(self, user: 'User', channel: str) -> Option...
FILE: komidabot/subscriptions/daily_menu.py
class Query (line 17) | class Query(subscriptions.SubscriptionQuery):
method __init__ (line 18) | def __init__(self, day: models.Day, campus: models.Campus = None):
class Data (line 23) | class Data(subscriptions.SubscriptionData):
class Day (line 24) | class Day:
method __init__ (line 25) | def __init__(self):
method __init__ (line 29) | def __init__(self):
class Channel (line 39) | class Channel(subscriptions.SubscriptionChannel):
method get_subscribed_users (line 40) | def get_subscribed_users(self, /, query: Union[Query, Dict] = None) ->...
method get_query_from (line 56) | def get_query_from(self, query: Dict = None) -> Optional[Query]:
method deliver_message (line 61) | def deliver_message(self, message: Message):
method get_name (line 75) | def get_name(self):
method user_subscribe (line 78) | def user_subscribe(self, user: 'User', /, data: Data = None) -> bool:
method user_unsubscribe (line 81) | def user_unsubscribe(self, user: 'User') -> bool:
method user_subscription_data (line 84) | def user_subscription_data(self, user: 'User') -> Optional[Data]:
FILE: komidabot/translation.py
function _fix_language (line 10) | def _fix_language(language: Language):
class TranslationService (line 19) | class TranslationService:
method translate (line 20) | def translate(self, text: str, from_language: Language, to_language: L...
method identifier (line 31) | def identifier(self):
method pretty_name (line 35) | def pretty_name(self):
class KomidaTranslationService (line 39) | class KomidaTranslationService(TranslationService):
method translate (line 40) | def translate(self, text: str, from_language: Language, to_language: L...
method identifier (line 44) | def identifier(self):
method pretty_name (line 48) | def pretty_name(self):
class GoogleTranslationService (line 52) | class GoogleTranslationService(TranslationService):
method __init__ (line 53) | def __init__(self):
method translate (line 56) | def translate(self, text: str, from_language: Language, to_language: L...
method identifier (line 60) | def identifier(self):
method pretty_name (line 64) | def pretty_name(self):
class BingTranslationService (line 68) | class BingTranslationService(TranslationService):
method translate (line 69) | def translate(self, text: str, from_language: Language, to_language: L...
method identifier (line 73) | def identifier(self):
method pretty_name (line 77) | def pretty_name(self):
FILE: komidabot/triggers.py
class SubscriptionTrigger (line 7) | class SubscriptionTrigger(Trigger):
method __init__ (line 8) | def __init__(self, *args, date: datetime.date = None, **kwargs):
method get_repr_text (line 12) | def get_repr_text(self):
class TextTrigger (line 16) | class TextTrigger(Trigger):
method __init__ (line 17) | def __init__(self, text, *args, **kwargs):
method get_repr_text (line 21) | def get_repr_text(self):
class NewUserAspect (line 25) | class NewUserAspect(Aspect):
method __repr__ (line 26) | def __repr__(self):
class SenderAspect (line 30) | class SenderAspect(Aspect):
method __init__ (line 31) | def __init__(self, sender: users.User):
method __repr__ (line 35) | def __repr__(self):
class AtAdminAspect (line 39) | class AtAdminAspect(Aspect):
method __repr__ (line 40) | def __repr__(self):
class DatetimeAspect (line 44) | class DatetimeAspect(Aspect):
method __init__ (line 47) | def __init__(self, value: str, grain: str):
method __repr__ (line 52) | def __repr__(self):
class LocaleAspect (line 56) | class LocaleAspect(Aspect):
method __init__ (line 57) | def __init__(self, locale: str, confidence: float):
method __repr__ (line 62) | def __repr__(self):
FILE: komidabot/users.py
class UserId (line 14) | class UserId(NamedTuple):
method __repr__ (line 18) | def __repr__(self):
class UserManager (line 22) | class UserManager: # TODO: This probably could use more methods
method get_user (line 23) | def get_user(self, user: 'Union[UserId, models.AppUser]', **kwargs) ->...
method get_administrators (line 26) | def get_administrators(self) -> 'List[User]':
method initialise (line 31) | def initialise(self):
method get_identifier (line 34) | def get_identifier(self) -> str:
class User (line 38) | class User:
method id (line 40) | def id(self) -> UserId:
method get_provider_name (line 43) | def get_provider_name(self) -> 'str':
method manager (line 47) | def manager(self) -> UserManager:
method get_manager (line 50) | def get_manager(self) -> UserManager:
method get_internal_id (line 53) | def get_internal_id(self) -> 'str':
method get_db_user (line 56) | def get_db_user(self) -> 'Optional[models.AppUser]':
method add_to_db (line 60) | def add_to_db(self):
method remove_from_db (line 64) | def remove_from_db(self):
method get_locale (line 74) | def get_locale(self) -> 'Optional[str]': # TODO: Properly look into this
method get_is_notified_new_site (line 81) | def get_is_notified_new_site(self) -> 'Optional[bool]':
method set_is_notified_new_site (line 88) | def set_is_notified_new_site(self, value: bool):
method get_campus_for_day (line 95) | def get_campus_for_day(self, date: Union[models.Day, datetime.date]) -...
method set_campus_for_day (line 109) | def set_campus_for_day(self, campus: models.Campus, date: Union[models...
method disable_subscription_for_day (line 129) | def disable_subscription_for_day(self, date: Union[models.Day, datetim...
method get_subscription_for_day (line 148) | def get_subscription_for_day(self, date: Union[models.Day, datetime.da...
method mark_reachable (line 163) | def mark_reachable(self) -> bool:
method mark_unreachable (line 178) | def mark_unreachable(self):
method is_reachable (line 188) | def is_reachable(self) -> bool:
method supports_subscription_channel (line 199) | def supports_subscription_channel(self, channel: str) -> bool:
method is_admin (line 202) | def is_admin(self):
method is_feature_active (line 206) | def is_feature_active(self, feature_id: str) -> bool:
method get_data (line 209) | def get_data(self) -> Optional[Dict]:
method set_data (line 224) | def set_data(self, data: Optional[Dict]):
method get_message_handler (line 234) | def get_message_handler(self) -> messages.MessageHandler:
method send_message (line 237) | def send_message(self, message: 'messages.Message') -> 'messages.Messa...
method send_message_or_remove (line 247) | def send_message_or_remove(self, channel: str, message: 'messages.Mess...
method __repr__ (line 272) | def __repr__(self):
class UnifiedUserManager (line 277) | class UnifiedUserManager(UserManager):
method __init__ (line 278) | def __init__(self):
method register_manager (line 281) | def register_manager(self, manager: UserManager):
method get_user (line 289) | def get_user(self, user: 'Union[UserId, models.AppUser]', **kwargs) ->...
method get_administrators (line 295) | def get_administrators(self):
method initialise (line 298) | def initialise(self):
method get_identifier (line 302) | def get_identifier(self):
FILE: komidabot/util.py
function check_exceptions (line 9) | def check_exceptions(fallback=None):
function get_list_diff (line 29) | def get_list_diff(old_list: List[T], new_list: List[T]) -> Tuple[List[T]...
function date_to_string (line 51) | def date_to_string(locale: str, date):
function expected (line 73) | def expected(name, value, *types):
function expected_or_none (line 78) | def expected_or_none(value, *types):
FILE: komidabot/web/messages.py
class MessageHandler (line 21) | class MessageHandler(messages.MessageHandler):
method send_message (line 22) | def send_message(self, user: users.User, message: messages.Message) ->...
method _send_notification (line 36) | def _send_notification(subscription_information, data) -> messages.Mes...
method _send_text_message (line 78) | def _send_text_message(user: users.User, message: messages.TextMessage...
method _send_menu_message (line 99) | def _send_menu_message(user: users.User, message: messages.MenuMessage...
method _send_subscription_menu_message (line 131) | def _send_subscription_menu_message(user: users.User,
FILE: komidabot/web/users.py
class UserManager (line 12) | class UserManager(users.UserManager):
method __init__ (line 13) | def __init__(self):
method get_user (line 16) | def get_user(self, user: 'Union[users.UserId, models.AppUser]', **kwar...
method initialise (line 27) | def initialise(self):
method get_identifier (line 30) | def get_identifier(self):
class User (line 34) | class User(users.User):
method __init__ (line 35) | def __init__(self, manager: UserManager, id_str: str):
method get_provider_name (line 39) | def get_provider_name(self) -> 'str':
method get_internal_id (line 42) | def get_internal_id(self) -> 'str':
method supports_subscription_channel (line 45) | def supports_subscription_channel(self, channel: str) -> bool:
method get_manager (line 48) | def get_manager(self) -> UserManager:
method get_message_handler (line 51) | def get_message_handler(self) -> messages.MessageHandler:
method get_data (line 54) | def get_data(self) -> 'Optional[UserData]':
method set_data (line 57) | def set_data(self, data: 'Optional[UserData]'):
class UserData (line 61) | class UserData(TypedDict):
FILE: manage.py
function recreate_db (line 24) | def recreate_db():
function seed_db (line 29) | def seed_db():
function run_subscription (line 35) | def run_subscription():
function update_menus (line 40) | def update_menus():
function cleanup (line 45) | def cleanup():
function synchronize_menus (line 50) | def synchronize_menus():
function upload_learning_data (line 55) | def upload_learning_data():
function test (line 130) | def test(case: Optional[str]):
function handler (line 143) | def handler(signum: int, _):
FILE: manual_menu_scraper.py
function get_by_external_id (line 21) | def get_by_external_id(campus_id: int):
function get_by_short_name (line 25) | def get_by_short_name(short_name: str):
FILE: migrations/env.py
function run_migrations_offline (line 36) | def run_migrations_offline():
function run_migrations_online (line 57) | def run_migrations_online():
FILE: migrations/versions/1a2e04608ee9_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 23) | def downgrade():
FILE: migrations/versions/1dafd2bf730a_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 22) | def downgrade():
FILE: migrations/versions/276ad61a41a5_.py
function upgrade (line 22) | def upgrade():
function downgrade (line 76) | def downgrade():
FILE: migrations/versions/2887dcc37788_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 78) | def downgrade():
FILE: migrations/versions/3806b46f7f00_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 28) | def downgrade():
FILE: migrations/versions/4fafafd2400f_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 29) | def downgrade():
FILE: migrations/versions/528821121657_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 21) | def downgrade():
FILE: migrations/versions/55696107a6b9_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 37) | def downgrade():
FILE: migrations/versions/5cd86de4dffe_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 26) | def downgrade():
FILE: migrations/versions/5ee455656a96_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 21) | def downgrade():
FILE: migrations/versions/7751a57b029e_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 34) | def downgrade():
FILE: migrations/versions/79e0c9de90f0_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 65) | def downgrade():
FILE: migrations/versions/85b659320f83_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 26) | def downgrade():
FILE: migrations/versions/92e4e9f8ff64_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 25) | def downgrade():
FILE: migrations/versions/93b9de63cd7b_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 24) | def downgrade():
FILE: migrations/versions/9b9afdcf4e4e_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 23) | def downgrade():
FILE: migrations/versions/a223b578f7b0_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 85) | def downgrade():
FILE: migrations/versions/aa31c90dc353_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 26) | def downgrade():
FILE: migrations/versions/b384f281e755_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 22) | def downgrade():
FILE: migrations/versions/bc1ef0083bb4_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 24) | def downgrade():
FILE: migrations/versions/bd04cd56036f_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 29) | def downgrade():
FILE: migrations/versions/d225cbda8c77_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 36) | def downgrade():
FILE: migrations/versions/daf22dcadb8d_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 22) | def downgrade():
FILE: migrations/versions/ddf5bd871988_.py
function upgrade (line 19) | def upgrade():
function downgrade (line 36) | def downgrade():
FILE: migrations/versions/e18b14ed6b98_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 42) | def downgrade():
FILE: migrations/versions/ea6e1f581a7b_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 23) | def downgrade():
FILE: migrations/versions/ecce0e669d8c_.py
function upgrade (line 19) | def upgrade():
function downgrade (line 28) | def downgrade():
FILE: migrations/versions/eda0c928c279_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 47) | def downgrade():
FILE: migrations/versions/ee24af8d3121_.py
function upgrade (line 17) | def upgrade():
function downgrade (line 24) | def downgrade():
FILE: migrations/versions/fe4aca6853a2_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 26) | def downgrade():
FILE: migrations/versions/fe7bda58c5a4_.py
function upgrade (line 18) | def upgrade():
function downgrade (line 22) | def downgrade():
FILE: tests/base.py
function with_context (line 26) | def with_context(func):
class BaseTestCase (line 51) | class BaseTestCase(TestCase):
method __init__ (line 52) | def __init__(self, *args, **kwargs):
method create_app (line 57) | def create_app(self):
method setUp (line 61) | def setUp(self):
method tearDown (line 70) | def tearDown(self):
method assertEqualCommutative (line 77) | def assertEqualCommutative(self, first, second, msg=None):
method assertNotEqualCommutative (line 81) | def assertNotEqualCommutative(self, first, second, msg=None):
method create_translation (line 86) | def create_translation(self, data: Dict[str, str], default_language: s...
method create_test_campuses (line 109) | def create_test_campuses(self) -> List[models.Campus]:
method activate_feature (line 121) | def activate_feature(self, feature_id: str, user_list: 'List[users.Use...
method create_menu (line 139) | def create_menu(self, campus: models.Campus, day: datetime.date, items...
class HttpCapture (line 151) | class HttpCapture:
method __init__ (line 161) | def __init__(self, allow_net_connect=False):
method __enter__ (line 164) | def __enter__(self):
method __exit__ (line 168) | def __exit__(self, exc_type, exc_val, exc_tb):
method register_uri (line 173) | def register_uri(self, method, uri, body, status=200):
FILE: tests/external_menus/download_external_jsons.py
class Limiter (line 30) | class Limiter:
method __init__ (line 31) | def __init__(self, max_rate: int):
method __call__ (line 35) | def __call__(self):
FILE: tests/test_debug_state.py
class TestConstants (line 6) | class TestConstants(unittest.TestCase):
method test_no_raise (line 11) | def test_no_raise(self):
method test_simple_raise (line 18) | def test_simple_raise(self):
method test_simple_nested (line 35) | def test_simple_nested(self):
method test_simple_branched (line 54) | def test_simple_branched(self):
method test_multi_nested (line 91) | def test_multi_nested(self):
method test_multi_branched (line 113) | def test_multi_branched(self):
FILE: tests/test_external_menu.py
function filter_meta (line 16) | def filter_meta(value: Union[List[Any], Dict[str, Any]]):
class TestExternalMenu (line 28) | class TestExternalMenu(BaseTestCase):
method setUp (line 29) | def setUp(self):
method create_validator (line 50) | def create_validator(schema):
method test_saved_requests (line 57) | def test_saved_requests(self):
FILE: tests/test_models_campus.py
class TestModelsCampus (line 8) | class TestModelsCampus(BaseTestCase):
method test_simple_constructors (line 13) | def test_simple_constructors(self):
method test_invalid_constructors (line 43) | def test_invalid_constructors(self):
method test_create (line 59) | def test_create(self):
method test_create_no_add_to_db (line 84) | def test_create_no_add_to_db(self):
method test_keywords (line 107) | def test_keywords(self):
method test_get_by_id (line 189) | def test_get_by_id(self):
method test_get_by_external_id (line 203) | def test_get_by_external_id(self):
method test_get_by_short_name (line 217) | def test_get_by_short_name(self):
method test_find_by_keyword (line 232) | def test_find_by_keyword(self):
method test_get_all (line 259) | def test_get_all(self):
method test_get_all_active (line 279) | def test_get_all_active(self):
FILE: tests/test_models_closing_days.py
class TestModelsClosingDays (line 9) | class TestModelsClosingDays(BaseTestCase):
method setUp (line 14) | def setUp(self):
method test_simple_constructors (line 19) | def test_simple_constructors(self):
method test_invalid_constructors (line 45) | def test_invalid_constructors(self):
method test_create (line 74) | def test_create(self):
method test_create_no_add_to_db (line 94) | def test_create_no_add_to_db(self):
method test_find_is_closed (line 118) | def test_find_is_closed(self):
method test_find_closing_days_including (line 152) | def test_find_closing_days_including(self):
FILE: tests/test_models_menu.py
class TestModelsMenu (line 9) | class TestModelsMenu(BaseTestCase):
method setUp (line 14) | def setUp(self):
method test_simple_constructors (line 19) | def test_simple_constructors(self):
method test_invalid_constructors (line 40) | def test_invalid_constructors(self):
method test_create (line 58) | def test_create(self):
method test_create_no_add_first (line 72) | def test_create_no_add_first(self):
FILE: tests/test_models_menu_item.py
class TestModelsMenuItem (line 9) | class TestModelsMenuItem(BaseTestCase):
method setUp (line 14) | def setUp(self):
method test_simple_constructors (line 19) | def test_simple_constructors(self):
method test_add_menu_item (line 41) | def test_add_menu_item(self):
method test_get_translation (line 74) | def test_get_translation(self):
FILE: tests/test_models_registered_user.py
class TestModelsRegisteredUsers (line 10) | class TestModelsRegisteredUsers(BaseTestCase):
method test_simple_constructors (line 15) | def test_simple_constructors(self):
method test_invalid_constructors (line 35) | def test_invalid_constructors(self):
method test_create (line 69) | def test_create(self):
method test_get_by_id (line 87) | def test_get_by_id(self):
method test_find_by_provider_id (line 105) | def test_find_by_provider_id(self):
method test_find_by_email (line 123) | def test_find_by_email(self):
method test_get_all (line 140) | def test_get_all(self):
method test_get_all_active (line 162) | def test_get_all_active(self):
method test_get_all_by_role (line 187) | def test_get_all_by_role(self):
method test_roles (line 225) | def test_roles(self):
method test_delete (line 261) | def test_delete(self):
method test_user_mixin (line 287) | def test_user_mixin(self):
method test_subscriptions (line 307) | def test_subscriptions(self):
FILE: tests/test_models_translations.py
class TestModelsTranslations (line 9) | class TestModelsTranslations(BaseTestCase):
method test_simple_constructors (line 14) | def test_simple_constructors(self):
method test_get_or_create (line 52) | def test_get_or_create(self):
method test_add_translation (line 70) | def test_add_translation(self):
method test_has_translation (line 104) | def test_has_translation(self):
method test_get_translation (line 127) | def test_get_translation(self):
method test_get_by_id (line 168) | def test_get_by_id(self):
FILE: tests/test_subscriptions.py
class BaseSubscriptionsTestCase (line 16) | class BaseSubscriptionsTestCase(BaseTestCase):
method setUp (line 17) | def setUp(self):
class TestGenericSubscriptions (line 23) | class TestGenericSubscriptions(BaseSubscriptionsTestCase):
method setUp (line 24) | def setUp(self):
method setup_subscriptions (line 38) | def setup_subscriptions(self):
method setup_menu (line 76) | def setup_menu(self):
method test_active_subscriptions (line 113) | def test_active_subscriptions(self):
FILE: tests/test_test_utils.py
class TestConstants (line 5) | class TestConstants(BaseTestCase):
method test_days (line 10) | def test_days(self):
method test_days_list (line 19) | def test_days_list(self):
FILE: tests/test_triggers.py
class TestTriggers (line 8) | class TestTriggers(BaseTestCase):
method setUp (line 9) | def setUp(self):
method test_simple_trigger_constructors (line 28) | def test_simple_trigger_constructors(self):
method test_trigger_constructors_with_aspects (line 34) | def test_trigger_constructors_with_aspects(self):
method test_aspect_constructors (line 41) | def test_aspect_constructors(self):
method test_simple_extend (line 47) | def test_simple_extend(self):
method test_extend_with_aspects (line 57) | def test_extend_with_aspects(self):
method test_no_aspects (line 68) | def test_no_aspects(self):
method test_single_aspect (line 79) | def test_single_aspect(self):
method test_multiple_aspects (line 96) | def test_multiple_aspects(self):
FILE: tests/test_users_base.py
class TestUsersBase (line 7) | class TestUsersBase(BaseTestCase):
method setUp (line 12) | def setUp(self):
method test_get_administrators (line 30) | def test_get_administrators(self):
FILE: tests/users_stub.py
class UserManager (line 13) | class UserManager(users.UserManager):
method __init__ (line 14) | def __init__(self):
method add_user (line 19) | def add_user(self, internal_id: str, locale: str = 'nl') -> 'User':
method get_user (line 33) | def get_user(self, user: 'Union[users.UserId, AppUser]', **kwargs) -> ...
method initialise (line 45) | def initialise(self):
method get_identifier (line 48) | def get_identifier(self):
class User (line 52) | class User(users.User):
method __init__ (line 53) | def __init__(self, manager: UserManager, internal_id: str):
method get_provider_name (line 57) | def get_provider_name(self) -> 'str':
method get_internal_id (line 60) | def get_internal_id(self) -> 'str':
method supports_subscription_channel (line 63) | def supports_subscription_channel(self, channel: str) -> bool:
method get_manager (line 66) | def get_manager(self) -> UserManager:
method get_message_handler (line 69) | def get_message_handler(self):
class MessageHandler (line 75) | class MessageHandler(messages.MessageHandler):
method __init__ (line 78) | def __init__(self):
method reset (line 81) | def reset(self):
method send_message (line 84) | def send_message(self, user, message: messages.Message) -> messages.Me...
FILE: tests/utils.py
class StubTranslator (line 18) | class StubTranslator(translation.TranslationService):
method translate (line 19) | def translate(self, text: str, from_language: translation.Language, to...
method identifier (line 23) | def identifier(self):
method pretty_name (line 27) | def pretty_name(self):
Condensed preview — 198 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,576K chars).
[
{
"path": ".dockerignore",
"chars": 112,
"preview": ".dockerignore\n.git\n.gitignore\n.idea\nvenv\n__pycache__\n\nlearning-data\n\nconfig-*.env\ndocker-compose.yml\nDockerfile\n"
},
{
"path": ".github/dependabot.yml",
"chars": 538,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/workflows/codeql-analysis.yml",
"chars": 2141,
"preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
},
{
"path": ".github/workflows/tests.yml",
"chars": 627,
"preview": "name: Tests\n\non:\n push:\n branches: [ master ]\n pull_request:\n branches: [ master ]\n\njobs:\n test:\n runs-on: u"
},
{
"path": ".gitignore",
"chars": 79,
"preview": "*.env\ndump*.txt\n*.pem\n\n.idea/\n__pycache__/\nvenv/\n\nout.png\npage.xml\n\nscratches/\n"
},
{
"path": "Dockerfile",
"chars": 1003,
"preview": "# base image\nFROM python:3.10-slim\n\nENV TZ=Europe/Brussels\n\n# install dependencies\nRUN set -eu ; \\\n apt-get -qq updat"
},
{
"path": "Makefile",
"chars": 287,
"preview": ".PHONY: test run-prod run-dev stop\n\ntest:\n\tdocker-compose build komidabot-dev && \\\n\tdocker-compose run --rm komidabot-de"
},
{
"path": "README.md",
"chars": 188,
"preview": "# komidabot-docker\n\n\n as a main course because there is no snack or salad specif"
},
{
"path": "commands.txt",
"chars": 176,
"preview": "\nCreate a new migration script after a schema change:\n\ndocker-compose exec komidabot-dev flask db migrate\n\n\nRun tests:\n\n"
},
{
"path": "config.py",
"chars": 3690,
"preview": "import os\nfrom collections import namedtuple\nfrom typing import List, Optional, TypedDict\n\nPOSTGRES_HOST = os.getenv('PO"
},
{
"path": "database/.dockerignore",
"chars": 25,
"preview": ".dockerignore\nDockerfile\n"
},
{
"path": "database/Dockerfile",
"chars": 106,
"preview": "# base image\nFROM postgres:11-alpine\n\n# run create.sql on init\nADD create.sql /docker-entrypoint-initdb.d\n"
},
{
"path": "database/create.sql",
"chars": 95,
"preview": "CREATE DATABASE komidabot_prod;\nCREATE DATABASE komidabot_dev;\nCREATE DATABASE komidabot_test;\n"
},
{
"path": "docker-compose.yml",
"chars": 1335,
"preview": "version: '3.7'\n\nservices:\n\n komidabot-db:\n build:\n context: ./database\n dockerfile: Dockerfile\n restart"
},
{
"path": "entrypoint.sh",
"chars": 510,
"preview": "#!/usr/bin/env /bin/bash\n\nexport PYTHONDONTWRITEBYTECODE=1\n\n./wait-postgres.sh\n\nif [ $# -eq 0 ]; then\n trap 'kill -TERM"
},
{
"path": "extensions.py",
"chars": 441,
"preview": "from flask_login import LoginManager\nfrom flask_migrate import Migrate\nfrom flask_session import Session\nfrom flask_sqla"
},
{
"path": "komidabot/api_utils.py",
"chars": 3677,
"preview": "import json\nimport os\nimport sys\nimport traceback\nfrom functools import wraps\n\nfrom flask import jsonify, request\nfrom j"
},
{
"path": "komidabot/app.py",
"chars": 2479,
"preview": "import logging\n\nfrom flask import current_app as _current_app\n\nfrom config import ConfigType\n\n\ndef get_app() -> 'App':\n "
},
{
"path": "komidabot/blueprint.py",
"chars": 12537,
"preview": "import hashlib\nimport hmac\nimport json\nimport pprint\nimport sys\nimport time\nimport traceback\nfrom functools import wraps"
},
{
"path": "komidabot/blueprint_api.py",
"chars": 11481,
"preview": "import json\nfrom datetime import date, timedelta\nfrom typing import Any, Dict, TypedDict, Union\n\nfrom flask import Bluep"
},
{
"path": "komidabot/blueprint_authentication.py",
"chars": 6000,
"preview": "import json\nfrom typing import Optional, Union\nfrom urllib.parse import urlparse, quote, unquote\n\nimport requests\nfrom f"
},
{
"path": "komidabot/bot.py",
"chars": 275,
"preview": "from komidabot.messages import Trigger\n\n\nclass Bot:\n def trigger_received(self, trigger: Trigger):\n raise NotI"
},
{
"path": "komidabot/config.py",
"chars": 141,
"preview": "from komidabot.models import AppSettings\n\n\ndef is_registrations_enabled():\n return AppSettings.get_value('registratio"
},
{
"path": "komidabot/debug/administration.py",
"chars": 4072,
"preview": "import copy\nimport json\nfrom typing import Any, Callable, NoReturn\n\nfrom pywebpush import webpush, WebPushException\n\nimp"
},
{
"path": "komidabot/debug/state.py",
"chars": 3474,
"preview": "from logging import Logger\nfrom typing import Any, List, Optional\n\n\nclass ProgramStateTrace:\n def __init__(self):\n "
},
{
"path": "komidabot/external_menu.py",
"chars": 20635,
"preview": "import atexit\nimport datetime\nimport json\nimport re\nfrom decimal import Decimal\nfrom typing import Any, Dict, Optional, "
},
{
"path": "komidabot/facebook/api_interface.py",
"chars": 5336,
"preview": "import json\nimport threading\n\nimport requests\nfrom cachetools import cachedmethod, TTLCache\n\nimport komidabot.messages a"
},
{
"path": "komidabot/facebook/constants.py",
"chars": 25,
"preview": "PROVIDER_ID = 'facebook'\n"
},
{
"path": "komidabot/facebook/messages.py",
"chars": 2950,
"preview": "import komidabot.facebook.constants as fb_constants\nimport komidabot.menu\nimport komidabot.messages as messages\nimport k"
},
{
"path": "komidabot/facebook/nlp_dates.py",
"chars": 641,
"preview": "from typing import List\n\nimport dateutil.parser as date_parser\n\nimport komidabot.triggers as triggers\n\n\ndef extract_days"
},
{
"path": "komidabot/facebook/postbacks.py",
"chars": 8228,
"preview": "import json\nfrom typing import Callable, Dict, Optional\n\nimport komidabot.facebook.messages as fb_messages\nimport komida"
},
{
"path": "komidabot/facebook/triggers.py",
"chars": 482,
"preview": "from komidabot.triggers import *\n\n\nclass PostbackTrigger(Trigger):\n def __init__(self, name, d_args, d_kwargs, *args,"
},
{
"path": "komidabot/facebook/users.py",
"chars": 2997,
"preview": "from typing import Optional, Union\n\nimport komidabot.facebook.constants as fb_constants\nimport komidabot.messages as mes"
},
{
"path": "komidabot/features.py",
"chars": 4154,
"preview": "from collections import namedtuple\nfrom typing import Dict, Optional\n\nimport komidabot.models as models\nfrom extensions "
},
{
"path": "komidabot/komidabot.py",
"chars": 19403,
"preview": "import atexit\nimport datetime\nimport threading\nfrom typing import List\n\nfrom apscheduler.executors.pool import ThreadPoo"
},
{
"path": "komidabot/localisation.py",
"chars": 11403,
"preview": "import random\n\nfrom typing import Callable\n\n\ndef localisation_definition(name, obj, fallback='en') -> Callable[[str], st"
},
{
"path": "komidabot/menu.py",
"chars": 2459,
"preview": "import datetime\nfrom typing import Optional\n\nimport komidabot.localisation as localisation\nimport komidabot.models as mo"
},
{
"path": "komidabot/messages.py",
"chars": 5240,
"preview": "import datetime\nimport enum\nfrom typing import Any, Dict, List, Optional, Type, TypeVar, Union\n\nimport komidabot.models "
},
{
"path": "komidabot/models.py",
"chars": 33814,
"preview": "import datetime\nimport enum\nimport json\nimport locale\nfrom decimal import Decimal\nfrom typing import Any, Collection, Di"
},
{
"path": "komidabot/models_training.py",
"chars": 9088,
"preview": "import datetime\nimport enum\nimport json\nfrom typing import Any, List, NamedTuple, Optional, TypedDict, Union\n\nfrom sqlal"
},
{
"path": "komidabot/models_users.py",
"chars": 6481,
"preview": "import json\nfrom typing import Dict, List, Optional, TypedDict, Union\n\nfrom flask_login import UserMixin\nfrom sqlalchemy"
},
{
"path": "komidabot/rate_limit.py",
"chars": 521,
"preview": "import time\nfrom collections import deque\nfrom datetime import datetime\n\n\nclass Limiter:\n def __init__(self, max_rate"
},
{
"path": "komidabot/subscriptions/__init__.py",
"chars": 3247,
"preview": "from typing import Dict, List, Optional, Union\n\nfrom komidabot.messages import Message\nfrom komidabot.users import User\n"
},
{
"path": "komidabot/subscriptions/daily_menu.py",
"chars": 2759,
"preview": "from typing import Dict, List, Optional, Union\n\nimport komidabot.messages as messages\nimport komidabot.models as models\n"
},
{
"path": "komidabot/translation.py",
"chars": 2059,
"preview": "from googletrans import Translator\n\nLanguage = str\n\nLANGUAGE_DUTCH = 'nl'\nLANGUAGE_ENGLISH = 'en'\nLANGUAGE_FRENCH = 'fr'"
},
{
"path": "komidabot/triggers.py",
"chars": 1565,
"preview": "import datetime\n\nimport komidabot.users as users\nfrom komidabot.messages import Aspect, Trigger\n\n\nclass SubscriptionTrig"
},
{
"path": "komidabot/users.py",
"chars": 9171,
"preview": "import datetime\nimport functools\nimport json\nfrom typing import Dict, List, Optional, Union\nfrom typing import NamedTupl"
},
{
"path": "komidabot/util.py",
"chars": 3222,
"preview": "import traceback\nfrom functools import wraps\nfrom typing import List, Tuple, TypeVar\n\nimport komidabot.localisation as l"
},
{
"path": "komidabot/web/constants.py",
"chars": 20,
"preview": "PROVIDER_ID = 'web'\n"
},
{
"path": "komidabot/web/messages.py",
"chars": 6990,
"preview": "import copy\nimport json\n\nfrom pywebpush import webpush, WebPushException\n\nimport komidabot.localisation as localisation\n"
},
{
"path": "komidabot/web/users.py",
"chars": 1837,
"preview": "from typing import Dict, Optional, TypedDict, Union\n\nimport komidabot.messages as messages\nimport komidabot.models as mo"
},
{
"path": "learning-data/.gitignore",
"chars": 13,
"preview": "*.yml\n*.json\n"
},
{
"path": "learning-data/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "manage.py",
"chars": 4165,
"preview": "import datetime\nimport glob\nimport json\nimport os\nimport signal\nimport sys\nimport traceback\nimport unittest\nfrom typing "
},
{
"path": "manual_menu_scraper.py",
"chars": 2782,
"preview": "import datetime\nimport sys\n\nimport komidabot.external_menu as external_menu\nfrom komidabot.debug.state import Debuggable"
},
{
"path": "migrations/README",
"chars": 38,
"preview": "Generic single-database configuration."
},
{
"path": "migrations/alembic.ini",
"chars": 770,
"preview": "# A generic, single database configuration.\n\n[alembic]\n# template used to generate migration files\n# file_template = %%("
},
{
"path": "migrations/env.py",
"chars": 2918,
"preview": "from __future__ import with_statement\n\nimport logging\nfrom logging.config import fileConfig\n\nfrom sqlalchemy import engi"
},
{
"path": "migrations/script.py.mako",
"chars": 494,
"preview": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom ale"
},
{
"path": "migrations/versions/1a2e04608ee9_.py",
"chars": 711,
"preview": "\"\"\"Add web_subscriptions and provider column to registered_user table\n\nRevision ID: 1a2e04608ee9\nRevises: d225cbda8c77\nC"
},
{
"path": "migrations/versions/1dafd2bf730a_.py",
"chars": 527,
"preview": "\"\"\"Add course allergens column to menu item table\n\nRevision ID: 1dafd2bf730a\nRevises: aa31c90dc353\nCreate Date: 2020-10-"
},
{
"path": "migrations/versions/276ad61a41a5_.py",
"chars": 2471,
"preview": "\"\"\"Change food type in menu items to course type and sub type\n\nRevision ID: 276ad61a41a5\nRevises: ddf5bd871988\nCreate Da"
},
{
"path": "migrations/versions/2887dcc37788_.py",
"chars": 6348,
"preview": "\"\"\"Change registered_users table to have an internal id, rather than having a primary key based on 2 columns\n\nRevision I"
},
{
"path": "migrations/versions/3806b46f7f00_.py",
"chars": 1002,
"preview": "\"\"\"Modify model for external API\n\nRevision ID: 3806b46f7f00\nRevises: 4fafafd2400f\nCreate Date: 2019-11-03 23:26:02.35784"
},
{
"path": "migrations/versions/4fafafd2400f_.py",
"chars": 594,
"preview": "\"\"\"Add new FoodType enum values\n\nRevision ID: 4fafafd2400f\nRevises: 7751a57b029e\nCreate Date: 2019-10-28 19:54:52.943891"
},
{
"path": "migrations/versions/528821121657_.py",
"chars": 491,
"preview": "\"\"\"Rename user_subscription to user_day_campus_preference\n\nRevision ID: 528821121657\nRevises: bd04cd56036f\nCreate Date: "
},
{
"path": "migrations/versions/55696107a6b9_.py",
"chars": 1601,
"preview": "\"\"\"Add columns for feature participation\n\nRevision ID: 55696107a6b9\nRevises: 79e0c9de90f0\nCreate Date: 2019-10-14 13:17:"
},
{
"path": "migrations/versions/5cd86de4dffe_.py",
"chars": 1083,
"preview": "\"\"\"Drop onboarding_done as this will not be necessary anymore, and add an enabled field.\n\nRevision ID: 5cd86de4dffe\nRevi"
},
{
"path": "migrations/versions/5ee455656a96_.py",
"chars": 424,
"preview": "\"\"\"Rename table subscription -> user\n\nRevision ID: 5ee455656a96\nRevises: 85b659320f83\nCreate Date: 2019-10-14 00:49:07.2"
},
{
"path": "migrations/versions/7751a57b029e_.py",
"chars": 1337,
"preview": "\"\"\"Add a table to indicate restaurant closures\n\nRevision ID: 7751a57b029e\nRevises: 55696107a6b9\nCreate Date: 2019-10-28 "
},
{
"path": "migrations/versions/79e0c9de90f0_.py",
"chars": 6624,
"preview": "\"\"\"Split app_user subscription data into a separate table to allow a more fine-grained control over subscriptions\n\nRevis"
},
{
"path": "migrations/versions/85b659320f83_.py",
"chars": 888,
"preview": "\"\"\"Make tables be lowercase to please Postgres\n\nRevision ID: 85b659320f83\nRevises: fe4aca6853a2\nCreate Date: 2019-10-13 "
},
{
"path": "migrations/versions/92e4e9f8ff64_.py",
"chars": 761,
"preview": "\"\"\"Remove fields relating to the old menu parsing\n\nRevision ID: 92e4e9f8ff64\nRevises: e18b14ed6b98\nCreate Date: 2019-11-"
},
{
"path": "migrations/versions/93b9de63cd7b_.py",
"chars": 617,
"preview": "\"\"\"Add field to users to indicate whether they've received an introduction to the bot yet\n\nRevision ID: 93b9de63cd7b\nRev"
},
{
"path": "migrations/versions/9b9afdcf4e4e_.py",
"chars": 636,
"preview": "\"\"\"Add column for storing whether a user has been informed about our new site at https://komidabot.xyz/\n\nRevision ID: 9b"
},
{
"path": "migrations/versions/a223b578f7b0_.py",
"chars": 4891,
"preview": "\"\"\"Initial imported database structure\n\nRevision ID: a223b578f7b0\nRevises: \nCreate Date: 2019-10-13 22:02:33.540908\n\n\"\"\""
},
{
"path": "migrations/versions/aa31c90dc353_.py",
"chars": 501,
"preview": "\"\"\"Add dessert type to coursetype enum\n\nRevision ID: aa31c90dc353\nRevises: daf22dcadb8d\nCreate Date: 2020-10-28 02:34:17"
},
{
"path": "migrations/versions/b384f281e755_.py",
"chars": 507,
"preview": "\"\"\"Add column to AppUser to store data that some providers may need to store.\n\nRevision ID: b384f281e755\nRevises: ee24af"
},
{
"path": "migrations/versions/bc1ef0083bb4_.py",
"chars": 618,
"preview": "\"\"\"Allow closing days to not have an end date\n\nRevision ID: bc1ef0083bb4\nRevises: 9b9afdcf4e4e\nCreate Date: 2020-09-22 1"
},
{
"path": "migrations/versions/bd04cd56036f_.py",
"chars": 627,
"preview": "\"\"\"Rename VEGAN to VEGETARIAN and add a real VEGAN enum option\n\nRevision ID: bd04cd56036f\nRevises: bc1ef0083bb4\nCreate D"
},
{
"path": "migrations/versions/d225cbda8c77_.py",
"chars": 1253,
"preview": "\"\"\"Added registered_user and app_settings table\n\nRevision ID: d225cbda8c77\nRevises: 528821121657\nCreate Date: 2020-10-24"
},
{
"path": "migrations/versions/daf22dcadb8d_.py",
"chars": 428,
"preview": "\"\"\"Drop food_type type\n\nRevision ID: daf22dcadb8d\nRevises: fe7bda58c5a4\nCreate Date: 2020-10-28 01:52:05.428570\n\n\"\"\"\nfro"
},
{
"path": "migrations/versions/ddf5bd871988_.py",
"chars": 2865,
"preview": "\"\"\"Add field to Translation indicating the provider of the translation. e.g. google, bing, komida, ...\nAlso truncates la"
},
{
"path": "migrations/versions/e18b14ed6b98_.py",
"chars": 1098,
"preview": "\"\"\"Change price columns to store as numerics instead of strings\n\nRevision ID: e18b14ed6b98\nRevises: 3806b46f7f00\nCreate "
},
{
"path": "migrations/versions/ea6e1f581a7b_.py",
"chars": 646,
"preview": "\"\"\"Add external id column to menu items\n\nRevision ID: ea6e1f581a7b\nRevises: 1a2e04608ee9\nCreate Date: 2020-10-25 21:42:3"
},
{
"path": "migrations/versions/ecce0e669d8c_.py",
"chars": 526,
"preview": "\"\"\"Add snack type to coursetype enum\n\nRevision ID: ecce0e669d8c\nRevises: 2887dcc37788\nCreate Date: 2020-11-03 17:35:48.1"
},
{
"path": "migrations/versions/eda0c928c279_.py",
"chars": 2252,
"preview": "\"\"\"Add learning datapoints table for gathering data to train a classifier\n\nRevision ID: eda0c928c279\nRevises: 1dafd2bf73"
},
{
"path": "migrations/versions/ee24af8d3121_.py",
"chars": 545,
"preview": "\"\"\"Remove size constraint on AppUser internal ID.\n\nRevision ID: ee24af8d3121\nRevises: 5cd86de4dffe\nCreate Date: 2020-02-"
},
{
"path": "migrations/versions/fe4aca6853a2_.py",
"chars": 1224,
"preview": "\"\"\"Change subscriptions storage from facebook_id to (provider, internal_id)\n\nRevision ID: fe4aca6853a2\nRevises: a223b578"
},
{
"path": "migrations/versions/fe7bda58c5a4_.py",
"chars": 527,
"preview": "\"\"\"Add data_frozen column to menu_item table\n\nRevision ID: fe7bda58c5a4\nRevises: ea6e1f581a7b\nCreate Date: 2020-10-25 23"
},
{
"path": "requirements.txt",
"chars": 541,
"preview": "wheel==0.37.1\nsetuptools==65.3.0\nFlask==2.2.2\nFlask-Login==0.6.2\nFlask-Migrate==3.1.0\nFlask-Session==0.4.0\nFlask-SQLAlch"
},
{
"path": "schemas/DELETE_api_subscribe.json",
"chars": 320,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"DeleteSubscriptionMessage\",\n \"type\": \"object\",\n "
},
{
"path": "schemas/GET_api_authorized.response.json",
"chars": 283,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$ref\": \"api_response_strict.json\",\n \"title\": \"AuthorizedAp"
},
{
"path": "schemas/GET_api_learning.response.json",
"chars": 1218,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$ref\": \"api_response_strict.json\",\n \"title\": \"LearningApiR"
},
{
"path": "schemas/POST_api_learning.json",
"chars": 860,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"LearningPostMessage\",\n \"type\": \"object\",\n \"prope"
},
{
"path": "schemas/POST_api_login.json",
"chars": 309,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"LoginMessage\",\n \"type\": \"object\",\n \"properties\":"
},
{
"path": "schemas/POST_api_subscribe.json",
"chars": 391,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"AddSubscriptionMessage\",\n \"type\": \"object\",\n \"pr"
},
{
"path": "schemas/POST_api_trigger.json",
"chars": 360,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"LoginMessage\",\n \"type\": \"object\",\n \"properties\":"
},
{
"path": "schemas/PUT_api_subscribe.json",
"chars": 387,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"ReplaceSubscriptionMessage\",\n \"type\": \"object\",\n "
},
{
"path": "schemas/api_response_base.json",
"chars": 270,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"ApiResponse\",\n \"type\": \"object\",\n \"properties\": "
},
{
"path": "schemas/api_response_strict.json",
"chars": 160,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$ref\": \"api_response_base.json\",\n \"title\": \"StrictApiRespo"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/base.py",
"chars": 5971,
"preview": "import datetime\nfrom decimal import Decimal\nfrom functools import partial, wraps\nfrom typing import Dict, List, NamedTup"
},
{
"path": "tests/external_menus/.gitignore",
"chars": 31,
"preview": "*.parsed.yaml\n*.processed.yaml\n"
},
{
"path": "tests/external_menus/2019-11-25_cde.parsed.expected.yaml",
"chars": 3302,
"preview": "$test_case:\n course_of_interest: 1353\n reason: |\n This response originally broke an assumption that only one compon"
},
{
"path": "tests/external_menus/2019-11-25_cde.processed.expected.yaml",
"chars": 2327,
"preview": "campus: cde\ndate: '2019-11-25'\nmenu:\n- course_allergens:\n - CELERY\n - MILK_LACTOSE\n - WHEAT_GLUTEN\n course_attribute"
},
{
"path": "tests/external_menus/2019-11-25_cde.raw.json",
"chars": 21647,
"preview": "{\n \"id\": 208,\n \"menuDate\": \"2019-11-25T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-11-25_cmi.parsed.expected.yaml",
"chars": 4256,
"preview": "$test_case:\n course_of_interest: 1390\n reason: |\n This response originally broke an assumption that \"maincourse\" ha"
},
{
"path": "tests/external_menus/2019-11-25_cmi.raw.json",
"chars": 29183,
"preview": "{\n \"id\": 177,\n \"menuDate\": \"2019-11-25T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-11-25_cmu.parsed.expected.yaml",
"chars": 1417,
"preview": "campus: cmu\ndate: '2019-11-25'\nmenu:\n- components:\n - allergens:\n - NUTS\n - SESAME\n - SOY\n - WHEAT_GLUTEN\n "
},
{
"path": "tests/external_menus/2019-11-25_cmu.raw.json",
"chars": 9909,
"preview": "{\n \"id\": 242,\n \"menuDate\": \"2019-11-25T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-11-25_cst.parsed.expected.yaml",
"chars": 3558,
"preview": "campus: cst\ndate: '2019-11-25'\nmenu:\n- components:\n - allergens:\n - CELERY\n - MILK_LACTOSE\n - WHEAT_GLUTEN\n "
},
{
"path": "tests/external_menus/2019-11-25_cst.raw.json",
"chars": 28498,
"preview": "{\n \"id\": 194,\n \"menuDate\": \"2019-11-25T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-11-25_hzs.parsed.expected.yaml",
"chars": 2152,
"preview": "campus: hzs\ndate: '2019-11-25'\nmenu:\n- components:\n - allergens:\n - CELERY\n - WHEAT_GLUTEN\n attributes:\n - "
},
{
"path": "tests/external_menus/2019-11-25_hzs.raw.json",
"chars": 15395,
"preview": "{\n \"id\": 227,\n \"menuDate\": \"2019-11-25T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-12_cde.parsed.expected.yaml",
"chars": 2820,
"preview": "campus: cde\ndate: '2019-12-12'\nmenu:\n- components:\n - allergens:\n - CELERY\n - EGG\n - FISH\n - MOLLUSKS\n -"
},
{
"path": "tests/external_menus/2019-12-12_cde.processed.expected.yaml",
"chars": 2599,
"preview": "campus: cde\ndate: '2019-12-12'\nmenu:\n- course_allergens:\n - CELERY\n - EGG\n - FISH\n - MOLLUSKS\n - SESAME\n - SOY\n -"
},
{
"path": "tests/external_menus/2019-12-12_cde.raw.json",
"chars": 23095,
"preview": "{\n \"id\": 286,\n \"menuDate\": \"2019-12-12T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-12_cgb.raw.json",
"chars": 11346,
"preview": "{\n \"id\": 317,\n \"menuDate\": \"2019-12-12T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-12_cmi.raw.json",
"chars": 25535,
"preview": "{\n \"id\": 240,\n \"menuDate\": \"2019-12-12T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-12_cmu.raw.json",
"chars": 16210,
"preview": "{\n \"id\": 312,\n \"menuDate\": \"2019-12-12T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-12_cst.raw.json",
"chars": 27306,
"preview": "{\n \"id\": 296,\n \"menuDate\": \"2019-12-12T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-12_hzs.raw.json",
"chars": 16903,
"preview": "{\n \"id\": 270,\n \"menuDate\": \"2019-12-12T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-19_cde.parsed.expected.yaml",
"chars": 2112,
"preview": "campus: cde\ndate: '2019-12-19'\nmenu:\n- components:\n - allergens:\n - CELERY\n - SOY\n - WHEAT_GLUTEN\n attribut"
},
{
"path": "tests/external_menus/2019-12-19_cde.processed.expected.yaml",
"chars": 2084,
"preview": "campus: cde\ndate: '2019-12-19'\nmenu:\n- course_allergens:\n - CELERY\n - SOY\n - WHEAT_GLUTEN\n course_attributes:\n - SO"
},
{
"path": "tests/external_menus/2019-12-19_cde.raw.json",
"chars": 17167,
"preview": "{\n \"id\": 291,\n \"menuDate\": \"2019-12-19T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-19_cgb.raw.json",
"chars": 8784,
"preview": "{\n \"id\": 356,\n \"menuDate\": \"2019-12-19T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-19_cmi.raw.json",
"chars": 14978,
"preview": "{\n \"id\": 267,\n \"menuDate\": \"2019-12-19T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-19_cmu.raw.json",
"chars": 16279,
"preview": "{\n \"id\": 326,\n \"menuDate\": \"2019-12-19T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-19_cst.raw.json",
"chars": 14108,
"preview": "{\n \"id\": 323,\n \"menuDate\": \"2019-12-19T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2019-12-19_hzs.raw.json",
"chars": 19851,
"preview": "{\n \"id\": 306,\n \"menuDate\": \"2019-12-19T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-10_cde.raw.json",
"chars": 25126,
"preview": "{\n \"id\": 544,\n \"menuDate\": \"2020-02-10T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-10_cgb.raw.json",
"chars": 8530,
"preview": "{\n \"id\": 631,\n \"menuDate\": \"2020-02-10T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-10_cmi.raw.json",
"chars": 28737,
"preview": "{\n \"id\": 534,\n \"menuDate\": \"2020-02-10T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-10_cmu.parsed.expected.yaml",
"chars": 3266,
"preview": "$test_case:\n course_of_interest: 3155\n reason: |\n This response contains a menu item which on its own has \"enabled\""
},
{
"path": "tests/external_menus/2020-02-10_cmu.raw.json",
"chars": 18314,
"preview": "{\n \"id\": 510,\n \"menuDate\": \"2020-02-10T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-10_cst.raw.json",
"chars": 24440,
"preview": "{\n \"id\": 626,\n \"menuDate\": \"2020-02-10T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-10_hzs.raw.json",
"chars": 16051,
"preview": "{\n \"id\": 500,\n \"menuDate\": \"2020-02-10T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-13_cde.raw.json",
"chars": 24669,
"preview": "{\n \"id\": 614,\n \"menuDate\": \"2020-02-13T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-13_cgb.raw.json",
"chars": 13000,
"preview": "{\n \"id\": 634,\n \"menuDate\": \"2020-02-13T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-13_cmi.raw.json",
"chars": 25810,
"preview": "{\n \"id\": 542,\n \"menuDate\": \"2020-02-13T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-13_cmu.raw.json",
"chars": 16550,
"preview": "{\n \"id\": 513,\n \"menuDate\": \"2020-02-13T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-13_cst.raw.json",
"chars": 25516,
"preview": "{\n \"id\": 629,\n \"menuDate\": \"2020-02-13T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-02-13_hzs.raw.json",
"chars": 14101,
"preview": "{\n \"id\": 503,\n \"menuDate\": \"2020-02-13T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-12_cde.raw.json",
"chars": 24422,
"preview": "{\n \"id\": 797,\n \"menuDate\": \"2020-03-12T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-12_cgb.raw.json",
"chars": 13198,
"preview": "{\n \"id\": 807,\n \"menuDate\": \"2020-03-12T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-12_cmi.parsed.expected.yaml",
"chars": 3730,
"preview": "$test_case:\n course_of_interest: 5398\n reason: |\n On this response, Komidabot differred from the official site by o"
},
{
"path": "tests/external_menus/2020-03-12_cmi.raw.json",
"chars": 26830,
"preview": "{\n \"id\": 599,\n \"menuDate\": \"2020-03-12T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-12_cmu.raw.json",
"chars": 21688,
"preview": "{\n \"id\": 748,\n \"menuDate\": \"2020-03-12T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-12_cst.raw.json",
"chars": 25279,
"preview": "{\n \"id\": 792,\n \"menuDate\": \"2020-03-12T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-12_hzs.raw.json",
"chars": 13914,
"preview": "{\n \"id\": 683,\n \"menuDate\": \"2020-03-12T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-16_cde.raw.json",
"chars": 1716,
"preview": "{\n \"id\": 799,\n \"menuDate\": \"2020-03-16T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-16_cgb.raw.json",
"chars": 1716,
"preview": "{\n \"id\": 825,\n \"menuDate\": \"2020-03-16T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-16_cmi.raw.json",
"chars": 1716,
"preview": "{\n \"id\": 602,\n \"menuDate\": \"2020-03-16T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-16_cmu.raw.json",
"chars": 1586,
"preview": "{\n \"id\": 750,\n \"menuDate\": \"2020-03-16T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-16_cst.raw.json",
"chars": 1716,
"preview": "{\n \"id\": 814,\n \"menuDate\": \"2020-03-16T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-03-16_hzs.raw.json",
"chars": 1586,
"preview": "{\n \"id\": 685,\n \"menuDate\": \"2020-03-16T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-25_cde.raw.json",
"chars": 30500,
"preview": "{\n \"id\": 954,\n \"menuDate\": \"2020-09-25T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-25_cgb.raw.json",
"chars": 19307,
"preview": "{\n \"id\": 983,\n \"menuDate\": \"2020-09-25T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-25_cmi.raw.json",
"chars": 25501,
"preview": "{\n \"id\": 905,\n \"menuDate\": \"2020-09-25T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-25_cmu.raw.json",
"chars": 1618,
"preview": "{\n \"id\": 1007,\n \"menuDate\": \"2020-09-25T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-09-25_cst.raw.json",
"chars": 17603,
"preview": "{\n \"id\": 921,\n \"menuDate\": \"2020-09-25T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-25_hzs.raw.json",
"chars": 537,
"preview": "{\n \"id\": 1021,\n \"menuDate\": \"2020-09-25T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-09-28_cde.raw.json",
"chars": 24865,
"preview": "{\n \"id\": 958,\n \"menuDate\": \"2020-09-28T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-28_cgb.raw.json",
"chars": 19873,
"preview": "{\n \"id\": 1011,\n \"menuDate\": \"2020-09-28T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-09-28_cmi.raw.json",
"chars": 30901,
"preview": "{\n \"id\": 906,\n \"menuDate\": \"2020-09-28T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-28_cmu.raw.json",
"chars": 1534,
"preview": "{\n \"id\": 1027,\n \"menuDate\": \"2020-09-28T00:00:00\",\n \"restaurantId\": 5,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-09-28_cst.parsed.expected.yaml",
"chars": 2641,
"preview": "$test_case:\n course_of_interest: 7221\n reason: |\n This response originally broke an assumption that a course compon"
},
{
"path": "tests/external_menus/2020-09-28_cst.processed.expected.yaml",
"chars": 2406,
"preview": "campus: cst\ndate: '2020-09-28'\nmenu:\n- course_allergens:\n - CELERY\n - MILK_LACTOSE\n - SOY\n - SULFITES\n - WHEAT_GLUT"
},
{
"path": "tests/external_menus/2020-09-28_cst.raw.json",
"chars": 17294,
"preview": "{\n \"id\": 922,\n \"menuDate\": \"2020-09-28T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-09-28_hzs.raw.json",
"chars": 1818,
"preview": "{\n \"id\": 1022,\n \"menuDate\": \"2020-09-28T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-10-26_cde.raw.json",
"chars": 26458,
"preview": "{\n \"id\": 1072,\n \"menuDate\": \"2020-10-26T00:00:00\",\n \"restaurantId\": 2,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-10-26_cgb.raw.json",
"chars": 20914,
"preview": "{\n \"id\": 1136,\n \"menuDate\": \"2020-10-26T00:00:00\",\n \"restaurantId\": 4,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-10-26_cmi.raw.json",
"chars": 21354,
"preview": "{\n \"id\": 1104,\n \"menuDate\": \"2020-10-26T00:00:00\",\n \"restaurantId\": 3,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/2020-10-26_cst.raw.json",
"chars": 15921,
"preview": "{\n \"id\": 932,\n \"menuDate\": \"2020-10-26T00:00:00\",\n \"restaurantId\": 1,\n \"chefId\": 0,\n \"description\": null,\n \"approv"
},
{
"path": "tests/external_menus/2020-10-26_hzs.raw.json",
"chars": 537,
"preview": "{\n \"id\": 1155,\n \"menuDate\": \"2020-10-26T00:00:00\",\n \"restaurantId\": 6,\n \"chefId\": 0,\n \"description\": null,\n \"appro"
},
{
"path": "tests/external_menus/download_external_jsons.py",
"chars": 4131,
"preview": "import datetime\nimport json\nimport os\nimport sys\nimport time\nfrom collections import deque\nfrom datetime import datetime"
},
{
"path": "tests/external_menus/parsed.schema.json",
"chars": 3183,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"ParsedMenu\",\n \"type\": \"object\",\n \"properties\": {"
},
{
"path": "tests/external_menus/processed.schema.json",
"chars": 3223,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"ProcessedMenu\",\n \"type\": \"object\",\n \"properties\""
},
{
"path": "tests/external_menus/raw.schema.json",
"chars": 8123,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"definitions\": {\n \"optional_string\": {\n \"oneOf\": [\n "
},
{
"path": "tests/test_debug_state.py",
"chars": 6236,
"preview": "import unittest\n\nfrom komidabot.debug.state import DebuggableException, ProgramStateTrace, SimpleProgramState\n\n\nclass Te"
},
{
"path": "tests/test_external_menu.py",
"chars": 6346,
"preview": "import glob\nimport json\nimport os\nimport re\nfrom typing import Any, Dict, List, Union\n\nimport yaml\nfrom jsonschema impor"
},
{
"path": "tests/test_models_campus.py",
"chars": 12489,
"preview": "from sqlalchemy import inspect\n\nimport komidabot.models as models\nfrom app import db\nfrom tests.base import BaseTestCase"
},
{
"path": "tests/test_models_closing_days.py",
"chars": 7635,
"preview": "from sqlalchemy import inspect\n\nimport komidabot.models as models\nimport tests.utils as utils\nfrom app import db\nfrom te"
},
{
"path": "tests/test_models_menu.py",
"chars": 3818,
"preview": "from decimal import Decimal\n\nimport tests.utils as utils\nfrom app import db\nfrom komidabot.models import Menu, MenuItem,"
},
{
"path": "tests/test_models_menu_item.py",
"chars": 5921,
"preview": "from decimal import Decimal\n\nimport tests.utils as utils\nfrom app import db\nfrom komidabot.models import Menu, MenuItem,"
},
{
"path": "tests/test_models_registered_user.py",
"chars": 20358,
"preview": "import datetime\n\nfrom sqlalchemy import inspect\n\nfrom app import db\nfrom komidabot.models_users import RegisteredUser, R"
},
{
"path": "tests/test_models_translations.py",
"chars": 8160,
"preview": "from sqlalchemy import inspect\n\nimport komidabot.models as models\nfrom app import db\nfrom tests.base import BaseTestCase"
},
{
"path": "tests/test_subscriptions.py",
"chars": 6860,
"preview": "import datetime\nfrom decimal import Decimal\nfrom typing import Dict, List, Tuple\n\nimport komidabot.models as models\nimpo"
},
{
"path": "tests/test_test_utils.py",
"chars": 1413,
"preview": "import tests.utils as utils\nfrom tests.base import BaseTestCase\n\n\nclass TestConstants(BaseTestCase):\n \"\"\"\n Sanity "
},
{
"path": "tests/test_triggers.py",
"chars": 4953,
"preview": "import datetime\n\nimport komidabot.triggers as triggers\nfrom tests.base import BaseTestCase\nfrom tests.users_stub import "
},
{
"path": "tests/test_users_base.py",
"chars": 1317,
"preview": "import tests.users_stub as users_stub\nfrom app import db\nfrom komidabot.users import UserId\nfrom tests.base import BaseT"
},
{
"path": "tests/users_stub.py",
"chars": 3685,
"preview": "from typing import Dict, List\nfrom typing import Union\n\nimport komidabot.menu\nimport komidabot.messages as messages\nimpo"
},
{
"path": "tests/utils.py",
"chars": 781,
"preview": "import datetime\n\nimport komidabot.translation as translation\n\nDAYS = {\n 'MON': datetime.date(2019, 7, 1),\n 'TUE': "
},
{
"path": "wait-postgres.sh",
"chars": 375,
"preview": "#!/usr/bin/env /bin/bash\n\necho \"Waiting for postgres...\"\n\n# Wait for the database in a safe manner\nwhile :\ndo\n trap '"
}
]
About this extraction
This page contains the full source code of the heldplayer/komidabot-docker GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 198 files (1.4 MB), approximately 362.2k tokens, and a symbol index with 653 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.