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

402 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-26 19:52 +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 

16 

17import abc 

18import asyncio 

19import contextlib 

20import logging 

21import multiprocessing.synchronize 

22import random 

23import sys 

24import time 

25from collections.abc import ( 

26 Callable, 

27 Iterable, 

28 Mapping, 

29 MutableMapping, 

30 Sequence, 

31) 

32from dataclasses import dataclass 

33from datetime import date 

34from typing import Any, Final, Literal, cast 

35from urllib.parse import urlencode 

36 

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

38import elasticapm 

39import orjson as json 

40import typed_stream 

41from redis.asyncio import Redis 

42from tornado.httpclient import AsyncHTTPClient 

43from tornado.web import Application, HTTPError 

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

45 

46from .. import ( 

47 CA_BUNDLE_PATH, 

48 DIR as ROOT_DIR, 

49 EVENT_REDIS, 

50 EVENT_SHUTDOWN, 

51 NAME, 

52 ORJSON_OPTIONS, 

53 pytest_is_running, 

54) 

55from ..utils.request_handler import HTMLRequestHandler 

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

57 

58DIR: Final = ROOT_DIR / "quotes" 

59 

60LOGGER: Final = logging.getLogger(__name__) 

61 

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

63 

64 

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

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

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

68 

69 lock: multiprocessing.synchronize.RLock 

70 

71 

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

73 buffer_size=1024**2, serializer=dill 

74) 

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

76 buffer_size=1024**2, serializer=dill 

77) 

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

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

80) 

81 

82 

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

84class QuotesObjBase(abc.ABC): 

85 """An object with an id.""" 

86 

87 id: int 

88 

89 @classmethod 

90 @abc.abstractmethod 

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

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

93 raise NotImplementedError 

94 

95 @abc.abstractmethod 

96 async def fetch_new_data(self) -> QuotesObjBase | None: 

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

98 raise NotImplementedError 

99 

100 # pylint: disable=unused-argument 

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

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

103 return str(self.id) 

104 

105 @abc.abstractmethod 

106 def get_path(self) -> str: 

107 """Return the path to the Object.""" 

108 raise NotImplementedError 

109 

110 

111@dataclass(slots=True) 

112class Author(QuotesObjBase): 

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

114 

115 name: str 

116 # tuple(url_to_info, info_str, creation_date) 

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

118 

119 def __str__(self) -> str: 

120 """Return the name of the author.""" 

121 return self.name 

122 

123 @classmethod 

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

125 """Endpoint to fetch all authors.""" 

126 return "authors" 

127 

128 async def fetch_new_data(self) -> Author | None: 

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

130 data = await make_api_request( 

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

132 ) 

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

134 

135 def get_path(self) -> str: 

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

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

138 

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

140 """Get the author as JSON.""" 

141 return { 

142 "id": self.id, 

143 "name": str(self), 

144 "path": self.get_path(), 

145 "info": ( 

146 { 

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

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

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

150 } 

151 if self.info 

152 else None 

153 ), 

154 } 

155 

156 

157@dataclass(slots=True) 

158class Quote(QuotesObjBase): 

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

160 

161 quote: str 

162 author_id: int 

163 

164 def __str__(self) -> str: 

165 """Return the content of the quote.""" 

166 return self.quote.strip() 

167 

168 @property 

169 def author(self) -> Author: 

170 """Get the corresponding author object.""" 

171 return AUTHORS_CACHE[self.author_id] 

172 

173 @classmethod 

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

175 """Endpoint to fetch all quotes.""" 

176 return "quotes" 

177 

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

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

180 data = await make_api_request( 

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

182 ) 

183 if data is None: 

184 return None 

185 return parse_quote(data, self) 

186 

187 def get_path(self) -> str: 

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

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

190 

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

192 """Get the quote as JSON.""" 

193 return { 

194 "id": self.id, 

195 "quote": str(self), 

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

197 "path": self.get_path(), 

198 } 

199 

200 

