Coverage for an_website / quotes / create.py: 64.198%
162 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 to create new wrong quotes."""
16from __future__ import annotations
18import logging
19from dataclasses import dataclass
20from typing import Final, cast
22from tornado.web import HTTPError, MissingArgumentError
24from ..utils.data_parsing import parse_args
25from ..utils.utils import bounded_edit_distance
26from .utils import (
27 AUTHORS_CACHE,
28 QUOTES_CACHE,
29 Author,
30 Quote,
31 QuoteReadyCheckHandler,
32 fix_author_name,
33 fix_quote_str,
34 get_wrong_quote,
35 make_api_request,
36 parse_author,
37 parse_quote,
38 parse_wrong_quote,
39)
41LOGGER: Final = logging.getLogger(__name__)
44async def create_quote(quote_str: str, author: Author) -> Quote:
45 """Create a quote."""
46 quote_str = fix_quote_str(quote_str)
48 quote = get_quote_by_str(quote_str)
49 if quote is not None:
50 return quote
52 data = await make_api_request(
53 "quotes",
54 method="POST",
55 body={
56 "author": str(author.id),
57 "quote": quote_str,
58 },
59 entity_should_exist=False,
60 )
61 if data is None:
62 LOGGER.error("Failed to create quote: „%s“ - %s", quote_str, author)
63 raise HTTPError(500)
65 result = parse_quote(data)
67 LOGGER.info("Created quote %d: %r", result.id, result.quote)
69 return result
72async def create_author(author_str: str) -> Author:
73 """Create an author."""
74 author_str = fix_author_name(author_str)
76 author = get_author_by_name(author_str)
77 if author is not None:
78 return author
80 data = await make_api_request(
81 "authors",
82 method="POST",
83 body={"author": author_str},
84 entity_should_exist=False,
85 )
87 if data is None:
88 LOGGER.error("Failed to create author: %s", author_str)
89 raise HTTPError(500)
91 result = parse_author(data)
93 LOGGER.info("Created author %d: %r", result.id, result.name)
95 return result
98async def create_wrong_quote(
99 real_author_param: Author | str,
100 fake_author_param: Author | str,
101 quote_param: Quote | str,
102 *,
103 contributed_by: str | None = None,
104) -> str:
105 """Create a wrong quote and return the id in the q_id-a_id format."""
106 if isinstance(fake_author_param, str):
107 if not fake_author_param:
108 raise HTTPError(400, "Fake author is needed, but empty.")
109 fake_author = await create_author(fake_author_param)
110 else:
111 fake_author = fake_author_param
113 if isinstance(quote_param, str):
114 if not quote_param:
115 raise HTTPError(400, "Quote is needed, but empty.")
117 if isinstance(real_author_param, str):
118 if not real_author_param:
119 raise HTTPError(400, "Real author is needed, but empty.")
120 real_author = await create_author(real_author_param)
121 else:
122 real_author = real_author_param
123 quote = await create_quote(quote_param, real_author)
124 else:
125 quote = quote_param
127 wrong_quote = await get_wrong_quote(
128 quote.id, fake_author.id, use_cache=True
129 )
131 if not wrong_quote:
132 LOGGER.error(
133 "%r and %r were just created, wronquote should exist.",
134 quote,
135 fake_author,
136 )
137 raise HTTPError()
139 if wrong_quote.id == -1:
140 result = await make_api_request(
141 "wrongquotes",
142 method="POST",
143 body={
144 "author": fake_author.id,
145 "quote": quote.id,
146 "contributed_by": contributed_by or "Mithilfe von asozial.org",
147 },
148 entity_should_exist=False,
149 )
150 if result is None:
151 LOGGER.error(
152 "Failed to create wrong quote (%s).",
153 wrong_quote.get_id_as_str(True),
154 )
155 raise HTTPError(500)
156 wrong_quote = parse_wrong_quote(result)
157 LOGGER.info(
158 "Successfully created wrong quote: %s\n\n%s",
159 wrong_quote.get_id_as_str(True),
160 wrong_quote,
161 )
163 return wrong_quote.get_id_as_str()
166async def get_authors(author_name: str) -> list[Author | str]:
167 """Get the possible meant authors based on the str."""
168 author = get_author_by_name(author_name)
169 if author is not None:
170 return [author]
172 author_name_lower = author_name.lower()
173 max_distance = min(5, len(author_name) // 2 + 1)
174 authors: list[Author | str] = [
175 *(
176 author
177 for author in AUTHORS_CACHE.values()
178 if bounded_edit_distance(
179 author.name.lower(), author_name_lower, max_distance + 1
180 )
181 <= max_distance
182 ),
183 fix_author_name(author_name),
184 ]
185 # authors should be in most cases title case
186 fixed_author = author_name.title()
187 if fixed_author not in authors:
188 authors.append(fixed_author)
189 # no other fixes for authors that are less long
190 if len(author_name) < 2:
191 return authors
192 # maybe only the first letter upper case
193 fixed_author_2 = author_name[0].upper() + author_name[1:]
194 if fixed_author_2 not in authors:
195 authors.append(fixed_author_2)
196 return authors
199def get_author_by_name(name: str) -> None | Author:
200 """Get an author by its name."""
201 lower_name = fix_author_name(name).lower()
202 for author in AUTHORS_CACHE.values():
203 if author.name.lower() == lower_name:
204 return author
205 return None
208def get_quote_by_str(quote_str: str) -> None | Quote:
209 """Get an author by its name."""
210 lower_quote = fix_quote_str(quote_str).lower()
211 for quote in QUOTES_CACHE.values():
212 if quote.quote.lower() == lower_quote:
213 return quote
214 return None
217async def get_quotes(quote_str: str) -> list[Quote | str]:
218 """Get the possible meant quotes based on the str."""
219 quote: None | Quote = get_quote_by_str(quote_str)
220 if isinstance(quote, Quote):
221 return [quote]
223 lower_quote_str = quote_str.lower()
224 max_distance = min(16, len(quote_str) // 2 + 1)
225 quotes: list[Quote | str] = [
226 *(
227 quote
228 for quote in QUOTES_CACHE.values()
229 if bounded_edit_distance(
230 quote.quote.lower(), lower_quote_str, max_distance + 1
231 )
232 <= max_distance
233 ),
234 fix_quote_str(quote_str),
235 ]
236 if not (
237 quote_str.endswith("?")
238 or quote_str.endswith(".")
239 or quote_str.endswith("!")
240 ):
241 quotes.append(quote_str + ".")
242 return quotes
245@dataclass(slots=True, frozen=True)
246class QuoteInfoArgs:
247 """Class representing a quote id and an author id."""
249 quote: int | None = None
250 author: int | None = None
253class CreatePage1(QuoteReadyCheckHandler):
254 """The request handler for the create page."""
256 RATELIMIT_POST_LIMIT = 5
257 RATELIMIT_POST_COUNT_PER_PERIOD = 10
259 @parse_args(type_=QuoteInfoArgs)
260 async def get(self, *, args: QuoteInfoArgs, head: bool = False) -> None:
261 """Handle GET requests to the create page."""
262 if args.quote is not None and args.author is not None:
263 return self.redirect(f"/zitate/{args.quote}-{args.author}")
265 if head:
266 return
268 await self.render(
269 "pages/quotes/create1.html",
270 quotes=tuple(QUOTES_CACHE.values()),
271 authors=tuple(AUTHORS_CACHE.values()),
272 selected_quote=(
273 None if args.quote is None else QUOTES_CACHE.get(args.quote)
274 ),
275 selected_author=(
276 None if args.author is None else AUTHORS_CACHE.get(args.author)
277 ),
278 )
280 async def post(self) -> None:
281 """Handle POST requests to the create page."""
282 if self.get_argument("keine-aktion", ""):
283 LOGGER.info(
284 "Versuch Zitat zu erstellen mit keine-aktion Parameter.\n"
285 "Pfad: %s\nBody: %s",
286 self.request.path,
287 self.request.body,
288 )
289 await self.render(
290 "pages/empty.html",
291 text="Kein Zitat erstellt.",
292 )
293 return
295 user_name = self.get_argument("user-name", None)
296 quote_str = self.get_argument("quote-1", None)
297 fake_author_str = self.get_argument("fake-author-1", None)
298 if not (quote_str and fake_author_str):
299 raise HTTPError(
300 400,
301 reason=(
302 "Missing arguments. quote-1 and fake-author-1 are needed."
303 ),
304 )
305 quote: None | Quote = get_quote_by_str(quote_str)
306 fake_author: None | Author = get_author_by_name(fake_author_str)
308 if quote and fake_author:
309 wq_id = await create_wrong_quote(
310 real_author_param=quote.author,
311 fake_author_param=fake_author,
312 quote_param=quote,
313 contributed_by=user_name,
314 )
315 return self.redirect(
316 self.fix_url(f"/zitate/{wq_id}"),
317 status=303,
318 )
320 if not quote:
321 # TODO: search for real author, to reduce work for users
322 real_author_str = self.get_argument("real-author-1", None)
323 if not real_author_str:
324 raise HTTPError(
325 400, reason="Missing arguments. real-author-1 is needed."
326 )
328 quotes: list[Quote | str] = (
329 [quote] if quote else await get_quotes(quote_str)
330 )
331 real_authors: list[Author | str] = (
332 [quote.author]
333 if quote
334 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950
335 )
336 fake_authors: list[Author | str] = (
337 [fake_author] if fake_author else await get_authors(fake_author_str)
338 )
340 if 1 == len(quotes) == len(real_authors) == len(fake_authors):
341 LOGGER.info(
342 "Creating wrong quote using existing: %r (%r) - %r",
343 quotes[0],
344 real_authors[0],
345 fake_authors[0],
346 )
348 wq_id = await create_wrong_quote(
349 real_authors[0],
350 fake_authors[0],
351 quotes[0],
352 contributed_by=user_name,
353 )
354 return self.redirect(
355 self.fix_url(f"/zitate/{wq_id}"),
356 status=303,
357 )
359 await self.render(
360 "pages/quotes/create2.html",
361 quotes=quotes,
362 real_authors=real_authors,
363 fake_authors=fake_authors,
364 user_name=user_name,
365 )
368class CreatePage2(QuoteReadyCheckHandler):
369 """The request handler for the second part of the create page."""
371 RATELIMIT_POST_LIMIT = 2
372 RATELIMIT_POST_COUNT_PER_PERIOD = 1
373 RATELIMIT_POST_PERIOD = 45
375 async def post(self) -> None:
376 """Handle POST requests to the create page."""
377 if self.get_argument("nicht-erstellen", ""):
378 LOGGER.info(
379 "Versuch Zitat zu erstellen mit nicht-erstellen Parameter.\n"
380 "Pfad: %s\nBody: %s",
381 self.request.path,
382 self.request.body,
383 )
384 await self.render(
385 "pages/empty.html",
386 text="Kein Zitat erstellt.",
387 )
388 return
390 quote_str = self.get_argument("quote-2", None)
391 if not quote_str:
392 raise MissingArgumentError("quote-2")
393 fake_author_str = self.get_argument("fake-author-2", None)
394 if not fake_author_str:
395 raise MissingArgumentError("fake-author-2")
397 if (quote := get_quote_by_str(quote_str)) and (
398 fake_author := get_author_by_name(fake_author_str)
399 ):
400 # if selected existing quote and existing
401 # fake author just redirect to the page of this quote
402 return self.redirect(
403 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
404 status=303,
405 )
407 real_author = self.get_argument("real-author-2", None)
408 if not real_author:
409 raise MissingArgumentError("real-author-2")
411 wq_id = await create_wrong_quote(
412 real_author,
413 fake_author_str,
414 quote_str,
415 contributed_by=self.get_argument(
416 "user-name", "Nutzer von asozial.org"
417 ),
418 )
419 return self.redirect(
420 self.fix_url(
421 f"/zitate/{wq_id}",
422 ),
423 status=303,
424 )