Coverage for an_website / quotes / create.py: 68.874%
151 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 19:37 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 19:37 +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 not wrong_quote:
125 LOGGER.error(
126 "%r and %r were just created, wronquote should exist.",
127 quote,
128 fake_author,
129 )
130 raise HTTPError()
132 if wrong_quote.id == -1:
133 result = await make_api_request(
134 "wrongquotes",
135 method="POST",
136 body={
137 "author": fake_author.id,
138 "quote": quote.id,
139 "contributed_by": contributed_by or "Mithilfe von asozial.org",
140 },
141 entity_should_exist=False,
142 )
143 wrong_quote = parse_wrong_quote(result)
144 LOGGER.info(
145 "Successfully created wrong quote: %s\n\n%s",
146 wrong_quote.get_id_as_str(True),
147 wrong_quote,
148 )
150 return wrong_quote.get_id_as_str()
153async def get_authors(author_name: str) -> list[Author | str]:
154 """Get the possible meant authors based on the str."""
155 author = get_author_by_name(author_name)
156 if author is not None:
157 return [author]
159 author_name_lower = author_name.lower()
160 max_distance = min(5, len(author_name) // 2 + 1)
161 authors: list[Author | str] = [
162 *(
163 author
164 for author in AUTHORS_CACHE.values()
165 if bounded_edit_distance(
166 author.name.lower(), author_name_lower, max_distance + 1
167 )
168 <= max_distance
169 ),
170 fix_author_name(author_name),
171 ]
172 # authors should be in most cases title case
173 fixed_author = author_name.title()
174 if fixed_author not in authors:
175 authors.append(fixed_author)
176 # no other fixes for authors that are less long
177 if len(author_name) < 2:
178 return authors
179 # maybe only the first letter upper case
180 fixed_author_2 = author_name[0].upper() + author_name[1:]
181 if fixed_author_2 not in authors:
182 authors.append(fixed_author_2)
183 return authors
186def get_author_by_name(name: str) -> None | Author:
187 """Get an author by its name."""
188 lower_name = fix_author_name(name).lower()
189 for author in AUTHORS_CACHE.values():
190 if author.name.lower() == lower_name:
191 return author
192 return None
195def get_quote_by_str(quote_str: str) -> None | Quote:
196 """Get an author by its name."""
197 lower_quote = fix_quote_str(quote_str).lower()
198 for quote in QUOTES_CACHE.values():
199 if quote.quote.lower() == lower_quote:
200 return quote
201 return None
204async def get_quotes(quote_str: str) -> list[Quote | str]:
205 """Get the possible meant quotes based on the str."""
206 quote: None | Quote = get_quote_by_str(quote_str)
207 if isinstance(quote, Quote):
208 return [quote]
210 lower_quote_str = quote_str.lower()
211 max_distance = min(16, len(quote_str) // 2 + 1)
212 quotes: list[Quote | str] = [
213 *(
214 quote
215 for quote in QUOTES_CACHE.values()
216 if bounded_edit_distance(
217 quote.quote.lower(), lower_quote_str, max_distance + 1
218 )
219 <= max_distance
220 ),
221 fix_quote_str(quote_str),
222 ]
223 if not (
224 quote_str.endswith("?")
225 or quote_str.endswith(".")
226 or quote_str.endswith("!")
227 ):
228 quotes.append(quote_str + ".")
229 return quotes
232@dataclass(slots=True, frozen=True)
233class QuoteInfoArgs:
234 """Class representing a quote id and an author id."""
236 quote: int | None = None
237 author: int | None = None
240class CreatePage1(QuoteReadyCheckHandler):
241 """The request handler for the create page."""
243 RATELIMIT_POST_LIMIT = 5
244 RATELIMIT_POST_COUNT_PER_PERIOD = 10
246 @parse_args(type_=QuoteInfoArgs)
247 async def get(self, *, args: QuoteInfoArgs, head: bool = False) -> None:
248 """Handle GET requests to the create page."""
249 if args.quote is not None and args.author is not None:
250 return self.redirect(f"/zitate/{args.quote}-{args.author}")
252 if head:
253 return
255 await self.render(
256 "pages/quotes/create1.html",
257 quotes=tuple(QUOTES_CACHE.values()),
258 authors=tuple(AUTHORS_CACHE.values()),
259 selected_quote=(
260 None if args.quote is None else QUOTES_CACHE.get(args.quote)
261 ),
262 selected_author=(
263 None if args.author is None else AUTHORS_CACHE.get(args.author)
264 ),
265 )
267 async def post(self) -> None:
268 """Handle POST requests to the create page."""
269 if self.get_argument("keine-aktion", ""):
270 LOGGER.info(
271 "Versuch Zitat zu erstellen mit keine-aktion Parameter.\n"
272 "Pfad: %s\nBody: %s",
273 self.request.path,
274 self.request.body,
275 )
276 await self.render(
277 "pages/empty.html",
278 text="Kein Zitat erstellt.",
279 )
280 return
282 user_name = self.get_argument("user-name", None)
283 quote_str = self.get_argument("quote-1", None)
284 fake_author_str = self.get_argument("fake-author-1", None)
285 if not (quote_str and fake_author_str):
286 raise HTTPError(
287 400,
288 reason=(
289 "Missing arguments. quote-1 and fake-author-1 are needed."
290 ),
291 )
292 quote: None | Quote = get_quote_by_str(quote_str)
293 fake_author: None | Author = get_author_by_name(fake_author_str)
295 if quote and fake_author:
296 wq_id = await create_wrong_quote(
297 real_author_param=quote.author,
298 fake_author_param=fake_author,
299 quote_param=quote,
300 contributed_by=user_name,
301 )
302 return self.redirect(
303 self.fix_url(f"/zitate/{wq_id}"),
304 status=303,
305 )
307 if not quote:
308 # TODO: search for real author, to reduce work for users
309 real_author_str = self.get_argument("real-author-1", None)
310 if not real_author_str:
311 raise HTTPError(
312 400, reason="Missing arguments. real-author-1 is needed."
313 )
315 quotes: list[Quote | str] = (
316 [quote] if quote else await get_quotes(quote_str)
317 )
318 real_authors: list[Author | str] = (
319 [quote.author]
320 if quote
321 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950
322 )
323 fake_authors: list[Author | str] = (
324 [fake_author] if fake_author else await get_authors(fake_author_str)
325 )
327 if 1 == len(quotes) == len(real_authors) == len(fake_authors):
328 LOGGER.info(
329 "Creating wrong quote using existing: %r (%r) - %r",
330 quotes[0],
331 real_authors[0],
332 fake_authors[0],
333 )
335 wq_id = await create_wrong_quote(
336 real_authors[0],
337 fake_authors[0],
338 quotes[0],
339 contributed_by=user_name,
340 )
341 return self.redirect(
342 self.fix_url(f"/zitate/{wq_id}"),
343 status=303,
344 )
346 await self.render(
347 "pages/quotes/create2.html",
348 quotes=quotes,
349 real_authors=real_authors,
350 fake_authors=fake_authors,
351 user_name=user_name,
352 )
355class CreatePage2(QuoteReadyCheckHandler):
356 """The request handler for the second part of the create page."""
358 RATELIMIT_POST_LIMIT = 2
359 RATELIMIT_POST_COUNT_PER_PERIOD = 1
360 RATELIMIT_POST_PERIOD = 45
362 async def post(self) -> None:
363 """Handle POST requests to the create page."""
364 if self.get_argument("nicht-erstellen", ""):
365 LOGGER.info(
366 "Versuch Zitat zu erstellen mit nicht-erstellen Parameter.\n"
367 "Pfad: %s\nBody: %s",
368 self.request.path,
369 self.request.body,
370 )
371 await self.render(
372 "pages/empty.html",
373 text="Kein Zitat erstellt.",
374 )
375 return
377 quote_str = self.get_argument("quote-2", None)
378 if not quote_str:
379 raise MissingArgumentError("quote-2")
380 fake_author_str = self.get_argument("fake-author-2", None)
381 if not fake_author_str:
382 raise MissingArgumentError("fake-author-2")
384 if (quote := get_quote_by_str(quote_str)) and (
385 fake_author := get_author_by_name(fake_author_str)
386 ):
387 # if selected existing quote and existing
388 # fake author just redirect to the page of this quote
389 return self.redirect(
390 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
391 status=303,
392 )
394 real_author = self.get_argument("real-author-2", None)
395 if not real_author:
396 raise MissingArgumentError("real-author-2")
398 wq_id = await create_wrong_quote(
399 real_author,
400 fake_author_str,
401 quote_str,
402 contributed_by=self.get_argument(
403 "user-name", "Nutzer von asozial.org"
404 ),
405 )
406 return self.redirect(
407 self.fix_url(
408 f"/zitate/{wq_id}",
409 ),
410 status=303,
411 )