Coverage for an_website / quotes / quotes.py: 72.642%
212 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 17:35 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 17:35 +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"""
15A page with wrong quotes.
17It displays funny, but wrong, quotes.
18"""
20import asyncio
21import logging
22import random
23from asyncio import AbstractEventLoop, Future
24from dataclasses import dataclass
25from typing import Any, ClassVar, Final, Literal, TypeAlias
27import regex
28from tornado.web import HTTPError
30from .. import EVENT_REDIS
31from ..utils.data_parsing import parse_args
32from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler
33from ..utils.utils import hash_ip
34from .image import IMAGE_CONTENT_TYPES, create_image
35from .quote_of_the_day import QuoteOfTheDayBaseHandler
36from .utils import (
37 WRONG_QUOTES_CACHE,
38 QuoteReadyCheckHandler,
39 WrongQuote,
40 create_wq_and_vote,
41 get_authors,
42 get_random_id,
43 get_wrong_quote,
44 get_wrong_quotes,
45)
47LOGGER: Final = logging.getLogger(__name__)
50RatingFilter: TypeAlias = Literal["w", "n", "unrated", "rated", "all", "smart"]
51SMART_RATING_FILTERS: Final[tuple[RatingFilter, ...]] = (
52 *(("n",) * 1),
53 *(("all",) * 5),
54 *(("w",) * 5),
55)
58@dataclass(frozen=True)
59class VoteArgument:
60 """Voting matters."""
62 vote: Literal[-1, 0, 1]
65def parse_rating_filter(rating_filter_str: str) -> RatingFilter:
66 """Get a rating filter."""
67 match rating_filter_str:
68 case "w":
69 return "w"
70 case "n":
71 return "n"
72 case "unrated":
73 return "unrated"
74 case "rated":
75 return "rated"
76 case "all":
77 return "all"
78 return "smart"
81# pylint: disable-next=too-complex
82def get_next_id(rating_filter: RatingFilter) -> tuple[int, int]:
83 """Get the id of the next quote."""
84 if rating_filter == "smart":
85 rating_filter = random.choice(SMART_RATING_FILTERS) # nosec: B311
87 match rating_filter:
88 case "unrated":
89 # get a random quote, but filter out already rated quotes
90 # pylint: disable=while-used
91 while (ids := get_random_id()) in WRONG_QUOTES_CACHE:
92 if WRONG_QUOTES_CACHE[ids].id == -1:
93 # check for wrong quotes that are unrated but in the cache
94 # they don't have a real wrong_quotes_id
95 return ids
96 return ids
97 case "all":
98 return get_random_id()
99 case "w":
100 wrong_quotes = get_wrong_quotes(lambda wq: wq.rating > 0)
101 case "n":
102 wrong_quotes = get_wrong_quotes(lambda wq: wq.rating < 0)
103 case "rated":
104 wrong_quotes = get_wrong_quotes(lambda wq: wq.id not in {-1, None})
105 case _:
106 LOGGER.error("Invalid rating filter %s", rating_filter)
107 return get_random_id()
109 if not wrong_quotes:
110 # invalid rating filter or no wrong quotes with that filter
111 return get_random_id()
113 return random.choice(wrong_quotes).get_id() # nosec: B311
116class QuoteBaseHandler(QuoteReadyCheckHandler):
117 """The base request handler for the quotes package."""
119 RATELIMIT_GET_LIMIT = 20
120 RATELIMIT_GET_COUNT_PER_PERIOD = 20
121 RATELIMIT_GET_PERIOD = 10
123 FUTURES: set[Future[Any]] = set()
125 loop: AbstractEventLoop
126 next_id: tuple[int, int]
127 rating_filter: RatingFilter
129 def future_callback(self, future: Future[WrongQuote | None]) -> None:
130 """Discard the future and log the exception if one occured."""
131 self.FUTURES.discard(future)
132 if exc := future.exception():
133 LOGGER.error(
134 "Failed to pre-fetch quote %d-%d (%s)",
135 *self.next_id,
136 exc,
137 exc_info=(type(exc), exc, exc.__traceback__),
138 )
139 else:
140 LOGGER.debug("Pre-fetched quote %d-%d", *self.next_id)
142 def get_next_url(self) -> str:
143 """Get the URL of the next quote."""
144 return self.fix_url(
145 f"/zitate/{self.next_id[0]}-{self.next_id[1]}",
146 query_args={
147 "r": (
148 None
149 if self.rating_filter == "smart"
150 else self.rating_filter
151 ),
152 "show-rating": self.get_show_rating() or None,
153 },
154 )
156 def get_show_rating(self) -> bool:
157 """Return whether the user wants to see the rating."""
158 return self.get_bool_argument("show-rating", False)
160 def on_finish(self) -> None:
161 """
162 Pre-fetch the data for the next quote.
164 This is done to show the users less out-of-date data.
165 """
166 if len(self.FUTURES) > 1 or (
167 self.content_type
168 and self.content_type.startswith("image/")
169 or self.content_type
170 in {"application/pdf", "application/vnd.ms-excel"}
171 ):
172 return # don't spam and don't do this for images
174 user_agent = self.request.headers.get("User-Agent")
175 if not user_agent or "bot" in user_agent.lower():
176 return # don't do this for bots
178 if hasattr(self, "loop"):
179 task = self.loop.create_task(
180 get_wrong_quote(*self.next_id, use_cache=False)
181 )
182 self.FUTURES.add(task)
183 task.add_done_callback(self.future_callback)
185 async def prepare(self) -> None:
186 """Set the id of the next wrong_quote to show."""
187 await super().prepare()
188 self.loop = asyncio.get_running_loop()
189 self.rating_filter = parse_rating_filter(self.get_argument("r", ""))
190 self.next_id = get_next_id(self.rating_filter)
193class QuoteMainPage(QuoteBaseHandler, QuoteOfTheDayBaseHandler):
194 """The main quote page that explains everything and links to stuff."""
196 async def get(self, *, head: bool = False) -> None:
197 """Render the main quote page, with a few links."""
198 # pylint: disable=unused-argument
199 quote = self.get_argument("quote", "")
200 author = self.get_argument("author", "")
201 if (quote or author) and regex.fullmatch(r"^[0-9]+$", quote + author):
202 self.redirect(self.fix_url(f"/zitate/{quote}-{author}"))
203 return
205 await self.render(
206 "pages/quotes/main_page.html",
207 funny_quote_url=self.id_to_url(
208 *(
209 get_wrong_quotes(lambda wq: wq.rating > 0, shuffle=True)[
210 0
211 ].get_id()
212 ),
213 rating_param="w",
214 ),
215 random_quote_url=self.id_to_url(*self.next_id),
216 quote_of_the_day=self.redis and await self.get_quote_of_today(), # type: ignore[truthy-bool]
217 one_stone_url=self.get_author_url("Albert Einstein"),
218 kangaroo_url=self.get_author_url("Das Känguru"),
219 muk_url=self.get_author_url("Marc-Uwe Kling"),
220 )
222 def get_author_url(self, author_name: str) -> None | str:
223 """Get the info URL of an author."""
224 authors = get_authors(lambda _a: _a.name.lower() == author_name.lower())
225 if not authors:
226 return None
227 return self.fix_url(f"/zitate/info/a/{authors[0].id}")
229 def id_to_url(
230 self, quote_id: int, author_id: int, rating_param: None | str = None
231 ) -> str:
232 """Get the URL of a quote."""
233 return self.fix_url(
234 f"/zitate/{quote_id}-{author_id}",
235 query_args={"r": rating_param},
236 )
239def wrong_quote_to_json(
240 wq_: WrongQuote, vote: int, next_: tuple[int, int], rating: str, full: bool
241) -> dict[str, Any]: # TODO: improve lazy return type typing
242 """Convert a wrong quote to a dict."""
243 next_q, next_a = next_
244 if full:
245 return {
246 "wrong_quote": wq_.to_json(),
247 "next": f"{next_q}-{next_a}",
248 "vote": vote,
249 }
250 return {
251 "id": wq_.get_id_as_str(),
252 "quote": str(wq_.quote),
253 "author": str(wq_.author),
254 "real_author": str(wq_.quote.author),
255 "real_author_id": wq_.quote.author_id,
256 "rating": rating,
257 "vote": vote,
258 "next": f"{next_q}-{next_a}",
259 }
262class QuoteById(QuoteBaseHandler):
263 """The page with a specified quote that then gets rendered."""
265 RATELIMIT_POST_LIMIT: ClassVar[int] = 10
266 RATELIMIT_POST_COUNT_PER_PERIOD: ClassVar[int] = 5
267 RATELIMIT_POST_PERIOD: ClassVar[int] = 10
269 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = (
270 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES,
271 *APIRequestHandler.POSSIBLE_CONTENT_TYPES,
272 *IMAGE_CONTENT_TYPES,
273 )
275 LONG_PATH: ClassVar[str] = "/zitate/%d-%d"
277 async def get(
278 self, quote_id: str, author_id: None | str = None, *, head: bool = False
279 ) -> None:
280 """Handle GET requests to this page and render the quote."""
281 int_quote_id = int(quote_id)
282 if author_id is None:
283 wqs = get_wrong_quotes(lambda wq: wq.id == int_quote_id)
284 if not wqs:
285 raise HTTPError(404, f"No wrong quote with id {quote_id}")
286 return self.redirect(self.fix_url(self.LONG_PATH % wqs[0].get_id()))
288 if head:
289 return
291 if (
292 self.content_type
293 and self.content_type.startswith("image/")
294 or self.content_type
295 in {"application/pdf", "application/vnd.ms-excel"}
296 ):
297 wrong_quote = await get_wrong_quote(int_quote_id, int(author_id))
298 if not wrong_quote:
299 raise HTTPError(404, reason="Falsches Zitat nicht gefunden")
300 return await self.finish(
301 await asyncio.to_thread(
302 create_image,
303 wrong_quote.quote.quote,
304 wrong_quote.author.name,
305 wrong_quote.rating,
306 f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}",
307 (
308 self.content_type.removeprefix("image/").removeprefix(
309 "x-"
310 )
311 if self.content_type.startswith("image/")
312 else {
313 "application/pdf": "pdf",
314 "application/vnd.ms-excel": "xlsx",
315 }[self.content_type]
316 ),
317 wq_id=wrong_quote.get_id_as_str(),
318 )
319 )
321 await self.render_quote(int_quote_id, int(author_id))
323 async def get_old_vote(
324 self, quote_id: int, author_id: int
325 ) -> Literal[-1, 0, 1]:
326 """Get the old vote from the saved vote."""
327 old_vote = await self.get_saved_vote(quote_id, author_id)
328 if old_vote is None:
329 return 0
330 return old_vote
332 async def get_rating_str(self, wrong_quote: WrongQuote) -> str:
333 """Get the rating str to display on the page."""
334 if wrong_quote.id in {None, -1}:
335 return "---"
336 if (
337 not self.get_show_rating() # don't hide the rating on wish of user
338 and self.rating_filter == "smart"
339 and self.request.method
340 and self.request.method.upper() == "GET"
341 and await self.get_saved_vote(
342 wrong_quote.quote_id, wrong_quote.author_id
343 )
344 is None
345 ):
346 return "???"
347 return str(wrong_quote.rating)
349 def get_redis_votes_key(self, quote_id: int, author_id: int) -> str:
350 """Get the key to save the votes with Redis."""
351 return (
352 f"{self.redis_prefix}:quote-votes:"
353 f"{self.get_user_id()}:{quote_id}-{author_id}"
354 )
356 async def get_saved_vote(
357 self, quote_id: int, author_id: int
358 ) -> None | Literal[-1, 0, 1]:
359 """
360 Get the vote of the current user saved with Redis.
362 Use the quote_id and author_id to query the vote.
363 Return None if nothing is saved.
364 """
365 if not EVENT_REDIS.is_set():
366 LOGGER.warning("No Redis connection")
367 return 0
368 result = await self.redis.get(
369 self.get_redis_votes_key(quote_id, author_id)
370 )
371 if result == "-1":
372 return -1
373 if result == "0":
374 return 0
375 if result == "1":
376 return 1
377 return None
379 @parse_args(type_=VoteArgument)
380 async def post(
381 self,
382 quote_id_str: str,
383 author_id_str: str | None = None,
384 *,
385 args: VoteArgument,
386 ) -> None:
387 """
388 Handle POST requests to this page and render the quote.
390 This is used to vote the quote, without changing the URL.
391 """
392 quote_id = int(quote_id_str)
393 if author_id_str is None:
394 wqs = get_wrong_quotes(lambda wq: wq.id == quote_id)
395 if not wqs:
396 raise HTTPError(404, f"No wrong quote with id {quote_id}")
397 return self.redirect(self.fix_url(self.LONG_PATH % wqs[0].get_id()))
399 author_id = int(author_id_str)
401 new_vote_str = self.get_argument("vote", None)
403 if not new_vote_str:
404 return await self.render_quote(quote_id, author_id)
406 old_vote: Literal[-1, 0, 1] = await self.get_old_vote(
407 quote_id, author_id
408 )
410 new_vote: Literal[-1, 0, 1] = args.vote
411 vote_diff: int = new_vote - old_vote
413 if not vote_diff: # == 0
414 return await self.render_quote(quote_id, author_id)
416 await self.update_saved_votes(quote_id, author_id, new_vote)
418 to_vote: Literal[-1, 1] = -1 if vote_diff < 0 else 1
420 contributed_by = f"an-website_{hash_ip(self.request.remote_ip, 10)}"
422 # do the voting
423 wrong_quote = await create_wq_and_vote(
424 to_vote, quote_id, author_id, contributed_by
425 )
426 if abs(vote_diff) == 2:
427 await wrong_quote.vote(to_vote, lazy=True)
429 await self.render_wrong_quote(wrong_quote, new_vote)
431 async def render_quote(self, quote_id: int, author_id: int) -> None:
432 """Get and render a wrong quote based on author id and author id."""
433 await self.render_wrong_quote(
434 await get_wrong_quote(quote_id, author_id),
435 await self.get_old_vote(quote_id, author_id),
436 )
438 async def render_wrong_quote(
439 self, wrong_quote: WrongQuote | None, vote: int
440 ) -> None:
441 """Render the page with the wrong_quote and this vote."""
442 if not wrong_quote:
443 # TODO: maybe show 404 inside of the quotes ui
444 self.set_status(404, reason="Zitat nicht gefunden")
445 self.write_error(404)
446 return
448 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES:
449 return await self.finish(
450 wrong_quote_to_json(
451 wrong_quote,
452 vote,
453 self.next_id,
454 await self.get_rating_str(wrong_quote),
455 self.get_bool_argument("full", False),
456 )
457 )
459 await self.render(
460 "pages/quotes/quotes.html",
461 wrong_quote=wrong_quote,
462 next_href=self.get_next_url(),
463 next_id=f"{self.next_id[0]}-{self.next_id[1]}",
464 description=str(wrong_quote),
465 rating_filter=self.rating_filter,
466 rating=await self.get_rating_str(wrong_quote),
467 show_rating=self.get_show_rating(),
468 vote=vote,
469 )
471 async def update_saved_votes(
472 self, quote_id: int, author_id: int, vote: int
473 ) -> None:
474 """Save the new vote in Redis."""
475 if not EVENT_REDIS.is_set():
476 raise HTTPError(503)
477 result = await self.redis.setex(
478 self.get_redis_votes_key(quote_id, author_id),
479 60 * 60 * 24 * 90, # time to live in seconds (3 months)
480 str(vote), # value to save (the vote)
481 )
482 if result:
483 return
484 LOGGER.warning("Could not save vote in Redis: %s", result)
485 raise HTTPError(500, "Could not save vote")
488# pylint: disable-next=too-many-ancestors
489class QuoteAPIHandler(APIRequestHandler, QuoteById):
490 """API request handler for the quotes page."""
492 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET", "POST")
494 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = (
495 *APIRequestHandler.POSSIBLE_CONTENT_TYPES,
496 *IMAGE_CONTENT_TYPES,
497 )
499 LONG_PATH: ClassVar[str] = "/api/zitate/%d-%d"
501 async def render_wrong_quote(
502 self, wrong_quote: WrongQuote | None, vote: int
503 ) -> None:
504 """Return the relevant data for the quotes page as JSON."""
505 if not wrong_quote:
506 return await super().render_wrong_quote(wrong_quote, vote)
507 if self.content_type == "text/plain":
508 return await self.finish(str(wrong_quote))
509 return await self.finish(
510 wrong_quote_to_json(
511 wrong_quote,
512 vote,
513 self.next_id,
514 await self.get_rating_str(wrong_quote),
515 self.request.path.endswith("/full"),
516 )
517 )
520class QuoteRedirectAPI(APIRequestHandler, QuoteBaseHandler):
521 """Redirect to the API for a random quote."""
523 POSSIBLE_CONTENT_TYPES = QuoteAPIHandler.POSSIBLE_CONTENT_TYPES
525 async def get( # pylint: disable=unused-argument
526 self, suffix: str = "", *, head: bool = False
527 ) -> None:
528 """Redirect to a random funny quote."""
529 next_filter = parse_rating_filter(self.get_argument("r", "") or "w")
530 quote_id, author_id = get_next_id(next_filter)
531 kwargs: dict[str, str] = {"r": next_filter}
532 if self.get_show_rating():
533 kwargs["show-rating"] = "sure"
534 return self.redirect(
535 self.fix_url(
536 f"/api/zitate/{quote_id}-{author_id}{suffix}",
537 query_args=kwargs,
538 )
539 )