Coverage for an_website/quotes/create.py: 69.595%
148 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-14 14:44 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-14 14: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"""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 result = parse_quote(
53 await make_api_request(
54 "quotes",
55 method="POST",
56 body={
57 "author": str(author.id),
58 "quote": quote_str,
59 },
60 entity_should_exist=False,
61 )
62 )
64 LOGGER.info("Created quote %d: %r", result.id, result.quote)
66 return result
69async def create_author(author_str: str) -> Author:
70 """Create an author."""
71 author_str = fix_author_name(author_str)
73 author = get_author_by_name(author_str)
74 if author is not None:
75 return author
77 result = parse_author(
78 await make_api_request(
79 "authors",
80 method="POST",
81 body={"author": author_str},
82 entity_should_exist=False,
83 )
84 )
86 LOGGER.info("Created author %d: %r", result.id, result.name)
88 return result
91async def create_wrong_quote(
92 real_author_param: Author | str,
93 fake_author_param: Author | str,
94 quote_param: Quote | str,
95 *,
96 contributed_by: str | None = None,
97) -> str:
98 """Create a wrong quote and return the id in the q_id-a_id format."""
99 if isinstance(fake_author_param, str):
100 if not fake_author_param:
101 raise HTTPError(400, "Fake author is needed, but empty.")
102 fake_author = await create_author(fake_author_param)
103 else:
104 fake_author = fake_author_param
106 if isinstance(quote_param, str):
107 if not quote_param:
108 raise HTTPError(400, "Quote is needed, but empty.")
110 if isinstance(real_author_param, str):
111 if not real_author_param:
112 raise HTTPError(400, "Real author is needed, but empty.")
113 real_author = await create_author(real_author_param)
114 else:
115 real_author = real_author_param
116 quote = await create_quote(quote_param, real_author)
117 else:
118 quote = quote_param
120 wrong_quote = await get_wrong_quote(
121 quote.id, fake_author.id, use_cache=True
122 )
124 if wrong_quote.id == -1:
125 result = await make_api_request(
126 "wrongquotes",
127 method="POST",
128 body={
129 "author": fake_author.id,
130 "quote": quote.id,
131 "contributed_by": contributed_by or "Mithilfe von asozial.org",
132 },
133 entity_should_exist=False,
134 )
135 wrong_quote = parse_wrong_quote(result)
136 LOGGER.info(
137 "Successfully created wrong quote: %s\n\n%s",
138 wrong_quote.get_id_as_str(True),
139 wrong_quote,
140 )
142 return wrong_quote.get_id_as_str()
145async def get_authors(author_name: str) -> list[Author | str]:
146 """Get the possible meant authors based on the str."""
147 author = get_author_by_name(author_name)
148 if author is not None:
149 return [author]
151 author_name_lower = author_name.lower()
152 max_distance = min(5, len(author_name) // 2 + 1)
153 authors: list[Author | str] = [
154 *(
155 author
156 for author in AUTHORS_CACHE.values()
157 if bounded_edit_distance(
158 author.name.lower(), author_name_lower, max_distance + 1
159 )
160 <= max_distance
161 ),
162 fix_author_name(author_name),
163 ]
164 # authors should be in most cases title case
165 fixed_author = author_name.title()
166 if fixed_author not in authors:
167 authors.append(fixed_author)
168 # no other fixes for authors that are less long
169 if len(author_name) < 2:
170 return authors
171 # maybe only the first letter upper case
172 fixed_author_2 = author_name[0].upper() + author_name[1:]
173 if fixed_author_2 not in authors:
174 authors.append(fixed_author_2)
175 return authors
178def get_author_by_name(name: str) -> None | Author:
179 """Get an author by its name."""
180 lower_name = fix_author_name(name).lower()
181 for author in AUTHORS_CACHE.values():
182 if author.name.lower() == lower_name:
183 return author
184 return None
187def get_quote_by_str(quote_str: str) -> None | Quote:
188 """Get an author by its name."""
189 lower_quote = fix_quote_str(quote_str).lower()
190 for quote in QUOTES_CACHE.values():
191 if quote.quote.lower() == lower_quote:
192 return quote
193 return None
196async def get_quotes(quote_str: str) -> list[Quote | str]:
197 """Get the possible meant quotes based on the str."""
198 quote: None | Quote = get_quote_by_str(quote_str)
199 if isinstance(quote, Quote):
200 return [quote]
202 lower_quote_str = quote_str.lower()
203 max_distance = min(16, len(quote_str) // 2 + 1)
204 quotes: list[Quote | str] = [
205 *(
206 quote
207 for quote in QUOTES_CACHE.values()
208 if bounded_edit_distance(
209 quote.quote.lower(), lower_quote_str, max_distance + 1
210 )
211 <= max_distance
212 ),
213 fix_quote_str(quote_str),
214 ]
215 if not (
216 quote_str.endswith("?")
217 or quote_str.endswith(".")
218 or quote_str.endswith("!")
219 ):
220 quotes.append(quote_str + ".")
221 return quotes
224@dataclass(slots=True, frozen=True)
225class QuoteInfoArgs:
226 """Class representing a quote id and an author id."""
228 quote: int | None = None
229 author: int | None = None
232class CreatePage1(QuoteReadyCheckHandler):
233 """The request handler for the create page."""
235 RATELIMIT_POST_LIMIT = 5
236 RATELIMIT_POST_COUNT_PER_PERIOD = 10
238 @parse_args(type_=QuoteInfoArgs)
239 async def get(self, *, args: QuoteInfoArgs, head: bool = False) -> None:
240 """Handle GET requests to the create page."""
241 if args.quote is not None and args.author is not None:
242 return self.redirect(f"/zitate/{args.quote}-{args.author}")
244 if head:
245 return
247 await self.render(
248 "pages/quotes/create1.html",
249 quotes=tuple(QUOTES_CACHE.values()),
250 authors=tuple(AUTHORS_CACHE.values()),
251 selected_quote=(
252 None if args.quote is None else QUOTES_CACHE.get(args.quote)
253 ),
254 selected_author=(
255 None if args.author is None else AUTHORS_CACHE.get(args.author)
256 ),
257 )
259 async def post(self) -> None:
260 """Handle POST requests to the create page."""
261 if self.get_argument("keine-aktion", ""):
262 LOGGER.info(
263 "Versuch Zitat zu erstellen mit keine-aktion Parameter.\n"
264 "Pfad: %s\nBody: %s",
265 self.request.path,
266 self.request.body,
267 )
268 await self.render(
269 "pages/empty.html",
270 text="Kein Zitat erstellt.",
271 )
272 return
274 user_name = self.get_argument("user-name", None)
275 quote_str = self.get_argument("quote-1", None)
276 fake_author_str = self.get_argument("fake-author-1", None)
277 if not (quote_str and fake_author_str):
278 raise HTTPError(
279 400,
280 reason=(
281 "Missing arguments. quote-1 and fake-author-1 are needed."
282 ),
283 )
284 quote: None | Quote = get_quote_by_str(quote_str)
285 fake_author: None | Author = get_author_by_name(fake_author_str)
287 if quote and fake_author:
288 wq_id = await create_wrong_quote(
289 real_author_param=quote.author,
290 fake_author_param=fake_author,
291 quote_param=quote,
292 contributed_by=user_name,
293 )
294 return self.redirect(
295 self.fix_url(f"/zitate/{wq_id}"),
296 status=303,
297 )
299 if not quote:
300 # TODO: search for real author, to reduce work for users
301 real_author_str = self.get_argument("real-author-1", None)
302 if not real_author_str:
303 raise HTTPError(
304 400, reason="Missing arguments. real-author-1 is needed."
305 )
307 quotes: list[Quote | str] = (
308 [quote] if quote else await get_quotes(quote_str)
309 )
310 real_authors: list[Author | str] = (
311 [quote.author]
312 if quote
313 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950
314 )
315 fake_authors: list[Author | str] = (
316 [fake_author] if fake_author else await get_authors(fake_author_str)
317 )
319 if 1 == len(quotes) == len(real_authors) == len(fake_authors):
320 LOGGER.info(
321 "Creating wrong quote using existing: %r (%r) - %r",
322 quotes[0],
323 real_authors[0],
324 fake_authors[0],
325 )
327 wq_id = await create_wrong_quote(
328 real_authors[0],
329 fake_authors[0],
330 quotes[0],
331 contributed_by=user_name,
332 )
333 return self.redirect(
334 self.fix_url(f"/zitate/{wq_id}"),
335 status=303,
336 )
338 await self.render(
339 "pages/quotes/create2.html",
340 quotes=quotes,
341 real_authors=real_authors,
342 fake_authors=fake_authors,
343 user_name=user_name,
344 )
347class CreatePage2(QuoteReadyCheckHandler):
348 """The request handler for the second part of the create page."""
350 RATELIMIT_POST_LIMIT = 2
351 RATELIMIT_POST_COUNT_PER_PERIOD = 1
352 RATELIMIT_POST_PERIOD = 45
354 async def post(self) -> None:
355 """Handle POST requests to the create page."""
356 if self.get_argument("nicht-erstellen", ""):
357 LOGGER.info(
358 "Versuch Zitat zu erstellen mit nicht-erstellen Parameter.\n"
359 "Pfad: %s\nBody: %s",
360 self.request.path,
361 self.request.body,
362 )
363 await self.render(
364 "pages/empty.html",
365 text="Kein Zitat erstellt.",
366 )
367 return
369 quote_str = self.get_argument("quote-2", None)
370 if not quote_str:
371 raise MissingArgumentError("quote-2")
372 fake_author_str = self.get_argument("fake-author-2", None)
373 if not fake_author_str:
374 raise MissingArgumentError("fake-author-2")
376 if (quote := get_quote_by_str(quote_str)) and (
377 fake_author := get_author_by_name(fake_author_str)
378 ):
379 # if selected existing quote and existing
380 # fake author just redirect to the page of this quote
381 return self.redirect(
382 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
383 status=303,
384 )
386 real_author = self.get_argument("real-author-2", None)
387 if not real_author:
388 raise MissingArgumentError("real-author-2")
390 wq_id = await create_wrong_quote(
391 real_author,
392 fake_author_str,
393 quote_str,
394 contributed_by=self.get_argument(
395 "user-name", "Nutzer von asozial.org"
396 ),
397 )
398 return self.redirect(
399 self.fix_url(
400 f"/zitate/{wq_id}",
401 ),
402 status=303,
403 )