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

386 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 19:37 +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 typing import Any, Final, Literal, cast 

36from urllib.parse import urlencode 

37 

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

39import elasticapm 

40import orjson as json 

41import typed_stream 

42from redis.asyncio import Redis 

43from tornado.httpclient import AsyncHTTPClient 

44from tornado.web import Application, HTTPError 

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

46 

47from .. import ( 

48 CA_BUNDLE_PATH, 

49 DIR as ROOT_DIR, 

50 EVENT_REDIS, 

51 EVENT_SHUTDOWN, 

52 NAME, 

53 ORJSON_OPTIONS, 

54 pytest_is_running, 

55) 

56from ..utils.request_handler import HTMLRequestHandler 

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

58 

59DIR: Final = ROOT_DIR / "quotes" 

60 

61LOGGER: Final = logging.getLogger(__name__) 

62 

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

64 

65 

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

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

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

69 

70 lock: multiprocessing.synchronize.RLock 

71 

72 

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

74 buffer_size=1024**2, serializer=dill 

75) 

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

77 buffer_size=1024**2, serializer=dill 

78) 

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

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

81) 

82 

83 

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

85class QuotesObjBase(abc.ABC): 

86 """An object with an id.""" 

87 

88 id: int 

89 

90 @classmethod 

91 @abc.abstractmethod 

92 def fetch_all_endpoint(cls) -> Literal["quotes", "authors", "wrongquotes"]: 

93 """Endpoint to fetch all of this type.""" 

94 raise NotImplementedError 

95 

96 @abc.abstractmethod 

97 async def fetch_new_data(self) -> QuotesObjBase: 

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

99 raise NotImplementedError 

100 

101 # pylint: disable=unused-argument 

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

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

104 return str(self.id) 

105 

106 @abc.abstractmethod 

107 def get_path(self) -> str: 

108 """Return the path to the Object.""" 

109 raise NotImplementedError 

110 

111 

112@dataclass(slots=True) 

113class Author(QuotesObjBase): 

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

115 

116 name: str 

117 # tuple(url_to_info, info_str, creation_date) 

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

119 

120 def __str__(self) -> str: 

121 """Return the name of the author.""" 

122 return self.name 

123 

124 @classmethod 

125 def fetch_all_endpoint(cls) -> Literal["authors"]: 

126 """Endpoint to fetch all authors.""" 

127 return "authors" 

128 

129 async def fetch_new_data(self) -> Author: 

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

131 return parse_author( 

132 await make_api_request( 

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

134 ) 

135 ) 

136 

137 def get_path(self) -> str: 

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

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

140 

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

142 """Get the author as JSON.""" 

143 return { 

144 "id": self.id, 

145 "name": str(self), 

146 "path": self.get_path(), 

147 "info": ( 

148 { 

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

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

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

152 } 

153 if self.info 

154 else None 

155 ), 

156 } 

157 

158 

159@dataclass(slots=True) 

160class Quote(QuotesObjBase): 

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

162 

163 quote: str 

164 author_id: int 

165 

166 def __str__(self) -> str: 

167 """Return the content of the quote.""" 

168 return self.quote.strip() 

169 

170 @property 

171 def author(self) -> Author: 

172 """Get the corresponding author object.""" 

173 return AUTHORS_CACHE[self.author_id] 

174 

175 @classmethod 

176 def fetch_all_endpoint(cls) -> Literal["quotes"]: 

177 """Endpoint to fetch all quotes.""" 

178 return "quotes" 

179 

180 async def fetch_new_data(self) -> Quote: 

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

182 return parse_quote( 

183 await make_api_request( 

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

185 ), 

186 self, 

187 ) 

188 

189 def get_path(self) -> str: 

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

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

192 

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

194 """Get the quote as JSON.""" 

195 return { 

196 "id": self.id, 

197 "quote": str(self), 

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

199 "path": self.get_path(), 

200 } 

201 

202 

203@dataclass(slots=True) 

204class WrongQuote(QuotesObjBase): 

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

206 

207 quote_id: int 

208 author_id: int 

209 rating: int 

210 

211 def __str__(self) -> str: 

212 r""" 

213 Return the wrong quote. 

214 

215 like: '»quote« - author'. 

216 """ 

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

