Coverage for an_website/quotes/utils.py: 64.793%

338 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-01 14:47 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""A page with wrong quotes.""" 

15 

16from __future__ import annotations 

17 

18import abc 

19import asyncio 

20import contextlib 

21import logging 

22import multiprocessing.synchronize 

23import random 

24import sys 

25import time 

26from collections.abc import ( 

27 Callable, 

28 Iterable, 

29 Mapping, 

30 MutableMapping, 

31 Sequence, 

32) 

33from dataclasses import dataclass 

34from datetime import date 

35from multiprocessing import Value 

36from typing import Any, Final, Literal, cast 

37from urllib.parse import urlencode 

38 

39import dill # type: ignore[import-untyped] # nosec: B403 

40import elasticapm 

41import orjson as json 

42import typed_stream 

43from redis.asyncio import Redis 

44from tornado.httpclient import AsyncHTTPClient 

45from tornado.web import Application, HTTPError 

46from UltraDict import UltraDict # type: ignore[import-untyped] 

47 

48from .. import ( 

49 CA_BUNDLE_PATH, 

50 DIR as ROOT_DIR, 

51 EVENT_REDIS, 

52 EVENT_SHUTDOWN, 

53 NAME, 

54 ORJSON_OPTIONS, 

55 pytest_is_running, 

56) 

57from ..utils.request_handler import HTMLRequestHandler 

58from ..utils.utils import ModuleInfo, Permission, ratelimit 

59 

60DIR: Final = ROOT_DIR / "quotes" 

61 

62LOGGER: Final = logging.getLogger(__name__) 

63 

64API_URL: Final[str] = "https://zitate.prapsschnalinen.de/api" 

65 

66 

67# pylint: disable-next=too-few-public-methods 

68class UltraDictType[K, V](MutableMapping[K, V], abc.ABC): 

69 """The type of the shared dictionaries.""" 

70 

71 lock: multiprocessing.synchronize.RLock 

72 

73 

74QUOTES_CACHE: Final[UltraDictType[int, Quote]] = UltraDict( 

75 buffer_size=1024**2, serializer=dill 

76) 

77AUTHORS_CACHE: Final[UltraDictType[int, Author]] = UltraDict( 

78 buffer_size=1024**2, serializer=dill 

79) 

80WRONG_QUOTES_CACHE: Final[UltraDictType[tuple[int, int], WrongQuote]] = ( 

81 UltraDict(buffer_size=1024**2, serializer=dill) 

82) 

83 

84MAX_QUOTES_ID = Value("Q", 0) 

85MAX_AUTHORS_ID = Value("Q", 0) 

86 

87 

88@dataclass(init=False, slots=True) 

89class QuotesObjBase(abc.ABC): 

90 """An object with an id.""" 

91 

92 id: int 

93 

94 @abc.abstractmethod 

95 async def fetch_new_data(self) -> QuotesObjBase: 

96 """Fetch new data from the API.""" 

97 raise NotImplementedError 

98 

99 # pylint: disable=unused-argument 

100 def get_id_as_str(self, minify: bool = False) -> str: 

101 """Get the id of the object as a string.""" 

102 return str(self.id) 

103 

104 @abc.abstractmethod 

105 def get_path(self) -> str: 

106 """Return the path to the Object.""" 

107 raise NotImplementedError 

108 

109 

110@dataclass(slots=True) 

111class Author(QuotesObjBase): 

112 """The author object with a name.""" 

113 

114 name: str 

115 # tuple(url_to_info, info_str, creation_date) 

116 info: None | tuple[str, None | str, date] 

117 

118 def __str__(self) -> str: 

119 """Return the name of the author.""" 

120 return self.name 

121 

122 async def fetch_new_data(self) -> Author: 

123 """Fetch new data from the API.""" 

124 return parse_author( 

125 await make_api_request( 

126 f"authors/{self.id}", entity_should_exist=True 

127 ) 

128 ) 

129 

130 def get_path(self) -> str: 

131 """Return the path to the author info.""" 

132 return f"/zitate/info/a/{self.id}" 

133 

134 def to_json(self) -> dict[str, Any]: 

135 """Get the author as JSON.""" 

136 return { 

137 "id": self.id, 

138 "name": str(self), 

139 "path": self.get_path(), 

140 "info": ( 

141 { 

142 "source": self.info[0], 

143 "text": self.info[1], 

144 "date": self.info[2].isoformat(), 

145 } 

146 if self.info 

147 else None 

148 ), 

149 } 

150 

151 

152@dataclass(slots=True) 

153class Quote(QuotesObjBase): 

154 """The quote object with a quote text and an author.""" 

155 

156 quote: str 

157 author_id: int 

158 

159 def __str__(self) -> str: 

160 """Return the content of the quote.""" 

161 return self.quote.strip() 

162 

163 @property 

164 def author(self) -> Author: 

165 """Get the corresponding author object.""" 

166 return AUTHORS_CACHE[self.author_id] 

167 

168 async def fetch_new_data(self) -> Quote: 

169 """Fetch new data from the API.""" 

170 return parse_quote( 

171 await make_api_request( 

172 f"quotes/{self.id}", entity_should_exist=True 

173 ), 

174 self, 

175 ) 

176 

177 def get_path(self) -> str: 

178 """Return the path to the quote info.""" 

179 return f"/zitate/info/z/{self.id}" 

180 

181 def to_json(self) -> dict[str, Any]: 

182 """Get the quote as JSON.""" 

183 return { 

184 "id": self.id, 

185 "quote": str(self), 

186 "author": self.author.to_json(), 

187 "path": self.get_path(), 

188 } 

189 

190 

191@dataclass(slots=True) 

192class WrongQuote(QuotesObjBase): 

193 """The wrong quote object with a quote, an author and a rating.""" 

194 

195 quote_id: int 

196 author_id: int 

197 rating: int 

198 

199 def __str__(self) -> str: 

200 r""" 