201@dataclass(slots=True) 

202class WrongQuote(QuotesObjBase): 

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

204 

205 quote_id: int 

206 author_id: int 

207 rating: int 

208 

209 def __str__(self) -> str: 

210 r""" 

211 Return the wrong quote. 

212 

213 like: '»quote« - author'. 

214 """ 

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

216 

217 @property 

218 def author(self) -> Author: 

219 """Get the corresponding author object.""" 

220 return AUTHORS_CACHE[self.author_id] 

221 

222 @classmethod 

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

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

225 return "wrongquotes" 

226 

227 async def fetch_new_data(self) -> WrongQuote: 

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

229 if self.id == -1: 

230 api_data = await make_api_request( 

231 "wrongquotes", 

232 { 

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

234 "simulate": "true", 

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

236 }, 

237 entity_should_exist=True, 

238 ) 

239 if api_data: 

240 api_data = api_data[0] 

241 else: 

242 api_data = await make_api_request( 

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

244 ) 

245 if not api_data: 

246 return self 

247 return parse_wrong_quote(api_data, self) 

248 

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

250 """ 

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

252 

253 :return tuple(quote_id, author_id) 

254 """ 

255 return self.quote_id, self.author_id 

256 

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

258 """ 

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

260 

261 Format: quote_id-author_id 

262 """ 

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

264 return str(self.id) 

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

266 

267 def get_path(self) -> str: 

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

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

270 

271 @property 

272 def quote(self) -> Quote: 

273 """Get the corresponding quote object.""" 

274 return QUOTES_CACHE[self.quote_id] 

275 

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

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

278 return { 

279 "id": self.get_id_as_str(), 

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

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

282 "rating": self.rating, 

283 "path": self.get_path(), 

284 } 

285 

286 async def vote( 

287 # pylint: disable=unused-argument 

288 self, 

289 vote: Literal[-1, 1], 

290 lazy: bool = False, 

291 ) -> WrongQuote | None: 

292 """Vote for the wrong quote.""" 

293 if self.id == -1: 

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

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

296 # self.rating += vote 

297 # asyncio.get_running_loop().call_soon_threadsafe( 

298 # self.vote, 

299 # vote, 

300 # ) 

301 # return self 

302 # do the voting 

303 data = await make_api_request( 

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

305 method="POST", 

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

307 entity_should_exist=True, 

308 ) 

309 if data is None: 

310 self.id = -1 

311 return None 

312 

313 return parse_wrong_quote( 

314 data, 

315 self, 

316 ) 

317 

318 

319def get_wrong_quotes( 

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

321 *, 

322 sort: bool = False, # sorted by rating 

323 filter_real_quotes: bool = True, 

324 shuffle: bool = False, 

325) -> Sequence[WrongQuote]: 

326 """Get cached wrong quotes.""" 

327 if shuffle and sort: 

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

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

330 if filter_fun or filter_real_quotes: 

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

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

333 filter_real_quotes 

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

335 ): 

336 del wqs[i] 

337 if shuffle: 

338 random.shuffle(wqs) 

339 elif sort: 

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

341 return wqs 

342 

343 

344def get_quotes( 

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

346 shuffle: bool = False, 

347) -> list[Quote]: 

348 """Get cached quotes.""" 

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

350 if filter_fun: 

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

352 if not filter_fun(quotes[i]): 

353 del quotes[i] 

354 if shuffle: 

355 random.shuffle(quotes) 

356 return quotes 

357 

358 

359def get_authors( 

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

361 shuffle: bool = False, 

362) -> list[Author]: 

363 """Get cached authors.""" 

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

365 if filter_fun: 

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

367 if not filter_fun(authors[i]): 

368 del authors[i] 

369 if shuffle: 

370 random.shuffle(authors) 

371 return authors 

372 

373 

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

