Coverage for an_website / quotes / utils.py: 54.612%
412 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 20:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 20:05 +0000
1# This program is free software: you can redistribute it and/or modify
2# it under the terms of the GNU Affero General Public License as
3# published by the Free Software Foundation, either version 3 of the
4# License, or (at your option) any later version.
5#
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU Affero General Public License for more details.
10#
11# You should have received a copy of the GNU Affero General Public License
12# along with this program. If not, see <https://www.gnu.org/licenses/>.
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 | None:
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 | None:
130 """Fetch new data from the API."""
131 data = await make_api_request(
132 f"authors/{self.id}", entity_should_exist=True
133 )
134 return None if data is None else parse_author(data)
136 def get_path(self) -> str:
137 """Return the path to the author info."""
138 return f"/zitate/info/a/{self.id}"
140 def to_json(self) -> dict[str, Any]:
141 """Get the author as JSON."""
142 return {
143 "id": self.id,
144 "name": str(self),
145 "path": self.get_path(),
146 "info": (
147 {
148 "source": self.info[0],
149 "text": self.info[1],
150 "date": self.info[2].isoformat(),
151 }
152 if self.info
153 else None
154 ),
155 }
158@dataclass(slots=True)
159class Quote(QuotesObjBase):
160 """The quote object with a quote text and an author."""
162 quote: str
163 author_id: int
165 def __str__(self) -> str:
166 """Return the content of the quote."""
167 return self.quote.strip()
169 @property
170 def author(self) -> Author:
171 """Get the corresponding author object."""
172 return AUTHORS_CACHE[self.author_id]
174 @classmethod
175 def fetch_all_endpoint(cls) -> Literal["quotes"]:
176 """Endpoint to fetch all quotes."""
177 return "quotes"
179 async def fetch_new_data(self) -> Quote | None:
180 """Fetch new data from the API."""
181 data = await make_api_request(
182 f"quotes/{self.id}", entity_should_exist=True
183 )
184 if data is None:
185 return None
186 return parse_quote(data, self)
188 def get_path(self) -> str:
189 """Return the path to the quote info."""
190 return f"/zitate/info/z/{self.id}"
192 def to_json(self) -> dict[str, Any]:
193 """Get the quote as JSON."""
194 return {
195 "id": self.id,
196 "quote": str(self),
197 "author": self.author.to_json(),
198 "path": self.get_path(),
199 }
202@dataclass(slots=True)
203class WrongQuote(QuotesObjBase):
204 """The wrong quote object with a quote, an author and a rating."""
206 quote_id: int
207 author_id: int
208 rating: int
210 def __str__(self) -> str:
211 r"""
212 Return the wrong quote.
214 like: '»quote« - author'.
215 """
216 return f"»{self.quote}« - {self.author}"
218 @property
219 def author(self) -> Author:
220 """Get the corresponding author object."""
221 return AUTHORS_CACHE[self.author_id]
223 @classmethod
224 def fetch_all_endpoint(cls) -> Literal["wrongquotes"]:
225 """Endpoint to fetch all wrong quotes."""
226 return "wrongquotes"
228 async def fetch_new_data(self) -> WrongQuote:
229 """Fetch new data from the API."""
230 if self.id == -1:
231 api_data = await make_api_request(
232 "wrongquotes",
233 {
234 "quote": str(self.quote_id),
235 "simulate": "true",
236 "author": str(self.author_id),
237 },
238 entity_should_exist=True,
239 )
240 if api_data:
241 api_data = api_data[0]
242 else:
243 api_data = await make_api_request(
244 f"wrongquotes/{self.id}", entity_should_exist=True
245 )
246 if not api_data:
247 return self
248 return parse_wrong_quote(api_data, self)
250 def get_id(self) -> tuple[int, int]:
251 """
252 Get the id of the quote and the author in a tuple.
254 :return tuple(quote_id, author_id)
255 """
256 return self.quote_id, self.author_id
258 def get_id_as_str(self, minify: bool = False) -> str:
259 """
260 Get the id of the wrong quote as a string.
262 Format: quote_id-author_id
263 """
264 if minify and self.id != -1:
265 return str(self.id)
266 return f"{self.quote_id}-{self.author_id}"
268 def get_path(self) -> str:
269 """Return the path to the wrong quote."""
270 return f"/zitate/{self.get_id_as_str()}"
272 @property
273 def quote(self) -> Quote:
274 """Get the corresponding quote object."""
275 return QUOTES_CACHE[self.quote_id]
277 def to_json(self) -> dict[str, Any]:
278 """Get the wrong quote as JSON."""
279 return {
280 "id": self.get_id_as_str(),
281 "quote": self.quote.to_json(),
282 "author": self.author.to_json(),
283 "rating": self.rating,
284 "path": self.get_path(),
285 }
287 async def vote(
288 # pylint: disable=unused-argument
289 self,
290 vote: Literal[-1, 1],
291 lazy: bool = False,
292 ) -> WrongQuote | None:
293 """Vote for the wrong quote."""
294 if self.id == -1:
295 raise ValueError("Can't vote for a not existing quote.")
296 # if lazy: # simulate the vote and do the actual voting later
297 # self.rating += vote
298 # asyncio.get_running_loop().call_soon_threadsafe(
299 # self.vote,
300 # vote,
301 # )
302 # return self
303 # do the voting
304 data = await make_api_request(
305 f"wrongquotes/{self.id}",
306 method="POST",
307 body={"vote": str(vote)},
308 entity_should_exist=True,
309 )
310 if data is None:
311 self.id = -1
312 return None
314 return parse_wrong_quote(
315 data,
316 self,
317 )
320def get_wrong_quotes(
321 filter_fun: None | Callable[[WrongQuote], bool] = None,
322 *,
323 sort: bool = False, # sorted by rating
324 filter_real_quotes: bool = True,
325 shuffle: bool = False,
326) -> Sequence[WrongQuote]:
327 """Get cached wrong quotes."""
328 if shuffle and sort:
329 raise ValueError("Sort and shuffle can't be both true.")
330 wqs: list[WrongQuote] = list(WRONG_QUOTES_CACHE.values())
331 if filter_fun or filter_real_quotes:
332 for i in reversed(range(len(wqs))):
333 if (filter_fun and not filter_fun(wqs[i])) or (
334 filter_real_quotes
335 and wqs[i].quote.author_id == wqs[i].author_id
336 ):
337 del wqs[i]
338 if shuffle:
339 random.shuffle(wqs)
340 elif sort:
341 wqs.sort(key=lambda wq: wq.rating, reverse=True)
342 return wqs
345def get_quotes(
346 filter_fun: None | Callable[[Quote], bool] = None,
347 shuffle: bool = False,
348) -> list[Quote]:
349 """Get cached quotes."""
350 quotes: list[Quote] = list(QUOTES_CACHE.values())
351 if filter_fun:
352 for i in reversed(range(len(quotes))):
353 if not filter_fun(quotes[i]):
354 del quotes[i]
355 if shuffle:
356 random.shuffle(quotes)
357 return quotes
360def get_authors(
361 filter_fun: None | Callable[[Author], bool] = None,
362 shuffle: bool = False,
363) -> list[Author]:
364 """Get cached authors."""
365 authors: list[Author] = list(AUTHORS_CACHE.values())
366 if filter_fun:
367 for i in reversed(range(len(authors))):
368 if not filter_fun(authors[i]):
369 del authors[i]
370 if shuffle:
371 random.shuffle(authors)
372 return authors
375# pylint: disable-next=too-many-arguments
376async def make_api_request(
377 endpoint: str,
378 args: Mapping[str, str] | None = None,
379 *,
380 # pylint: disable-next=unused-argument
381 entity_should_exist: bool,
382 method: Literal["GET", "POST"] = "GET",
383 body: None | Mapping[str, str | int] = None,
384 request_timeout: float | None = None,
385) -> Any | None: # TODO: list[dict[str, Any]] | dict[str, Any] | None
386 """Make API request and return the result as dict."""
387 if pytest_is_running():
388 return None
389 query = f"?{urlencode(args)}" if args else ""
390 url = f"{API_URL}/{endpoint}{query}"
391 body_str = urlencode(body) if body else body
392 response = await AsyncHTTPClient().fetch(
393 url,
394 method=method,
395 headers={"Content-Type": "application/x-www-form-urlencoded"},
396 body=body_str,
397 raise_error=False,
398 ca_certs=CA_BUNDLE_PATH,
399 request_timeout=request_timeout,
400 )
401 if response.code != 200:
402 if response.code == 404:
403 return None
404 LOGGER.log(
405 logging.ERROR if response.code >= 500 else logging.WARNING,
406 "%s request to %r with body=%r failed with code=%d and reason=%r",
407 method,
408 url,
409 body_str,
410 response.code,
411 response.reason,
412 )
413 raise HTTPError(
414 503,
415 reason=f"{url} returned: {response.code} {response.reason}",
416 )
417 return json.loads(response.body)
420def fix_author_name(name: str) -> str:
421 """Fix common mistakes in authors."""
422 if len(name) > 2 and name.startswith("(") and name.endswith(")"):
423 # remove () from author name, that shouldn't be there
424 name = name[1:-1]
425 return name.strip()
428def parse_author(json_data: Mapping[str, Any]) -> Author:
429 """Parse an author from JSON data."""
430 id_ = int(json_data["id"])
431 name = fix_author_name(json_data["author"])
433 with AUTHORS_CACHE.lock:
434 author = AUTHORS_CACHE.get(id_)
435 if author is None:
436 # pylint: disable-next=too-many-function-args
437 author = Author(id_, name, None)
438 elif author.name != name:
439 author.name = name
440 author.info = None # reset info
442 AUTHORS_CACHE[author.id] = author
444 return author
447def fix_quote_str(quote_str: str) -> str:
448 """Fix common mistakes in quotes."""
449 if (
450 len(quote_str) > 2
451 and quote_str.startswith(('"', "„", "“"))
452 and quote_str.endswith(('"', "“", "”"))
453 ):
454 # remove quotation marks from quote, that shouldn't be there
455 quote_str = quote_str[1:-1]
457 return quote_str.strip()
460def parse_quote(
461 json_data: Mapping[str, Any], quote: None | Quote = None
462) -> Quote:
463 """Parse a quote from JSON data."""
464 quote_id = int(json_data["id"])
465 author = parse_author(json_data["author"]) # update author
466 quote_str = fix_quote_str(json_data["quote"])
468 with QUOTES_CACHE.lock:
469 if quote is None: # no quote supplied, try getting it from cache
470 quote = QUOTES_CACHE.get(quote_id)
471 if quote is None: # new quote
472 # pylint: disable=too-many-function-args
473 quote = Quote(quote_id, quote_str, author.id)
474 else: # quote was already saved
475 quote.quote = quote_str
476 quote.author_id = author.id
478 QUOTES_CACHE[quote.id] = quote
480 return quote
483def parse_wrong_quote(
484 json_data: Mapping[str, Any], wrong_quote: None | WrongQuote = None
485) -> WrongQuote:
486 """Parse a wrong quote and update the cache."""
487 quote = parse_quote(json_data["quote"])
488 author = parse_author(json_data["author"])
490 id_tuple = (quote.id, author.id)
491 rating = json_data["rating"]
492 wrong_quote_id = int(json_data.get("id") or -1)
494 if wrong_quote is None:
495 with WRONG_QUOTES_CACHE.lock:
496 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple)
497 if wrong_quote is None:
498 wrong_quote = (
499 WrongQuote( # pylint: disable=unexpected-keyword-arg
500 id=wrong_quote_id,
501 quote_id=quote.id,
502 author_id=author.id,
503 rating=rating,
504 )
505 )
506 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
507 return wrong_quote
509 # make sure the wrong quote is the correct one
510 if (wrong_quote.quote_id, wrong_quote.author_id) != id_tuple:
511 raise HTTPError(reason="ERROR: -41")
513 # update the data of the wrong quote
514 if wrong_quote.rating != rating:
515 wrong_quote.rating = rating
516 if wrong_quote.id != wrong_quote_id:
517 wrong_quote.id = wrong_quote_id
519 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
521 return wrong_quote
524async def parse_list_of_quote_data[Q: QuotesObjBase]( # noqa: D103
525 json_list: str | Iterable[Mapping[str, Any]],
526 parse_fun: Callable[[Mapping[str, Any]], Q],
527) -> tuple[Q, ...]:
528 """Parse a list of quote data."""
529 if not json_list:
530 return ()
531 if isinstance(json_list, str):
532 json_list = cast(list[dict[str, Any]], json.loads(json_list))
533 return_list = []
534 for json_data in json_list:
535 _ = parse_fun(json_data)
536 await asyncio.sleep(0)
537 return_list.append(_)
538 return tuple(return_list)
541async def update_cache_periodically(
542 app: Application, worker: int | None
543) -> None:
544 """Start updating the cache every hour."""
545 # pylint: disable=too-complex, too-many-branches
546 if "/troet" in typed_stream.Stream(
547 cast(Iterable[ModuleInfo], app.settings.get("MODULE_INFOS", ()))
548 ).map(lambda m: m.path):
549 app.settings["SHOW_SHARING_ON_MASTODON"] = True
550 if worker:
551 return
552 with contextlib.suppress(asyncio.TimeoutError):
553 await asyncio.wait_for(EVENT_REDIS.wait(), 5)
554 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS"))
555 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev")
556 apm: None | elasticapm.Client
557 if EVENT_REDIS.is_set(): # pylint: disable=too-many-nested-blocks
558 await parse_list_of_quote_data(
559 await redis.get(f"{prefix}:cached-quote-data:authors"), # type: ignore[arg-type] # noqa: B950
560 parse_author,
561 )
562 await parse_list_of_quote_data(
563 await redis.get(f"{prefix}:cached-quote-data:quotes"), # type: ignore[arg-type] # noqa: B950
564 parse_quote,
565 )
566 await parse_list_of_quote_data(
567 await redis.get(f"{prefix}:cached-quote-data:wrongquotes"), # type: ignore[arg-type] # noqa: B950
568 parse_wrong_quote,
569 )
570 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE:
571 last_update = await redis.get(
572 f"{prefix}:cached-quote-data:last-update"
573 )
574 if last_update:
575 last_update_int = int(last_update)
576 since_last_update = int(time.time()) - last_update_int
577 if 0 <= since_last_update < 60 * 60:
578 # wait until the last update is at least one hour old
579 update_cache_in = 60 * 60 - since_last_update
580 if not sys.flags.dev_mode and update_cache_in > 60:
581 # if in production mode update wrong quotes just to be sure
582 try:
583 await update_cache(
584 app, update_quotes=False, update_authors=False
585 )
586 except Exception: # pylint: disable=broad-except
587 LOGGER.exception("Updating quotes cache failed")
588 apm = app.settings.get("ELASTIC_APM", {}).get(
589 "CLIENT"
590 )
591 if apm:
592 apm.capture_exception()
593 else:
594 LOGGER.info("Updated quotes cache successfully")
595 LOGGER.info(
596 "Next update of quotes cache in %d seconds",
597 update_cache_in,
598 )
599 await asyncio.sleep(update_cache_in)
601 # update the cache every hour
602 failed = 0
603 while not EVENT_SHUTDOWN.is_set(): # pylint: disable=while-used
604 try:
605 await update_cache(app)
606 except Exception: # pylint: disable=broad-except
607 LOGGER.exception("Updating quotes cache failed")
608 if apm := app.settings.get("ELASTIC_APM", {}).get("CLIENT"):
609 apm.capture_exception()
610 failed += 1
611 await asyncio.sleep(pow(min(failed * 2, 60), 2)) # 4,16,...,60*60
612 else:
613 LOGGER.info("Updated quotes cache successfully")
614 failed = 0
615 await asyncio.sleep(60 * 60)
618async def update_cache( # pylint: disable=too-complex,too-many-branches,too-many-locals,too-many-statements # noqa: B950,C901
619 app: Application,
620 update_wrong_quotes: bool = True,
621 update_quotes: bool = True,
622 update_authors: bool = True,
623) -> None:
624 """Fill the cache with all data from the API."""
625 LOGGER.info("Updating quotes cache")
626 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS"))
627 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev")
628 redis_available = EVENT_REDIS.is_set()
629 exceptions: list[Exception] = []
631 if update_wrong_quotes:
632 try:
633 await _update_cache(WrongQuote, parse_wrong_quote, redis, prefix)
634 except Exception as err: # pylint: disable=broad-exception-caught
635 exceptions.append(err)
637 deleted_quotes: set[int] = set()
639 if update_quotes:
640 try:
641 quotes = await _update_cache(Quote, parse_quote, redis, prefix)
642 except Exception as err: # pylint: disable=broad-exception-caught
643 exceptions.append(err)
644 else:
645 with QUOTES_CACHE.lock:
646 all_quote_ids = {q.id for q in quotes}
647 max_quote_id = max(all_quote_ids)
648 old_ids_in_cache = {
649 _id for _id in QUOTES_CACHE if _id <= max_quote_id
650 }
651 deleted_quotes = old_ids_in_cache - all_quote_ids
652 for _id in deleted_quotes:
653 del QUOTES_CACHE[_id]
655 if len(QUOTES_CACHE) < len(quotes):
656 LOGGER.error("Cache has less elements than just fetched")
658 deleted_authors: set[int] = set()
660 if update_authors:
661 try:
662 authors = await _update_cache(Author, parse_author, redis, prefix)
663 except Exception as err: # pylint: disable=broad-exception-caught
664 exceptions.append(err)
665 else:
666 with AUTHORS_CACHE.lock:
667 all_author_ids = {q.id for q in authors}
668 max_author_id = max(all_author_ids)
669 old_ids_in_cache = {
670 _id for _id in AUTHORS_CACHE if _id <= max_author_id
671 }
672 deleted_authors = old_ids_in_cache - all_author_ids
673 for _id in deleted_authors:
674 del AUTHORS_CACHE[_id]
676 if len(AUTHORS_CACHE) < len(authors):
677 LOGGER.error("Cache has less elements than just fetched")
679 if deleted_authors or deleted_quotes:
680 deleted_wrong_quotes: set[tuple[int, int]] = set()
681 with WRONG_QUOTES_CACHE.lock:
682 for qid, aid in tuple(WRONG_QUOTES_CACHE):
683 if qid in deleted_quotes or aid in deleted_authors:
684 deleted_wrong_quotes.add((qid, aid))
685 del WRONG_QUOTES_CACHE[(qid, aid)]
686 LOGGER.warning(
687 "Deleted %d wrong quotes: %r",
688 len(deleted_wrong_quotes),
689 deleted_wrong_quotes,
690 )
692 if exceptions:
693 raise ExceptionGroup("Cache could not be updated", exceptions)
695 if (
696 redis_available
697 and update_wrong_quotes
698 and update_quotes
699 and update_authors
700 ):
701 await redis.setex(
702 f"{prefix}:cached-quote-data:last-update",
703 60 * 60 * 24 * 30,
704 int(time.time()),
705 )
708async def _update_cache[Q: QuotesObjBase](
709 klass: type[Q],
710 parse: Callable[[Mapping[str, Any]], Q],
711 redis: Redis[str],
712 redis_prefix: str,
713) -> tuple[Q, ...]:
714 wq_data = await make_api_request(
715 klass.fetch_all_endpoint(), entity_should_exist=True
716 )
717 if wq_data is None:
718 LOGGER.error("%s returned 404", klass.fetch_all_endpoint())
719 return ()
720 parsed_data = await parse_list_of_quote_data(
721 wq_data,
722 parse,
723 )
724 if wq_data and EVENT_REDIS.is_set():
725 await redis.setex(
726 f"{redis_prefix}:cached-quote-data:{klass.fetch_all_endpoint()}",
727 60 * 60 * 24 * 30,
728 json.dumps(wq_data, option=ORJSON_OPTIONS),
729 )
730 return parsed_data
733async def get_author_by_id(author_id: int) -> Author | None:
734 """Get an author by its id."""
735 author = AUTHORS_CACHE.get(author_id)
736 if author is not None:
737 return author
738 data = await make_api_request(
739 f"authors/{author_id}", entity_should_exist=False
740 )
741 if data is None:
742 return None
743 return parse_author(data)
746async def get_quote_by_id(quote_id: int) -> Quote | None:
747 """Get a quote by its id."""
748 quote = QUOTES_CACHE.get(quote_id)
749 if quote is not None:
750 return quote
751 data = await make_api_request(
752 f"quotes/{quote_id}", entity_should_exist=False
753 )
754 if data is None:
755 return None
756 return parse_quote(data)
759async def get_wrong_quote(
760 quote_id: int, author_id: int, use_cache: bool = True
761) -> WrongQuote | None:
762 """Get a wrong quote with a quote id and an author id."""
763 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id))
764 if wrong_quote:
765 if use_cache:
766 return wrong_quote
767 # do not use cache, so update the wrong quote data
768 return await wrong_quote.fetch_new_data()
769 # wrong quote not in cache
770 if use_cache and quote_id in QUOTES_CACHE and author_id in AUTHORS_CACHE:
771 # we don't need to request anything, as the wrong_quote probably has
772 # no ratings just use the cached quote and author
773 # pylint: disable-next=too-many-function-args
774 return WrongQuote(-1, quote_id, author_id, 0)
775 # request the wrong quote from the API
776 result = await make_api_request(
777 "wrongquotes",
778 {
779 "quote": str(quote_id),
780 "simulate": "true",
781 "author": str(author_id),
782 },
783 entity_should_exist=False,
784 )
785 if result:
786 return parse_wrong_quote(result[0])
788 return None
791async def get_rating_by_id(quote_id: int, author_id: int) -> int | None:
792 """Get the rating of a wrong quote."""
793 if wq := await get_wrong_quote(quote_id, author_id):
794 return wq.rating
795 return None
798def get_random_quote_id() -> int:
799 """Get random quote id."""
800 return random.choice(tuple(QUOTES_CACHE))
803def get_random_author_id() -> int:
804 """Get random author id."""
805 return random.choice(tuple(AUTHORS_CACHE))
808def get_random_id() -> tuple[int, int]:
809 """Get random wrong quote id."""
810 return (
811 get_random_quote_id(),
812 get_random_author_id(),
813 )
816async def create_wq_and_vote(
817 vote: Literal[-1, 1],
818 quote_id: int,
819 author_id: int,
820 contributed_by: str,
821 fast: bool = False,
822) -> WrongQuote:
823 """
824 Vote for the wrong_quote with the API.
826 If the wrong_quote doesn't exist yet, create it.
827 """
828 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id))
829 if (
830 wrong_quote
831 and wrong_quote.id != -1
832 and (result := await wrong_quote.vote(vote, fast)) is not None
833 ):
834 return result
835 # we don't know the wrong_quote_id, so we have to create the wrong_quote
836 data = await make_api_request(
837 "wrongquotes",
838 method="POST",
839 body={
840 "quote": str(quote_id),
841 "author": str(author_id),
842 "contributed_by": contributed_by,
843 },
844 entity_should_exist=True,
845 )
846 if data is None:
847 LOGGER.error(
848 "Creating wrong quote (%s-%s) failed with 404", quote_id, author_id
849 )
850 raise HTTPError(500)
851 wrong_quote = parse_wrong_quote(data)
852 if (result := await wrong_quote.vote(vote, lazy=True)) is not None:
853 return result
854 LOGGER.error(
855 "Voting just created wrong quote (%s) failed with 404",
856 wrong_quote.get_id_as_str(True),
857 )
858 raise HTTPError(500)
861class QuoteReadyCheckHandler(HTMLRequestHandler):
862 """Class that checks if quotes have been loaded."""
864 async def check_ready(self) -> None:
865 """Fail if quotes aren't ready yet."""
866 if not WRONG_QUOTES_CACHE:
867 # should work in a few seconds, the quotes just haven't loaded yet
868 self.set_header("Retry-After", "5")
869 raise HTTPError(503, reason="Service available in a few seconds")
871 async def prepare(self) -> None: # noqa: D102
872 await super().prepare()
873 if self.request.method != "OPTIONS":
874 await self.check_ready()
876 if ( # pylint: disable=too-many-boolean-expressions
877 self.settings.get("RATELIMITS")
878 and self.request.method not in {"HEAD", "OPTIONS"}
879 and not self.is_authorized(Permission.RATELIMITS)
880 and not self.crawler
881 and (
882 self.request.path.endswith(".xlsx")
883 or self.content_type == "application/vnd.ms-excel"
884 )
885 ):
886 if self.settings.get("UNDER_ATTACK") or not EVENT_REDIS.is_set():
887 raise HTTPError(503)
889 ratelimited, headers = await ratelimit(
890 self.redis,
891 self.redis_prefix,
892 str(self.request.remote_ip),
893 bucket="quotes:image:xlsx",
894 max_burst=4,
895 count_per_period=1,
896 period=60,
897 tokens=1 if self.request.method != "HEAD" else 0,
898 )
900 for header, value in headers.items():
901 self.set_header(header, value)
903 if ratelimited:
904 if self.now.date() == date(self.now.year, 4, 20):
905 self.set_status(420)
906 self.write_error(420)
907 else:
908 self.set_status(429)
909 self.write_error(429)