• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

beaufour / boonli_api / 8655629058

12 Apr 2024 01:33AM UTC coverage: 72.727%. Remained the same
8655629058

Pull #7

github

web-flow
Bump idna from 3.4 to 3.7

Bumps [idna](https://github.com/kjd/idna) from 3.4 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.4...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Pull Request #7: Bump idna from 3.4 to 3.7

128 of 176 relevant lines covered (72.73%)

2.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

65.22
/boonli_api/api.py
1
#!/usr/bin/env python
2
"""API main functionality."""
3✔
3
import argparse
3✔
4
import logging
3✔
5
import sys
3✔
6
from datetime import date, timedelta
3✔
7
from typing import Any, Dict, List, Union
3✔
8

9
import requests
3✔
10
from bs4 import BeautifulSoup
3✔
11
from dateutil.relativedelta import MO, relativedelta
3✔
12
from requests_toolbelt import sessions
3✔
13

14
if sys.version_info >= (3, 8):
3✔
15
    from typing import TypedDict
2✔
16
else:
17
    from typing_extensions import TypedDict
1✔
18

19

20
ApiData = Dict[str, Union[str, int]]
3✔
21

22

23
class ParseError(Exception):
3✔
24
    """If we cannot parse the returned data."""
3✔
25

26

27
class LoginError(Exception):
3✔
28
    """If we cannot login to the Boonli website."""
3✔
29

30

31
class APIError(Exception):
3✔
32
    """If the Boonli website returns an error."""
3✔
33

34

35
class Menu(TypedDict):
3✔
36
    "The menu for a given date"
3✔
37
    menu: Union[str, None]
3✔
38
    day: date
3✔
39

40

41
def _create_session(customer_id: str) -> requests.Session:
3✔
42
    """Creates a requests session with the base API url, headers, etc."""
43
    base_url = f"https://{customer_id}.boonli.com/"
3✔
44
    http = sessions.BaseUrlSession(base_url=base_url)
3✔
45
    headers = {
3✔
46
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:86.0)"
47
        "Gecko/20100101 Firefox/86.0",
48
        "Accept": "application/json, text/javascript, */*; q=0.01",
49
        "Accept-Language": "en-US,en;q=0.5",
50
        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
51
        "Origin": base_url,
52
        "Connection": "keep-alive",
53
    }
54
    http.headers.update(headers)
3✔
55
    return http
3✔
56

57

58
def _extract_csrf_token(text: str) -> str:
3✔
59
    soup = BeautifulSoup(text, features="lxml")
3✔
60
    token_tag = soup.find(attrs={"name": "csrftk"})
3✔
61
    if not token_tag:
3✔
62
        raise ParseError("Could not find token tag!")
3✔
63

64
    token = token_tag.get("value")  # type: ignore
3✔
65
    return str(token)
3✔
66

67

68
def _extract_api_data(text: str) -> ApiData:
3✔
69
    if "Invalid username/password" in text:
3✔
70
        # Man, this is an ugly way to detect that
71
        raise LoginError("Wrong username/password")
3✔
72

73
    soup = BeautifulSoup(text, features="lxml")
3✔
74
    api_token_tag = soup.find("input", attrs={"id": "lxbat"})
3✔
75
    if not api_token_tag:
3✔
76
        raise ParseError("Couldn't find value for API token!")
×
77
    api_token = str(api_token_tag.get("value"))  # type: ignore
3✔
78
    sid_tag = soup.find("input", attrs={"name": "sid"})
3✔
79
    if not sid_tag:
3✔
80
        raise ParseError("Couldn't find value for SID")
×
81
    sid = int(sid_tag.get("value"))  # type: ignore
3✔
82
    pid_tag = soup.find("input", attrs={"name": "pid"})
3✔
83
    if not pid_tag:
3✔
84
        raise ParseError("Couldn't find value for PID")
3✔
85
    pid = int(pid_tag.get("value"))  # type: ignore
3✔
86

87
    # It seems like the "cycles" overlap at least for the school I'm
88
    # using. So it's showing both the old and the new cycle for the same month.
89
    # I can't seem to find any metadata for the cycle, so just taking the
90
    # newest cycle here, which is the last one listed in my case. This is
91
    # bound to break or be wrong in other cases...
92
    #
93
    # Moreover, it will only show one cycle's menus, so if some weeks are in 1)
94
    # and some in 2), it will not show a complete menu listing for that period.
95

96
    cur_mcid_tag = soup.find_all("a", attrs={"class": "mcycle_button"})[-1]
3✔
97
    if not cur_mcid_tag:
3✔
98
        raise ParseError("Couldn't find value for MCID")
×
99
    cur_mcid = int(cur_mcid_tag.get("id"))  # type: ignore
3✔
100
    logging.debug("Selecting for %s", cur_mcid_tag.text)
3✔
101

102
    data: ApiData = {
3✔
103
        "api_token": api_token,
104
        "pid": pid,
105
        "sid": sid,
106
        "cur_mcid": cur_mcid,
107
    }
108
    return data
3✔
109

110

