Compare commits

..

5 Commits

Author SHA256 Message Date
Hadrian Burkhardt f5d89cee2f docker compose 2026-05-01 01:47:55 +00:00
Hadrian Burkhardt b31796da39 fixed salattheke prices 2026-05-01 00:38:18 +00:00
Hadrian Burkhardt e9b7866bb1 refactor: make webspeiseplan fetching explicit 2026-05-01 00:13:33 +00:00
Hadrian Burkhardt 7720002d30 chore: clean packaging validation and config model 2026-05-01 00:08:43 +00:00
Hadrian Burkhardt bfe72b4f77 chore: unify packaging around uv 2026-04-30 23:59:56 +00:00
28 changed files with 333 additions and 1341 deletions
+3
View File
@@ -9,6 +9,9 @@ jobs:
- image: cimg/python:3.13 - image: cimg/python:3.13
steps: steps:
- checkout - checkout
- run:
name: Install uv
command: python -m pip install uv
- run: - run:
name: Install Dependencies name: Install Dependencies
command: make dependencies command: make dependencies
+2
View File
@@ -0,0 +1,2 @@
PUBLIC_HOST=menus.example.org
BASE_URL=https://menus.example.org
+5
View File
@@ -3,8 +3,13 @@
.pytest_cache .pytest_cache
/.coverage /.coverage
/htmlcov /htmlcov
/build
/dist
*.egg-info/
*.pyc *.pyc
.venv .venv
__pycache__ __pycache__
.devcontainer .devcontainer
.vscode .vscode
.codex
.env.local
-4
View File
@@ -30,10 +30,6 @@ persistent=yes
# Specify a configuration file. # Specify a configuration file.
#rcfile= #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 # Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code. # active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no unsafe-load-any-extension=no
+3
View File
@@ -0,0 +1,3 @@
{$PUBLIC_HOST} {
reverse_proxy app:3080
}
+7 -6
View File
@@ -9,7 +9,7 @@ ARG DEPLOY_DIR
ARG USERNAME ARG USERNAME
# Install dependencies # Install dependencies
RUN pip install pipenv RUN pip install --no-cache-dir uv
# Add app user # Add app user
RUN adduser --disabled-password ${USERNAME} RUN adduser --disabled-password ${USERNAME}
@@ -27,8 +27,9 @@ WORKDIR ${DEPLOY_DIR}
COPY stw_potsdam/ ./stw_potsdam COPY stw_potsdam/ ./stw_potsdam
COPY tests ./tests COPY tests ./tests
COPY Makefile . COPY Makefile .
COPY Pipfile . COPY README.md .
COPY Pipfile.lock . COPY pyproject.toml .
COPY uv.lock .
# Apply app user to app folder # Apply app user to app folder
RUN chown -R ${USERNAME} . RUN chown -R ${USERNAME} .
@@ -48,7 +49,7 @@ RUN apt-get update && apt-get install -y build-essential python3-dev
# Install environment # Install environment
USER ${USERNAME} USER ${USERNAME}
RUN pipenv install --dev RUN uv sync --frozen --extra dev
# Run tests # Run tests
RUN make test RUN make test
@@ -58,7 +59,7 @@ RUN rm -rf .venv
RUN rm -rf ./tests ./Makefile RUN rm -rf ./tests ./Makefile
# Install production environment # Install production environment
RUN pipenv install --deploy RUN uv sync --frozen --no-dev
### Production container ### Production container
@@ -82,6 +83,6 @@ ENV LISTEN_PORT=${LISTEN_PORT}
ENV LISTEN=0.0.0.0:${LISTEN_PORT} ENV LISTEN=0.0.0.0:${LISTEN_PORT}
EXPOSE ${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 HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:$LISTEN_PORT/health_check || exit 1
+11 -12
View File
@@ -1,34 +1,33 @@
RUN=uv run flask --app stw_potsdam.views run
RUN=FLASK_APP="stw_potsdam.views" pipenv run flask run
dependencies: dependencies:
pipenv sync --dev uv sync --extra dev
run: run:
$(RUN) $(RUN)
debug: debug:
FLASK_ENV=development $(RUN) --debug uv run flask --app stw_potsdam.views --debug run
test: 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: test_debug:
pipenv run python -m pytest -v --trace uv run --extra dev python -m pytest -v --trace
coverage_publish: coverage_publish:
pipenv run python -m coveralls uv run --extra dev python -m coveralls
coverage_report: coverage_report:
pipenv run python -m coverage report --fail-under 90 uv run --extra dev python -m coverage report --fail-under 90
lint: lint:
pipenv run pycodestyle stw_potsdam tests uv run --extra dev pycodestyle stw_potsdam tests
pipenv run pydocstyle stw_potsdam tests uv run --extra dev pydocstyle stw_potsdam tests
pipenv run pylint stw_potsdam tests uv run --extra dev pylint stw_potsdam tests
clean: clean:
pipenv run python -m coverage erase uv run --extra dev python -m coverage erase
rm -rf .pytest_cache .cache rm -rf .pytest_cache .cache
.PHONY: dependencies run debug test test_debug coverage_publish coverage_report lint clean .PHONY: dependencies run debug test test_debug coverage_publish coverage_report lint clean
-21
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -25,14 +25,9 @@ Recommended: Python 3.12+.
$ python -m venv .venv $ python -m venv .venv
$ . .venv/bin/activate $ . .venv/bin/activate
$ pip install -r requirements-dev.txt $ pip install -e ".[dev]"
$ FLASK_APP=stw_potsdam.views flask run $ 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. **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. **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.
+29
View File
@@ -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
View File
@@ -6,9 +6,9 @@ Because the parser may break on changes to the canteen website, it should be eas
Quickstart 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 test # Check setup by running tests
$ make debug # Start app instance with debugger and pretty printing of JSON $ make debug # Start app instance with debugger and pretty printing of JSON
$ make run # Start app without debugger $ 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``. 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 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 .. 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 .. autoclass:: stw_potsdam.xml_types.builder.Builder
.. autofunction:: stw_potsdam.feed.render_meta
Tests 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. 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.
+67
View File
@@ -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
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/f4lco/om-parser-stw-potsdam-v2/tree/master.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/f4lco/om-parser-stw-potsdam-v2/tree/master)
[![Coverage Status](https://coveralls.io/repos/github/f4lco/om-parser-stw-potsdam-v2/badge.svg?branch=master)](https://coveralls.io/github/f4lco/om-parser-stw-potsdam-v2?branch=master)
[![Read the Docs](https://readthedocs.org/projects/om-parser-stw-potsdam-v2/badge/?version=latest&style=flat)](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
+10
View File
@@ -1,3 +1,7 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "om-parser-stw-potsdam-v2" name = "om-parser-stw-potsdam-v2"
version = "2.0.1" version = "2.0.1"
@@ -26,3 +30,9 @@ dev = [
"sphinx-autobuild", "sphinx-autobuild",
"sphinx-rtd-theme", "sphinx-rtd-theme",
] ]
[tool.setuptools.packages.find]
include = ["stw_potsdam*"]
[tool.setuptools.package-data]
stw_potsdam = ["canteens.ini"]
-51
View File
@@ -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'
-15
View File
@@ -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
View File
@@ -4,11 +4,20 @@ import configparser
import io import io
import os import os
from collections import namedtuple from dataclasses import dataclass
from functools import partial 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): def _get_config(filename):
+33 -12
View File
@@ -1,9 +1,22 @@
import logging import logging
import urllib.request import urllib.request
import urllib.parse
import re import re
import time import time
import json 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: class SWPWebspeiseplanAPI:
"""This class is used download content from SWP_Webspeiseplan. """This class is used download content from SWP_Webspeiseplan.
@@ -16,25 +29,34 @@ class SWPWebspeiseplanAPI:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def __init__(self): def __init__(self):
"""Initialize the configuration for the web service.""" """Initialize the web service client."""
logging.basicConfig() logging.basicConfig()
def fetch_all(self) -> SWPWebspeiseplanData:
"""Download all data required to render OpenMensa feeds."""
proxy_token = self.parse_token() proxy_token = self.parse_token()
self.outlets = self.parse_outlets(proxy_token) outlets = self.parse_outlets(proxy_token)
self.locations: dict[str, dict] = {}
locations = { locations = {
item["id"]: item item["id"]: item
for item in self.parse_location(proxy_token) for item in self.parse_location(proxy_token)
} }
self.menus: dict[str, dict] = {} menus: dict[str, dict] = {}
self.meal_categories: dict[str, dict] = {} meal_categories: dict[str, dict] = {}
for outlet in self.outlets.values(): outlet_locations: dict[str, dict] = {}
for outlet in outlets.values():
location = outlet["standortID"] location = outlet["standortID"]
menu = self.parse_menu(proxy_token, location) menu = self.parse_menu(proxy_token, location)
categories = self.parse_meal_category(proxy_token, location) categories = self.parse_meal_category(proxy_token, location)
id2cat = {item["gerichtkategorieID"]: item for item in categories} id2cat = {item["gerichtkategorieID"]: item for item in categories}
self.menus[outlet["name"]] = menu menus[outlet["name"]] = menu
self.meal_categories[outlet["name"]] = id2cat meal_categories[outlet["name"]] = id2cat
self.locations[outlet["name"]] = locations[location] 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): def __spoof_req_headers(self, req: urllib.request.Request):
"""Add headers to a request . """Add headers to a request .
@@ -77,9 +99,8 @@ class SWPWebspeiseplanAPI:
Returns: Returns:
[type]: [description] [type]: [description]
""" """
url = f"{SWPWebspeiseplanAPI.URL_BASE}/index.php?" + "&".join( query = urllib.parse.urlencode(params)
[f"{k}={v}" for k, v in params.items()] url = f"{SWPWebspeiseplanAPI.URL_BASE}/index.php?{query}"
)
SWPWebspeiseplanAPI.logger.debug("__parse_model: %s", url) SWPWebspeiseplanAPI.logger.debug("__parse_model: %s", url)
req = urllib.request.Request(url) req = urllib.request.Request(url)
self.__spoof_req_headers(req) self.__spoof_req_headers(req)
+53 -4
View File
@@ -1,10 +1,14 @@
import logging import logging
import re
from datetime import datetime, date from datetime import datetime, date
from stw_potsdam.xml_types.canteen_xml import CanteenMeta, CanteenXML 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.times_xml import CanteenOpenTimespec, TimesXML
from stw_potsdam.xml_types.meal_xml import MealXML from stw_potsdam.xml_types.meal_xml import MealXML
EURO_PRICE_PATTERN = re.compile(r"(\d+(?:[,.]\d{1,2})?)\s*€")
class SWPWebspeiseplanParser: class SWPWebspeiseplanParser:
"""Class method to parse SWP_Webspeiseplan.""" """Class method to parse SWP_Webspeiseplan."""
@@ -56,6 +60,42 @@ class SWPWebspeiseplanParser:
canteen = CanteenXML(canteen_meta, canteen_times) canteen = CanteenXML(canteen_meta, canteen_times)
return canteen 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( def parse_meals(
self, menu_data, meal_categories self, menu_data, meal_categories
) -> list[tuple[date, str, MealXML]]: ) -> list[tuple[date, str, MealXML]]:
@@ -66,11 +106,20 @@ class SWPWebspeiseplanParser:
info = meal_data["speiseplanAdvancedGericht"] info = meal_data["speiseplanAdvancedGericht"]
additional_info = meal_data["zusatzinformationen"] additional_info = meal_data["zusatzinformationen"]
price = { price = {
"student": additional_info["mitarbeiterpreisDecimal2"], "student": self._parse_price(
"employee": additional_info["price3Decimal2"], additional_info["mitarbeiterpreisDecimal2"]
"other": additional_info["gaestepreisDecimal2"], ),
"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() day = datetime.fromisoformat(info["datum"]).date()
category = meal_categories[info["gerichtkategorieID"]]["name"] category = meal_categories[info["gerichtkategorieID"]]["name"]
meals.append( meals.append(
+16 -8
View File
@@ -3,7 +3,10 @@ from dataclasses import dataclass
import logging import logging
from flask import url_for from flask import url_for
from stw_potsdam.xml_types.openmensa_xml import OpenMensaXML 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.swp_webspeiseplan_parser import SWPWebspeiseplanParser
from stw_potsdam.config import Canteen from stw_potsdam.config import Canteen
from stw_potsdam.xml_types.feed_xml import FeedXML, ScheduleXML from stw_potsdam.xml_types.feed_xml import FeedXML, ScheduleXML
@@ -15,21 +18,26 @@ class Builder:
VERSION = "2.0.1" 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.""" """Initialize the object for the OpenMensa Feed Doc XML."""
logging.basicConfig() logging.basicConfig()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self._xml_data = {} self._xml_data = {}
swp_api = SWPWebspeiseplanAPI() if swp_data is None:
swp_data = SWPWebspeiseplanAPI().fetch_all()
swp_parser = SWPWebspeiseplanParser() swp_parser = SWPWebspeiseplanParser()
for cname, ntup in config.items(): 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) self.logger.warning("%s not found in keys", ntup.name)
continue continue
outlet = swp_api.outlets[ntup.name] outlet = swp_data.outlets[ntup.name]
menus = swp_api.menus[ntup.name] menus = swp_data.menus[ntup.name]
categories = swp_api.meal_categories[ntup.name] categories = swp_data.meal_categories[ntup.name]
locations = swp_api.locations[ntup.name] locations = swp_data.locations[ntup.name]
outlet["isPublic"] = locations["isPublic"] outlet["isPublic"] = locations["isPublic"]
canteen = swp_parser.parse_canteen_meta_times(outlet) canteen = swp_parser.parse_canteen_meta_times(outlet)
meals = swp_parser.parse_meals(menus, categories) meals = swp_parser.parse_meals(menus, categories)
+12
View File
@@ -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()
+60
View File
@@ -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,
}
Generated
+1 -1
View File
@@ -423,7 +423,7 @@ wheels = [
[[package]] [[package]]
name = "om-parser-stw-potsdam-v2" name = "om-parser-stw-potsdam-v2"
version = "2.0.1" version = "2.0.1"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cachetools" }, { name = "cachetools" },
{ name = "flask" }, { name = "flask" },