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
« 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/>.
14"""A page with wrong quotes."""
16from __future__ import annotations
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
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]
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
59DIR: Final = ROOT_DIR / "quotes"
61LOGGER: Final = logging.getLogger(__name__)
63API_URL: Final[str] = "https://zitate.prapsschnalinen.de/api"
66# pylint: disable-next=too-few-public-methods
67class UltraDictType[K, V](MutableMapping[K, V], abc.ABC):
68 """The type of the shared dictionaries."""
70 lock: multiprocessing.synchronize.RLock
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)
84@dataclass(init=False, slots=True)
85class QuotesObjBase(abc.ABC):
86 """An object with an id."""
88 id: int
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
96 @abc.abstractmethod
97 async def fetch_new_data(self) -> QuotesObjBase:
98 """Fetch new data from the API."""
99 raise NotImplementedError
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)
106 @abc.abstractmethod
107 def get_path(self) -> str:
108 """Return the path to the Object."""
109 raise NotImplementedError
112@dataclass(slots=True)
113class Author(QuotesObjBase):
114 """The author object with a name."""
116 name: str
117 # tuple(url_to_info, info_str, creation_date)
118 info: None | tuple[str, None | str, date]
120 def __str__(self) -> str:
121 """Return the name of the author."""
122 return self.name
124 @classmethod
125 def fetch_all_endpoint(cls) -> Literal["authors"]:
126 """Endpoint to fetch all authors."""
127 return "authors"
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 )
137 def get_path(self) -> str:
138 """Return the path to the author info."""
139 return f"/zitate/info/a/{self.id}"
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 }
159@dataclass(slots=True)
160class Quote(QuotesObjBase):
161 """The quote object with a quote text and an author."""
163 quote: str
164 author_id: int
166 def __str__(self) -> str:
167 """Return the content of the quote."""
168 return self.quote.strip()
170 @property
171 def author(self) -> Author:
172 """Get the corresponding author object."""
173 return AUTHORS_CACHE[self.author_id]
175 @classmethod
176 def fetch_all_endpoint(cls) -> Literal["quotes"]:
177 """Endpoint to fetch all quotes."""
178 return "quotes"
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 )
189 def get_path(self) -> str:
190 """Return the path to the quote info."""
191 return f"/zitate/info/z/{self.id}"
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 }
203@dataclass(slots=True)
204class WrongQuote(QuotesObjBase):
205 """The wrong quote object with a quote, an author and a rating."""
207 quote_id: int
208 author_id: int
209 rating: int
211 def __str__(self) -> str:
212 r"""
213 Return the wrong quote.
215 like: '»quote« - author'.
216 """
217 return f"»{self.quote}« - {self.author}"
219 @property
220 def author(self) -> Author:
221 """Get the corresponding author object."""
222 return AUTHORS_CACHE[self.author_id]
224 @classmethod
225 def fetch_all_endpoint(cls) -> Literal["wrongquotes"]:
226 """Endpoint to fetch all wrong quotes."""
227 return "wrongquotes"
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)
251 def get_id(self) -> tuple[int, int]:
252 """
253 Get the id of the quote and the author in a tuple.
255 :return tuple(quote_id, author_id)
256 """
257 return self.quote_id, self.author_id
259 def get_id_as_str(self, minify: bool = False) -> str:
260 """
261 Get the id of the wrong quote as a string.
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}"
269 def get_path(self) -> str:
270 """Return the path to the wrong quote."""
271 return f"/zitate/{self.get_id_as_str()}"
273 @property
274 def quote(self) -> Quote:
275 """Get the corresponding quote object."""
276 return QUOTES_CACHE[self.quote_id]
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 }
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 )
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
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
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
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)
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()
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"])
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
440 AUTHORS_CACHE[author.id] = author
442 return author
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]
455 return quote_str.strip()
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"])
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
476 QUOTES_CACHE[quote.id] = quote
478 return quote
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"])
488 id_tuple = (quote.id, author.id)
489 rating = json_data["rating"]
490 wrong_quote_id = int(json_data.get("id") or -1)
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
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")
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
517 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
519 return wrong_quote
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)
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)
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)
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] = []
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)
635 deleted_quotes: set[int] = set()
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]
653 if len(QUOTES_CACHE) < len(quotes):
654 LOGGER.error("Cache has less elements than just fetched")
656 deleted_authors: set[int] = set()
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]
674 if len(AUTHORS_CACHE) < len(authors):
675 LOGGER.error("Cache has less elements than just fetched")
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 )
690 if exceptions:
691 raise ExceptionGroup("Cache could not be updated", exceptions)
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 )
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
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 )
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 )
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])
780 return None
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
790def get_random_quote_id() -> int:
791 """Get random quote id."""
792 return random.choice(tuple(QUOTES_CACHE))
795def get_random_author_id() -> int:
796 """Get random author id."""
797 return random.choice(tuple(AUTHORS_CACHE))
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 )
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.
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)
839class QuoteReadyCheckHandler(HTMLRequestHandler):
840 """Class that checks if quotes have been loaded."""
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")
849 async def prepare(self) -> None: # noqa: D102
850 await super().prepare()
851 if self.request.method != "OPTIONS":
852 await self.check_ready()
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)
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 )
878 for header, value in headers.items():
879 self.set_header(header, value)
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)