111
def _extract_menu(json: Any) -> str:
3✔
112
    alert_msg = json.get("alert_msg")
3✔
113
    if alert_msg:
3✔
114
        logging.info("Got alert message: %s", alert_msg)
3✔
115
    error = json.get("error")
3✔
116
    if error:
3✔
117
        if error == "unauthenticated":
3✔
118
            raise LoginError("Unauthenticated")
3✔
119

120
        raise APIError(f"Got an error from the API: {error}")
3✔
121

122
    soup = BeautifulSoup(json["body"], features="lxml")
3✔
123
    menu_tag = soup.find(attrs={"class": "menu-name"})
3✔
124
    if not menu_tag:
3✔
125
        logging.debug("Missing menu tag in API Response: %s", json)
3✔
126
        return alert_msg or ""
3✔
127
    # This takes the second child, to skip the item_preface element
128
    # <span class=\"menu-name\"><span class=\"item_preface\">02-Pasta<\/span>
129
    # Macaroni and Cheese w\/ Sliced Cucumbers (on the side)<\/span>
130
    return menu_tag.contents[1].text.strip()  # type: ignore
3✔
131

132

133
class BoonliAPI:
3✔
134
    """The main API.
3✔
135

136
    Call :func:`~boonli.api.BoonliAPI.login` first, before using any
137
    other methods.
138
    """
139

140
    _session = None
3✔
141
    _api_data = None
3✔
142

143
    def login(self, customer_id: str, username: str, password: str) -> None:
3✔
144
        """Logs into the Boonli API and retrieves the API parameters used for
145
        doing API calls."""
146
        if self._session or self._api_data:
3✔
147
            logging.warning("Already logged in")
×
148
            return
×
149

150
        self._session = _create_session(customer_id)
3✔
151

152
        url = "login"
3✔
153
        logging.debug("\n########## Login GET %s", url)
3✔
154
        try:
3✔
155
            resp = self._session.get(url)
3✔
156
        except requests.exceptions.ConnectionError as ex:
3✔
157
            logging.warning("Got error logging in: %s", ex)
3✔
158
            raise LoginError("Cannot connect to Boonli. Customer ID invalid?") from ex
3✔
159
        token = _extract_csrf_token(resp.text)
×
160
        logging.debug("Token: %s", token)
×
161
        logging.debug("Cookies: %s", self._session.cookies)
×
162

163
        logging.debug("\n########## Login POST %s", url)
×
164
        data = {
×
165
            "username": username,
166
            "password": password,
167
            "csrftk": token,
168
        }
169
        login_response = self._session.post(url, data=data)
×
170
        logging.debug("Login post: %s", login_response)
×
171
        logging.debug("Headers: %s", login_response.request.headers)
×
172
        logging.debug("Cookies: %s", self._session.cookies)
×
173

174
        home_response = self._session.get("home")
×
175
        self._api_data = _extract_api_data(home_response.text)
×
176
        logging.debug("API Data: %s", self._api_data)
×
177

178
    def get_day(self, day: date) -> str:
3✔
179
        """Returns the menu for the given day."""
180
        if not self._api_data or not self._session:
×
181
            raise Exception("Not logged in")
×
182

183
        data = self._api_data.copy()
×
184
        data.update(
×
185
            {
186
                "cy": day.year,
187
                "cm": day.month,
188
                "cday": day.day,
189
            }
190
        )
191
        url = "api/cal/getDay"
×
192
        api_response = self._session.post(url, data=data)
×
193
        api_json = api_response.json()
×
194
        menu = _extract_menu(api_json)
×
195
        return menu
×
196

197
    def get_range(self, start: date, count: int) -> List[Menu]:
3✔
198
        """Returns the menu from a `start` date and `count` days forward."""
199
        ret = []
×
200
        for _ in range(count):
×
201
            menu = self.get_day(start)
×
202
            ret.append(Menu(menu=menu, day=start))
×
203
            start += timedelta(days=1)
×
204
        return ret
×
205

206

207
def main() -> None:
3✔
208
    """main function."""
209
    logging.basicConfig()
×
210

211
    parser = argparse.ArgumentParser()
×
212
    parser.add_argument(
×
213
        "-c",
214
        "--customer_id",
215
        type=str,
216
        required=True,
217
        help="Boonli customer id, ie the first part of the domain name you log in on",
218
    )
219
    parser.add_argument("-u", "--username", type=str, required=True, help="Boonli username")
×
220
    parser.add_argument("-p", "--password", type=str, required=True, help="Boonli password")
×
221
    parser.add_argument("-v", "--verbose", action="store_true", help="Turns on verbose logging")
×
222
    args = parser.parse_args()
×
223
    if args.verbose:
×
224
        logging.getLogger().setLevel(logging.DEBUG)
×
225

226
    api = BoonliAPI()
×
227
    api.login(args.customer_id, args.username, args.password)
×
228
    day = date.today()
×
229
    if day.weekday() != MO:
×
230
        day = day + relativedelta(weekday=MO(-1))
×
231
    print(api.get_range(day, 7))
×
232

233

234
if __name__ == "__main__":
3✔
235
    main()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc