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