201 Return the wrong quote. 

202 

203 like: '»quote« - author'. 

204 """ 

205 return f"»{self.quote}« - {self.author}" 

206 

207 @property 

208 def author(self) -> Author: 

209 """Get the corresponding author object.""" 

210 return AUTHORS_CACHE[self.author_id] 

211 

212 async def fetch_new_data(self) -> WrongQuote: 

213 """Fetch new data from the API.""" 

214 if self.id == -1: 

215 api_data = await make_api_request( 

216 "wrongquotes", 

217 { 

218 "quote": str(self.quote_id), 

219 "simulate": "true", 

220 "author": str(self.author_id), 

221 }, 

222 entity_should_exist=True, 

223 ) 

224 if api_data: 

225 api_data = api_data[0] 

226 else: 

227 api_data = await make_api_request( 

228 f"wrongquotes/{self.id}", entity_should_exist=True 

229 ) 

230 if not api_data: 

231 return self 

232 return parse_wrong_quote(api_data, self) 

233 

234 def get_id(self) -> tuple[int, int]: 

235 """ 

236 Get the id of the quote and the author in a tuple. 

237 

238 :return tuple(quote_id, author_id) 

239 """ 

240 return self.quote_id, self.author_id 

241 

242 def get_id_as_str(self, minify: bool = False) -> str: 

243 """ 

244 Get the id of the wrong quote as a string. 

245 

246 Format: quote_id-author_id 

