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

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/>. 

13 

14""" 

15A page with wrong quotes. 

16 

17It displays funny, but wrong, quotes. 

18""" 

19 

20from __future__ import annotations 

21 

22import asyncio 

23import logging 

24import random 

25from asyncio import AbstractEventLoop, Future 

26from dataclasses import dataclass 

27from typing import Any, ClassVar, Final, Literal, TypeAlias, cast 

28 

29import regex 

30from tornado.web import HTTPError 

31 

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) 

48 

49LOGGER: Final = logging.getLogger(__name__) 

50 

51 

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) 

58 

59 

60@dataclass(frozen=True) 

61class VoteArgument: 

62 """Voting matters.""" 

63 

64 vote: Literal[-1, 0, 1] 

65 

66 

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" 

81 

82 

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 

87 

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() 

109 

110 if not wrong_quotes: 

111 # invalid rating filter or no wrong quotes with that filter 

112 return get_random_id() 

113 

114 return random.choice(wrong_quotes).get_id() # nosec: B311 

115 

116 

117class QuoteBaseHandler(QuoteReadyCheckHandler): 

118 """The base request handler for the quotes package.""" 

119 

120 RATELIMIT_GET_LIMIT = 20 

121 RATELIMIT_GET_COUNT_PER_PERIOD = 20 

122 RATELIMIT_GET_PERIOD = 10 

123 

124 FUTURES: set[Future[Any]] = set() 

125 

126 loop: AbstractEventLoop 

127 next_id: tuple[int, int] 

128 rating_filter: RatingFilter 

129 

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) 

142 

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 ) 

150 

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) 

154 

155 def on_finish(self) -> None: 

156 """ 

157 Pre-fetch the data for the next quote. 

158 

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 

168 

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 

172 

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) 

179 

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) 

186 

187 

188class QuoteMainPage(QuoteBaseHandler, QuoteOfTheDayBaseHandler): 

189 """The main quote page that explains everything and links to stuff.""" 

190 

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 

199 

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 ) 

216 

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}") 

223 

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) 

229 

230 

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 } 

252 

253 

254class QuoteById(QuoteBaseHandler): 

255 """The page with a specified quote that then gets rendered.""" 

256 

257 RATELIMIT_POST_LIMIT: ClassVar[int] = 10 

258 RATELIMIT_POST_COUNT_PER_PERIOD: ClassVar[int] = 5 

259 RATELIMIT_POST_PERIOD: ClassVar[int] = 10 

260 

261 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ( 

262 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES, 

263 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

264 *IMAGE_CONTENT_TYPES, 

265 ) 

266 

267 LONG_PATH: ClassVar[str] = "/zitate/%d-%d" 

268 

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())) 

279 

280 if head: 

281 return 

282 

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 ) 

310 

311 await self.render_quote(int_quote_id, int(author_id)) 

312 

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 

321 

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) 

338 

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 ) 

345 

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. 

351 

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 

368 

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. 

379 

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())) 

388 

389 author_id = int(author_id_str) 

390 

391 new_vote_str = self.get_argument("vote", None) 

392 

393 if not new_vote_str: 

394 return await self.render_quote(quote_id, author_id) 

395 

396 old_vote: Literal[-1, 0, 1] = await self.get_old_vote( 

397 quote_id, author_id 

398 ) 

399 

400 new_vote: Literal[-1, 0, 1] = args.vote 

401 vote_diff: int = new_vote - old_vote 

402 

403 if not vote_diff: # == 0 

404 return await self.render_quote(quote_id, author_id) 

405 

406 await self.update_saved_votes(quote_id, author_id, new_vote) 

407 

408 to_vote = cast(Literal[-1, 1], -1 if vote_diff < 0 else 1) 

409 

410 contributed_by = f"an-website_{hash_ip(self.request.remote_ip, 10)}" 

411 

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) 

418 

419 await self.render_wrong_quote(wrong_quote, new_vote) 

420 

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 ) 

427 

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 ) 

453 

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") 

469 

470 

471# pylint: disable-next=too-many-ancestors 

472class QuoteAPIHandler(APIRequestHandler, QuoteById): 

473 """API request handler for the quotes page.""" 

474 

475 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET", "POST") 

476 

477 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ( 

478 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

479 *IMAGE_CONTENT_TYPES, 

480 ) 

481 

482 LONG_PATH: ClassVar[str] = "/api/zitate/%d-%d" 

483 

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 ) 

499 

500 

501class QuoteRedirectAPI(APIRequestHandler, QuoteBaseHandler): 

502 """Redirect to the API for a random quote.""" 

503 

504 POSSIBLE_CONTENT_TYPES = QuoteAPIHandler.POSSIBLE_CONTENT_TYPES 

505 

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 )