218 

219 @property 

220 def author(self) -> Author: 

221 """Get the corresponding author object.""" 

222 return AUTHORS_CACHE[self.author_id] 

223 

224 @classmethod 

225 def fetch_all_endpoint(cls) -> Literal["wrongquotes"]: 

226 """Endpoint to fetch all wrong quotes.""" 

227 return "wrongquotes" 

228 

229 async def fetch_new_data(self) -> WrongQuote: 

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

231 if self.id == -1: 

232 api_data = await make_api_request( 

233 "wrongquotes", 

234 { 

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

236 "simulate": "true", 

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

238 }, 

239 entity_should_exist=True, 

240 ) 

241 if api_data: 

242 api_data = api_data[0] 

243 else: 

244 api_data = await make_api_request( 

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

246 ) 

247 if not api_data: 

248 return self 

249 return parse_wrong_quote(api_data, self) 

250 

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

252 """ 

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

254 

255 :return tuple(quote_id, author_id) 

256 """ 

257 return self.quote_id, self.author_id 

258 

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

260 """ 

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

262 

263 Format: quote_id-author_id 

264 """ 

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

266 return str(self.id) 

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

268 

269 def get_path(self) -> str: 

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

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

272 

273 @property 

274 def quote(self) -> Quote: 

275 """Get the corresponding quote object.""" 

276 return QUOTES_CACHE[self.quote_id] 

277 

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

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

280 return { 

281 "id": self.get_id_as_str(), 

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

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

284 "rating": self.rating, 

285 "path": self.get_path(), 

286 } 

287 

288 async def vote( 

289 # pylint: disable=unused-argument 

290 self, 

291 vote: Literal[-1, 1], 

292 lazy: bool = False, 

293 ) -> WrongQuote: 

294 """Vote for the wrong quote.""" 

295 if self.id == -1: 

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

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

298 # self.rating += vote 

299 # asyncio.get_running_loop().call_soon_threadsafe( 

300 # self.vote, 

301 # vote, 

302 # ) 

303 # return self 

304 # do the voting 

305 return parse_wrong_quote( 

306 await make_api_request( 

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

308 method="POST", 

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

310 entity_should_exist=True, 

311 ), 

312 self, 

313 ) 

314 

315 

316def get_wrong_quotes( 

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

318 *, 

319 sort: bool = False, # sorted by rating 

320 filter_real_quotes: bool = True, 

321 shuffle: bool = False, 

322) -> Sequence[WrongQuote]: 

323 """Get cached wrong quotes.""" 

324 if shuffle and sort: 

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

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

327 if filter_fun or filter_real_quotes: 

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

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

330 filter_real_quotes 

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

332 ): 

333 del wqs[i] 

334 if shuffle: 

335 random.shuffle(wqs) 

336 elif sort: 

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

338 return wqs 

339 

340 

341def get_quotes( 

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

343 shuffle: bool = False, 

344) -> list[Quote]: 

345 """Get cached quotes.""" 

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

347 if filter_fun: 

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

349 if not filter_fun(quotes[i]): 

350 del quotes[i] 

351 if shuffle: 

352 random.shuffle(quotes) 

353 return quotes 

354 

355 

356def get_authors( 

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

358 shuffle: bool = False, 

359) -> list[Author]: 

360 """Get cached authors.""" 

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

362 if filter_fun: 

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

364 if not filter_fun(authors[i]): 

365 del authors[i] 

366 if shuffle: 

367 random.shuffle(authors) 

368 return authors 

369 

370 

371# pylint: disable-next=too-many-arguments 

