Compare commits
5 Commits
b0fc264cb2
...
f5d89cee2f
| Author | SHA256 | Date | |
|---|---|---|---|
| f5d89cee2f | |||
| b31796da39 | |||
| e9b7866bb1 | |||
| 7720002d30 | |||
| bfe72b4f77 |
@@ -9,6 +9,9 @@ jobs:
|
||||
- image: cimg/python:3.13
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Install uv
|
||||
command: python -m pip install uv
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: make dependencies
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
PUBLIC_HOST=menus.example.org
|
||||
BASE_URL=https://menus.example.org
|
||||
@@ -3,8 +3,13 @@
|
||||
.pytest_cache
|
||||
/.coverage
|
||||
/htmlcov
|
||||
/build
|
||||
/dist
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
.venv
|
||||
__pycache__
|
||||
.devcontainer
|
||||
.vscode
|
||||
.codex
|
||||
.env.local
|
||||
|
||||
@@ -30,10 +30,6 @@ persistent=yes
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
+7
-6
@@ -9,7 +9,7 @@ ARG DEPLOY_DIR
|
||||
ARG USERNAME
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install pipenv
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Add app user
|
||||
RUN adduser --disabled-password ${USERNAME}
|
||||
@@ -27,8 +27,9 @@ WORKDIR ${DEPLOY_DIR}
|
||||
COPY stw_potsdam/ ./stw_potsdam
|
||||
COPY tests ./tests
|
||||
COPY Makefile .
|
||||
COPY Pipfile .
|
||||
COPY Pipfile.lock .
|
||||
COPY README.md .
|
||||
COPY pyproject.toml .
|
||||
COPY uv.lock .
|
||||
|
||||
# Apply app user to app folder
|
||||
RUN chown -R ${USERNAME} .
|
||||
@@ -48,7 +49,7 @@ RUN apt-get update && apt-get install -y build-essential python3-dev
|
||||
|
||||
# Install environment
|
||||
USER ${USERNAME}
|
||||
RUN pipenv install --dev
|
||||
RUN uv sync --frozen --extra dev
|
||||
|
||||
# Run tests
|
||||
RUN make test
|
||||
@@ -58,7 +59,7 @@ RUN rm -rf .venv
|
||||
RUN rm -rf ./tests ./Makefile
|
||||
|
||||
# Install production environment
|
||||
RUN pipenv install --deploy
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
|
||||
### Production container
|
||||
@@ -82,6 +83,6 @@ ENV LISTEN_PORT=${LISTEN_PORT}
|
||||
ENV LISTEN=0.0.0.0:${LISTEN_PORT}
|
||||
|
||||
EXPOSE ${LISTEN_PORT}
|
||||
CMD ["pipenv", "run", "uwsgi", "--master", "--http11-socket", "$LISTEN", "--plugins", "python", "--protocol", "uwsgi", "--wsgi", "stw_potsdam.views:app", "--virtualenv", "./.venv"]
|
||||
CMD ["sh", "-c", "uv run uwsgi --master --http11-socket \"$LISTEN\" --plugins python --protocol uwsgi --wsgi stw_potsdam.views:app --virtualenv ./.venv"]
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:$LISTEN_PORT/health_check || exit 1
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
|
||||
RUN=FLASK_APP="stw_potsdam.views" pipenv run flask run
|
||||
RUN=uv run flask --app stw_potsdam.views run
|
||||
|
||||
dependencies:
|
||||
pipenv sync --dev
|
||||
uv sync --extra dev
|
||||
|
||||
run:
|
||||
$(RUN)
|
||||
|
||||
debug:
|
||||
FLASK_ENV=development $(RUN) --debug
|
||||
uv run flask --app stw_potsdam.views --debug run
|
||||
|
||||
test:
|
||||
pipenv run python -m pytest -vv --cov-branch --cov stw_potsdam --cov-report term --cov-report html
|
||||
uv run --extra dev python -m pytest -vv --cov-branch --cov stw_potsdam --cov-report term --cov-report html
|
||||
|
||||
test_debug:
|
||||
pipenv run python -m pytest -v --trace
|
||||
uv run --extra dev python -m pytest -v --trace
|
||||
|
||||
coverage_publish:
|
||||
pipenv run python -m coveralls
|
||||
uv run --extra dev python -m coveralls
|
||||
|
||||
coverage_report:
|
||||
pipenv run python -m coverage report --fail-under 90
|
||||
uv run --extra dev python -m coverage report --fail-under 90
|
||||
|
||||
lint:
|
||||
pipenv run pycodestyle stw_potsdam tests
|
||||
pipenv run pydocstyle stw_potsdam tests
|
||||
pipenv run pylint stw_potsdam tests
|
||||
uv run --extra dev pycodestyle stw_potsdam tests
|
||||
uv run --extra dev pydocstyle stw_potsdam tests
|
||||
uv run --extra dev pylint stw_potsdam tests
|
||||
|
||||
clean:
|
||||
pipenv run python -m coverage erase
|
||||
uv run --extra dev python -m coverage erase
|
||||
rm -rf .pytest_cache .cache
|
||||
|
||||
.PHONY: dependencies run debug test test_debug coverage_publish coverage_report lint clean
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[packages]
|
||||
requests = "*"
|
||||
pyopenmensa = "*"
|
||||
flask = "*"
|
||||
cachetools = "*"
|
||||
uwsgi = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
coveralls = "*"
|
||||
pytest-cov = "*"
|
||||
httpretty = "*"
|
||||
pycodestyle = "*"
|
||||
pydocstyle = "*"
|
||||
pylint = "*"
|
||||
sphinx = "*"
|
||||
sphinx-autobuild = "*"
|
||||
sphinx-rtd-theme = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.13"
|
||||
Generated
-1073
File diff suppressed because it is too large
Load Diff
@@ -25,14 +25,9 @@ Recommended: Python 3.12+.
|
||||
|
||||
$ python -m venv .venv
|
||||
$ . .venv/bin/activate
|
||||
$ pip install -r requirements-dev.txt
|
||||
$ pip install -e ".[dev]"
|
||||
$ FLASK_APP=stw_potsdam.views flask run
|
||||
|
||||
**Legacy (Pipenv)** ::
|
||||
|
||||
$ pipenv install --dev
|
||||
$ make run
|
||||
|
||||
**Contributions** are always welcome, in particular if the response format of the canteens change. Feel free to file a PR with improvements.
|
||||
|
||||
**Deployment** If in need of a deployment, file a PR to this fork: [kaifabian/om-parser-potsdam-v2][kai]. Kai is currently in charge of running an instance of the parser and the registration on the OpenMensa platform.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BASE_URL: ${BASE_URL}
|
||||
LISTEN_PORT: 3080
|
||||
expose:
|
||||
- "3080"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
environment:
|
||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
+8
-10
@@ -6,9 +6,9 @@ Because the parser may break on changes to the canteen website, it should be eas
|
||||
Quickstart
|
||||
~~~~~~~~~~
|
||||
|
||||
Use `Pipenv <https://pipenv.readthedocs.io/en/latest/>`_ to setup the environment and start coding: ::
|
||||
Use `uv <https://docs.astral.sh/uv/>`_ to setup the environment and start coding: ::
|
||||
|
||||
$ pipenv install --dev # Create venv
|
||||
$ uv sync --extra dev # Create venv and install dependencies
|
||||
$ make test # Check setup by running tests
|
||||
$ make debug # Start app instance with debugger and pretty printing of JSON
|
||||
$ make run # Start app without debugger
|
||||
@@ -17,7 +17,7 @@ Use `Pipenv <https://pipenv.readthedocs.io/en/latest/>`_ to setup the environmen
|
||||
|
||||
The list of available canteens is available at the ``/canteens`` endpoint. The ``/canteens/<name>`` endpoint provides the XML feed for individual canteens, e.g., ``/canteens/griebnitzsee``.
|
||||
|
||||
Given the default configuration, http://127.0.0.1:5000/canteens/griebnitzsee will display the `OpenMensa` meta-feed for the Griebnitzsee canteen, and http://127.0.0.1:5000/canteens/griebnitzsee/menu will render the menu feed.
|
||||
Given the default configuration, http://127.0.0.1:5000/canteens/griebnitzsee will display the `OpenMensa` feed for the Griebnitzsee canteen.
|
||||
|
||||
Main Module Entry Points
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -29,16 +29,15 @@ Generating a new `OpenMensa` feed starts by reading the configured canteens. Som
|
||||
|
||||
.. autoclass:: stw_potsdam.config.Canteen
|
||||
|
||||
Use the canteen data to assemble the menu parameters in order to download the menu JSON. The menu parameters are also used as cache key: it should uniquely identify the retrieved menu.
|
||||
Use the canteen data to select matching upstream outlets, download the required menu JSON, and render the OpenMensa XML.
|
||||
|
||||
.. autoclass:: stw_potsdam.canteen_api.MenuParams
|
||||
.. autoclass:: stw_potsdam.swp_webspeiseplan_api.SWPWebspeiseplanAPI
|
||||
|
||||
.. autofunction:: stw_potsdam.canteen_api.download_menu
|
||||
.. autoclass:: stw_potsdam.swp_webspeiseplan_parser.SWPWebspeiseplanParser
|
||||
|
||||
The render module contains methods for converting the JSON response into valid `OpenMensa` menu and meta feeds, respectively:
|
||||
The XML type modules contain the OpenMensa rendering objects:
|
||||
|
||||
.. autofunction:: stw_potsdam.feed.render_menu
|
||||
.. autofunction:: stw_potsdam.feed.render_meta
|
||||
.. autoclass:: stw_potsdam.xml_types.builder.Builder
|
||||
|
||||
Tests
|
||||
~~~~~
|
||||
@@ -58,4 +57,3 @@ The first invocation runs tests whose outcome can solely be determined by the te
|
||||
Setting the environment variable ``ENABLE_API_QUERY`` enables tests which require querying the canteen API. Because third-party services are queried, those are more suited to manual execution. Developers can quickly check if their change is applicable to today's menu.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
FritzBox Self-Hosting
|
||||
=====================
|
||||
|
||||
This setup runs the parser behind Caddy. Caddy receives public HTTP/HTTPS
|
||||
traffic, obtains TLS certificates, and proxies requests to the app container.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* A machine on your home network with Docker and Docker Compose.
|
||||
* A DNS name that points to your home connection.
|
||||
* FritzBox port sharing from the internet to the Docker host:
|
||||
|
||||
* TCP 80 -> Docker host TCP 80
|
||||
* TCP 443 -> Docker host TCP 443
|
||||
* UDP 443 -> Docker host UDP 443
|
||||
|
||||
If your internet provider uses CGNAT and you do not have a reachable public IP,
|
||||
plain FritzBox port forwarding will not work. Use IPv6 with an AAAA record, a
|
||||
provider option for public IPv4, or a tunnel/VPN.
|
||||
|
||||
Configure
|
||||
---------
|
||||
|
||||
Create a local environment file:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
cp .env.local.example .env.local
|
||||
|
||||
Edit ``.env.local``:
|
||||
|
||||
.. code-block:: dotenv
|
||||
|
||||
PUBLIC_HOST=menus.example.org
|
||||
BASE_URL=https://menus.example.org
|
||||
|
||||
``PUBLIC_HOST`` is the domain Caddy serves. ``BASE_URL`` is used by the Flask
|
||||
app when it generates absolute OpenMensa feed URLs.
|
||||
|
||||
Run
|
||||
---
|
||||
|
||||
Start or update the local deployment:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
docker compose --env-file .env.local -f compose.local.yml up -d --build
|
||||
|
||||
Check logs:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
docker compose --env-file .env.local -f compose.local.yml logs -f
|
||||
|
||||
Check health locally:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
curl http://127.0.0.1/health_check
|
||||
|
||||
Stop
|
||||
----
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
docker compose --env-file .env.local -f compose.local.yml down
|
||||
@@ -1,74 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: om-parser-stw-potsdam-v2
|
||||
Version: 2.0.1
|
||||
Summary: OpenMensa parser components for Studentenwerk Potsdam.
|
||||
Requires-Python: >=3.12
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: requests
|
||||
Requires-Dist: pyopenmensa
|
||||
Requires-Dist: flask
|
||||
Requires-Dist: cachetools
|
||||
Requires-Dist: uwsgi
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest; extra == "dev"
|
||||
Requires-Dist: coveralls; extra == "dev"
|
||||
Requires-Dist: pytest-cov; extra == "dev"
|
||||
Requires-Dist: httpretty; extra == "dev"
|
||||
Requires-Dist: pycodestyle; extra == "dev"
|
||||
Requires-Dist: pydocstyle; extra == "dev"
|
||||
Requires-Dist: pylint; extra == "dev"
|
||||
Requires-Dist: sphinx; extra == "dev"
|
||||
Requires-Dist: sphinx-autobuild; extra == "dev"
|
||||
Requires-Dist: sphinx-rtd-theme; extra == "dev"
|
||||
|
||||
# OpenMensa Parser STW Potsdam
|
||||
|
||||
[](https://dl.circleci.com/status-badge/redirect/gh/f4lco/om-parser-stw-potsdam-v2/tree/master)
|
||||
[](https://coveralls.io/github/f4lco/om-parser-stw-potsdam-v2?branch=master)
|
||||
[](https://om-parser-stw-potsdam-v2.readthedocs.io/en/latest/)
|
||||
|
||||
[OpenMensa][om] parser components query canteen websites for menus and transform them into OpenMensa's data format.
|
||||
This project came to life after the website of the canteens of the Studentenwerk Potsdam changed, and is therefore the successor to [kaifabian/om-parser-potsdam][prev-parser] (hence the "-v2").
|
||||
|
||||
Among others, OpenMensa powers the popular [Mensa Uni Potsdam][steppschuh] Android app.
|
||||
|
||||
The current application is built with [Python][py], [PyOpenMensa][pom], and [Flask][flask]. Learn more about the technical details at [Read the Docs][rtd].
|
||||
|
||||
## Local development (modern)
|
||||
|
||||
Recommended: Python 3.12+.
|
||||
|
||||
**Option A (uv, recommended)** ::
|
||||
|
||||
$ uv venv
|
||||
$ uv pip install -e ".[dev]"
|
||||
$ uv run flask --app stw_potsdam.views run
|
||||
|
||||
**Option B (venv + pip)** ::
|
||||
|
||||
$ python -m venv .venv
|
||||
$ . .venv/bin/activate
|
||||
$ pip install -r requirements-dev.txt
|
||||
$ FLASK_APP=stw_potsdam.views flask run
|
||||
|
||||
**Legacy (Pipenv)** ::
|
||||
|
||||
$ pipenv install --dev
|
||||
$ make run
|
||||
|
||||
**Contributions** are always welcome, in particular if the response format of the canteens change. Feel free to file a PR with improvements.
|
||||
|
||||
**Deployment** If in need of a deployment, file a PR to this fork: [kaifabian/om-parser-potsdam-v2][kai]. Kai is currently in charge of running an instance of the parser and the registration on the OpenMensa platform.
|
||||
|
||||
**Where to go next** maybe use this parser or the OpenMensa API to source a new dataset for training a predictor for your favorite lunch?
|
||||
|
||||
**License** Just assume this project is licensed in terms of [WTFPL](http://www.wtfpl.net/) ;)
|
||||
|
||||
[om]: https://openmensa.org
|
||||
[prev-parser]: https://github.com/kaifabian/om-parser-potsdam
|
||||
[rtd]: https://om-parser-stw-potsdam-v2.readthedocs.io/en/latest/
|
||||
[steppschuh]: https://steppschuh.net/blog/?p=951
|
||||
[py]: http://python.org
|
||||
[pom]: https://github.com/mswart/pyopenmensa
|
||||
[flask]: https://palletsprojects.com/p/flask/
|
||||
[kai]: https://github.com/kaifabian/om-parser-stw-potsdam-v2
|
||||
@@ -1,22 +0,0 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
om_parser_stw_potsdam_v2.egg-info/PKG-INFO
|
||||
om_parser_stw_potsdam_v2.egg-info/SOURCES.txt
|
||||
om_parser_stw_potsdam_v2.egg-info/dependency_links.txt
|
||||
om_parser_stw_potsdam_v2.egg-info/requires.txt
|
||||
om_parser_stw_potsdam_v2.egg-info/top_level.txt
|
||||
stw_potsdam/__init__.py
|
||||
stw_potsdam/config.py
|
||||
stw_potsdam/swp_webspeiseplan_api.py
|
||||
stw_potsdam/swp_webspeiseplan_parser.py
|
||||
stw_potsdam/views.py
|
||||
stw_potsdam/xml_types/__init__.py
|
||||
stw_potsdam/xml_types/builder.py
|
||||
stw_potsdam/xml_types/canteen_xml.py
|
||||
stw_potsdam/xml_types/feed_xml.py
|
||||
stw_potsdam/xml_types/meal_xml.py
|
||||
stw_potsdam/xml_types/openmensa_xml.py
|
||||
stw_potsdam/xml_types/times_xml.py
|
||||
tests/test_consistency.py
|
||||
tests/test_retrieval.py
|
||||
tests/test_views.py
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
requests
|
||||
pyopenmensa
|
||||
flask
|
||||
cachetools
|
||||
uwsgi
|
||||
|
||||
[dev]
|
||||
pytest
|
||||
coveralls
|
||||
pytest-cov
|
||||
httpretty
|
||||
pycodestyle
|
||||
pydocstyle
|
||||
pylint
|
||||
sphinx
|
||||
sphinx-autobuild
|
||||
sphinx-rtd-theme
|
||||
@@ -1 +0,0 @@
|
||||
stw_potsdam
|
||||
@@ -1,3 +1,7 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "om-parser-stw-potsdam-v2"
|
||||
version = "2.0.1"
|
||||
@@ -26,3 +30,9 @@ dev = [
|
||||
"sphinx-autobuild",
|
||||
"sphinx-rtd-theme",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["stw_potsdam*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
stw_potsdam = ["canteens.ini"]
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
-r requirements.txt
|
||||
alabaster==1.0.0; python_version >= '3.10'
|
||||
anyio==4.6.2.post1; python_version >= '3.9'
|
||||
astroid==3.3.5; python_full_version >= '3.9.0'
|
||||
babel==2.16.0; python_version >= '3.8'
|
||||
certifi==2024.8.30; python_version >= '3.6'
|
||||
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
|
||||
click==8.1.7; python_version >= '3.7'
|
||||
colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'
|
||||
coverage[toml]==6.5.0; python_version >= '3.7'
|
||||
coveralls==3.3.1; python_version >= '3.5'
|
||||
dill==0.3.9; python_version >= '3.8'
|
||||
docopt==0.6.2
|
||||
docutils==0.21.2; python_version >= '3.9'
|
||||
h11==0.14.0; python_version >= '3.7'
|
||||
httpretty==1.1.4; python_version >= '3'
|
||||
idna==3.10; python_version >= '3.6'
|
||||
imagesize==1.4.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
iniconfig==2.0.0; python_version >= '3.7'
|
||||
isort==5.13.2; python_full_version >= '3.8.0'
|
||||
jinja2==3.1.4; python_version >= '3.7'
|
||||
markupsafe==3.0.2; python_version >= '3.9'
|
||||
mccabe==0.7.0; python_version >= '3.6'
|
||||
packaging==24.2; python_version >= '3.8'
|
||||
platformdirs==4.3.6; python_version >= '3.8'
|
||||
pluggy==1.5.0; python_version >= '3.8'
|
||||
pycodestyle==2.12.1; python_version >= '3.8'
|
||||
pydocstyle==6.3.0; python_version >= '3.6'
|
||||
pygments==2.18.0; python_version >= '3.8'
|
||||
pylint==3.3.1; python_full_version >= '3.9.0'
|
||||
pytest-cov==5.0.0; python_version >= '3.8'
|
||||
pytest==8.3.3; python_version >= '3.8'
|
||||
requests==2.32.3; python_version >= '3.8'
|
||||
sniffio==1.3.1; python_version >= '3.7'
|
||||
snowballstemmer==2.2.0
|
||||
sphinx-autobuild==2024.10.3; python_version >= '3.9'
|
||||
sphinx-rtd-theme==3.0.2; python_version >= '3.8'
|
||||
sphinx==8.1.3; python_version >= '3.10'
|
||||
sphinxcontrib-applehelp==2.0.0; python_version >= '3.9'
|
||||
sphinxcontrib-devhelp==2.0.0; python_version >= '3.9'
|
||||
sphinxcontrib-htmlhelp==2.1.0; python_version >= '3.9'
|
||||
sphinxcontrib-jquery==4.1; python_version >= '2.7'
|
||||
sphinxcontrib-jsmath==1.0.1; python_version >= '3.5'
|
||||
sphinxcontrib-qthelp==2.0.0; python_version >= '3.9'
|
||||
sphinxcontrib-serializinghtml==2.0.0; python_version >= '3.9'
|
||||
starlette==0.41.2; python_version >= '3.8'
|
||||
tomlkit==0.13.2; python_version >= '3.8'
|
||||
urllib3==2.2.3; python_version >= '3.8'
|
||||
uvicorn==0.32.0; python_version >= '3.8'
|
||||
watchfiles==0.24.0; python_version >= '3.8'
|
||||
websockets==14.1; python_version >= '3.9'
|
||||
@@ -1,15 +0,0 @@
|
||||
blinker==1.9.0; python_version >= '3.9'
|
||||
cachetools==5.5.0; python_version >= '3.7'
|
||||
certifi==2024.8.30; python_version >= '3.6'
|
||||
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
|
||||
click==8.1.7; python_version >= '3.7'
|
||||
flask==3.1.0; python_version >= '3.9'
|
||||
idna==3.10; python_version >= '3.6'
|
||||
itsdangerous==2.2.0; python_version >= '3.8'
|
||||
jinja2==3.1.4; python_version >= '3.7'
|
||||
markupsafe==3.0.2; python_version >= '3.9'
|
||||
pyopenmensa==0.95.0
|
||||
requests==2.32.3; python_version >= '3.8'
|
||||
urllib3==2.2.3; python_version >= '3.8'
|
||||
uwsgi==2.0.28
|
||||
werkzeug==3.1.3; python_version >= '3.9'
|
||||
+12
-3
@@ -4,11 +4,20 @@ import configparser
|
||||
import io
|
||||
import os
|
||||
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
|
||||
Canteen = namedtuple('Canteen',
|
||||
('key', 'name', 'street', 'city', 'id', 'chash'))
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Canteen:
|
||||
"""Configured OpenMensa canteen mapping."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
street: str
|
||||
city: str
|
||||
id: str
|
||||
chash: str
|
||||
|
||||
|
||||
def _get_config(filename):
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SWPWebspeiseplanData:
|
||||
"""Downloaded SWP Webspeiseplan data grouped by outlet name."""
|
||||
|
||||
outlets: dict[str, dict]
|
||||
locations: dict[str, dict]
|
||||
menus: dict[str, dict]
|
||||
meal_categories: dict[str, dict]
|
||||
|
||||
|
||||
class SWPWebspeiseplanAPI:
|
||||
"""This class is used download content from SWP_Webspeiseplan.
|
||||
@@ -16,25 +29,34 @@ class SWPWebspeiseplanAPI:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the configuration for the web service."""
|
||||
"""Initialize the web service client."""
|
||||
logging.basicConfig()
|
||||
|
||||
def fetch_all(self) -> SWPWebspeiseplanData:
|
||||
"""Download all data required to render OpenMensa feeds."""
|
||||
proxy_token = self.parse_token()
|
||||
self.outlets = self.parse_outlets(proxy_token)
|
||||
self.locations: dict[str, dict] = {}
|
||||
outlets = self.parse_outlets(proxy_token)
|
||||
locations = {
|
||||
item["id"]: item
|
||||
for item in self.parse_location(proxy_token)
|
||||
}
|
||||
self.menus: dict[str, dict] = {}
|
||||
self.meal_categories: dict[str, dict] = {}
|
||||
for outlet in self.outlets.values():
|
||||
menus: dict[str, dict] = {}
|
||||
meal_categories: dict[str, dict] = {}
|
||||
outlet_locations: dict[str, dict] = {}
|
||||
for outlet in outlets.values():
|
||||
location = outlet["standortID"]
|
||||
menu = self.parse_menu(proxy_token, location)
|
||||
categories = self.parse_meal_category(proxy_token, location)
|
||||
id2cat = {item["gerichtkategorieID"]: item for item in categories}
|
||||
self.menus[outlet["name"]] = menu
|
||||
self.meal_categories[outlet["name"]] = id2cat
|
||||
self.locations[outlet["name"]] = locations[location]
|
||||
menus[outlet["name"]] = menu
|
||||
meal_categories[outlet["name"]] = id2cat
|
||||
outlet_locations[outlet["name"]] = locations[location]
|
||||
return SWPWebspeiseplanData(
|
||||
outlets=outlets,
|
||||
locations=outlet_locations,
|
||||
menus=menus,
|
||||
meal_categories=meal_categories,
|
||||
)
|
||||
|
||||
def __spoof_req_headers(self, req: urllib.request.Request):
|
||||
"""Add headers to a request .
|
||||
@@ -77,9 +99,8 @@ class SWPWebspeiseplanAPI:
|
||||
Returns:
|
||||
[type]: [description]
|
||||
"""
|
||||
url = f"{SWPWebspeiseplanAPI.URL_BASE}/index.php?" + "&".join(
|
||||
[f"{k}={v}" for k, v in params.items()]
|
||||
)
|
||||
query = urllib.parse.urlencode(params)
|
||||
url = f"{SWPWebspeiseplanAPI.URL_BASE}/index.php?{query}"
|
||||
SWPWebspeiseplanAPI.logger.debug("__parse_model: %s", url)
|
||||
req = urllib.request.Request(url)
|
||||
self.__spoof_req_headers(req)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
from stw_potsdam.xml_types.canteen_xml import CanteenMeta, CanteenXML
|
||||
from stw_potsdam.xml_types.times_xml import CanteenOpenTimespec, TimesXML
|
||||
from stw_potsdam.xml_types.meal_xml import MealXML
|
||||
|
||||
|
||||
EURO_PRICE_PATTERN = re.compile(r"(\d+(?:[,.]\d{1,2})?)\s*€")
|
||||
|
||||
|
||||
class SWPWebspeiseplanParser:
|
||||
"""Class method to parse SWP_Webspeiseplan."""
|
||||
|
||||
@@ -56,6 +60,42 @@ class SWPWebspeiseplanParser:
|
||||
canteen = CanteenXML(canteen_meta, canteen_times)
|
||||
return canteen
|
||||
|
||||
def _parse_price(self, value):
|
||||
if value in (None, "", {}):
|
||||
return None
|
||||
return float(str(value).replace(",", "."))
|
||||
|
||||
def _parse_embedded_prices(
|
||||
self, name: str, price: dict[str, float | None]
|
||||
) -> tuple[str, dict[str, float | None]]:
|
||||
if any(price.values()):
|
||||
return name, price
|
||||
|
||||
matches = EURO_PRICE_PATTERN.findall(name)
|
||||
if len(matches) < 2:
|
||||
return name, price
|
||||
|
||||
parsed = [self._parse_price(match) for match in matches]
|
||||
if len(parsed) >= 3:
|
||||
price = {
|
||||
"student": parsed[0],
|
||||
"employee": parsed[1],
|
||||
"other": parsed[2],
|
||||
}
|
||||
elif "Stud" in name and ("Gäste" in name or "Gaeste" in name):
|
||||
price = {
|
||||
"student": parsed[0],
|
||||
"employee": price["employee"],
|
||||
"other": parsed[1],
|
||||
}
|
||||
else:
|
||||
return name, price
|
||||
|
||||
name = EURO_PRICE_PATTERN.sub("", name)
|
||||
name = re.sub(r"\s*/\s*", " ", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
return name, price
|
||||
|
||||
def parse_meals(
|
||||
self, menu_data, meal_categories
|
||||
) -> list[tuple[date, str, MealXML]]:
|
||||
@@ -66,11 +106,20 @@ class SWPWebspeiseplanParser:
|
||||
info = meal_data["speiseplanAdvancedGericht"]
|
||||
additional_info = meal_data["zusatzinformationen"]
|
||||
price = {
|
||||
"student": additional_info["mitarbeiterpreisDecimal2"],
|
||||
"employee": additional_info["price3Decimal2"],
|
||||
"other": additional_info["gaestepreisDecimal2"],
|
||||
"student": self._parse_price(
|
||||
additional_info["mitarbeiterpreisDecimal2"]
|
||||
),
|
||||
"employee": self._parse_price(
|
||||
additional_info["price3Decimal2"]
|
||||
),
|
||||
"other": self._parse_price(
|
||||
additional_info["gaestepreisDecimal2"]
|
||||
),
|
||||
}
|
||||
meal = MealXML(name=info["gerichtname"], price=price)
|
||||
name, price = self._parse_embedded_prices(
|
||||
info["gerichtname"], price
|
||||
)
|
||||
meal = MealXML(name=name, price=price)
|
||||
day = datetime.fromisoformat(info["datum"]).date()
|
||||
category = meal_categories[info["gerichtkategorieID"]]["name"]
|
||||
meals.append(
|
||||
|
||||
@@ -3,7 +3,10 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from flask import url_for
|
||||
from stw_potsdam.xml_types.openmensa_xml import OpenMensaXML
|
||||
from stw_potsdam.swp_webspeiseplan_api import SWPWebspeiseplanAPI
|
||||
from stw_potsdam.swp_webspeiseplan_api import (
|
||||
SWPWebspeiseplanAPI,
|
||||
SWPWebspeiseplanData,
|
||||
)
|
||||
from stw_potsdam.swp_webspeiseplan_parser import SWPWebspeiseplanParser
|
||||
from stw_potsdam.config import Canteen
|
||||
from stw_potsdam.xml_types.feed_xml import FeedXML, ScheduleXML
|
||||
@@ -15,21 +18,26 @@ class Builder:
|
||||
|
||||
VERSION = "2.0.1"
|
||||
|
||||
def __init__(self, config: dict[str, Canteen]):
|
||||
def __init__(
|
||||
self,
|
||||
config: dict[str, Canteen],
|
||||
swp_data: SWPWebspeiseplanData | None = None,
|
||||
):
|
||||
"""Initialize the object for the OpenMensa Feed Doc XML."""
|
||||
logging.basicConfig()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._xml_data = {}
|
||||
swp_api = SWPWebspeiseplanAPI()
|
||||
if swp_data is None:
|
||||
swp_data = SWPWebspeiseplanAPI().fetch_all()
|
||||
swp_parser = SWPWebspeiseplanParser()
|
||||
for cname, ntup in config.items():
|
||||
if ntup.name not in swp_api.outlets.keys():
|
||||
if ntup.name not in swp_data.outlets.keys():
|
||||
self.logger.warning("%s not found in keys", ntup.name)
|
||||
continue
|
||||
outlet = swp_api.outlets[ntup.name]
|
||||
menus = swp_api.menus[ntup.name]
|
||||
categories = swp_api.meal_categories[ntup.name]
|
||||
locations = swp_api.locations[ntup.name]
|
||||
outlet = swp_data.outlets[ntup.name]
|
||||
menus = swp_data.menus[ntup.name]
|
||||
categories = swp_data.meal_categories[ntup.name]
|
||||
locations = swp_data.locations[ntup.name]
|
||||
outlet["isPublic"] = locations["isPublic"]
|
||||
canteen = swp_parser.parse_canteen_meta_times(outlet)
|
||||
meals = swp_parser.parse_meals(menus, categories)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from stw_potsdam.swp_webspeiseplan_api import SWPWebspeiseplanAPI
|
||||
|
||||
# pytest fixtures are linked via parameter names of test methods
|
||||
# pragma pylint: disable=unused-import,unused-argument,redefined-outer-name
|
||||
from tests.stub_api import api_offline
|
||||
|
||||
|
||||
def test_api_init_does_not_fetch(api_offline):
|
||||
"""Creating the API client does not perform network requests."""
|
||||
SWPWebspeiseplanAPI()
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from stw_potsdam.swp_webspeiseplan_parser import SWPWebspeiseplanParser
|
||||
|
||||
|
||||
def _menu_item(name):
|
||||
return [
|
||||
{
|
||||
"speiseplanGerichtData": [
|
||||
{
|
||||
"speiseplanAdvancedGericht": {
|
||||
"datum": "2026-05-01T00:00:00",
|
||||
"gerichtkategorieID": 1,
|
||||
"gerichtname": name,
|
||||
},
|
||||
"zusatzinformationen": {
|
||||
"mitarbeiterpreisDecimal2": 0,
|
||||
"price3Decimal2": 0,
|
||||
"gaestepreisDecimal2": 0,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _parse_meal(name):
|
||||
parser = SWPWebspeiseplanParser()
|
||||
meals = parser.parse_meals(_menu_item(name), {1: {"name": "Salattheke"}})
|
||||
return meals[0]["meal"]
|
||||
|
||||
|
||||
def test_parse_salad_bar_three_embedded_prices():
|
||||
meal = _parse_meal(
|
||||
"große Schale kleine Schale Relevo Schale 100g "
|
||||
"1,10 €/ 1,70 €/ 1,90€"
|
||||
)
|
||||
|
||||
assert meal.name == "große Schale kleine Schale Relevo Schale 100g"
|
||||
assert meal.price == {
|
||||
"student": 1.10,
|
||||
"employee": 1.70,
|
||||
"other": 1.90,
|
||||
}
|
||||
|
||||
|
||||
def test_parse_salad_bar_student_guest_embedded_prices():
|
||||
meal = _parse_meal(
|
||||
"große Schale\nkleine Schale\nRelevo Schale\n"
|
||||
"100g Stud. 1,00€/ Gäste 1,45€"
|
||||
)
|
||||
|
||||
assert meal.name == (
|
||||
"große Schale kleine Schale Relevo Schale 100g Stud. Gäste"
|
||||
)
|
||||
assert meal.price == {
|
||||
"student": 1.00,
|
||||
"employee": 0.0,
|
||||
"other": 1.45,
|
||||
}
|
||||
Reference in New Issue
Block a user