Coverage for an_website/quotes/create.py: 66.197%
142 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-01 02:01 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-01 02:01 +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 make_api_request,
35 parse_author,
36 parse_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 result = parse_quote(
51 await make_api_request(
52 "quotes",
53 method="POST",
54 body={
55 "author": str(author.id),
56 "quote": quote_str,
57 },
58 entity_should_exist=False,
59 )
60 )
62 LOGGER.info("Created quote %d: %r", result.id, result.quote)
64 return result
67async def create_author(author_str: str) -> Author:
68 """Create an author."""
69 author_str = fix_author_name(author_str)
71 author = get_author_by_name(author_str)
72 if author is not None:
73 return author
75 result = parse_author(
76 await make_api_request(
77 "authors",
78 method="POST",
79 body={"author": author_str},
80 entity_should_exist=False,
81 )
82 )
84 LOGGER.info("Created author %d: %r", result.id, result.name)
86 return result
89async def create_wrong_quote(
90 real_author_param: Author | str,
91 fake_author_param: Author | str,
92 quote_param: Quote | str,
93) -> str:
94 """Create a wrong quote and return the id in the q_id-a_id format."""
95 if isinstance(fake_author_param, str):
96 if not fake_author_param:
97 raise HTTPError(400, "Fake author is needed, but empty.")
98 fake_author = await create_author(fake_author_param)
99 else:
100 fake_author = fake_author_param
102 if isinstance(quote_param, str):
103 if not quote_param:
104 raise HTTPError(400, "Quote is needed, but empty.")
106 if isinstance(real_author_param, str):
107 if not real_author_param:
108 raise HTTPError(400, "Real author is needed, but empty.")
109 real_author = await create_author(real_author_param)
110 else:
111 real_author = real_author_param
112 quote = await create_quote(quote_param, real_author)
113 else:
114 quote = quote_param
116 return f"{quote.id}-{fake_author.id}"
119async def get_authors(author_name: str) -> list[Author | str]:
120 """Get the possible meant authors based on the str."""
121 author = get_author_by_name(author_name)
122 if author is not None:
123 return [author]
125 author_name_lower = author_name.lower()
126 max_distance = min(5, len(author_name) // 2 + 1)
127 authors: list[Author | str] = [
128 *(
129 author
130 for author in AUTHORS_CACHE.values()
131 if bounded_edit_distance(
132 author.name.lower(), author_name_lower, max_distance + 1
133 )
134 <= max_distance
135 ),
136 fix_author_name(author_name),
137 ]
138 # authors should be in most cases title case
139 fixed_author = author_name.title()
140 if fixed_author not in authors:
141 authors.append(fixed_author)
142 # no other fixes for authors that are less long
143 if len(author_name) < 2:
144 return authors
145 # maybe only the first letter upper case
146 fixed_author_2 = author_name[0].upper() + author_name[1:]
147 if fixed_author_2 not in authors:
148 authors.append(fixed_author_2)
149 return authors
152def get_author_by_name(name: str) -> None | Author:
153 """Get an author by its name."""
154 lower_name = fix_author_name(name).lower()
155 for author in AUTHORS_CACHE.values():
156 if author.name.lower() == lower_name:
157 return author
158 return None
161def get_quote_by_str(quote_str: str) -> None | Quote:
162 """Get an author by its name."""
163 lower_quote = fix_quote_str(quote_str).lower()
164 for quote in QUOTES_CACHE.values():
165 if quote.quote.lower() == lower_quote:
166 return quote
167 return None
170async def get_quotes(quote_str: str) -> list[Quote | str]:
171 """Get the possible meant quotes based on the str."""
172 quote: None | Quote = get_quote_by_str(quote_str)
173 if isinstance(quote, Quote):
174 return [quote]
176 lower_quote_str = quote_str.lower()
177 max_distance = min(16, len(quote_str) // 2 + 1)
178 quotes: list[Quote | str] = [
179 *(
180 quote
181 for quote in QUOTES_CACHE.values()
182 if bounded_edit_distance(
183 quote.quote.lower(), lower_quote_str, max_distance + 1
184 )
185 <= max_distance
186 ),
187 fix_quote_str(quote_str),
188 ]
189 if not (
190 quote_str.endswith("?")
191 or quote_str.endswith(".")
192 or quote_str.endswith("!")
193 ):
194 quotes.append(quote_str + ".")
195 return quotes
198@dataclass(slots=True, frozen=True)
199class QuoteInfoArgs:
200 """Class representing a quote id and an author id."""
202 quote: int | None = None
203 author: int | None = None
206class CreatePage1(QuoteReadyCheckHandler):
207 """The request handler for the create page."""
209 RATELIMIT_POST_LIMIT = 5
210 RATELIMIT_POST_COUNT_PER_PERIOD = 10
212 @parse_args(type_=QuoteInfoArgs)
213 async def get(self, *, args: QuoteInfoArgs, head: bool = False) -> None:
214 """Handle GET requests to the create page."""
215 if args.quote is not None and args.author is not None:
216 return self.redirect(f"/zitate/{args.quote}-{args.author}")
218 if head:
219 return
221 await self.render(
222 "pages/quotes/create1.html",
223 quotes=tuple(QUOTES_CACHE.values()),
224 authors=tuple(AUTHORS_CACHE.values()),
225 selected_quote=(
226 None if args.quote is None else QUOTES_CACHE.get(args.quote)
227 ),
228 selected_author=(
229 None if args.author is None else AUTHORS_CACHE.get(args.author)
230 ),
231 )
233 async def post(self) -> None:
234 """Handle POST requests to the create page."""
235 if self.get_argument("keine-aktion", ""):
236 LOGGER.info(
237 "Versuch Zitat zu erstellen mit keine-aktion Parameter.\n"
238 "Pfad: %s\nBody: %s",
239 self.request.path,
240 self.request.body,
241 )
242 await self.render(
243 "pages/empty.html",
244 text="Kein Zitat erstellt.",
245 )
246 return
248 quote_str = self.get_argument("quote-1", None)
249 fake_author_str = self.get_argument("fake-author-1", None)
250 if not (quote_str and fake_author_str):
251 raise HTTPError(
252 400,
253 reason=(
254 "Missing arguments. quote-1 and fake-author-1 are needed."
255 ),
256 )
257 quote: None | Quote = get_quote_by_str(quote_str)
258 fake_author: None | Author = get_author_by_name(fake_author_str)
260 if quote and fake_author:
261 # if selected existing quote and existing
262 # fake author just redirect to the page of this quote
263 return self.redirect(
264 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
265 status=303,
266 )
268 if not quote:
269 # TODO: search for real author, to reduce work for users
270 real_author_str = self.get_argument("real-author-1", None)
271 if not real_author_str:
272 raise HTTPError(
273 400, reason="Missing arguments. real-author-1 is needed."
274 )
276 quotes: list[Quote | str] = (
277 [quote] if quote else await get_quotes(quote_str)
278 )
279 real_authors: list[Author | str] = (
280 [quote.author]
281 if quote
282 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950
283 )
284 fake_authors: list[Author | str] = (
285 [fake_author] if fake_author else await get_authors(fake_author_str)
286 )
288 if 1 == len(quotes) == len(real_authors) == len(fake_authors):
289 LOGGER.info(
290 "Creating wrong quote using existing: %r (%r) - %r",
291 quotes[0],
292 real_authors[0],
293 fake_authors[0],
294 )
296 wq_id = await create_wrong_quote(
297 real_authors[0],
298 fake_authors[0],
299 quotes[0],
300 )
301 return self.redirect(
302 self.fix_url(f"/zitate/{wq_id}"),
303 status=303,
304 )
306 await self.render(
307 "pages/quotes/create2.html",
308 quotes=quotes,
309 real_authors=real_authors,
310 fake_authors=fake_authors,
311 )
314class CreatePage2(QuoteReadyCheckHandler):
315 """The request handler for the second part of the create page."""
317 RATELIMIT_POST_LIMIT = 2
318 RATELIMIT_POST_COUNT_PER_PERIOD = 1
319 RATELIMIT_POST_PERIOD = 45
321 async def post(self) -> None:
322 """Handle POST requests to the create page."""
323 if self.get_argument("nicht-erstellen", ""):
324 LOGGER.info(
325 "Versuch Zitat zu erstellen mit nicht-erstellen Parameter.\n"
326 "Pfad: %s\nBody: %s",
327 self.request.path,
328 self.request.body,
329 )
330 await self.render(
331 "pages/empty.html",
332 text="Kein Zitat erstellt.",
333 )
334 return
336 quote_str = self.get_argument("quote-2", None)
337 if not quote_str:
338 raise MissingArgumentError("quote-2")
339 fake_author_str = self.get_argument("fake-author-2", None)
340 if not fake_author_str:
341 raise MissingArgumentError("fake-author-2")
343 if (quote := get_quote_by_str(quote_str)) and (
344 fake_author := get_author_by_name(fake_author_str)
345 ):
346 # if selected existing quote and existing
347 # fake author just redirect to the page of this quote
348 return self.redirect(
349 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
350 status=303,
351 )
353 real_author = self.get_argument("real-author-2", None)
354 if not real_author:
355 raise MissingArgumentError("real-author-2")
357 LOGGER.info(
358 "Creating wrong quote: %r (%r) - %r",
359 quote_str,
360 real_author,
361 fake_author_str,
362 )
364 wq_id = await create_wrong_quote(
365 real_author,
366 fake_author_str,
367 quote_str,
368 )
369 return self.redirect(
370 self.fix_url(
371 f"/zitate/{wq_id}",
372 ),
373 status=303,
374 )