247 """ 

248 if minify and self.id != -1: 

249 return str(self.id) 

250 return f"{self.quote_id}-{self.author_id}" 

251 

252 def get_path(self) -> str: 

253 """Return the path to the wrong quote.""" 

254 return f"/zitate/{self.get_id_as_str()}" 

255 

256 @property 

257 def quote(self) -> Quote: 

258 """Get the corresponding quote object.""" 

259 return QUOTES_CACHE[self.quote_id] 

260 

261 def to_json(self) -> dict[str, Any]: 

262 """Get the wrong quote as JSON.""" 

263 return { 

264 "id": self.get_id_as_str(), 

265 "quote": self.quote.to_json(), 

266 "author": self.author.to_json(), 

267 "rating": self.rating, 

268 "path": self.get_path(), 

269 } 

270 

271 async def vote( 

272 # pylint: disable=unused-argument 

273 self, 

274 vote: Literal[-1, 1], 

275 lazy: bool = False, 

276 ) -> WrongQuote: 

277 """Vote for the wrong quote.""" 

278 if self.id == -1: 

279 raise ValueError("Can't vote for a not existing quote.") 

280 # if lazy: # simulate the vote and do the actual voting later 

281 # self.rating += vote 

282 # asyncio.get_running_loop().call_soon_threadsafe( 

283 # self.vote, 

284 # vote, 

285 # ) 

286 # return self 

287 # do the voting 

288 return parse_wrong_quote( 

289 await make_api_request( 

290 f"wrongquotes/{self.id}", 

291 method="POST", 

292 body={"vote": str(vote)}, 

293 entity_should_exist=True, 

294 ), 

295 self, 

296 ) 

297 

298 

299def get_wrong_quotes( 

300 filter_fun: None | Callable[[WrongQuote], bool] = None, 

301 *, 

302 sort: bool = False, # sorted by rating 

303 filter_real_quotes: bool = True, 

304 shuffle: bool = False, 

305) -> Sequence[WrongQuote]: 

306 """Get cached wrong quotes.""" 

307 if shuffle and sort: 

308 raise ValueError("Sort and shuffle can't be both true.") 

309 wqs: list[WrongQuote] = list(WRONG_QUOTES_CACHE.values()) 

310 if filter_fun or filter_real_quotes: 

311 for i in reversed(range(len(wqs))): 

312 if (filter_fun and not filter_fun(wqs[i])) or ( 

313 filter_real_quotes 

314 and wqs[i].quote.author_id == wqs[i].author_id 

315 ): 

316 del wqs[i] 

317 if shuffle: 

318 random.shuffle(wqs) 

319 elif sort: 

320 wqs.sort(key=lambda wq: wq.rating, reverse=True) 

321 return wqs 

322 

323 

324def get_quotes( 

325 filter_fun: None | Callable[[Quote], bool] = None, 

326 shuffle: bool = False, 

327) -> list[Quote]: 

328 """Get cached quotes.""" 

329 quotes: list[Quote] = list(QUOTES_CACHE.values()) 

330 if filter_fun: 

331 for i in reversed(range(len(quotes))): 

332 if not filter_fun(quotes[i]): 

333 del quotes[i] 

334 if shuffle: 

335 random.shuffle(quotes) 

336 return quotes 

337 

338 

339def get_authors( 

340 filter_fun: None | Callable[[Author], bool] = None, 

341 shuffle: bool = False, 

342) -> list[Author]: 

343 """Get cached authors.""" 

344 authors: list[Author] = list(AUTHORS_CACHE.values()) 

345 if filter_fun: 

346 for i in reversed(range(len(authors))): 

347 if not filter_fun(authors[i]): 

348 del authors[i] 

349 if shuffle: 

350 random.shuffle(authors) 

351 return authors 

352 

353 

354async def make_api_request( 

355 endpoint: str, 

356 args: Mapping[str, str] | None = None, 

357 *, 

358 entity_should_exist: bool, 

359 method: Literal["GET", "POST"] = "GET", 

360 body: None | Mapping[str, str] = None, 

361) -> Any: # TODO: list[dict[str, Any]] | dict[str, Any]: 

362 """Make API request and return the result as dict.""" 

363 if pytest_is_running(): 

364 return None 

365 query = f"?{urlencode(args)}" if args else "" 

366 url = f"{API_URL}/{endpoint}{query}" 

367 body_str = urlencode(body) if body else body 

368 response = await AsyncHTTPClient().fetch( 

369 url, 

370 method=method, 

371 headers={"Content-Type": "application/x-www-form-urlencoded"}, 

372 body=body_str, 

373 raise_error=False, 

374 ca_certs=CA_BUNDLE_PATH, 

375 ) 

376 if response.code != 200: 

377 normed_response_code = ( 

378 400 

379 if not entity_should_exist and response.code == 500 

380 else response.code 

381 ) 

382 LOGGER.log( 

383 logging.ERROR if normed_response_code >= 500 else logging.WARNING, 

384 "%s request to %r with body=%r failed with code=%d and reason=%r", 

385 method, 

386 url, 

387 body_str, 

388 response.code, 

389 response.reason, 

390 ) 

391 raise HTTPError( 

392 normed_response_code if normed_response_code in {400, 404} else 503, 

393 reason=( 

394 f"{API_URL}/{endpoint} returned: " 

395 f"{response.code} {response.reason}" 

396 ), 

397 ) 

398 return json.loads(response.body) 

399 

400 

401def fix_author_name(name: str) -> str: 

402 """Fix common mistakes in authors.""" 

403 if len(name) > 2 and name.startswith("(") and name.endswith(")"): 

404 # remove () from author name, that shouldn't be there 

405 name = name[1:-1] 

406 return name.strip() 

407 

408 

409def parse_author(json_data: Mapping[str, Any]) -> Author: 

410 """Parse an author from JSON data.""" 

411 id_ = int(json_data["id"]) 

412 name = fix_author_name(json_data["author"]) 

413 

414 with AUTHORS_CACHE.lock: 

415 author = AUTHORS_CACHE.get(id_) 

416 if author is None: 

417 # pylint: disable-next=too-many-function-args 

418 author = Author(id_, name, None) 

419 MAX_AUTHORS_ID.value = max(MAX_AUTHORS_ID.value, id_) 

420 elif author.name != name: 

421 author.name = name 

422 author.info = None # reset info 

423 

424 AUTHORS_CACHE[author.id] = author 

425 

426 return author 

427 

428 

429def fix_quote_str(quote_str: str) -> str: 

430 """Fix common mistakes in quotes.""" 

431 if ( 

432 len(quote_str) > 2 

433 and quote_str.startswith(('"', "„", "“")) 

434 and quote_str.endswith(('"', "“", "”")) 

435 ): 

436 # remove quotation marks from quote, that shouldn't be there 

437 quote_str = quote_str[1:-1] 

438 

439 return quote_str.strip() 

440 

441 

442def parse_quote( 

443 json_data: Mapping[str, Any], quote: None | Quote = None 

444) -> Quote: 

445 """Parse a quote from JSON data.""" 

446 quote_id = int(json_data["id"]) 

447 author = parse_author(json_data["author"]) # update author 

448 quote_str = fix_quote_str(json_data["quote"]) 

449 

450 with QUOTES_CACHE.lock: 

451 if quote is None: # no quote supplied, try getting it from cache 

452 quote = QUOTES_CACHE.get(quote_id) 

453 if quote is None: # new quote 

454 # pylint: disable=too-many-function-args 

455 quote = Quote(quote_id, quote_str, author.id) 

456 MAX_QUOTES_ID.value = max(MAX_QUOTES_ID.value, quote.id) 

457 else: # quote was already saved 

458 quote.quote = quote_str 

459 quote.author_id = author.id 

460 

461 QUOTES_CACHE[quote.id] = quote 

462 

463 return quote 

464 

465 

466def parse_wrong_quote( 

467 json_data: Mapping[str, Any], wrong_quote: None | WrongQuote = None 

468) -> WrongQuote: 

469 """Parse a wrong quote and update the cache.""" 

470 quote = parse_quote(json_data["quote"]) 

471 author = parse_author(json_data["author"]) 

472 

473 id_tuple = (quote.id, author.id) 

474 rating = json_data["rating"] 

475 wrong_quote_id = int(json_data.get("id") or -1) 

476 

477 if wrong_quote is None: 

478 with WRONG_QUOTES_CACHE.lock: 

479 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple) 

480 if wrong_quote is None: 

481 wrong_quote = ( 

482 WrongQuote( # pylint: disable=unexpected-keyword-arg 

483 id=wrong_quote_id, 

484 quote_id=quote.id, 

485 author_id=author.id, 

486 rating=rating, 

487 ) 

488 ) 

489 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

490 return wrong_quote 

491 

492 # make sure the wrong quote is the correct one 

493 if (wrong_quote.quote_id, wrong_quote.author_id) != id_tuple: 

494 raise HTTPError(reason="ERROR: -41") 

495 

496 # update the data of the wrong quote 

497 if wrong_quote.rating != rating: 

498 wrong_quote.rating = rating 

499 if wrong_quote.id != wrong_quote_id: 

500 wrong_quote.id = wrong_quote_id 

501 

502 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

503 

504 return wrong_quote 

505 

506 

507async def parse_list_of_quote_data( 

508 json_list: str | Iterable[Mapping[str, Any]], 

509 parse_fun: Callable[[Mapping[str, Any]], QuotesObjBase], 

510) -> tuple[QuotesObjBase, ...]: 

511 """Parse a list of quote data.""" 

512 if not json_list: 

513 return () 

514 if isinstance(json_list, str): 

515 json_list = cast(list[dict[str, Any]], json.loads(json_list)) 

516 return_list = [] 

517 for json_data in json_list: 

518 _ = parse_fun(json_data) 

519 await asyncio.sleep(0) 

520 return_list.append(_) 

521 return tuple(return_list) 

522 

523 

524async def update_cache_periodically( 

525 app: Application, worker: int | None 

526) -> None: 

527 """Start updating the cache every hour.""" 

528 # pylint: disable=too-complex, too-many-branches 

529 if "/troet" in typed_stream.Stream( 

530 cast(Iterable[ModuleInfo], app.settings.get("MODULE_INFOS", ())) 

531 ).map(lambda m: m.path): 

532 app.settings["SHOW_SHARING_ON_MASTODON"] = True 

533 if worker: 

534 return 

535 with contextlib.suppress(asyncio.TimeoutError): 

536 await asyncio.wait_for(EVENT_REDIS.wait(), 5) 

537 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS")) 

538 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev") 

539 apm: None | elasticapm.Client 

540 if EVENT_REDIS.is_set(): # pylint: disable=too-many-nested-blocks 

541 await parse_list_of_quote_data( 

542 await redis.get(f"{prefix}:cached-quote-data:authors"), # type: ignore[arg-type] # noqa: B950 

543 parse_author, 

544 ) 

545 await parse_list_of_quote_data( 

546 await redis.get(f"{prefix}:cached-quote-data:quotes"), # type: ignore[arg-type] # noqa: B950 

547 parse_quote, 

548 ) 

549 await parse_list_of_quote_data( 

550 await redis.get(f"{prefix}:cached-quote-data:wrongquotes"), # type: ignore[arg-type] # noqa: B950 

551 parse_wrong_quote, 

552 ) 

553 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE: 

554 last_update = await redis.get( 

555 f"{prefix}:cached-quote-data:last-update" 

556 ) 

557 if last_update: 

558 last_update_int = int(last_update) 

559 since_last_update = int(time.time()) - last_update_int 

560 if 0 <= since_last_update < 60 * 60: 

561 # wait until the last update is at least one hour old 

562 update_cache_in = 60 * 60 - since_last_update 

563 if not sys.flags.dev_mode and update_cache_in > 60: 

564 # if in production mode update wrong quotes just to be sure 

565 try: 

566 await update_cache( 

567 app, update_quotes=False, update_authors=False 

568 ) 

569 except Exception: # pylint: disable=broad-except 

570 LOGGER.exception("Updating quotes cache failed") 

571 apm = app.settings.get("ELASTIC_APM", {}).get( 

572 "CLIENT" 

573 ) 

574 if apm: 

575 apm.capture_exception() # type: ignore[no-untyped-call] 

576 else: 

577 LOGGER.info("Updated quotes cache successfully") 

578 LOGGER.info( 

579 "Next update of quotes cache in %d seconds", 

580 update_cache_in, 

581 ) 

582 await asyncio.sleep(update_cache_in) 

583 

584 # update the cache every hour 

585 failed = 0 

586 while not EVENT_SHUTDOWN.is_set(): # pylint: disable=while-used 

587 try: 

588 await update_cache(app) 

589 except Exception: # pylint: disable=broad-except 

590 LOGGER.exception("Updating quotes cache failed") 

591 if apm := app.settings.get("ELASTIC_APM", {}).get("CLIENT"): 

592 apm.capture_exception() 

593 failed += 1 

594 await asyncio.sleep(pow(min(failed * 2, 60), 2)) # 4,16,...,60*60 

595 else: 

596 LOGGER.info("Updated quotes cache successfully") 

597 failed = 0 

598 await asyncio.sleep(60 * 60) 

599 

600 

601async def update_cache( 

602 app: Application, 

603 update_wrong_quotes: bool = True, 

604 update_quotes: bool = True, 

605 update_authors: bool = True, 

606) -> None: 

607 """Fill the cache with all data from the API.""" 

608 LOGGER.info("Updating quotes cache") 

609 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS")) 

610 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev") 

611 redis_available = EVENT_REDIS.is_set() 

612 

613 if update_wrong_quotes: 

614 await parse_list_of_quote_data( 

615 wq_data := await make_api_request( 

616 "wrongquotes", entity_should_exist=True 

617 ), 

618 parse_wrong_quote, 

619 ) 

620 if wq_data and redis_available: 

621 await redis.setex( 

622 f"{prefix}:cached-quote-data:wrongquotes", 

623 60 * 60 * 24 * 30, 

624 json.dumps(wq_data, option=ORJSON_OPTIONS), 

625 ) 

626 

627 if update_quotes: 

628 await parse_list_of_quote_data( 

629 quotes_data := await make_api_request( 

630 "quotes", entity_should_exist=True 

631 ), 

632 parse_quote, 

633 ) 

634 if quotes_data and redis_available: 

635 await redis.setex( 

636 f"{prefix}:cached-quote-data:quotes", 

637 60 * 60 * 24 * 30, 

638 json.dumps(quotes_data, option=ORJSON_OPTIONS), 

639 ) 

640 

641 if update_authors: 

642 await parse_list_of_quote_data( 

643 authors_data := await make_api_request( 

644 "authors", entity_should_exist=True 

645 ), 

646 parse_author, 

647 ) 

648 if authors_data and redis_available: 

649 await redis.setex( 

650 f"{prefix}:cached-quote-data:authors", 

651 60 * 60 * 24 * 30, 

652 json.dumps(authors_data, option=ORJSON_OPTIONS), 

653 ) 

654 

655 if ( 

656 redis_available 

657 and update_wrong_quotes 

658 and update_quotes 

659 and update_authors 

660 ): 

661 await redis.setex( 

662 f"{prefix}:cached-quote-data:last-update", 

663 60 * 60 * 24 * 30, 

664 int(time.time()), 

665 ) 

666 

667 

668async def get_author_by_id(author_id: int) -> Author: 

669 """Get an author by its id.""" 

670 author = AUTHORS_CACHE.get(author_id) 

671 if author is not None: 

672 return author 

673 return parse_author( 

674 await make_api_request( 

675 f"authors/{author_id}", entity_should_exist=False 

676 ) 

677 ) 

678 

679 

680async def get_quote_by_id(quote_id: int) -> Quote: 

681 """Get a quote by its id.""" 

682 quote = QUOTES_CACHE.get(quote_id) 

683 if quote is not None: 

684 return quote 

685 return parse_quote( 

686 await make_api_request(f"quotes/{quote_id}", entity_should_exist=False) 

687 ) 

688 

689 

690async def get_wrong_quote( 

691 quote_id: int, author_id: int, use_cache: bool = True 

692) -> WrongQuote: 

693 """Get a wrong quote with a quote id and an author id.""" 

694 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id)) 

695 if wrong_quote: 

696 if use_cache: 

697 return wrong_quote 

698 # do not use cache, so update the wrong quote data 

699 return await wrong_quote.fetch_new_data() 

700 # wrong quote not in cache 

701 if use_cache and quote_id in QUOTES_CACHE and author_id in AUTHORS_CACHE: 

702 # we don't need to request anything, as the wrong_quote probably has 

703 # no ratings just use the cached quote and author 

704 # pylint: disable-next=too-many-function-args 

705 return WrongQuote(-1, quote_id, author_id, 0) 

706 # request the wrong quote from the API 

707 result = await make_api_request( 

708 "wrongquotes", 

709 { 

710 "quote": str(quote_id), 

711 "simulate": "true", 

712 "author": str(author_id), 

713 }, 

714 entity_should_exist=False, 

715 ) 

716 if result: 

717 return parse_wrong_quote(result[0]) 

718 

719 raise HTTPError(404, reason="Falsches Zitat nicht gefunden") 

720 

721 

722async def get_rating_by_id(quote_id: int, author_id: int) -> int: 

723 """Get the rating of a wrong quote.""" 

724 return (await get_wrong_quote(quote_id, author_id)).rating 

725 

726 

727def get_random_quote_id() -> int: 

728 """Get random quote id.""" 

729 return random.randint(1, MAX_QUOTES_ID.value) # nosec: B311 

730 

731 

732def get_random_author_id() -> int: 

733 """Get random author id.""" 

734 return random.randint(1, MAX_AUTHORS_ID.value) # nosec: B311 

735 

736 

737def get_random_id() -> tuple[int, int]: 

738 """Get random wrong quote id.""" 

739 return ( 

740 get_random_quote_id(), 

741 get_random_author_id(), 

742 ) 

743 

744 

745async def create_wq_and_vote( 

746 vote: Literal[-1, 1], 

747 quote_id: int, 

748 author_id: int, 

749 contributed_by: str, 

750 fast: bool = False, 

751) -> WrongQuote: 

752 """ 

