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

338 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +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, datetime, timezone 

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, emojify, 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=undefined-variable,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 ( 

121 emojify(self.name.strip()) 

122 if (now := datetime.now(timezone.utc)).day == 1 and now.month == 4 

123 else self.name.strip() 

124 ) 

125 

126 async def fetch_new_data(self) -> Author: 

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

128 return parse_author( 

129 await make_api_request( 

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

131 ) 

132 ) 

133 

134 def get_path(self) -> str: 

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

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

137 

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

139 """Get the author as JSON.""" 

140 return { 

141 "id": self.id, 

142 "name": str(self), 

143 "path": self.get_path(), 

144 "info": ( 

145 { 

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

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

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

149 } 

150 if self.info 

151 else None 

152 ), 

153 } 

154 

155 

156@dataclass(slots=True) 

157class Quote(QuotesObjBase): 

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

159 

160 quote: str 

161 author_id: int 

162 

163 def __str__(self) -> str: 

164 """Return the content of the quote.""" 

165 return ( 

166 emojify(self.quote.strip()) 

167 if (now := datetime.now(timezone.utc)).day == 1 and now.month == 4 

168 else self.quote.strip() 

169 ) 

170 

171 @property 

172 def author(self) -> Author: 

173 """Get the corresponding author object.""" 

174 return AUTHORS_CACHE[self.author_id] 

175 

176 async def fetch_new_data(self) -> Quote: 

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

178 return parse_quote( 

179 await make_api_request( 

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

181 ), 

182 self, 

183 ) 

184 

185 def get_path(self) -> str: 

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

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

188 

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

190 """Get the quote as JSON.""" 

191 return { 

192 "id": self.id, 

193 "quote": str(self), 

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

195 "path": self.get_path(), 

196 } 

197 

198 

199@dataclass(slots=True) 

200class WrongQuote(QuotesObjBase): 

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

202 

203 quote_id: int 

204 author_id: int 

205 rating: int 

206 

207 def __str__(self) -> str: 

208 r""" 

209 Return the wrong quote. 

210 

211 like: '»quote« - author'. 

212 """ 

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

214 

215 @property 

216 def author(self) -> Author: 

217 """Get the corresponding author object.""" 

218 return AUTHORS_CACHE[self.author_id] 

219 

220 async def fetch_new_data(self) -> WrongQuote: 

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

222 if self.id == -1: 

223 api_data = await make_api_request( 

224 "wrongquotes", 

225 { 

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

227 "simulate": "true", 

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

229 }, 

230 entity_should_exist=True, 

231 ) 

232 if api_data: 

233 api_data = api_data[0] 

234 else: 

235 api_data = await make_api_request( 

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

237 ) 

238 if not api_data: 

239 return self 

240 return parse_wrong_quote(api_data, self) 

241 

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

243 """ 

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

245 

246 :return tuple(quote_id, author_id) 

247 """ 

248 return self.quote_id, self.author_id 

249 

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

251 """ 

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

253 

254 Format: quote_id-author_id 

255 """ 

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

257 return str(self.id) 

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

259 

260 def get_path(self) -> str: 

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

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

263 

264 @property 

265 def quote(self) -> Quote: 

266 """Get the corresponding quote object.""" 

267 return QUOTES_CACHE[self.quote_id] 

268 

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

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

271 return { 

272 "id": self.get_id_as_str(), 

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

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

275 "rating": self.rating, 

276 "path": self.get_path(), 

277 } 

278 

279 async def vote( 

280 # pylint: disable=unused-argument 

281 self, 

282 vote: Literal[-1, 1], 

283 lazy: bool = False, 

284 ) -> WrongQuote: 

285 """Vote for the wrong quote.""" 

286 if self.id == -1: 

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

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

289 # self.rating += vote 

290 # asyncio.get_running_loop().call_soon_threadsafe( 

291 # self.vote, 

292 # vote, 

293 # ) 

294 # return self 

295 # do the voting 

296 return parse_wrong_quote( 

297 await make_api_request( 

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

299 method="POST", 

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

301 entity_should_exist=True, 

302 ), 

303 self, 

304 ) 

305 

306 

307def get_wrong_quotes( 

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

309 *, 

310 sort: bool = False, # sorted by rating 

311 filter_real_quotes: bool = True, 

312 shuffle: bool = False, 

313) -> Sequence[WrongQuote]: 

314 """Get cached wrong quotes.""" 

315 if shuffle and sort: 

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

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

318 if filter_fun or filter_real_quotes: 

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

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

321 filter_real_quotes 

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

323 ): 

324 del wqs[i] 

325 if shuffle: 

326 random.shuffle(wqs) 

327 elif sort: 

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

329 return wqs 

330 

331 

332def get_quotes( 

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

334 shuffle: bool = False, 

335) -> list[Quote]: 

336 """Get cached quotes.""" 

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

338 if filter_fun: 

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

340 if not filter_fun(quotes[i]): 

341 del quotes[i] 

342 if shuffle: 

343 random.shuffle(quotes) 

344 return quotes 

345 

346 

347def get_authors( 

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

349 shuffle: bool = False, 

350) -> list[Author]: 