375async def make_api_request( 

376 endpoint: str, 

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

378 *, 

379 # pylint: disable-next=unused-argument 

380 entity_should_exist: bool, 

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

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

383 request_timeout: float | None = None, 

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

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

386 if pytest_is_running(): 

387 return None 

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

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

390 body_str = urlencode(body) if body else body 

391 response = await AsyncHTTPClient().fetch( 

392 url, 

393 method=method, 

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

395 body=body_str, 

396 raise_error=False, 

397 ca_certs=CA_BUNDLE_PATH, 

398 request_timeout=request_timeout, 

399 ) 

400 if response.code != 200: 

401 if response.code == 404: 

402 return None 

403 LOGGER.log( 

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

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

406 method, 

407 url, 

408 body_str, 

409 response.code, 

410 response.reason, 

411 ) 

412 raise HTTPError( 

413 503, 

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

415 ) 

416 return json.loads(response.body) 

417 

418 

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

420 """Fix common mistakes in authors.""" 

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

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

423 name = name[1:-1] 

424 return name.strip() 

425 

426 

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

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

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

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

431 

432 with AUTHORS_CACHE.lock: 

433 author = AUTHORS_CACHE.get(id_) 

434 if author is None: 

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

436 author = Author(id_, name, None) 

437 elif author.name != name: 

438 author.name = name 

439 author.info = None # reset info 

440 

441 AUTHORS_CACHE[author.id] = author 

442 

443 return author 

444 

445 

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

447 """Fix common mistakes in quotes.""" 

448 if ( 

449 len(quote_str) > 2 

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

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

452 ): 

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

454 quote_str = quote_str[1:-1] 

455 

456 return quote_str.strip() 

457 

458 

459def parse_quote( 

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

461) -> Quote: 

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

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

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

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

466 

467 with QUOTES_CACHE.lock: 

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

469 quote = QUOTES_CACHE.get(quote_id) 

470 if quote is None: # new quote 

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

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

473 else: # quote was already saved 

474 quote.quote = quote_str 

475 quote.author_id = author.id 

476 

477 QUOTES_CACHE[quote.id] = quote 

478 

479 return quote 

480 

481 

482def parse_wrong_quote( 

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

484) -> WrongQuote: 

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

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

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

488 

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

490 rating = json_data["rating"] 

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

492 

493 if wrong_quote is None: 

494 with WRONG_QUOTES_CACHE.lock: 

495 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple) 

496 if wrong_quote is None: 

497 wrong_quote = ( 

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

499 id=wrong_quote_id, 

500 quote_id=quote.id, 

501 author_id=author.id, 

502 rating=rating, 

503 ) 

504 ) 

505 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

506 return wrong_quote 

507 

508 # make sure the wrong quote is the correct one 

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

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

511 

512 # update the data of the wrong quote 

513 if wrong_quote.rating != rating: 

514 wrong_quote.rating = rating 

515 if wrong_quote.id != wrong_quote_id: 

516 wrong_quote.id = wrong_quote_id 

517 

518 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

519 

520 return wrong_quote 

521 

522 

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

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

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

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

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

528 if not json_list: 

529 return () 

530 if isinstance(json_list, str): 

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

532 return_list = [] 

533 for json_data in json_list: 

534 _ = parse_fun(json_data) 

535 await asyncio.sleep(0) 

536 return_list.append(_) 

537 return tuple(return_list) 

538 

539 

540async def update_cache_periodically( 

541 app: Application, worker: int | None 

542) -> None: 

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

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

545 if "/troet" in typed_stream.Stream( 

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

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

548 app.settings["SHOW_SHARING_ON_MASTODON"] = True 

549 if worker: 

550 return 

551 with contextlib.suppress(asyncio.TimeoutError): 

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

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

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

555 apm: None | elasticapm.Client 

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

557 await parse_list_of_quote_data( 

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

559 parse_author, 

560 ) 

561 await parse_list_of_quote_data( 

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

563 parse_quote, 

564 ) 

565 await parse_list_of_quote_data( 

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

567 parse_wrong_quote, 

568 ) 

569 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE: 

570 last_update = await redis.get( 

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

572 ) 

573 if last_update: 

574 last_update_int = int(last_update) 

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

576 if 0 <= since_last_update < 60 * 60: 

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

578 update_cache_in = 60 * 60 - since_last_update 

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

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

581 try: 

582 await update_cache( 

583 app, update_quotes=False, update_authors=False 

584 ) 

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

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

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

588 "CLIENT" 

589 ) 