753 Vote for the wrong_quote with the API. 

754 

755 If the wrong_quote doesn't exist yet, create it. 

756 """ 

757 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id)) 

758 if wrong_quote and wrong_quote.id != -1: 

759 return await wrong_quote.vote(vote, fast) 

760 # we don't know the wrong_quote_id, so we have to create the wrong_quote 

761 wrong_quote = parse_wrong_quote( 

762 await make_api_request( 

763 "wrongquotes", 

764 method="POST", 

765 body={ 

766 "quote": str(quote_id), 

767 "author": str(author_id), 

768 "contributed_by": contributed_by, 

769 }, 

770 entity_should_exist=False, 

771 ) 

772 ) 

773 return await wrong_quote.vote(vote, lazy=True) 

774 

775 

776class QuoteReadyCheckHandler(HTMLRequestHandler): 

777 """Class that checks if quotes have been loaded.""" 

778 

779 async def check_ready(self) -> None: 

780 """Fail if quotes aren't ready yet.""" 

781 if not WRONG_QUOTES_CACHE: 

782 # should work in a few seconds, the quotes just haven't loaded yet 

783 self.set_header("Retry-After", "5") 

784 raise HTTPError(503, reason="Service available in a few seconds") 

785 

786 async def prepare(self) -> None: # noqa: D102 