351 """Get cached authors.""" 

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

353 if filter_fun: 

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

355 if not filter_fun(authors[i]): 

356 del authors[i] 

357 if shuffle: 

358 random.shuffle(authors) 

359 return authors 

360 

361 

362async def make_api_request( 

363 endpoint: str, 

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

365 *, 

366 entity_should_exist: bool, 

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

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

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

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

371 if pytest_is_running(): 

372 return None 

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

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

375 body_str = urlencode(body) if body else body 

376 response = await AsyncHTTPClient().fetch( 

377 url, 

378 method=method, 

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

380 body=body_str, 

381 raise_error=False, 

382 ca_certs=CA_BUNDLE_PATH, 

383 ) 

384 if response.code != 200: 

385 normed_response_code = ( 

386 400 

387 if not entity_should_exist and response.code == 500 

388 else response.code 

389 ) 

390 LOGGER.log( 

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

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

393 method, 

394 url, 

395 body_str, 

396 response.code, 

397 response.reason, 

398 ) 

399 raise HTTPError( 

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

401 reason=( 

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

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

404 ), 

405 ) 

406 return json.loads(response.body) 

407 

408 

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

410 """Fix common mistakes in authors.""" 

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

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

413 name = name[1:-1] 

414 return name.strip() 

415 

416 

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

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

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

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

421 

422 with AUTHORS_CACHE.lock: 

423 author = AUTHORS_CACHE.get(id_) 

424 if author is None: 

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

426 author = Author(id_, name, None) 

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

428 elif author.name != name: 

429 author.name = name 

430 author.info = None # reset info 

431 

432 AUTHORS_CACHE[author.id] = author 

433 

434 return author 

435 

436 

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

438 """Fix common mistakes in quotes.""" 

439 if ( 

440 len(quote_str) > 2 

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

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

443 ): 

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

445 quote_str = quote_str[1:-1] 

446 

447 return quote_str.strip() 

448 

449 

450def parse_quote( 

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

452) -> Quote: 

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

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

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

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

457 

458 with QUOTES_CACHE.lock: 

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

460 quote = QUOTES_CACHE.get(quote_id) 

461 if quote is None: # new quote 

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

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

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

465 else: # quote was already saved 

466 quote.quote = quote_str 

467 quote.author_id = author.id 

468 

469 QUOTES_CACHE[quote.id] = quote 

470 

471 return quote 

472 

473 

474def parse_wrong_quote( 

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

476) -> WrongQuote: 

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

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

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

480 

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

482 rating = json_data["rating"] 

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

484 

485 if wrong_quote is None: 

486 with WRONG_QUOTES_CACHE.lock: 

487 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple) 

488 if wrong_quote is None: 

489 wrong_quote = ( 

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

491 id=wrong_quote_id, 

492 quote_id=quote.id, 

493 author_id=author.id, 

494 rating=rating, 

495 ) 

496 ) 

497 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

498 return wrong_quote 

499 

500 # make sure the wrong quote is the correct one 

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

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

503 

504 # update the data of the wrong quote 

505 if wrong_quote.rating != rating: 

506 wrong_quote.rating = rating 

507 if wrong_quote.id != wrong_quote_id: 

508 wrong_quote.id = wrong_quote_id 

509 

510 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote 

511 

512 return wrong_quote 

513 

514 

