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

412 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-04 20:05 +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 | None: 

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 | None: 

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

131 data = await make_api_request( 

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

133 ) 

134 return None if data is None else parse_author(data) 

135 

136 def get_path(self) -> str: 

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

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

139 

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

141 """Get the author as JSON.""" 

142 return { 

143 "id": self.id, 

144 "name": str(self), 

145 "path": self.get_path(), 

146 "info": ( 

147 { 

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

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

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

151 } 

152 if self.info 

153 else None 

154 ), 

155 } 

156 

157 

158@dataclass(slots=True) 

159class Quote(QuotesObjBase): 

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

161 

162 quote: str 

163 author_id: int 

164 

165 def __str__(self) -> str: 

166 """Return the content of the quote.""" 

167 return self.quote.strip() 

168 

169 @property 

170 def author(self) -> Author: 

171 """Get the corresponding author object.""" 

172 return AUTHORS_CACHE[self.author_id] 

173 

174 @classmethod 

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

176 """Endpoint to fetch all quotes.""" 

177 return "quotes" 

178 

179 async def fetch_new_data(self) -> Quote | None: 

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

181 data = await make_api_request( 

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

183 ) 

184 if data is None: 

185 return None 

186 return parse_quote(data, self) 

187 

188 def get_path(self) -> str: 

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

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

191 

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

193 """Get the quote as JSON.""" 

194 return { 

195 "id": self.id, 

196 "quote": str(self), 

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

198 "path": self.get_path(), 

199 } 

200 

201 

202@dataclass(slots=True) 

203class WrongQuote(QuotesObjBase): 

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

205 

206 quote_id: int 

207 author_id: int 

208 rating: int 

209 

210 def __str__(self) -> str: 

211 r""" 

212 Return the wrong quote. 

213 

214 like: '»quote« - author'. 

215 """ 

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

217 

218 @property 

219 def author(self) -> Author: 

220 """Get the corresponding author object.""" 

221 return AUTHORS_CACHE[self.author_id] 

222 

223 @classmethod 

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

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

226 return "wrongquotes" 

227 

228 async def fetch_new_data(self) -> WrongQuote: 

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

230 if self.id == -1: 

231 api_data = await make_api_request( 

232 "wrongquotes", 

233 { 

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

235 "simulate": "true", 

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

237 }, 

238 entity_should_exist=True, 

239 ) 

240 if api_data: 

241 api_data = api_data[0] 

242 else: 

243 api_data = await make_api_request( 

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

245 ) 

246 if not api_data: 

247 return self 

248 return parse_wrong_quote(api_data, self) 

249 

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

251 """ 

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

253 

254 :return tuple(quote_id, author_id) 

255 """ 

256 return self.quote_id, self.author_id 

257 

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

259 """ 

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

261 

262 Format: quote_id-author_id 

263 """ 

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

265 return str(self.id) 

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

267 

268 def get_path(self) -> str: 

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

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

271 

272 @property 

273 def quote(self) -> Quote: 

274 """Get the corresponding quote object.""" 

275 return QUOTES_CACHE[self.quote_id] 

276 

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

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

279 return { 

280 "id": self.get_id_as_str(), 

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

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

283 "rating": self.rating, 

284 "path": self.get_path(), 

285 } 

286 

287 async def vote( 

288 # pylint: disable=unused-argument 

289 self, 

290 vote: Literal[-1, 1], 

291 lazy: bool = False, 

292 ) -> WrongQuote | None: 

293 """Vote for the wrong quote.""" 

294 if self.id == -1: 

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

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

297 # self.rating += vote 

298 # asyncio.get_running_loop().call_soon_threadsafe( 

299 # self.vote, 

300 # vote, 

301 # ) 

302 # return self 

303 # do the voting 

304 data = await make_api_request( 

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

306 method="POST", 

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

308 entity_should_exist=True, 

309 ) 

310 if data is None: 

311 self.id = -1 

312 return None 

313 

314 return parse_wrong_quote( 

315 data, 

316 self, 

317 ) 

318 

319 

320def get_wrong_quotes( 

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

322 *, 

323 sort: bool = False, # sorted by rating 

324 filter_real_quotes: bool = True, 

325 shuffle: bool = False, 

326) -> Sequence[WrongQuote]: 

327 """Get cached wrong quotes.""" 

328 if shuffle and sort: 

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

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

331 if filter_fun or filter_real_quotes: 

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

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

334 filter_real_quotes 

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

336 ): 

337 del wqs[i] 