787 await super().prepare() 

788 if self.request.method != "OPTIONS": 

789 await self.check_ready() 

790 

791 if ( # pylint: disable=too-many-boolean-expressions 

792 self.settings.get("RATELIMITS") 

793 and self.request.method not in {"HEAD", "OPTIONS"} 

794 and not self.is_authorized(Permission.RATELIMITS) 

795 and not self.crawler 

796 and ( 

797 self.request.path.endswith(".xlsx") 

798 or self.content_type == "application/vnd.ms-excel" 

799 ) 

800 ): 

801 if self.settings.get("UNDER_ATTACK") or not EVENT_REDIS.is_set(): 

802 raise HTTPError(503) 

803 

804 ratelimited, headers = await ratelimit( 

805 self.redis, 

806 self.redis_prefix, 

807 str(self.request.remote_ip), 

808 bucket="quotes:image:xlsx", 

809 max_burst=4, 

810 count_per_period=1, 

811 period=60, 

812 tokens=1 if self.request.method != "HEAD" else 0, 

813 ) 

814 

815 for header, value in headers.items(): 

816 self.set_header(header, value) 

817 

818 if ratelimited: 

819 if self.now.date() == date(self.now.year, 4, 20): 

820 self.set_status(420) 

821 self.write_error(420) 

822 else: 

823 self.set_status(429) 

824 self.write_error(429)