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
« 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/>.
14"""A page with wrong quotes."""
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
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]
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
58DIR: Final = ROOT_DIR / "quotes"
60LOGGER: Final = logging.getLogger(__name__)
62API_URL: Final[str] = "https://zitate.prapsschnalinen.de/api"
65# pylint: disable-next=too-few-public-methods
66class UltraDictType[K, V](MutableMapping[K, V], abc.ABC):
67 """The type of the shared dictionaries."""
69 lock: multiprocessing.synchronize.RLock
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)
83@dataclass(init=False, slots=True)
84class QuotesObjBase(abc.ABC):
85 """An object with an id."""
87 id: int
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
95 @abc.abstractmethod
96 async def fetch_new_data(self) -> QuotesObjBase | None:
97 """Fetch new data from the API."""
98 raise NotImplementedError
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)
105 @abc.abstractmethod
106 def get_path(self) -> str:
107 """Return the path to the Object."""
108 raise NotImplementedError
111@dataclass(slots=True)
112class Author(QuotesObjBase):
113 """The author object with a name."""
115 name: str
116 # tuple(url_to_info, info_str, creation_date)
117 info: None | tuple[str, None | str, date]
119 def __str__(self) -> str:
120 """Return the name of the author."""
121 return self.name
123 @classmethod
124 def fetch_all_endpoint(cls) -> Literal["authors"]:
125 """Endpoint to fetch all authors."""
126 return "authors"
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)
135 def get_path(self) -> str:
136 """Return the path to the author info."""
137 return f"/zitate/info/a/{self.id}"
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 }
157@dataclass(slots=True)
158class Quote(QuotesObjBase):
159 """The quote object with a quote text and an author."""
161 quote: str
162 author_id: int
164 def __str__(self) -> str:
165 """Return the content of the quote."""
166 return self.quote.strip()
168 @property
169 def author(self) -> Author:
170 """Get the corresponding author object."""
171 return AUTHORS_CACHE[self.author_id]
173 @classmethod
174 def fetch_all_endpoint(cls) -> Literal["quotes"]:
175 """Endpoint to fetch all quotes."""
176 return "quotes"
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)
187 def get_path(self) -> str:
188 """Return the path to the quote info."""
189 return f"/zitate/info/z/{self.id}"
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 }
201@dataclass(slots=True)
202class WrongQuote(QuotesObjBase):
203 """The wrong quote object with a quote, an author and a rating."""
205 quote_id: int
206 author_id: int
207 rating: int
209 def __str__(self) -> str:
210 r"""
211 Return the wrong quote.
213 like: '»quote« - author'.
214 """
215 return f"»{self.quote}« - {self.author}"
217 @property
218 def author(self) -> Author:
219 """Get the corresponding author object."""
220 return AUTHORS_CACHE[self.author_id]
222 @classmethod
223 def fetch_all_endpoint(cls) -> Literal["wrongquotes"]:
224 """Endpoint to fetch all wrong quotes."""
225 return "wrongquotes"
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)
249 def get_id(self) -> tuple[int, int]:
250 """
251 Get the id of the quote and the author in a tuple.
253 :return tuple(quote_id, author_id)
254 """
255 return self.quote_id, self.author_id
257 def get_id_as_str(self, minify: bool = False) -> str:
258 """
259 Get the id of the wrong quote as a string.
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}"
267 def get_path(self) -> str:
268 """Return the path to the wrong quote."""
269 return f"/zitate/{self.get_id_as_str()}"
271 @property
272 def quote(self) -> Quote:
273 """Get the corresponding quote object."""
274 return QUOTES_CACHE[self.quote_id]
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 }
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
313 return parse_wrong_quote(
314 data,
315 self,
316 )
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
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
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
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)
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()
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"])
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
441 AUTHORS_CACHE[author.id] = author
443 return author
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]
456 return quote_str.strip()
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"])
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
477 QUOTES_CACHE[quote.id] = quote
479 return quote
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"])
489 id_tuple = (quote.id, author.id)
490 rating = json_data["rating"]
491 wrong_quote_id = int(json_data.get("id") or -1)
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
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")
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
518 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
520 return wrong_quote
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)
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)
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)
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] = []
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)
636 deleted_quotes: set[int] = set()
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]
654 if len(QUOTES_CACHE) < len(quotes):
655 LOGGER.error("Cache has less elements than just fetched")
657 deleted_authors: set[int] = set()
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]
675 if len(AUTHORS_CACHE) < len(authors):
676 LOGGER.error("Cache has less elements than just fetched")
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 )
691 if exceptions:
692 raise ExceptionGroup("Cache could not be updated", exceptions)
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 )
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
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)
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)
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])
787 return None
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
797def get_random_quote_id() -> int:
798 """Get random quote id."""
799 return random.choice(tuple(QUOTES_CACHE))
802def get_random_author_id() -> int:
803 """Get random author id."""
804 return random.choice(tuple(AUTHORS_CACHE))
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 )
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.
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)
860class QuoteReadyCheckHandler(HTMLRequestHandler):
861 """Class that checks if quotes have been loaded."""
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")
870 async def prepare(self) -> None: # noqa: D102
871 await super().prepare()
872 if self.request.method != "OPTIONS":
873 await self.check_ready()
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)
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 )
899 for header, value in headers.items():
900 self.set_header(header, value)
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)