372async def make_api_request( 

373 endpoint: str, 

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

375 *, 

376 entity_should_exist: bool, 

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

378 body: None | Mapping[str, str | int] = None, 

379 request_timeout: float | None = None, 

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

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

382 if pytest_is_running(): 

383 return None 

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

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

386 body_str = urlencode(body) if body else body 

387 response = await AsyncHTTPClient().fetch( 

388 url, 

389 method=method, 

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

391 body=body_str, 

392 raise_error=False, 

393 ca_certs=CA_BUNDLE_PATH, 

394 request_timeout=request_timeout, 

395 ) 

396 if response.code != 200: 

397 normed_response_code = ( 

398 400 

399 if not entity_should_exist and response.code == 500 

400 else response.code 

401 ) 

402 LOGGER.log( 

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

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

405 method, 

406 url, 

407 body_str, 

408 response.code, 

409 response.reason, 

410 ) 

411 raise HTTPError( 

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

413 reason=f"{url} returned: {response.code} {response.reason}", 

414 ) 

415 return json.loads(response.body) 

416 

417 

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

419 """Fix common mistakes in authors.""" 

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

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

422 name = name[1:-1] 

423 return name.strip() 

424 

425 

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

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

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

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

430 

431 with AUTHORS_CACHE.lock: 

432 author = AUTHORS_CACHE.get(id_) 

433 if author is None: 

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

435 author = Author(id_, name, None) 

436 elif author.name != name: 

437 author.name = name 

438 author.info = None # reset info 

439 

440 AUTHORS_CACHE[author.id] = author 

441 

442 return author 

443 

444 

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

446 """Fix common mistakes in quotes.""" 

447 if ( 

448 len(quote_str) > 2 

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

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

451 ): 

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

453 quote_str = quote_str[1:-1] 

454 

455 return quote_str.strip() 

456 

457 

458def parse_quote( 

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

460) -> Quote: 

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

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

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

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

465 

466 with QUOTES_CACHE.lock: 

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

468 quote = QUOTES_CACHE.get(quote_id) 

469 if quote is None: # new quote 

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

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

472 else: # quote was already saved 

473 quote.quote = quote_str 

474 quote.author_id = author.id 

475 

476 QUOTES_CACHE[quote.id] = quote 

477 

478 return quote 

479 

480 

481def parse_wrong_quote( 

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

483) -> WrongQuote: 

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

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

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

487 

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

489 rating = json_data["rating"] 

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

491 

492 if wrong_quote is None: 

493 with WRONG_QUOTES_CACHE.lock: 

494 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple) 

495 if wrong_quote is None: 

496 wrong_quote = ( 

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

498 id=wrong_quote_id, 

499 quote_id=quote.id, 

500 author_id=author.id, 

501 rating=rating, 

502 ) 

503 ) 

504 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

505 return wrong_quote 

506 

507 # make sure the wrong quote is the correct one 

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

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

510 

511 # update the data of the wrong quote 

512 if wrong_quote.rating != rating: 

513 wrong_quote.rating = rating 

514 if wrong_quote.id != wrong_quote_id: 

515 wrong_quote.id = wrong_quote_id 

516 

517 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

518 

519 return wrong_quote 

520 

521 

522async def parse_list_of_quote_data[Q: QuotesObjBase]( # noqa: D103 

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

524 parse_fun: Callable[[Mapping[str, Any]], Q], 

525) -> tuple[Q, ...]: 

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

527 if not json_list: 

528 return () 

529 if isinstance(json_list, str): 

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

531 return_list = [] 

532 for json_data in json_list: 

533 _ = parse_fun(json_data) 

534 await asyncio.sleep(0) 

535 return_list.append(_) 

536 return tuple(return_list) 

537 

538 

539async def update_cache_periodically( 

540 app: Application, worker: int | None 

541) -> None: 

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

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

544 if "/troet" in typed_stream.Stream( 

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

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

547 app.settings["SHOW_SHARING_ON_MASTODON"] = True 

548 if worker: 

549 return 

550 with contextlib.suppress(asyncio.TimeoutError): 

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

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

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

554 apm: None | elasticapm.Client 

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

556 await parse_list_of_quote_data( 

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

558 parse_author, 

559 ) 

560 await parse_list_of_quote_data( 

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

562 parse_quote, 

563 ) 

564 await parse_list_of_quote_data( 

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

566 parse_wrong_quote, 

567 ) 

568 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE: 

569 last_update = await redis.get( 

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

571 ) 

572 if last_update: 

573 last_update_int = int(last_update) 

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

575 if 0 <= since_last_update < 60 * 60: 

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

577 update_cache_in = 60 * 60 - since_last_update 

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

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

580 try: 

581 await update_cache( 

582 app, update_quotes=False, update_authors=False 

583 ) 

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

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

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

587 "CLIENT" 

588 ) 

589 if apm: 

