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