590 if apm: 

591 apm.capture_exception() 

592 else: 

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

594 LOGGER.info( 

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

596 update_cache_in, 

597 ) 

598 await asyncio.sleep(update_cache_in) 

599 

600 # update the cache every hour 

601 failed = 0 

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

603 try: 

604 await update_cache(app) 

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

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

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

608 apm.capture_exception() 

609 failed += 1 

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

611 else: 

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

613 failed = 0 

614 await asyncio.sleep(60 * 60) 

615 

616 

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

618 app: Application, 

619 update_wrong_quotes: bool = True, 

620 update_quotes: bool = True, 

621 update_authors: bool = True, 

622) -> None: 

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

624 LOGGER.info("Updating quotes cache") 

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

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

627 redis_available = EVENT_REDIS.is_set() 

628 exceptions: list[Exception] = [] 

629 

630 if update_wrong_quotes: 

631 try: 

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

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

634 exceptions.append(err) 

635 

636 deleted_quotes: set[int] = set() 

637 

638 if update_quotes: 

639 try: 

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

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

642 exceptions.append(err) 

643 else: 

644 with QUOTES_CACHE.lock: 

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

646 max_quote_id = max(all_quote_ids) 

647 old_ids_in_cache = { 

648 _id for _id in QUOTES_CACHE if _id <= max_quote_id 

649 } 

650 deleted_quotes = old_ids_in_cache - all_quote_ids 

651 for _id in deleted_quotes: 

652 del QUOTES_CACHE[_id] 

653 

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

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

656 

657 deleted_authors: set[int] = set() 

658 

659 if update_authors: 

660 try: 

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

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

663 exceptions.append(err) 

664 else: 

665 with AUTHORS_CACHE.lock: 

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

667 max_author_id = max(all_author_ids) 

668 old_ids_in_cache = { 

669 _id for _id in AUTHORS_CACHE if _id <= max_author_id 

670 } 

671 deleted_authors = old_ids_in_cache - all_author_ids 

672 for _id in deleted_authors: 

673 del AUTHORS_CACHE[_id] 

674 

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

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

677 

678 if deleted_authors or deleted_quotes: 

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

680 with WRONG_QUOTES_CACHE.lock: 

681 for qid, aid in tuple(WRONG_QUOTES_CACHE): 

682 if qid in deleted_quotes or aid in deleted_authors: 

683 deleted_wrong_quotes.add((qid, aid)) 

684 del WRONG_QUOTES_CACHE[(qid, aid)] 

685 LOGGER.warning( 

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

687 len(deleted_wrong_quotes), 

688 deleted_wrong_quotes, 

689 ) 

690 

691 if exceptions: 

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

693 

694 if ( 

695 redis_available 

696 and update_wrong_quotes 

697 and update_quotes 

698 and update_authors 

699 ): 

700 await redis.setex( 

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

702 60 * 60 * 24 * 30, 

703 int(time.time()), 

704 ) 

705 

706 