590 apm.capture_exception() 

591 else: 

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

593 LOGGER.info( 

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

595 update_cache_in, 

596 ) 

597 await asyncio.sleep(update_cache_in) 

598 

599 # update the cache every hour 

600 failed = 0 

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

602 try: 

603 await update_cache(app) 

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

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

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

607 apm.capture_exception() 

608 failed += 1 

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

610 else: 

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

612 failed = 0 

613 await asyncio.sleep(60 * 60) 

614 

615 

616async def update_cache( # pylint: disable=too-complex,too-many-branches,too-many-locals,too-many-statements # noqa: B950,C901 

617 app: Application, 

618 update_wrong_quotes: bool = True, 

619 update_quotes: bool = True, 

620 update_authors: bool = True, 

621) -> None: 

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

623 LOGGER.info("Updating quotes cache") 

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

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

626 redis_available = EVENT_REDIS.is_set() 

627 exceptions: list[Exception] = [] 

628 

629 if update_wrong_quotes: 

630 try: 

631 await _update_cache(WrongQuote, parse_wrong_quote, redis, prefix) 

632 except Exception as err: # pylint: disable=broad-exception-caught 

633 exceptions.append(err) 

634 

635 deleted_quotes: set[int] = set() 

636 

637 if update_quotes: 

638 try: 

639 quotes = await _update_cache(Quote, parse_quote, redis, prefix) 

640 except Exception as err: # pylint: disable=broad-exception-caught 

641 exceptions.append(err) 

642 else: 

643 with QUOTES_CACHE.lock: 

644 all_quote_ids = {q.id for q in quotes} 

645 max_quote_id = max(all_quote_ids) 

646 old_ids_in_cache = { 

647 _id for _id in QUOTES_CACHE if _id <= max_quote_id 

648 } 

649 deleted_quotes = old_ids_in_cache - all_quote_ids 

650 for _id in deleted_quotes: 

651 del QUOTES_CACHE[_id] 

652 

653 if len(QUOTES_CACHE) < len(quotes): 

654 LOGGER.error("Cache has less elements than just fetched") 

655 

656 deleted_authors: set[int] = set() 

657 

658 if update_authors: 

659 try: 

660 authors = await _update_cache(Author, parse_author, redis, prefix) 

661 except Exception as err: # pylint: disable=broad-exception-caught 

662 exceptions.append(err) 

663 else: 

664 with AUTHORS_CACHE.lock: 

665 all_author_ids = {q.id for q in authors} 

666 max_author_id = max(all_author_ids) 

667 old_ids_in_cache = { 

668 _id for _id in AUTHORS_CACHE if _id <= max_author_id 

669 } 

670 deleted_authors = old_ids_in_cache - all_author_ids 

671 for _id in deleted_authors: 

672 del AUTHORS_CACHE[_id] 

673 

674 if len(AUTHORS_CACHE) < len(authors): 

675 LOGGER.error("Cache has less elements than just fetched") 

676 

677 if deleted_authors or deleted_quotes: 

678 deleted_wrong_quotes: set[tuple[int, int]] = set() 

679 with WRONG_QUOTES_CACHE.lock: 

680 for qid, aid in tuple(WRONG_QUOTES_CACHE): 

681 if qid in deleted_quotes or aid in deleted_authors: 

682 deleted_wrong_quotes.add((qid, aid)) 

683 del WRONG_QUOTES_CACHE[(qid, aid)] 

684 LOGGER.warning( 

685 "Deleted %d wrong quotes: %r", 

686 len(deleted_wrong_quotes), 

687 deleted_wrong_quotes, 

688 ) 

689 

690 if exceptions: 

691 raise ExceptionGroup("Cache could not be updated", exceptions) 

692 

693 if ( 

694 redis_available 

695 and update_wrong_quotes 

696 and update_quotes 

697 and update_authors 

698 ): 

699 await redis.setex( 

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

701 60 * 60 * 24 * 30, 

702 int(time.time()), 

703 ) 

704 

705 

706async def _update_cache[Q: QuotesObjBase]( 

707 klass: type[Q], 

708 parse: Callable[[Mapping[str, Any]], Q], 

709 redis: Redis[str], 

710 redis_prefix: str, 

711) -> tuple[Q, ...]: 