338 if shuffle: 

339 random.shuffle(wqs) 

340 elif sort: 

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

342 return wqs 

343 

344 

345def get_quotes( 

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

347 shuffle: bool = False, 

348) -> list[Quote]: 

349 """Get cached quotes.""" 

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

351 if filter_fun: 

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

353 if not filter_fun(quotes[i]): 

354 del quotes[i] 

355 if shuffle: 

356 random.shuffle(quotes) 

357 return quotes 

358 

359 

360def get_authors( 

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

362 shuffle: bool = False, 

363) -> list[Author]: 

364 """Get cached authors.""" 

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

366 if filter_fun: 

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

368 if not filter_fun(authors[i]): 

369 del authors[i] 

370 if shuffle: 

371 random.shuffle(authors) 

372 return authors 

373 

374 

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

376async def make_api_request( 

377 endpoint: str, 

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

379 *, 

380 # pylint: disable-next=unused-argument 

381 entity_should_exist: bool, 

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

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

384 request_timeout: float | None = None, 

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

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

387 if pytest_is_running(): 

388 return None 

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

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

391 body_str = urlencode(body) if body else body 

392 response = await AsyncHTTPClient().fetch( 

393 url, 

394 method=method, 

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

396 body=body_str, 

397 raise_error=False, 

398 ca_certs=CA_BUNDLE_PATH, 

399 request_timeout=request_timeout, 

400 ) 

401 if response.code != 200: 

402 if response.code == 404: 

403 return None 

404 LOGGER.log( 

405 logging.ERROR if response.code >= 500 else logging.WARNING, 

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

407 method, 

408 url, 

409 body_str, 

410 response.code, 

411 response.reason, 

412 ) 

413 raise HTTPError( 

414 503, 

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

416 ) 

417 return json.loads(response.body) 

418 

419 

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

421 """Fix common mistakes in authors.""" 

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

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

424 name = name[1:-1] 

425 return name.strip() 

426 

427 

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

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

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

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

432 

433 with AUTHORS_CACHE.lock: 

434 author = AUTHORS_CACHE.get(id_) 

435 if author is None: 

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

437 author = Author(id_, name, None) 

438 elif author.name != name: 

439 author.name = name 

440 author.info = None # reset info 

441 

442 AUTHORS_CACHE[author.id] = author 

443 

444 return author 

445 

446 

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

448 """Fix common mistakes in quotes.""" 

449 if ( 

450 len(quote_str) > 2 

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

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

453 ): 

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

455 quote_str = quote_str[1:-1] 

456 

457 return quote_str.strip() 

458 

459 

460def parse_quote( 

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

462) -> Quote: 

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

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

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

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

467 

468 with QUOTES_CACHE.lock: 

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

470 quote = QUOTES_CACHE.get(quote_id) 

471 if quote is None: # new quote 

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

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

474 else: # quote was already saved 

475 quote.quote = quote_str 

476 quote.author_id = author.id 

477 

478 QUOTES_CACHE[quote.id] = quote 

479 

480 return quote 

481 

482 

483def parse_wrong_quote( 

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

485) -> WrongQuote: 

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

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

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

489 

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

491 rating = json_data["rating"] 

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

493 

494 if wrong_quote is None: 

495 with WRONG_QUOTES_CACHE.lock: 

496 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple) 

497 if wrong_quote is None: 

498 wrong_quote = ( 

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

500 id=wrong_quote_id, 

501 quote_id=quote.id, 

502 author_id=author.id, 

503 rating=rating, 

504 ) 

505 ) 

506 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

507 return wrong_quote 

508 

509 # make sure the wrong quote is the correct one 

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

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

512 

513 # update the data of the wrong quote 

514 if wrong_quote.rating != rating: 

515 wrong_quote.rating = rating 

516 if wrong_quote.id != wrong_quote_id: 

517 wrong_quote.id = wrong_quote_id 

518 

519 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

520 

521 return wrong_quote 

522 

523 

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

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

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

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

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

529 if not json_list: 

530 return () 

531 if isinstance(json_list, str): 

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

533 return_list = [] 

534 for json_data in json_list: 

535 _ = parse_fun(json_data) 

536 await asyncio.sleep(0) 

537 return_list.append(_) 

538 return tuple(return_list) 

539 

540 

541async def update_cache_periodically( 

542 app: Application, worker: int | None 

543) -> None: 

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

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

