Coverage for an_website/quotes/create.py: 68.939%
132 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 13:44 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 13: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 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 quote_str = self.get_argument("quote-1", None)
236 fake_author_str = self.get_argument("fake-author-1", None)
237 if not (quote_str and fake_author_str):
238 raise HTTPError(
239 400,
240 reason=(
241 "Missing arguments. quote-1 and fake-author-1 are needed."
242 ),
243 )
244 quote: None | Quote = get_quote_by_str(quote_str)
245 fake_author: None | Author = get_author_by_name(fake_author_str)
247 if quote and fake_author:
248 # if selected existing quote and existing
249 # fake author just redirect to the page of this quote
250 return self.redirect(
251 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
252 status=303,
253 )
255 if not quote:
256 # TODO: search for real author, to reduce work for users
257 real_author_str = self.get_argument("real-author-1", None)
258 if not real_author_str:
259 raise HTTPError(
260 400, reason="Missing arguments. real-author-1 is needed."
261 )
263 quotes: list[Quote | str] = (
264 [quote] if quote else await get_quotes(quote_str)
265 )
266 real_authors: list[Author | str] = (
267 [quote.author]
268 if quote
269 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950
270 )
271 fake_authors: list[Author | str] = (
272 [fake_author] if fake_author else await get_authors(fake_author_str)
273 )
275 if 1 == len(quotes) == len(real_authors) == len(fake_authors):
276 wq_id = await create_wrong_quote(
277 real_authors[0],
278 fake_authors[0],
279 quotes[0],
280 )
281 return self.redirect(
282 self.fix_url(f"/zitate/{wq_id}"),
283 status=303,
284 )
286 await self.render(
287 "pages/quotes/create2.html",
288 quotes=quotes,
289 real_authors=real_authors,
290 fake_authors=fake_authors,
291 )
294class CreatePage2(QuoteReadyCheckHandler):
295 """The request handler for the second part of the create page."""
297 RATELIMIT_POST_LIMIT = 5
298 RATELIMIT_POST_COUNT_PER_PERIOD = 10
300 async def post(self) -> None:
301 """Handle POST requests to the create page."""
302 quote_str = self.get_argument("quote-2", None)
303 if not quote_str:
304 raise MissingArgumentError("quote-2")
305 fake_author_str = self.get_argument("fake-author-2", None)
306 if not fake_author_str:
307 raise MissingArgumentError("fake-author-2")
309 if (quote := get_quote_by_str(quote_str)) and (
310 fake_author := get_author_by_name(fake_author_str)
311 ):
312 # if selected existing quote and existing
313 # fake author just redirect to the page of this quote
314 return self.redirect(
315 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"),
316 status=303,
317 )
319 real_author = self.get_argument("real-author-2", None)
320 if not real_author:
321 raise MissingArgumentError("real-author-2")
323 LOGGER.info(
324 "Creating wrong quote: %r (%r) - %r",
325 quote_str,
326 real_author,
327 fake_author_str,
328 )
330 wq_id = await create_wrong_quote(
331 real_author,
332 fake_author_str,
333 quote_str,
334 )
335 return self.redirect(
336 self.fix_url(
337 f"/zitate/{wq_id}",
338 ),
339 status=303,
340 )