712 parsed_data = await parse_list_of_quote_data( 

713 wq_data := await make_api_request( 

714 klass.fetch_all_endpoint(), 

715 entity_should_exist=True, 

716 request_timeout=100, 

717 ), 

718 parse, 

719 ) 

720 if wq_data and EVENT_REDIS.is_set(): 

721 await redis.setex( 

722 f"{redis_prefix}:cached-quote-data:{klass.fetch_all_endpoint()}", 

723 60 * 60 * 24 * 30, 

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

725 ) 

726 return parsed_data 

727 

728 

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

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

731 author = AUTHORS_CACHE.get(author_id) 

732 if author is not None: 

733 return author 

734 return parse_author( 

735 await make_api_request( 

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

737 ) 

738 ) 

739 

740 

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

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

743 quote = QUOTES_CACHE.get(quote_id) 

744 if quote is not None: 

745 return quote 

746 return parse_quote( 

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

748 ) 

749 

750 

751async def get_wrong_quote( 

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

753) -> WrongQuote | None: 

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

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

756 if wrong_quote: 

757 if use_cache: 

758 return wrong_quote 

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

760 return await wrong_quote.fetch_new_data() 

761 # wrong quote not in cache 

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

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

764 # no ratings just use the cached quote and author 

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

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

767 # request the wrong quote from the API 

768 result = await make_api_request( 

769 "wrongquotes", 

770 { 

771 "quote": str(quote_id), 

772 "simulate": "true", 

773 "author": str(author_id), 

774 }, 

775 entity_should_exist=False, 

776 ) 

777 if result: 

778 return parse_wrong_quote(result[0]) 

779 

780 return None 

781 

782 

783async def get_rating_by_id(quote_id: int, author_id: int) -> int | None: 

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

785 if wq := await get_wrong_quote(quote_id, author_id): 

786 return wq.rating 

787 return None 

788 

789 

790def get_random_quote_id() -> int: 

791 """Get random quote id.""" 

792 return random.choice(tuple(QUOTES_CACHE)) 

793 

794 

795def get_random_author_id() -> int: 

796 """Get random author id.""" 

797 return random.choice(tuple(AUTHORS_CACHE)) 

798 

799 

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

801 """Get random wrong quote id.""" 

802 return ( 

803 get_random_quote_id(), 

804 get_random_author_id(), 

805 ) 

806 

807 

808async def create_wq_and_vote( 

809 vote: Literal[-1, 1], 

810 quote_id: int, 

811 author_id: int, 

812 contributed_by: str, 

813 fast: bool = False, 

814) -> WrongQuote: 

815 """ 

816 Vote for the wrong_quote with the API. 

817 

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

819 """ 

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

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

822 return await wrong_quote.vote(vote, fast) 

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

824 wrong_quote = parse_wrong_quote( 

825 await make_api_request( 

826 "wrongquotes", 

827 method="POST", 

828 body={ 

829 "quote": str(quote_id), 

830 "author": str(author_id), 

831 "contributed_by": contributed_by, 

832 }, 

833 entity_should_exist=False, 

834 ) 

835 ) 

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

837 

838 

839class QuoteReadyCheckHandler(HTMLRequestHandler): 

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

841 

842 async def check_ready(self) -> None: 

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

844 if not WRONG_QUOTES_CACHE: 

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

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

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

848 

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

850 await super().prepare() 

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

852 await self.check_ready() 

853 

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

855 self.settings.get("RATELIMITS") 

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

857 and not self.is_authorized(Permission.RATELIMITS) 

858 and not self.crawler 

859 and ( 

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

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

862 ) 

863 ): 

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

865 raise HTTPError(503) 

866 

867 ratelimited, headers = await ratelimit( 

868 self.redis, 

869 self.redis_prefix, 

870 str(self.request.remote_ip), 

871 bucket="quotes:image:xlsx", 

872 max_burst=4, 

873 count_per_period=1, 

874 period=60, 

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

876 ) 

877 

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

879 self.set_header(header, value) 

880 

881 if ratelimited: 

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

883 self.set_status(420) 

884 self.write_error(420) 

885 else: 

886 self.set_status(429) 

887 self.write_error(429)