707async def _update_cache[Q: QuotesObjBase]( 

708 klass: type[Q], 

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

710 redis: Redis[str], 

711 redis_prefix: str, 

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

713 wq_data = await make_api_request( 

714 klass.fetch_all_endpoint(), entity_should_exist=True 

715 ) 

716 if wq_data is None: 

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

718 return () 

719 parsed_data = await parse_list_of_quote_data( 

720 wq_data, 

721 parse, 

722 ) 

723 if wq_data and EVENT_REDIS.is_set(): 

724 await redis.setex( 

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

726 60 * 60 * 24 * 30, 

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

728 ) 

729 return parsed_data 

730 

731 

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

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

734 author = AUTHORS_CACHE.get(author_id) 

735 if author is not None: 

736 return author 

737 data = await make_api_request( 

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

739 ) 

740 if data is None: 

741 return None 

742 return parse_author(data) 

743 

744 

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

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

747 quote = QUOTES_CACHE.get(quote_id) 

748 if quote is not None: 

749 return quote 

750 data = await make_api_request( 

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

752 ) 

753 if data is None: 

754 return None 

755 return parse_quote(data) 

756 

757 

758async def get_wrong_quote( 

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

760) -> WrongQuote | None: 

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

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

763 if wrong_quote: 

764 if use_cache: 

765 return wrong_quote 

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

767 return await wrong_quote.fetch_new_data() 

768 # wrong quote not in cache 

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

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

771 # no ratings just use the cached quote and author 

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

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

774 # request the wrong quote from the API 

775 result = await make_api_request( 

776 "wrongquotes", 

777 { 

778 "quote": str(quote_id), 

779 "simulate": "true", 

780 "author": str(author_id), 

781 }, 

782 entity_should_exist=False, 

783 ) 

784 if result: 

785 return parse_wrong_quote(result[0]) 

786 

787 return None 

788 

789 

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

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

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

793 return wq.rating 

794 return None 

795 

796 

797def get_random_quote_id() -> int: 

798 """Get random quote id.""" 

799 return random.choice(tuple(QUOTES_CACHE)) 

800 

801 

802def get_random_author_id() -> int: 

803 """Get random author id.""" 

804 return random.choice(tuple(AUTHORS_CACHE)) 

805 

806 

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

808 """Get random wrong quote id.""" 

809 return ( 

810 get_random_quote_id(), 

811 get_random_author_id(), 

812 ) 

813 

814 

815async def create_wq_and_vote( 

816 vote: Literal[-1, 1], 

817 quote_id: int, 

818 author_id: int, 

819 contributed_by: str, 

820 fast: bool = False, 

821) -> WrongQuote: 

822 """ 

823 Vote for the wrong_quote with the API. 

824 

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

826 """ 

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

828 if ( 

829 wrong_quote 

830 and wrong_quote.id != -1 

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

832 ): 

833 return result 

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

835 data = await make_api_request( 

836 "wrongquotes", 

837 method="POST", 

838 body={ 

839 "quote": str(quote_id), 

840 "author": str(author_id), 

841 "contributed_by": contributed_by, 

842 }, 

843 entity_should_exist=True, 

844 ) 

845 if data is None: 

846 LOGGER.error( 

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

848 ) 

849 raise HTTPError(500) 

850 wrong_quote = parse_wrong_quote(data) 

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

852 return result 

853 LOGGER.error( 

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

855 wrong_quote.get_id_as_str(True), 

856 ) 

857 raise HTTPError(500) 

858 

859 

860class QuoteReadyCheckHandler(HTMLRequestHandler): 

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

862 

863 async def check_ready(self) -> None: 

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

865 if not WRONG_QUOTES_CACHE: 

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

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

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

869 

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

871 await super().prepare() 

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

873 await self.check_ready() 

874 

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

876 self.settings.get("RATELIMITS") 

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

878 and not self.is_authorized(Permission.RATELIMITS) 

879 and not self.crawler 

880 and ( 

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

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

883 ) 

884 ): 

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

886 raise HTTPError(503) 

887 

888 ratelimited, headers = await ratelimit( 

889 self.redis, 

890 self.redis_prefix, 

891 str(self.request.remote_ip), 

892 bucket="quotes:image:xlsx", 

893 max_burst=4, 

894 count_per_period=1, 

895 period=60, 

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

897 ) 

898 

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

900 self.set_header(header, value) 

901 

902 if ratelimited: 

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

904 self.set_status(420) 

905 self.write_error(420) 

906 else: 

907 self.set_status(429) 

908 self.write_error(429)