Coverage for an_website/quotes/create.py: 63.975%
161 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 18:56 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 18:56 +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(
66 "Created quote %d: %r - %r",
67 result.id,
68 result.quote,
69 result.author.name,
70 )
72 return result
75async def create_author(author_str: str) -> Author:
76 """Create an author."""
77 author_str = fix_author_name(author_str)
79 author = get_author_by_name(author_str)
80 if author is not None:
81 return author
83 data = await make_api_request(
84 "authors",
85 method="POST",
86 body={"author": author_str},
87 entity_should_exist=False,
88 )
90 if data is None:
91 LOGGER.error("Failed to create author: %s", author_str)
92 raise HTTPError(500)
94 result = parse_author(data)
96 LOGGER.info("Created author %d: %r", result.id, result.name)
98 return result
101async def create_wrong_quote(
102 real_author_param: Author | str,
103 fake_author_param: Author | str,
104 quote_param: Quote | str,
105 *,
106 contributed_by: str | None = None,
107) -> str:
108 """Create a wrong quote and return the id in the q_id-a_id format."""
109 if isinstance(fake_author_param, str):
110 if not fake_author_param:
111 raise HTTPError(400, "Fake author is needed, but empty.")
112 fake_author = await create_author(fake_author_param)
113 else:
114 fake_author = fake_author_param
116 if isinstance(quote_param, str):
117 if not quote_param:
118 raise HTTPError(400, "Quote is needed, but empty.")
120 if isinstance(real_author_param, str):
121 if not real_author_param:
122 raise HTTPError(400, "Real author is needed, but empty.")
123 real_author = await create_author(real_author_param)
124 else:
125 real_author = real_author_param
126 quote = await create_quote(quote_param, real_author)
127 else:
128 quote = quote_param
130 wrong_quote = await get_wrong_quote(
131 quote.id, fake_author.id, use_cache=True
132 )
134 if not wrong_quote:
135 LOGGER.error(
136 "%r and %r were just created, wronquote should exist.",
137 quote,
138 fake_author,
139 )
140 raise HTTPError()
142 if wrong_quote.id == -1:
143 result = await make_api_request(
144 "wrongquotes",
145 method="POST",
146 body={
147 "author": fake_author.id,
148 "quote": quote.id,
149 "contributed_by": contributed_by or "Mithilfe von asozial.org",
150 },
151 entity_should_exist=False,
152 )
153 if result is None:
154 LOGGER.error(
155 "Failed to create wrong quote (%s).",
156 wrong_quote.get_id_as_str(True),
157 )
158 raise HTTPError(500)
159 wrong_quote = parse_wrong_quote(result)
160 LOGGER.info(
161 "Successfully created wrong quote: %s\n\n%s",
162 wrong_quote.get_id_as_str(True),
163 wrong_quote,
164 )
166 return wrong_quote.get_id_as_str()
169async def get_authors(author_name: str) -> list[Author | str]:
170 """Get the possible meant authors based on the str."""
171 author = get_author_by_name(author_name)
172 if author is not None:
173 return [author]
175 author_name_lower = author_name.lower()
176 max_distance = min(5, len(author_name) // 2 + 1)
177 authors: list[Author | str] = [
178 *(
179 author
180 for author in AUTHORS_CACHE.values()
181 if bounded_edit_distance(
182 author.name.lower(), author_name_lower, max_distance + 1
183 )
184 <= max_distance
185 ),
186 fix_author_name(author_name),
187 ]
188 # authors should be in most cases title case
189 fixed_author = author_name.title()
190 if fixed_author not in authors:
191 authors.append(fixed_author)
192 # no other fixes for authors that are less long
193 if len(author_name) < 2:
194 return authors
195 # maybe only the first letter upper case
196 fixed_author_2 = author_name[0].upper() + author_name[1:]
197 if fixed_author_2 not in authors:
198 authors.append(fixed_author_2)
199 return authors
202def get_author_by_name(name: str) -> None | Author:
203 """Get an author by its name."""
204 lower_name = fix_author_name(name).lower()
205 for author in AUTHORS_CACHE.values():
206 if author.name.lower() == lower_name:
207 return author
208 return None
211def get_quote_by_str(quote_str: str) -> None | Quote:
212 """Get an author by its name."""
213 lower_quote = fix_quote_str(quote_str).lower()
214 for quote in QUOTES_CACHE.values():
215 if quote.quote.lower() == lower_quote:
216 return quote
217 return None
220async def get_quotes(quote_str: str) -> list[Quote | str]:
221 """Get the possible meant quotes based on the str."""
222 quote: None | Quote = get_quote_by_str(quote_str)
223 if isinstance(quote, Quote):
224 return [quote]
226 lower_quote_str = quote_str.lower()
227 max_distance = min(16, len(quote_str) // 2 + 1)
228 quotes: list[Quote | str] = [
229 *(
230 quote
231 for quote in QUOTES_CACHE.values()
232 if bounded_edit_distance(
233 quote.quote.lower(), lower_quote_str, max_distance + 1
234 )
235 <= max_distance
236 ),
237 fix_quote_str(quote_str),
238 ]
239 if not (
240 quote_str.endswith("?")
241 or quote_str.endswith(".")
242 or quote_str.endswith("!")
243 ):
244 quotes.append(quote_str + ".")
245 return quotes
248@dataclass(slots=True, frozen=True)
249class QuoteInfoArgs:
250 """Class representing a quote id and an author id."""
252 quote: int | None = None
253 author: int | None = None
256class CreatePage1(QuoteReadyCheckHandler):
257 """The request handler for the create page."""
259 RATELIMIT_POST_LIMIT = 5
260 RATELIMIT_POST_COUNT_PER_PERIOD = 10
262 @parse_args(type_=QuoteInfoArgs)
263 async def get(self, *, args: QuoteInfoArgs, head: bool = False) -> None:
264 """Handle GET requests to the create page."""
265 if args.quote is not None and args.author is not None:
266 return self.redirect(f"/zitate/{args.quote}-{args.author}")
268 if head:
269 return
271 await self.render(
272 "pages/quotes/create1.html",
273 quotes=tuple(QUOTES_CACHE.values()),
274 authors=tuple(AUTHORS_CACHE.values()),
275 selected_quote=(
276 None if args.quote is None else QUOTES_CACHE.get(args.quote)
277 ),
278 selected_author=(
279 None if args.author is None else AUTHORS_CACHE.get(args.author)
280 ),
281 )
283 async def post(self) -> None:
284 """Handle POST requests to the create page."""
285 if self.get_argument("keine-aktion", ""):
286 LOGGER.info(
287 "Versuch Zitat zu erstellen mit keine-aktion Parameter.\n"
288 "Pfad: %s\nBody: %s",
289 self.request.path,
290 self.request.body,
291 )
292 await self.render(
293 "pages/empty.html",
294 text="Kein Zitat erstellt.",
295 )
296 return
298 user_name = self.get_argument("user-name", None)
299 quote_str = self.get_argument("quote-1", None)
300 fake_author_str = self.get_argument("fake-author-1", None)
301 if not (quote_str and fake_author_str):
302 raise HTTPError(
303 400,
304 reason=(
305 "Missing arguments. quote-1 and fake-author-1 are needed."
306 ),
307 )
308 quote: None | Quote = get_quote_by_str(quote_str)
309 fake_author: None | Author = get_author_by_name(fake_author_str)
311 if quote and fake_author:
312 wq_id = await create_wrong_quote(
313 real_author_param=quote.author,
314 fake_author_param=fake_author,
315 quote_param=quote,
316 contributed_by=user_name,
317 )
318 return self.redirect(
319 self.fix_url(f"/zitate/{wq_id}"),
320 status=303,
321 )
323 if not quote:
324 # TODO: search for real author, to reduce work for users
325 real_author_str = self.get_argument("real-author-1", None)
326 if not real_author_str:
327 raise HTTPError(
328 400, reason="Missing arguments. real-author-1 is needed."
329 )
331 quotes: list[Quote | str] = (
332 [quote] if quote else await get_quotes(quote_str)
333 )
334 real_authors: list[Author | str] = (
335 [quote.author]
336 if quote
337 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950
338 )
339 fake_authors: list[Author | str] = (
340 [fake_author] if fake_author else await get_authors(fake_author_str)
341 )
343 if 1 == len(quotes) == len(real_authors) == len(fake_authors):
344 LOGGER.info(
345 "Creating wrong quote using existing: %r (%r) - %r",
346 quotes[0],
347 real_authors[0],
348 fake_authors[0],
349 )
351 wq_id = await create_wrong_quote(
352 real_authors[0],
353 fake_authors[0],
354 quotes[0],
355 contributed_by=user_name,
356 )
357 return self.redirect(
358 self.fix_url(f"/zitate/{wq_id}"),
359 status=303,
360 )
362 await self.render(
363 "pages/quotes/create2.html",
364 quotes=quotes,
365 real_authors=real_authors,
366 fake_authors=fake_authors,
367 user_name=user_name,
368 )
371class CreatePage2(QuoteReadyCheckHandler):
372 """The request handler for the second part of the create page."""
374 RATELIMIT_POST_LIMIT = 2
375 RATELIMIT_POST_COUNT_PER_PERIOD = 1
376 RATELIMIT_POST_PERIOD = 45
378 async def post(self) -> None:
379 """Handle POST requests to the create page."""
380 if self.get_argument("nicht-erstellen", ""):
381 LOGGER.info(
382 "Versuch Zitat zu erstellen mit nicht-erstellen Parameter.\n"
383 "Pfad: %s\nBody: %s",
384 self.request.path,
385 self.request.body,
386 )
387 await self.render(
388 "pages/empty.html",
389 text="Kein Zitat erstellt.",
390 )
391 return
393 quote_str = self.get_argument("quote-2", None)
394 if not quote_str:
395 raise MissingArgumentError("quote-2")
396 fake_author_str = self.get_argument("fake-author-2", None)
397 if not fake_author_str:
398 raise MissingArgumentError("fake-author-2")
400 if (quote := get_quote_by_str(quote_str)) and (
401 fake_author := get_author_by_name(fake_author_str)
402 ):
403 # if selected existing quote and existing
404 # fake author just redirect to the page of this quote
405 return self.redirect(
406 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
407 status=303,
408 )
410 real_author = self.get_argument("real-author-2", None)
411 if not real_author:
412 raise MissingArgumentError("real-author-2")
414 wq_id = await create_wrong_quote(
415 real_author,
416 fake_author_str,
417 quote_str,
418 contributed_by=self.get_argument(
419 "user-name", "Nutzer von asozial.org"
420 ),
421 )
422 return self.redirect(
423 self.fix_url(
424 f"/zitate/{wq_id}",
425 ),
426 status=303,
427 )