515async def parse_list_of_quote_data( 

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

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

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

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

520 if not json_list: 

521 return () 

522 if isinstance(json_list, str): 

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

524 return_list = [] 

525 for json_data in json_list: 

526 _ = parse_fun(json_data) 

527 await asyncio.sleep(0) 

528 return_list.append(_) 

529 return tuple(return_list) 

530 

531 

532async def update_cache_periodically( 

533 app: Application, worker: int | None 

534) -> None: 

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

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

537 if "/troet" in typed_stream.Stream( 

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

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

540 app.settings["SHOW_SHARING_ON_MASTODON"] = True 

541 if worker: 

542 return 

543 with contextlib.suppress(asyncio.TimeoutError): 

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

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

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

547 apm: None | elasticapm.Client 

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

549 await parse_list_of_quote_data( 

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

551 parse_author, 

552 ) 

553 await parse_list_of_quote_data( 

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

555 parse_quote, 

556 ) 

557 await parse_list_of_quote_data( 

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

559 parse_wrong_quote, 

560 ) 

561 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE: 

562 last_update = await redis.get( 

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

564 ) 

565 if last_update: 

566 last_update_int = int(last_update) 

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

568 if 0 <= since_last_update < 60 * 60: 

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

570 update_cache_in = 60 * 60 - since_last_update 

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

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

573 try: 

574 await update_cache( 

575 app, update_quotes=False, update_authors=False 

576 ) 

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

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

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

580 "CLIENT" 

581 ) 

582 if apm: 

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

584 else: 

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

586 LOGGER.info( 

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

588 update_cache_in, 

589 ) 

590 await asyncio.sleep(update_cache_in) 

591 

592 # update the cache every hour 

593 failed = 0 

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

595 try: 

596 await update_cache(app) 

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

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

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

600 apm.capture_exception() 

601 failed += 1 

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

603 else: 

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

605 failed = 0 

606 await asyncio.sleep(60 * 60) 

607 

608 

609async def update_cache( 

610 app: Application, 

611 update_wrong_quotes: bool = True, 

612 update_quotes: bool = True, 

613 update_authors: bool = True, 

614) -> None: 

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

616 LOGGER.info("Updating quotes cache") 

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

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

619 redis_available = EVENT_REDIS.is_set() 

620 

621 if update_wrong_quotes: 

622 await parse_list_of_quote_data( 

623 wq_data := await make_api_request( 

624 "wrongquotes", entity_should_exist=True 

625 ), 

626 parse_wrong_quote, 

627 ) 

628 if wq_data and redis_available: 

629 await redis.setex( 

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

631 60 * 60 * 24 * 30, 

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

633 ) 

634 

635 if update_quotes: 

636 await parse_list_of_quote_data( 

637 quotes_data := await make_api_request( 

638 "quotes", entity_should_exist=True 

639 ), 

640 parse_quote, 

641 ) 

642 if quotes_data and redis_available: 

643 await redis.setex( 

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

645 60 * 60 * 24 * 30, 

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

647 ) 

648 

649 if update_authors: 

650 await parse_list_of_quote_data( 

651 authors_data := await make_api_request( 

652 "authors", entity_should_exist=True 

653 ), 

654 parse_author, 

655 ) 

656 if authors_data and redis_available: 

657 await redis.setex( 

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

659 60 * 60 * 24 * 30, 

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

661 ) 

662 

663 if ( 

664 redis_available 

665 and update_wrong_quotes 

666 and update_quotes 

667 and update_authors 

668 ): 

669 await redis.setex( 

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

671 60 * 60 * 24 * 30, 

672 int(time.time()), 

673 ) 

674 

675 

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

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

678 author = AUTHORS_CACHE.get(author_id) 

679 if author is not None: 

680 return author 

681 return parse_author( 

682 await make_api_request( 

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

684 ) 

685 ) 

686 

687 

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

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

690 quote = QUOTES_CACHE.get(quote_id) 

691 if quote is not None: 

692 return quote 

693 return parse_quote( 

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

695 ) 

696 

697 

698async def get_wrong_quote( 

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

700) -> WrongQuote: 

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

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

703 if wrong_quote: 

704 if use_cache: 

705 return wrong_quote 

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

707 return await wrong_quote.fetch_new_data() 

708 # wrong quote not in cache 

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

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

711 # no ratings just use the cached quote and author 

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

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

714 # request the wrong quote from the API 

715 result = await make_api_request( 

716 "wrongquotes", 

717 { 

718 "quote": str(quote_id), 

719 "simulate": "true", 

720 "author": str(author_id), 

721 }, 

722 entity_should_exist=False, 

723 ) 

724 if result: 

725 return parse_wrong_quote(result[0]) 

726 

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

728 

729 

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

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

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

733 

734 

735def get_random_quote_id() -> int: 

736 """Get random quote id.""" 

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

738 

739 

740def get_random_author_id() -> int: 

741 """Get random author id.""" 

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

743 

744 

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

746 """Get random wrong quote id.""" 

747 return ( 

748 get_random_quote_id(), 

749 get_random_author_id(), 

750 ) 

751 

752 

753async def create_wq_and_vote( 

754 vote: Literal[-1, 1], 

755 quote_id: int, 

756 author_id: int, 

757 contributed_by: str, 

758 fast: bool = False, 

759) -> WrongQuote: 

760 """ 

761 Vote for the wrong_quote with the API. 

762 

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

764 """ 

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

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

767 return await wrong_quote.vote(vote, fast) 

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

769 wrong_quote = parse_wrong_quote( 

770 await make_api_request( 

771 "wrongquotes", 

772 method="POST", 

773 body={ 

774 "quote": str(quote_id), 

775 "author": str(author_id), 

776 "contributed_by": contributed_by, 

777 }, 

778 entity_should_exist=False, 

779 ) 

780 ) 

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

782 

783 

784class QuoteReadyCheckHandler(HTMLRequestHandler): 

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

786 

787 async def check_ready(self) -> None: 

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

789 if not WRONG_QUOTES_CACHE: 

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

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

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

793 

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

795 await super().prepare() 

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

797 await self.check_ready() 

798 

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

800 self.settings.get("RATELIMITS") 

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

802 and not self.is_authorized(Permission.RATELIMITS) 

803 and not self.crawler 

804 and ( 

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

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

807 ) 

808 ): 

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

810 raise HTTPError(503) 

811 

812 ratelimited, headers = await ratelimit( 

813 self.redis, 

814 self.redis_prefix, 

815 str(self.request.remote_ip), 

816 bucket="quotes:image:xlsx", 

817 max_burst=4, 

818 count_per_period=1, 

819 period=60, 

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

821 ) 

822 

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

824 self.set_header(header, value) 

825 

826 if ratelimited: 

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

828 self.set_status(420) 

829 self.write_error(420) 

830 else: 

831 self.set_status(429) 

832 self.write_error(429)