546 if "/troet" in typed_stream.Stream( 

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

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

549 app.settings["SHOW_SHARING_ON_MASTODON"] = True 

550 if worker: 

551 return 

552 with contextlib.suppress(asyncio.TimeoutError): 

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

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

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

556 apm: None | elasticapm.Client 

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

558 await parse_list_of_quote_data( 

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

560 parse_author, 

561 ) 

562 await parse_list_of_quote_data( 

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

564 parse_quote, 

565 ) 

566 await parse_list_of_quote_data( 

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

568 parse_wrong_quote, 

569 ) 

570 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE: 

571 last_update = await redis.get( 

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

573 ) 

574 if last_update: 

575 last_update_int = int(last_update) 

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

577 if 0 <= since_last_update < 60 * 60: 

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

579 update_cache_in = 60 * 60 - since_last_update 

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

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

582 try: 

583 await update_cache( 

584 app, update_quotes=False, update_authors=False 

585 ) 

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

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

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

589 "CLIENT" 

590 ) 

591 if apm: 

592 apm.capture_exception() 

593 else: 

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

595 LOGGER.info( 

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

597 update_cache_in, 

598 ) 

599 await asyncio.sleep(update_cache_in) 

600 

601 # update the cache every hour 

602 failed = 0 

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

604 try: 

605 await update_cache(app) 

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

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

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

609 apm.capture_exception() 

610 failed += 1 

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

612 else: 

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

614 failed = 0 

615 await asyncio.sleep(60 * 60) 

616 

617 

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

619 app: Application, 

620 update_wrong_quotes: bool = True, 

621 update_quotes: bool = True, 

622 update_authors: bool = True, 

623) -> None: 

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

625 LOGGER.info("Updating quotes cache") 

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

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

628 redis_available = EVENT_REDIS.is_set() 

629 exceptions: list[Exception] = [] 

630 

631 if update_wrong_quotes: 

632 try: 

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

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

635 exceptions.append(err) 

636 

637 deleted_quotes: set[int] = set() 

638 

639 if update_quotes: 

640 try: 

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

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

643 exceptions.append(err) 

644 else: 

645 with QUOTES_CACHE.lock: 

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

647 max_quote_id = max(all_quote_ids) 

648 old_ids_in_cache = { 

649 _id for _id in QUOTES_CACHE if _id <= max_quote_id 

650 } 

651 deleted_quotes = old_ids_in_cache - all_quote_ids 

652 for _id in deleted_quotes: 

653 del QUOTES_CACHE[_id] 

654 

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

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

657 

658 deleted_authors: set[int] = set() 

659 

660 if update_authors: 

661 try: 

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

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

664 exceptions.append(err) 

665 else: 

666 with AUTHORS_CACHE.lock: 

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

668 max_author_id = max(all_author_ids) 

669 old_ids_in_cache = { 

670 _id for _id in AUTHORS_CACHE if _id <= max_author_id 

671 } 

672 deleted_authors = old_ids_in_cache - all_author_ids 

673 for _id in deleted_authors: 

674 del AUTHORS_CACHE[_id] 

675 

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

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

678 

679 if deleted_authors or deleted_quotes: 

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

681 with WRONG_QUOTES_CACHE.lock: 

682 for qid, aid in tuple(WRONG_QUOTES_CACHE): 

683 if qid in deleted_quotes or aid in deleted_authors: 

684 deleted_wrong_quotes.add((qid, aid)) 

685 del WRONG_QUOTES_CACHE[(qid, aid)] 

686 LOGGER.warning( 

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

688 len(deleted_wrong_quotes), 

689 deleted_wrong_quotes, 

690 ) 

691 

692 if exceptions: 

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

694 

695 if ( 

696 redis_available 

697 and update_wrong_quotes 

698 and update_quotes 

699 and update_authors 

700 ): 

701 await redis.setex( 

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

703 60 * 60 * 24 * 30, 

704 int(time.time()), 

705 ) 

706 

707 

708async def _update_cache[Q: QuotesObjBase]( 

709 klass: type[Q], 

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

711 redis: Redis[str], 

712 redis_prefix: str, 

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

714 wq_data = await make_api_request( 

715 klass.fetch_all_endpoint(), entity_should_exist=True 

716 ) 

717 if wq_data is None: 

718 LOGGER.error("%s returned 404", klass.fetch_all_endpoint()) 

719 return () 

720 parsed_data = await parse_list_of_quote_data( 

721 wq_data, 

722 parse, 

723 ) 

724 if wq_data and EVENT_REDIS.is_set(): 

725 await redis.setex( 

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

727 60 * 60 * 24 * 30, 

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

729 ) 

730 return parsed_data 

731 

732 

733async def get_author_by_id(author_id: int) -> Author | None: 

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

735 author = AUTHORS_CACHE.get(author_id) 

736 if author is not None: 

737 return author 

738 data = await make_api_request( 

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

740 ) 

741 if data is None: 

742 return None 

743 return parse_author(data) 

744 

745 

746async def get_quote_by_id(quote_id: int) -> Quote | None: 

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

748 quote = QUOTES_CACHE.get(quote_id) 

749 if quote is not None: 

750 return quote 

751 data = await make_api_request( 

752 f"quotes/{quote_id}", entity_should_exist=False 

753 ) 

754 if data is None: 

755 return None 

756 return parse_quote(data) 

757 

758 

759async def get_wrong_quote( 

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

761) -> WrongQuote | None: 

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

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

764 if wrong_quote: 

765 if use_cache: 

766 return wrong_quote 

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

768 return await wrong_quote.fetch_new_data() 

769 # wrong quote not in cache 

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

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

772 # no ratings just use the cached quote and author 

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

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

775 # request the wrong quote from the API 

776 result = await make_api_request( 

777 "wrongquotes", 

778 { 

779 "quote": str(quote_id), 

780 "simulate": "true", 

781 "author": str(author_id), 

782 }, 

783 entity_should_exist=False, 

784 ) 

785 if result: 

786 return parse_wrong_quote(result[0]) 

787 

788 return None 

789 

790 

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

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

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

794 return wq.rating 

795 return None 

796 

797 

798def get_random_quote_id() -> int: 

799 """Get random quote id.""" 

800 return random.choice(tuple(QUOTES_CACHE)) 

801 

802 

803def get_random_author_id() -> int: 

804 """Get random author id.""" 

805 return random.choice(tuple(AUTHORS_CACHE)) 

806 

807 

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

809 """Get random wrong quote id.""" 

810 return ( 

811 get_random_quote_id(), 

812 get_random_author_id(), 

813 ) 

814 

815 

816async def create_wq_and_vote( 

817 vote: Literal[-1, 1], 

818 quote_id: int, 

819 author_id: int, 

820 contributed_by: str, 

821 fast: bool = False, 

822) -> WrongQuote: 

823 """ 

824 Vote for the wrong_quote with the API. 

825 

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

827 """ 

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

829 if ( 

830 wrong_quote 

831 and wrong_quote.id != -1 

832 and (result := await wrong_quote.vote(vote, fast)) is not None 

833 ): 

834 return result 

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

836 data = await make_api_request( 

837 "wrongquotes", 

838 method="POST", 

839 body={ 

840 "quote": str(quote_id), 

841 "author": str(author_id), 

842 "contributed_by": contributed_by, 

843 }, 

844 entity_should_exist=True, 

845 ) 

846 if data is None: 

847 LOGGER.error( 

848 "Creating wrong quote (%s-%s) failed with 404", quote_id, author_id 

849 ) 

850 raise HTTPError(500) 

851 wrong_quote = parse_wrong_quote(data) 

852 if (result := await wrong_quote.vote(vote, lazy=True)) is not None: 

853 return result 

854 LOGGER.error( 

855 "Voting just created wrong quote (%s) failed with 404", 

856 wrong_quote.get_id_as_str(True), 

857 ) 

858 raise HTTPError(500) 

859 

860 

861class QuoteReadyCheckHandler(HTMLRequestHandler): 

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

863 

864 async def check_ready(self) -> None: 

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

866 if not WRONG_QUOTES_CACHE: 

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

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

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

870 

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

872 await super().prepare() 

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

874 await self.check_ready() 

875 

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

877 self.settings.get("RATELIMITS") 

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

879 and not self.is_authorized(Permission.RATELIMITS) 

880 and not self.crawler 

881 and ( 

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

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

884 ) 

885 ): 

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

887 raise HTTPError(503) 

888 

889 ratelimited, headers = await ratelimit( 

890 self.redis, 

891 self.redis_prefix, 

892 str(self.request.remote_ip), 

893 bucket="quotes:image:xlsx", 

894 max_burst=4, 

895 count_per_period=1, 

896 period=60, 

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

898 ) 

899 

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

901 self.set_header(header, value) 

902 

903 if ratelimited: 

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

905 self.set_status(420) 

906 self.write_error(420) 

907 else: 

908 self.set_status(429) 

909 self.write_error(429)