├── .containerignore ├── .flake8 ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── k8s ├── scripts │ └── create-secrets.sh ├── web-deployment.yaml ├── web-ingress.yaml └── web-service.yaml ├── reciperadar ├── __init__.py ├── api │ ├── autosuggest.py │ ├── feedback.py │ ├── recipes.py │ └── redirect.py ├── models │ ├── base.py │ ├── feedback.py │ └── recipes │ │ ├── __init__.py │ │ ├── ingredient.py │ │ ├── nutrition.py │ │ ├── product.py │ │ └── recipe.py ├── search │ ├── base.py │ ├── equipment.py │ ├── ingredients.py │ └── recipes.py ├── templates │ └── problem-report │ │ ├── correction.html │ │ ├── removal_request.html │ │ └── unsafe_content.html ├── utils │ └── bots.py └── workers │ ├── __init__.py │ ├── broker.py │ ├── events.py │ ├── recipes.py │ └── searches.py ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.in ├── requirements.txt └── tests ├── __init__.py ├── api ├── test_autosuggest.py ├── test_feedback.py ├── test_recipes.py └── test_redirect.py ├── conftest.py ├── models ├── recipes │ └── test_recipe.py └── test_feedback_model.py └── search └── test_base.py /.containerignore: -------------------------------------------------------------------------------- 1 | **/*.pyc 2 | **/__pycache__ 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=88 3 | per-file-ignores= 4 | reciperadar/ __init__.py:E402,F401 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | venv 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build deploy image lint tests 2 | 3 | SERVICE=$(shell basename $(shell git rev-parse --show-toplevel)) 4 | REGISTRY=registry.openculinary.org 5 | PROJECT=reciperadar 6 | 7 | IMAGE_NAME=${REGISTRY}/${PROJECT}/${SERVICE} 8 | IMAGE_COMMIT := $(shell git rev-parse HEAD) 9 | IMAGE_TAG := $(strip $(if $(shell git status --porcelain --untracked-files=no), latest, ${IMAGE_COMMIT})) 10 | 11 | build: image 12 | 13 | deploy: 14 | kubectl apply -f k8s 15 | kubectl set image deployments -l app=${SERVICE} ${SERVICE}=${IMAGE_NAME}:${IMAGE_TAG} 16 | 17 | image: 18 | $(eval container=$(shell buildah from docker.io/library/python:3.13-slim)) 19 | buildah copy $(container) 'reciperadar' 'reciperadar' 20 | buildah copy $(container) 'requirements.txt' 21 | buildah run $(container) -- useradd --home-dir /srv/ --no-create-home gunicorn --shell /sbin/nologin -- 22 | buildah run $(container) -- chown gunicorn /srv/ -- 23 | buildah run --user gunicorn $(container) -- pip install --no-deps --no-warn-script-location --progress-bar off --requirement requirements.txt --user -- 24 | # Begin: HACK: For rootless compatibility across podman and k8s environments, unset file ownership and grant read+exec to binaries 25 | buildah run $(container) -- chown -R nobody:nogroup /srv/ -- 26 | buildah run $(container) -- chmod -R a+rx /srv/.local/bin/ -- 27 | buildah run $(container) -- find /srv/ -type d -exec chmod a+rx {} \; 28 | # End: HACK 29 | buildah config --env PYTHONDONTWRITEBYTECODE=1 $(container) 30 | buildah config --cmd '/srv/.local/bin/gunicorn reciperadar:app --bind :8000 --reuse-port' --port 8000 --user gunicorn $(container) 31 | buildah commit --omit-timestamp --quiet --rm --squash $(container) ${IMAGE_NAME}:${IMAGE_TAG} 32 | 33 | # Virtualenv Makefile pattern derived from https://github.com/bottlepy/bottle/ 34 | venv: venv/.installed requirements.txt requirements-dev.txt 35 | venv/bin/pip install --requirement requirements-dev.txt --quiet 36 | touch venv 37 | venv/.installed: 38 | python3 -m venv venv 39 | venv/bin/pip install pip-tools 40 | touch venv/.installed 41 | 42 | requirements.txt: requirements.in 43 | venv/bin/pip-compile --allow-unsafe --generate-hashes --no-config --no-header --output-file requirements.txt --quiet --strip-extras requirements.in 44 | 45 | requirements-dev.txt: requirements.in requirements-dev.in 46 | venv/bin/pip-compile --allow-unsafe --generate-hashes --no-config --no-header --output-file requirements-dev.txt --quiet --strip-extras requirements.in requirements-dev.in 47 | 48 | lint: venv 49 | venv/bin/black --check --quiet tests 50 | venv/bin/black --check --quiet reciperadar 51 | venv/bin/flake8 tests 52 | venv/bin/flake8 reciperadar 53 | 54 | tests: venv 55 | venv/bin/pytest tests 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RecipeRadar API 2 | 3 | The RecipeRadar API provides data services to the RecipeRadar [frontend](https://www.github.com/openculinary/frontend) application. 4 | 5 | It provides endpoints to support the following functionality: 6 | 7 | * Recipe and ingredient search 8 | * User feedback collection 9 | 10 | The API has high uptime and availability requirements since it's a core part of the [frontend](https://www.github.com/openculinary/frontend) recipe search experience. 11 | 12 | ## Install dependencies 13 | 14 | Make sure to follow the RecipeRadar [infrastructure](https://www.github.com/openculinary/infrastructure) setup to ensure all cluster dependencies are available in your environment. 15 | 16 | ## Development 17 | 18 | To install development tools and run linting and tests locally, execute the following commands: 19 | 20 | ```sh 21 | $ make lint tests 22 | ``` 23 | 24 | ## Local Deployment 25 | 26 | To deploy the service to the local infrastructure environment, execute the following commands: 27 | 28 | ```sh 29 | $ make 30 | $ make deploy 31 | ``` 32 | -------------------------------------------------------------------------------- /k8s/scripts/create-secrets.sh: -------------------------------------------------------------------------------- 1 | echo "Please provide the mail account username" 2 | read username 3 | 4 | echo "Please provide the mail account password" 5 | read -s password 6 | 7 | kubectl delete secret api-contact-mail 8 | kubectl create secret generic api-contact-mail \ 9 | --from-literal=username="${username}" \ 10 | --from-literal=password="${password}" 11 | -------------------------------------------------------------------------------- /k8s/web-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: api-deployment 5 | labels: 6 | app: api 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: api 11 | role: web 12 | template: 13 | metadata: 14 | labels: 15 | app: api 16 | role: web 17 | spec: 18 | containers: 19 | - image: registry.openculinary.org/reciperadar/api 20 | imagePullPolicy: IfNotPresent 21 | name: api 22 | ports: 23 | - containerPort: 8000 24 | env: 25 | - name: MAIL_USERNAME 26 | valueFrom: 27 | secretKeyRef: 28 | name: api-contact-mail 29 | key: username 30 | - name: MAIL_PASSWORD 31 | valueFrom: 32 | secretKeyRef: 33 | name: api-contact-mail 34 | key: password 35 | securityContext: 36 | readOnlyRootFilesystem: true 37 | volumeMounts: 38 | - mountPath: /var/tmp 39 | name: var-tmp 40 | volumes: 41 | - name: var-tmp 42 | emptyDir: 43 | medium: "Memory" 44 | sizeLimit: "128Mi" 45 | -------------------------------------------------------------------------------- /k8s/web-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: api-ingress 5 | annotations: 6 | nginx.ingress.kubernetes.io/proxy-body-size: 4m 7 | spec: 8 | rules: 9 | - host: api 10 | http: 11 | paths: 12 | - pathType: Prefix 13 | path: / 14 | backend: 15 | service: 16 | name: api-service 17 | port: 18 | number: 80 19 | -------------------------------------------------------------------------------- /k8s/web-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: api-service 5 | spec: 6 | selector: 7 | app: api 8 | role: web 9 | ports: 10 | - protocol: TCP 11 | port: 80 12 | targetPort: 8000 13 | -------------------------------------------------------------------------------- /reciperadar/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask 4 | from flask_mail import Mail 5 | from flask_sqlalchemy import SQLAlchemy 6 | from werkzeug.middleware.proxy_fix import ProxyFix 7 | 8 | 9 | app = Flask(__name__) 10 | app.config.update( 11 | MAIL_SERVER="smtp.gmail.com", 12 | MAIL_PORT=587, 13 | MAIL_USE_TLS=True, 14 | MAIL_USERNAME=os.environ.get("MAIL_USERNAME"), 15 | MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD"), 16 | SQLALCHEMY_DATABASE_URI="sqlite://", 17 | SQLALCHEMY_TRACK_MODIFICATIONS=False, 18 | ) 19 | app.url_map.strict_slashes = False 20 | app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) 21 | 22 | mail = Mail(app) 23 | db = SQLAlchemy(app) 24 | 25 | 26 | import reciperadar.api.feedback 27 | import reciperadar.api.autosuggest 28 | import reciperadar.api.recipes 29 | import reciperadar.api.redirect 30 | -------------------------------------------------------------------------------- /reciperadar/api/autosuggest.py: -------------------------------------------------------------------------------- 1 | from flask import abort, jsonify, request 2 | 3 | from reciperadar import app 4 | from reciperadar.search.equipment import EquipmentSearch 5 | from reciperadar.search.ingredients import IngredientSearch 6 | 7 | 8 | @app.route("/autosuggest/equipment") 9 | def equipment(): 10 | prefix = request.args.get("pre") or "" 11 | if not (3 <= len(prefix) <= 64): 12 | return abort(400) 13 | results = EquipmentSearch().autosuggest(prefix) 14 | return jsonify(results) 15 | 16 | 17 | @app.route("/autosuggest/ingredients") 18 | def ingredients(): 19 | prefix = request.args.get("pre") or "" 20 | if not (3 <= len(prefix) <= 64): 21 | return abort(400) 22 | results = IngredientSearch().autosuggest(prefix) 23 | return jsonify(results) 24 | -------------------------------------------------------------------------------- /reciperadar/api/feedback.py: -------------------------------------------------------------------------------- 1 | from flask import abort, jsonify, request 2 | from urllib.request import urlopen 3 | 4 | from reciperadar import app 5 | from reciperadar.models.feedback import Feedback 6 | 7 | 8 | @app.route("/feedback", methods=["POST"]) 9 | def feedback(): 10 | issue, image_data_uri = request.json 11 | if not image_data_uri.startswith("data:image/png;base64"): 12 | abort(400) 13 | image = urlopen(image_data_uri) 14 | 15 | Feedback.distribute(issue=issue, image=image.file.read()) 16 | 17 | return jsonify({}) 18 | -------------------------------------------------------------------------------- /reciperadar/api/recipes.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from flask import abort, jsonify, request 4 | 5 | from reciperadar import app 6 | from reciperadar.models.feedback import ( 7 | Correction, 8 | Feedback, 9 | ProblemReport, 10 | RemovalRequest, 11 | UnsafeContent, 12 | ) 13 | from reciperadar.models.recipes import Recipe 14 | from reciperadar.search.base import EntityClause 15 | from reciperadar.search.recipes import RecipeSearch 16 | from reciperadar.utils.bots import is_suspected_bot 17 | from reciperadar.workers.events import store_event 18 | from reciperadar.workers.searches import recrawl_search 19 | 20 | 21 | @app.route("/recipes//view") 22 | def recipe_view(recipe_id): 23 | recipe = Recipe().get_by_id(recipe_id) 24 | if not recipe: 25 | return abort(404) 26 | 27 | if recipe.redirected_id: 28 | print( 29 | f"Redirecting from_id={recipe.id} to_id={recipe.redirected_id}", 30 | file=sys.stderr, 31 | ) 32 | return recipe_view(recipe.redirected_id) 33 | 34 | results = { 35 | "total": 1, 36 | "results": [recipe.to_dict()], 37 | } 38 | return jsonify(results) 39 | 40 | 41 | def dietary_args(args): 42 | return [ 43 | f'is_{arg.replace("-", "_")}' 44 | for arg in args 45 | if arg 46 | in { 47 | "dairy-free", 48 | "gluten-free", 49 | "vegan", 50 | "vegetarian", 51 | } 52 | ] 53 | 54 | 55 | @app.route("/recipes/search") 56 | def recipe_search(): 57 | ingredients = EntityClause.from_args(request.args.getlist("ingredients[]")) 58 | equipment = EntityClause.from_args(request.args.getlist("equipment[]")) 59 | offset = min(request.args.get("offset", type=int, default=0), (25 * 10) - 10) 60 | limit = min(request.args.get("limit", type=int, default=10), 10) 61 | sort = request.args.get("sort", type=str) 62 | domains = EntityClause.from_args(request.args.getlist("domains[]")) 63 | dietary_properties = EntityClause.from_args(dietary_args(request.args)) 64 | 65 | if sort and sort not in RecipeSearch.sort_methods(): 66 | return abort(400) 67 | 68 | results = RecipeSearch().query( 69 | ingredients=ingredients, 70 | equipment=equipment, 71 | offset=offset, 72 | limit=limit, 73 | sort=sort, 74 | domains=domains, 75 | dietary_properties=dietary_properties, 76 | ) 77 | 78 | user_agent = request.headers.get("user-agent") 79 | suspected_bot = is_suspected_bot(user_agent) 80 | 81 | include = EntityClause.term_list(ingredients, lambda x: x.positive) 82 | exclude = EntityClause.term_list(ingredients, lambda x: x.negative) 83 | dietary_properties = EntityClause.term_list(dietary_properties) 84 | 85 | # Perform a recrawl for the search to find any new/missing recipes 86 | if not suspected_bot: 87 | recrawl_search.delay(include, exclude, [], dietary_properties, offset) 88 | 89 | # Log a search event 90 | store_event.delay( 91 | event_table="searches", 92 | event_data={ 93 | "suspected_bot": suspected_bot, 94 | "path": request.path, 95 | "include": include, 96 | "exclude": exclude, 97 | "equipment": [], 98 | "dietary_properties": dietary_properties, 99 | "offset": offset, 100 | "limit": limit, 101 | "sort": sort, 102 | "results_ids": [result["id"] for result in results["results"]], 103 | "results_total": results["total"], 104 | }, 105 | ) 106 | 107 | return jsonify(results) 108 | 109 | 110 | @app.route("/recipes/explore") 111 | def recipe_explore(): 112 | ingredients = EntityClause.from_args(request.args.getlist("ingredients[]")) 113 | dietary_properties = EntityClause.from_args(dietary_args(request.args)) 114 | 115 | results = RecipeSearch().explore( 116 | ingredients=ingredients, 117 | dietary_properties=dietary_properties, 118 | ) 119 | 120 | user_agent = request.headers.get("user-agent") 121 | suspected_bot = is_suspected_bot(user_agent) 122 | 123 | include = EntityClause.term_list(ingredients, lambda x: x.positive) 124 | exclude = EntityClause.term_list(ingredients, lambda x: x.negative) 125 | depth = len(ingredients) 126 | limit = 10 if depth >= 3 else 0 127 | 128 | # Log a search event 129 | store_event.delay( 130 | event_table="searches", 131 | event_data={ 132 | "suspected_bot": suspected_bot, 133 | "path": request.path, 134 | "include": include, 135 | "exclude": exclude, 136 | "equipment": [], 137 | "offset": 0, 138 | "limit": limit, 139 | "sort": None, 140 | "results_ids": [result["id"] for result in results["results"]], 141 | "results_total": results["total"], 142 | }, 143 | ) 144 | 145 | return jsonify(results) 146 | 147 | 148 | @app.route("/recipes/report", methods=["POST"]) 149 | def recipe_report(): 150 | try: 151 | recipe_id = request.form.get("recipe-id") 152 | except Exception: 153 | return abort(400) 154 | if not recipe_id: 155 | return abort(400) 156 | 157 | recipe = Recipe().get_by_id(recipe_id) 158 | if not recipe: 159 | return abort(404) 160 | 161 | try: 162 | report_type = request.form.get("report-type") 163 | result_index = 0 # request.form.get("result-index", type=int) 164 | except Exception: 165 | return abort(400) 166 | 167 | if not report_type: 168 | return abort(400) 169 | 170 | if result_index is None: 171 | return abort(400) 172 | 173 | try: 174 | report: ProblemReport | None = None 175 | match report_type: 176 | case "removal-request": 177 | content_owner_email, content_reuse_policy, content_noindex_directive = ( 178 | request.form.get("content-owner-email"), 179 | request.form.get("content-reuse-policy"), 180 | request.form.get("content-noindex-directive"), 181 | ) 182 | if not (content_owner_email or content_reuse_policy): 183 | return abort(400) 184 | if content_noindex_directive is None: 185 | return abort(400) 186 | report = RemovalRequest( 187 | recipe_id=recipe_id, 188 | report_type=report_type, 189 | result_index=result_index, 190 | content_owner_email=content_owner_email, 191 | content_reuse_policy=content_reuse_policy, 192 | content_noindex_directive=bool(content_noindex_directive), 193 | ) 194 | case "unsafe-content": 195 | report = UnsafeContent( 196 | recipe_id=recipe_id, 197 | report_type=report_type, 198 | result_index=result_index, 199 | ) 200 | case "correction": 201 | content_expected, content_found = ( 202 | request.form.get("content-expected"), 203 | request.form.get("content-found"), 204 | ) 205 | if not (content_expected and content_found): 206 | return abort(400) 207 | if content_expected == content_found: 208 | return abort(400) 209 | report = Correction( 210 | recipe_id=recipe_id, 211 | report_type=report_type, 212 | result_index=result_index, 213 | content_expected=content_expected, 214 | content_found=content_found, 215 | ) 216 | case _: 217 | return abort(400) 218 | except AssertionError: 219 | return abort(400) 220 | 221 | Feedback.register_report(recipe, report) 222 | return jsonify({"recipe_id": report["recipe_id"]}) 223 | -------------------------------------------------------------------------------- /reciperadar/api/redirect.py: -------------------------------------------------------------------------------- 1 | from flask import abort, jsonify, redirect, request 2 | 3 | from reciperadar import app 4 | from reciperadar.models.recipes import Recipe 5 | from reciperadar.utils.bots import is_suspected_bot 6 | from reciperadar.workers.events import store_event 7 | 8 | 9 | @app.route("/redirect/recipe/", methods=["GET", "POST"]) 10 | def recipe_redirect(recipe_id): 11 | recipe = Recipe().get_by_id(recipe_id) 12 | if not recipe: 13 | return abort(404) 14 | 15 | user_agent = request.headers.get("user-agent") 16 | suspected_bot = request.method == "GET" or is_suspected_bot(user_agent) 17 | 18 | store_event.delay( 19 | event_table="redirects", 20 | event_data={ 21 | "suspected_bot": suspected_bot, 22 | "recipe_id": recipe.id, 23 | "domain": recipe.domain, 24 | "dst": recipe.dst, 25 | }, 26 | ) 27 | 28 | if request.method == "GET": 29 | return redirect(recipe.dst, code=301) 30 | else: 31 | return jsonify({}) 32 | -------------------------------------------------------------------------------- /reciperadar/models/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from opensearchpy import OpenSearch 3 | from opensearchpy.exceptions import NotFoundError 4 | 5 | from reciperadar import db 6 | 7 | 8 | class Storable(db.Model): 9 | __abstract__ = True 10 | 11 | def to_dict(self): 12 | raise NotImplementedError( 13 | f"No response representation defined for {self.__class__.__name__}" 14 | ) 15 | 16 | 17 | class Searchable: 18 | __metaclass__ = ABC 19 | 20 | es = OpenSearch("opensearch") 21 | 22 | @staticmethod 23 | @abstractmethod 24 | def from_doc(doc): 25 | pass 26 | 27 | @property 28 | @abstractmethod 29 | def noun(self): 30 | pass 31 | 32 | def get_by_id(self, id): 33 | try: 34 | doc = self.es.get(index=self.noun, id=id) 35 | except NotFoundError: 36 | return None 37 | return self.from_doc(doc["_source"]) 38 | -------------------------------------------------------------------------------- /reciperadar/models/feedback.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, ReadOnly, Required, TypedDict 2 | 3 | from flask.templating import render_template 4 | from flask_mail import Message 5 | 6 | from reciperadar.models.recipes import Recipe 7 | 8 | 9 | class ProblemReport(TypedDict): 10 | recipe_id: Required[ReadOnly[str]] 11 | report_type: Required[ 12 | ReadOnly[ 13 | Literal[ 14 | "removal-request", 15 | "unsafe-content", 16 | "correction", 17 | ] 18 | ] 19 | ] 20 | result_index: Required[ReadOnly[int]] 21 | 22 | 23 | class RemovalRequest(ProblemReport): 24 | content_owner_email: Required[ReadOnly[str | None]] 25 | content_reuse_policy: Required[ReadOnly[str | None]] 26 | content_noindex_directive: Required[ReadOnly[bool]] 27 | 28 | 29 | class UnsafeContent(ProblemReport): 30 | pass 31 | 32 | 33 | class Correction(ProblemReport): 34 | content_expected: Required[ReadOnly[str]] 35 | content_found: Required[ReadOnly[str]] 36 | 37 | 38 | class Feedback: 39 | 40 | @staticmethod 41 | def _construct(subject, sender, recipients, html): 42 | return Message(subject=subject, sender=sender, recipients=recipients, html=html) 43 | 44 | @staticmethod 45 | def distribute(issue, image): 46 | from reciperadar import app, mail 47 | 48 | with app.app_context(): 49 | description = issue.pop("issue") or "(empty)" 50 | title = description if len(description) < 25 else f"{description[:25]}..." 51 | 52 | html = "" 53 | for k, v in issue.items(): 54 | html += "" 55 | html += f"" 56 | html += f"" 57 | html += "" 58 | html += f"
{k}{v}

{description}

" 59 | 60 | message = Feedback._construct( 61 | subject=f"User feedback: {title}", 62 | sender="contact@reciperadar.com", 63 | recipients=["feedback@reciperadar.com"], 64 | html=html, 65 | ) 66 | message.attach("screenshot.png", "image/png", image) 67 | mail.send(message) 68 | 69 | @staticmethod 70 | def register_report(recipe: Recipe, report: ProblemReport) -> None: 71 | from reciperadar import app, mail 72 | 73 | with app.app_context(): 74 | report_type = report["report_type"] 75 | template = f"problem-report/{report_type.replace('-', '_')}.html" 76 | html = render_template(template, recipe=recipe, report=report) 77 | 78 | message = Feedback._construct( 79 | subject=f"Content report: {report_type}: {recipe.id}", 80 | sender="contact@reciperadar.com", 81 | recipients=["content-reports@reciperadar.com"], 82 | html=html, 83 | ) 84 | mail.send(message) 85 | -------------------------------------------------------------------------------- /reciperadar/models/recipes/__init__.py: -------------------------------------------------------------------------------- 1 | from .recipe import Recipe 2 | -------------------------------------------------------------------------------- /reciperadar/models/recipes/ingredient.py: -------------------------------------------------------------------------------- 1 | from reciperadar import db 2 | from reciperadar.models.base import Storable 3 | from reciperadar.models.recipes.product import Product 4 | 5 | 6 | class RecipeIngredient(Storable): 7 | __tablename__ = "recipe_ingredients" 8 | 9 | recipe_fk = db.ForeignKey("recipes.id") 10 | recipe_id = db.Column(db.String, recipe_fk) 11 | 12 | product_fk = db.ForeignKey("products.id") 13 | product_id = db.Column(db.String, product_fk) 14 | 15 | id = db.Column(db.String, primary_key=True) 16 | index = db.Column(db.Integer) 17 | description = db.Column(db.String) 18 | markup = db.Column(db.String) 19 | 20 | product = db.relationship("Product", uselist=False) 21 | 22 | magnitude = db.Column(db.Float) 23 | magnitude_parser = db.Column(db.String) 24 | units = db.Column(db.String) 25 | units_parser = db.Column(db.String) 26 | product_is_plural = db.Column(db.Boolean) 27 | product_parser = db.Column(db.String) 28 | verb = db.Column(db.String) 29 | 30 | @property 31 | def product_name(self): 32 | if self.product_is_plural: 33 | return self.product.plural 34 | else: 35 | return self.product.singular 36 | 37 | @staticmethod 38 | def from_doc(doc): 39 | return RecipeIngredient( 40 | id=doc["id"], 41 | index=doc["index"], 42 | description=doc["description"].strip(), 43 | markup=doc.get("markup"), 44 | product=Product.from_doc(doc["product"]), 45 | product_id=doc["product"].get("id"), 46 | product_is_plural=doc.get("product_is_plural"), 47 | product_parser=doc["product"].get("product_parser"), 48 | magnitude=doc.get("magnitude"), 49 | magnitude_parser=doc.get("magnitude_parser"), 50 | units=doc.get("units"), 51 | units_parser=doc.get("units_parser"), 52 | verb=doc.get("verb"), 53 | ) 54 | 55 | def to_dict(self, ingredients=None): 56 | return { 57 | "markup": self.markup, 58 | "product": { 59 | **self.product.to_dict(ingredients), 60 | **{"name": self.product_name}, 61 | }, 62 | "quantity": { 63 | "magnitude": self.magnitude, 64 | "units": self.units, 65 | }, 66 | } 67 | -------------------------------------------------------------------------------- /reciperadar/models/recipes/nutrition.py: -------------------------------------------------------------------------------- 1 | from reciperadar import db 2 | from reciperadar.models.base import Storable 3 | 4 | 5 | class Nutrition(Storable): 6 | __abstract__ = True 7 | 8 | id = db.Column(db.String, primary_key=True) 9 | carbohydrates = db.Column(db.Float) 10 | carbohydrates_units = db.Column(db.String) 11 | energy = db.Column(db.Float) 12 | energy_units = db.Column(db.String) 13 | fat = db.Column(db.Float) 14 | fat_units = db.Column(db.String) 15 | fibre = db.Column(db.Float) 16 | fibre_units = db.Column(db.String) 17 | protein = db.Column(db.Float) 18 | protein_units = db.Column(db.String) 19 | 20 | def to_dict(self): 21 | return { 22 | "carbohydrates": { 23 | "magnitude": self.carbohydrates, 24 | "units": self.carbohydrates_units, 25 | }, 26 | "energy": { 27 | "magnitude": self.energy, 28 | "units": self.energy_units, 29 | }, 30 | "fat": { 31 | "magnitude": self.fat, 32 | "units": self.fat_units, 33 | }, 34 | "fibre": { 35 | "magnitude": self.fibre, 36 | "units": self.fibre_units, 37 | }, 38 | "protein": { 39 | "magnitude": self.protein, 40 | "units": self.protein_units, 41 | }, 42 | } 43 | 44 | 45 | class RecipeNutrition(Nutrition): 46 | __tablename__ = "recipe_nutrition" 47 | 48 | fk = db.ForeignKey("recipes.id") 49 | recipe_id = db.Column(db.String, fk) 50 | 51 | @staticmethod 52 | def from_doc(doc): 53 | return RecipeNutrition( 54 | carbohydrates=doc.get("carbohydrates"), 55 | carbohydrates_units=doc.get("carbohydrates_units"), 56 | energy=doc.get("energy"), 57 | energy_units=doc.get("energy_units"), 58 | fat=doc.get("fat"), 59 | fat_units=doc.get("fat_units"), 60 | fibre=doc.get("fibre"), 61 | fibre_units=doc.get("fibre_units"), 62 | protein=doc.get("protein"), 63 | protein_units=doc.get("protein_units"), 64 | ) 65 | -------------------------------------------------------------------------------- /reciperadar/models/recipes/product.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.dialects import postgresql 2 | 3 | from reciperadar import db 4 | from reciperadar.models.base import Storable 5 | from reciperadar.search.base import EntityClause 6 | 7 | 8 | class Product(Storable): 9 | __tablename__ = "ingredient_products" 10 | 11 | ingredient_fk = db.ForeignKey("recipe_ingredients.id") 12 | ingredient_id = db.Column(db.String, ingredient_fk) 13 | 14 | id = db.Column(db.String, primary_key=True) 15 | product_parser = db.Column(db.String) 16 | singular = db.Column(db.String) 17 | plural = db.Column(db.String) 18 | category = db.Column(db.String) 19 | contents = db.Column(postgresql.ARRAY(db.String)) 20 | 21 | STATE_AVAILABLE = "available" 22 | STATE_REQUIRED = "required" 23 | 24 | @staticmethod 25 | def from_doc(doc): 26 | return Product( 27 | id=doc.get("id"), 28 | product_parser=doc.get("product_parser"), 29 | singular=doc.get("singular"), 30 | plural=doc.get("plural"), 31 | category=doc.get("category"), 32 | contents=doc.get("contents"), 33 | ) 34 | 35 | def state(self, ingredients): 36 | ingredients = ingredients or [] 37 | states = { 38 | True: Product.STATE_AVAILABLE, 39 | False: Product.STATE_REQUIRED, 40 | } 41 | include = EntityClause.term_list(ingredients, lambda x: x.positive) 42 | available = bool(set(self.contents or []) & set(include)) 43 | return states[available] 44 | 45 | def to_dict(self, ingredients): 46 | return { 47 | "id": self.id, 48 | "category": self.category, 49 | "singular": self.singular, 50 | "plural": self.plural, 51 | "state": self.state(ingredients), 52 | } 53 | -------------------------------------------------------------------------------- /reciperadar/models/recipes/recipe.py: -------------------------------------------------------------------------------- 1 | from reciperadar import db 2 | from reciperadar.models.base import Searchable, Storable 3 | from reciperadar.models.recipes.ingredient import RecipeIngredient 4 | from reciperadar.models.recipes.nutrition import RecipeNutrition 5 | 6 | 7 | class Recipe(Storable, Searchable): 8 | __tablename__ = "recipes" 9 | 10 | id = db.Column(db.String, primary_key=True) 11 | title = db.Column(db.String) 12 | src = db.Column(db.String) 13 | dst = db.Column(db.String) 14 | domain = db.Column(db.String) 15 | author = db.Column(db.String) 16 | author_url = db.Column(db.String) 17 | time = db.Column(db.Integer) 18 | servings = db.Column(db.Integer) 19 | rating = db.Column(db.Float) 20 | nutrition = db.Column(db.JSON) 21 | nutrition_source = db.Column(db.String) 22 | ingredients = db.relationship("RecipeIngredient", passive_deletes="all") 23 | nutrition = db.relationship("RecipeNutrition", uselist=False, passive_deletes="all") 24 | is_dairy_free = db.Column(db.Boolean) 25 | is_gluten_free = db.Column(db.Boolean) 26 | is_vegan = db.Column(db.Boolean) 27 | is_vegetarian = db.Column(db.Boolean) 28 | 29 | indexed_at = db.Column(db.DateTime) 30 | 31 | redirected_id = db.Column(db.String) 32 | redirected_at = db.Column(db.DateTime) 33 | 34 | @property 35 | def noun(self): 36 | return "recipes" 37 | 38 | @property 39 | def url(self): 40 | return f"/#action=view&id={self.id}" 41 | 42 | @property 43 | def products(self): 44 | unique_products = { 45 | ingredient.product.singular: ingredient.product 46 | for ingredient in self.ingredients 47 | } 48 | return unique_products.values() 49 | 50 | @property 51 | def hidden(self): 52 | for ingredient in self.ingredients: 53 | if not ingredient.product.singular: 54 | return True 55 | return False 56 | 57 | @staticmethod 58 | def from_doc(doc): 59 | return Recipe( 60 | id=doc["id"], 61 | title=doc["title"], 62 | src=doc["src"], 63 | dst=doc["dst"], 64 | domain=doc["domain"], 65 | author=doc.get("author"), 66 | author_url=doc.get("author_url"), 67 | ingredients=[ 68 | RecipeIngredient.from_doc(ingredient) 69 | for ingredient in doc["ingredients"] 70 | if ingredient["description"].strip() 71 | ], 72 | nutrition=( 73 | RecipeNutrition.from_doc(doc["nutrition"]) 74 | if doc.get("nutrition") 75 | else None 76 | ), 77 | nutrition_source=doc.get("nutrition_source"), 78 | is_dairy_free=doc.get("is_dairy_free"), 79 | is_gluten_free=doc.get("is_gluten_free"), 80 | is_vegan=doc.get("is_vegan"), 81 | is_vegetarian=doc.get("is_vegetarian"), 82 | servings=doc["servings"], 83 | time=doc["time"], 84 | rating=doc["rating"], 85 | indexed_at=doc["indexed_at"], 86 | redirected_id=doc.get("redirected_id"), 87 | redirected_at=doc.get("redirected_at"), 88 | ) 89 | 90 | def to_dict(self, ingredients=None): 91 | return { 92 | "id": self.id, 93 | "title": self.title, 94 | "time": self.time, 95 | "ingredients": [ 96 | ingredient.to_dict(ingredients) for ingredient in self.ingredients 97 | ], 98 | "directions": [ 99 | # direction.to_dict() 100 | # for direction 101 | # in sorted(self.directions, key=lambda x: x.index) 102 | ], 103 | "servings": self.servings, 104 | "rating": self.rating, 105 | "dst": self.dst, 106 | "domain": self.domain, 107 | "author": self.author, 108 | "author_url": self.author_url, 109 | "nutrition": ( 110 | self.nutrition.to_dict() 111 | if self.nutrition and self.nutrition_source == "crawler" 112 | else None 113 | ), 114 | "is_dairy_free": self.is_dairy_free, 115 | "is_gluten_free": self.is_gluten_free, 116 | "is_vegan": self.is_vegan, 117 | "is_vegetarian": self.is_vegetarian, 118 | } 119 | -------------------------------------------------------------------------------- /reciperadar/search/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from opensearchpy import OpenSearch 4 | 5 | 6 | class EntityClause: 7 | def __init__(self, term, positive): 8 | self.term = term 9 | self.positive = positive 10 | 11 | def __eq__(self, obj): 12 | return self.term == obj.term and self.positive == obj.positive 13 | 14 | @property 15 | def negative(self): 16 | return not self.positive 17 | 18 | @staticmethod 19 | def from_arg(arg): 20 | return EntityClause(arg.lstrip("-"), positive=not arg.startswith("-")) 21 | 22 | @staticmethod 23 | def from_args(args): 24 | return [EntityClause.from_arg(arg) for arg in args] 25 | 26 | @staticmethod 27 | def term_list(clauses, condition=lambda x: True, synonyms=None): 28 | synonyms = synonyms or {} 29 | seen = set() 30 | terms = [] 31 | for clause in filter(condition, clauses): 32 | for expansion in synonyms.get(clause.term) or [clause.term]: 33 | if expansion in seen: 34 | continue 35 | seen.add(expansion) 36 | terms.append(expansion) 37 | return terms 38 | 39 | 40 | class QueryRepository: 41 | __metaclass__ = ABC 42 | 43 | es = OpenSearch("opensearch") 44 | -------------------------------------------------------------------------------- /reciperadar/search/equipment.py: -------------------------------------------------------------------------------- 1 | from reciperadar.search.base import QueryRepository 2 | 3 | 4 | class EquipmentSearch(QueryRepository): 5 | def autosuggest(self, prefix): 6 | prefix = prefix.lower() 7 | query = { 8 | "aggregations": { 9 | "equipment": { 10 | "filter": { 11 | "bool": { 12 | "should": [ 13 | {"match": {"equipment_names": prefix}}, 14 | {"prefix": {"equipment_names": prefix}}, 15 | ] 16 | } 17 | }, 18 | "aggregations": { 19 | "equipment": { 20 | "terms": { 21 | "field": "equipment_names", 22 | "include": f"{prefix}.*", 23 | "min_doc_count": 1, 24 | "size": 10, 25 | } 26 | } 27 | }, 28 | } 29 | } 30 | } 31 | results = self.es.search(index="recipes", body=query)["aggregations"] 32 | results = results["equipment"]["equipment"]["buckets"] 33 | 34 | results.sort( 35 | key=lambda s: ( 36 | s["key"] != prefix, # exact matches first 37 | not s["key"].startswith(prefix), # prefix matches next 38 | len(s["key"]), 39 | ), # sort remaining matches by length 40 | ) 41 | return [{"equipment": result["key"]} for result in results] 42 | -------------------------------------------------------------------------------- /reciperadar/search/ingredients.py: -------------------------------------------------------------------------------- 1 | from reciperadar.models.recipes.product import Product 2 | from reciperadar.search.base import QueryRepository 3 | 4 | 5 | class IngredientSearch(QueryRepository): 6 | def autosuggest(self, prefix): 7 | prefix = prefix.lower() 8 | query = { 9 | "aggregations": { 10 | # aggregate across all nested ingredient documents 11 | "ingredients": { 12 | "nested": {"path": "ingredients"}, 13 | "aggregations": { 14 | # filter to product names which match the user search 15 | "products": { 16 | "filter": { 17 | "bool": { 18 | "should": [ 19 | { 20 | "match": { 21 | "ingredients.product_name.autocomplete": { # noqa 22 | "query": prefix, 23 | "operator": "AND", 24 | "fuzziness": "AUTO", 25 | } 26 | } 27 | }, 28 | { 29 | "prefix": { 30 | "ingredients.product_name": prefix 31 | } 32 | }, 33 | ] 34 | } 35 | }, 36 | "aggregations": { 37 | # retrieve the top products in singular pluralization 38 | "product_id": { 39 | "terms": { 40 | "field": "ingredients.product.id", 41 | "min_doc_count": 5, 42 | "size": 10, 43 | }, 44 | "aggregations": { 45 | # count products that were plural in the source recipe # noqa 46 | "plurality": { 47 | "filter": { 48 | "match": { 49 | "ingredients.product_is_plural": True # noqa 50 | } 51 | } 52 | }, 53 | # retrieve a category for each ingredient 54 | "category": { 55 | "terms": { 56 | "field": "ingredients.product.category", 57 | "size": 1, 58 | } 59 | }, 60 | "singular": { 61 | "terms": { 62 | "field": "ingredients.product.singular", 63 | "size": 1, 64 | } 65 | }, 66 | "plural": { 67 | "terms": { 68 | "field": "ingredients.product.plural", 69 | "size": 1, 70 | } 71 | }, 72 | }, 73 | } 74 | }, 75 | } 76 | }, 77 | } 78 | } 79 | } 80 | results = self.es.search(index="recipes", body=query)["aggregations"] 81 | results = results["ingredients"]["products"]["product_id"]["buckets"] 82 | 83 | # iterate through the suggestions and determine whether to display 84 | # the singular or plural form of the word based on how frequently 85 | # each form is used in the overall recipe corpus 86 | suggestions = [] 87 | for result in results: 88 | total_count = result["doc_count"] 89 | plural_count = result["plurality"]["doc_count"] 90 | plural_wins = plural_count > total_count - plural_count 91 | 92 | product_id = result["key"] 93 | category = (result["category"]["buckets"] or [{}])[0].get("key") 94 | singular = (result["singular"]["buckets"] or [{}])[0].get("key") 95 | plural = (result["plural"]["buckets"] or [{}])[0].get("key") 96 | 97 | product = Product( 98 | id=product_id, 99 | category=category, 100 | singular=singular, 101 | plural=plural, 102 | ) 103 | product.name = plural if plural_wins else singular 104 | suggestions.append(product) 105 | 106 | suggestions.sort( 107 | key=lambda s: ( 108 | s.name != prefix, # exact matches first 109 | not s.name.startswith(prefix), # prefix matches next 110 | len(s.name), 111 | ), # sort remaining matches by length 112 | ) 113 | return [ 114 | { 115 | "id": suggestion.id, 116 | "name": suggestion.name, 117 | "category": suggestion.category, 118 | "singular": suggestion.singular, 119 | "plural": suggestion.plural, 120 | } 121 | for suggestion in suggestions 122 | ] 123 | 124 | def synonyms(self): 125 | try: 126 | results = self.es.search(index="product_synonyms", size=10000) 127 | except Exception: 128 | return None 129 | return { 130 | result["_id"]: result["_source"]["synonyms"] 131 | for result in results["hits"]["hits"] 132 | } 133 | -------------------------------------------------------------------------------- /reciperadar/search/recipes.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import UTC, datetime, timedelta 3 | 4 | from reciperadar import app 5 | from reciperadar.models.recipes import Recipe 6 | from reciperadar.search.base import EntityClause, QueryRepository 7 | from reciperadar.search.ingredients import IngredientSearch 8 | 9 | 10 | def load_ingredient_synonyms(): 11 | # Return cached synonyms if they are available and have not yet expired 12 | if hasattr(app, "ingredient_synonyms"): 13 | expiry = app.ingredient_synonyms_loaded_at + timedelta(hours=1) 14 | if datetime.now(tz=UTC) < expiry: 15 | return app.ingredient_synonyms 16 | 17 | # Otherwise, attempt to update the synonym cache 18 | synonyms = IngredientSearch().synonyms() 19 | if synonyms: 20 | app.ingredient_synonyms = synonyms 21 | app.ingredient_synonyms_loaded_at = datetime.now(tz=UTC) 22 | 23 | # Return the latest-known synonyms 24 | if hasattr(app, "ingredient_synonyms"): 25 | return app.ingredient_synonyms 26 | 27 | 28 | class RecipeSearch(QueryRepository): 29 | @staticmethod 30 | def _generate_include_clause(ingredients): 31 | synonyms = load_ingredient_synonyms() 32 | include = EntityClause.term_list(ingredients, lambda x: x.positive, synonyms) 33 | return [ 34 | { 35 | "constant_score": { 36 | "boost": pow(10, idx), 37 | "filter": {"match": {"contents": inc}}, 38 | } 39 | } 40 | for idx, inc in enumerate(reversed(include)) 41 | ] 42 | 43 | @staticmethod 44 | def _generate_include_exact_clause(ingredients): 45 | synonyms = load_ingredient_synonyms() 46 | include = EntityClause.term_list(ingredients, lambda x: x.positive, synonyms) 47 | return [ 48 | { 49 | "nested": { 50 | "path": "ingredients", 51 | "query": { 52 | "constant_score": { 53 | "boost": pow(10, idx) * 2, 54 | "filter": {"match": {"ingredients.product.singular": inc}}, 55 | } 56 | }, 57 | } 58 | } 59 | for idx, inc in enumerate(reversed(include)) 60 | ] 61 | 62 | @staticmethod 63 | def _generate_exclude_clause(ingredients): 64 | synonyms = load_ingredient_synonyms() 65 | exclude = EntityClause.term_list(ingredients, lambda x: x.negative, synonyms) 66 | return [ 67 | # exclude 'hidden' recipes 68 | {"match": {"hidden": True}}, 69 | ] + [ 70 | # match any ingredients in the exclude list 71 | {"match": {"contents": exc}} 72 | for exc in exclude 73 | ] 74 | 75 | @staticmethod 76 | def sort_methods(match_count=1): 77 | score_limit = pow(10, match_count) * 2 78 | preamble = f""" 79 | def product_count = doc.product_count.value; 80 | def exact_found_count = 0; 81 | def found_count = 0; 82 | for (def score = (long) _score; score > 0; score /= 10) {{ 83 | if (score % 10 > 2) exact_found_count++; 84 | if (score % 10 > 0) found_count++; 85 | }} 86 | def missing_count = product_count - found_count; 87 | def exact_missing_count = product_count - exact_found_count; 88 | 89 | def relevance_score = (found_count * 2 + exact_found_count); 90 | def normalized_score = _score / {float(score_limit)}; 91 | def missing_score = (exact_missing_count * 2 - missing_count); 92 | def missing_ratio = missing_count / product_count; 93 | """ 94 | return { 95 | # rank: number of ingredient matches 96 | # tiebreak: normalized relevance score 97 | "relevance": { 98 | "script": f"{preamble} relevance_score + normalized_score", 99 | "order": "desc", 100 | }, 101 | # rank: number of missing ingredients 102 | # tiebreak: normalized relevance score 103 | "ingredients": { 104 | "script": f"{preamble} missing_score + 1 - normalized_score", 105 | "order": "asc", 106 | }, 107 | # rank: preparation time 108 | # tiebreak: percentage of missing ingredients 109 | "duration": { 110 | "script": f"{preamble} doc.time.value + missing_ratio", 111 | "order": "asc", 112 | }, 113 | } 114 | 115 | def _generate_sort_method(self, ingredients, sort): 116 | # set the default sort method 117 | if not sort: 118 | sort = "relevance" 119 | # if no ingredients are specified, we may be able to short-cut sorting 120 | include = [True for x in ingredients if x.positive] 121 | if include == [] and sort != "duration": 122 | return {"script": "doc.rating.value", "order": "desc"} 123 | return self.sort_methods(match_count=len(include))[sort] 124 | 125 | def _domain_facets(self): 126 | return {"domains": {"terms": {"field": "domain", "size": 100}}} 127 | 128 | def _product_filter(self, ingredients, dietary_properties): 129 | conditions = defaultdict(list) 130 | 131 | # Do not present staple ingredients as choices 132 | match = {"term": {"ingredients.product.is_kitchen_staple": True}} 133 | conditions["must_not"].append(match) 134 | 135 | # Do not present already-selected ingredients as choices 136 | for ingredient in ingredients: 137 | match = {"term": {"ingredients.product.singular": ingredient.term}} 138 | conditions["must_not"].append(match) 139 | 140 | # Filter to products that satisfy the user's dietary requirements 141 | for dietary_property in dietary_properties: 142 | field = f"ingredients.product.{dietary_property.term}" 143 | match = {"term": {field: True}} 144 | conditions["filter"].append(match) 145 | 146 | return {"bool": conditions} 147 | 148 | def _product_aggregation(self): 149 | return { 150 | "singular": { 151 | "terms": { 152 | "field": "ingredients.product.singular", 153 | "order": {"_count": "desc"}, 154 | "size": 50, 155 | } 156 | } 157 | } 158 | 159 | def _product_suggestions(self, ingredients, dietary_properties): 160 | product_filter = self._product_filter(ingredients, dietary_properties) 161 | product_aggregation = self._product_aggregation() 162 | return { 163 | "products": { 164 | "nested": {"path": "ingredients"}, 165 | "aggs": { 166 | "choices": { 167 | "filter": product_filter, 168 | "aggs": product_aggregation, 169 | } 170 | }, 171 | } 172 | } 173 | 174 | def _generate_aggregations(self, suggest_products, ingredients, dietary_properties): 175 | aggregations = { 176 | **self._domain_facets(), 177 | **( 178 | self._product_suggestions(ingredients, dietary_properties) 179 | if suggest_products 180 | else {} 181 | ), 182 | } 183 | return { 184 | "prefilter": { 185 | "filter": {"match_all": {}}, 186 | "aggs": aggregations, 187 | } 188 | } 189 | 190 | def _generate_post_filter(self, domains): 191 | conditions = defaultdict(list) 192 | for domain in domains: 193 | condition = "filter" if domain.positive else "must_not" 194 | match = {"match": {"domain": domain.term}} 195 | conditions[condition].append(match) 196 | return {"bool": conditions} 197 | 198 | def _render_query( 199 | self, 200 | ingredients, 201 | dietary_properties, 202 | sort, 203 | exact_match=True, 204 | min_include_match=None, 205 | ): 206 | include_exact_clause = self._generate_include_exact_clause(ingredients) 207 | include_clause = self._generate_include_clause(ingredients) 208 | exclude_clause = self._generate_exclude_clause(ingredients) 209 | sort_params = self._generate_sort_method(ingredients, sort) 210 | 211 | should = include_exact_clause if exact_match else include_clause 212 | must_not = exclude_clause 213 | filter = [ 214 | {"range": {"time": {"gte": 5}}}, 215 | {"range": {"product_count": {"gt": 0}}}, 216 | ] 217 | for dietary_property in dietary_properties: 218 | match = {"match": {dietary_property.term: True}} 219 | filter.append(match) 220 | if min_include_match is None: 221 | min_include_match = len(should) 222 | 223 | return { 224 | "function_score": { 225 | "boost_mode": "replace", 226 | "query": { 227 | "bool": { 228 | "should": should, 229 | "must_not": must_not, 230 | "filter": filter, 231 | "minimum_should_match": min_include_match, 232 | } 233 | }, 234 | "script_score": {"script": {"source": sort_params["script"]}}, 235 | } 236 | }, [{"_score": sort_params["order"]}] 237 | 238 | def _refined_queries(self, ingredients, dietary_properties, sort): 239 | # Provide an 'empty query' hint 240 | if not any([ingredients, sort]): 241 | query, sort_method = self._render_query( 242 | ingredients=ingredients, 243 | dietary_properties=dietary_properties, 244 | sort=sort, 245 | ) 246 | yield query, sort_method, "empty_query" 247 | return 248 | 249 | for exact_match in [False]: 250 | query, sort_method = self._render_query( 251 | ingredients=ingredients, 252 | dietary_properties=dietary_properties, 253 | exact_match=exact_match, 254 | sort=sort, 255 | ) 256 | yield query, sort_method, None 257 | 258 | positive_ingredients = sum(x.positive for x in ingredients) 259 | if positive_ingredients > 1: 260 | for min_include_match in range(positive_ingredients - 1, 0, -1): 261 | for exact_match in [False]: 262 | query, sort_method = self._render_query( 263 | ingredients=ingredients, 264 | dietary_properties=dietary_properties, 265 | sort=sort, 266 | exact_match=exact_match, 267 | min_include_match=min_include_match, 268 | ) 269 | yield query, sort_method, "match_any" 270 | 271 | def query( 272 | self, 273 | ingredients, 274 | equipment, 275 | offset, 276 | limit, 277 | sort, 278 | domains, 279 | dietary_properties, 280 | allow_refinement=True, 281 | suggest_products=False, 282 | ): 283 | """ 284 | Searching for recipes is currently supported in three different modes: 285 | 286 | * 'relevance' mode prioritizes matching as many ingredients as possible 287 | * 'ingredients' mode aims to find recipes with fewest extras required 288 | * 'duration' mode finds recipes that can be prepared most quickly 289 | 290 | In the search index, each recipe contains a list of ingredients. 291 | Each ingredient is identified by the 'ingredient.product.singular' 292 | field. 293 | 294 | When users select auto-suggested ingredients, they may be choosing from 295 | either singular or plural names - i.e. 'potato' or 'potatoes' may 296 | appear in their user interface. 297 | 298 | When the client makes a search request, it should always use the 299 | singular ingredient name form - 'potato' in the example above. This 300 | allows the search engine to match against the corresponding singular 301 | ingredient name in the recipe index. 302 | 303 | Recipe index 304 | 305 | Ingredient text Indexed ingredient name 306 | recipe 1 "3 sweet potatoes" -> "sweet potato" 307 | "1 onion" -> "onion" 308 | ... 309 | recipe 2 "2kg onions" -> "onion" 310 | ... 311 | 312 | 313 | End-to-end search 314 | 315 | Autosuggest Client query Recipe matches Displayed to user 316 | ["onions"] -> ["onion"] -> recipe 1 -> "3 sweet potatoes" 317 | "1 onion" 318 | ... 319 | recipe 2 -> "2kg onions" 320 | ... 321 | 322 | 323 | Recipes also contain an aggregated 'contents' field, which contains all 324 | of the ingredient identifiers and also their related ingredient names. 325 | 326 | Related ingredients can include ingredient ancestors (i.e. 'tortilla' 327 | is an ancestor of 'flour tortilla'). 328 | 329 | Example: 330 | { 331 | 'title': 'Tofu stir-fry', 332 | 'ingredients': [ 333 | { 334 | 'product': { 335 | 'singular': 'firm tofu', 336 | ... 337 | } 338 | }, 339 | ... 340 | ], 341 | 'contents': [ 342 | 'firm tofu', 343 | 'tofu', 344 | ... 345 | ] 346 | } 347 | 348 | Some queries are quite straightforward under this model. 349 | 350 | A search for 'firm tofu' can simply match on any recipes with 'firm 351 | tofu' in the 'contents' field. 352 | 353 | A more complex example is a search for 'tofu', where we want recipes 354 | which contain either 'tofu' or 'firm tofu' to appear. In this 355 | situation, we would prefer exact-matches on 'tofu' to appear before 356 | matches on 'firm tofu' which are a less precise match for the query. 357 | 358 | In this case we can search on the 'contents' field and we will find the 359 | recipe, but in order to determine whether a recipe contained an 'exact' 360 | match we also need to check the 'ingredient.product.singular' field and 361 | record whether the query term was present. 362 | 363 | To achieve this, we use OpenSearch's query syntax to encode information 364 | about the quality of each match during search execution. 365 | 366 | We use `constant_score` queries to store a power-of-ten score for each 367 | query ingredient, with the value doubled for exact matches. 368 | 369 | For example, in a query for `onion`, `tomato`, `tofu`: 370 | 371 | onion tomato tofu score 372 | recipe 1 exact exact partial 300 + 30 + 1 = 331 373 | recipe 2 partial no exact 100 + 0 + 3 = 103 374 | recipe 3 exact no exact 300 + 0 + 3 = 303 375 | 376 | This allows the final sorting stage to determine - with some small 377 | possibility of error* - how many exact and inexact matches were 378 | discovered for each recipe. 379 | 380 | score exact_matches all_matches 381 | recipe 1 331 1 + 1 + 0 = 2 1 + 1 + 1 = 3 382 | recipe 2 103 0 + 0 + 1 = 1 1 + 0 + 1 = 2 383 | recipe 3 303 1 + 0 + 1 = 2 1 + 0 + 1 = 2 384 | 385 | At this stage we have enough information to sort the result set based 386 | on the number of overall matches and to use the number of exact matches 387 | as a tiebreaker within each group. 388 | 389 | Result ranking: 390 | 391 | - (3 matches, 2 exact) recipe 1 392 | - (2 matches, 2 exact) recipe 3 393 | - (2 matches, 1 exact) recipe 2 394 | 395 | 396 | * Inconsistent results and ranking errors can occur if an ingredient 397 | appears multiple times in a recipe, resulting in duplicate counts 398 | """ 399 | offset = max(0, offset) 400 | limit = max(0, limit) 401 | limit = min(25, limit) 402 | 403 | aggregations = self._generate_aggregations( 404 | suggest_products=suggest_products, 405 | ingredients=ingredients, 406 | dietary_properties=dietary_properties, 407 | ) 408 | post_filter = self._generate_post_filter(domains=domains) 409 | 410 | queries = self._refined_queries( 411 | ingredients=ingredients, 412 | dietary_properties=dietary_properties, 413 | sort=sort, 414 | ) 415 | for query, sort_method, refinement in queries: 416 | results = self.es.search( 417 | index="recipes", 418 | body={ 419 | "query": query, 420 | "from": offset, 421 | "size": limit, 422 | "sort": sort_method, 423 | "aggs": aggregations, 424 | "post_filter": post_filter, 425 | }, 426 | ) 427 | if not allow_refinement: 428 | break 429 | if results["aggregations"]["prefilter"]["doc_count"] >= 5: 430 | break 431 | 432 | recipes = [] 433 | for result in results["hits"]["hits"]: 434 | recipe = Recipe.from_doc(result["_source"]) 435 | recipes.append(recipe.to_dict(ingredients)) 436 | 437 | # TODO: Can this bucket sorting be moved into the aggregation pipeline? 438 | if suggest_products: 439 | prefilter = results["aggregations"]["prefilter"] 440 | total = prefilter["doc_count"] 441 | 442 | products = prefilter["products"]["choices"]["singular"]["buckets"] 443 | products = [x for x in products if x["doc_count"] != total] 444 | products.sort(key=lambda x: abs(x["doc_count"] - (total / 2))) 445 | prefilter["products"] = {"buckets": products[:10]} 446 | 447 | facets = {} 448 | for field, content in results["aggregations"]["prefilter"].items(): 449 | if not isinstance(content, dict) or "buckets" not in content: 450 | continue 451 | facets[field] = [ 452 | { 453 | "key": bucket["key"], 454 | "count": min(bucket["doc_count"], 100), 455 | } 456 | for bucket in content["buckets"] 457 | ] 458 | 459 | refinements = [refinement] if recipes and refinement else [] 460 | if equipment: 461 | refinements += ["equipment_search_unavailable"] 462 | 463 | return { 464 | "authority": "api", 465 | "total": min(results["hits"]["total"]["value"], 25 * limit), 466 | "results": recipes, 467 | "facets": facets, 468 | "refinements": refinements, 469 | } 470 | 471 | def explore(self, ingredients, dietary_properties): 472 | depth = len(ingredients) 473 | limit = 10 if depth >= 3 else 0 474 | return self.query( 475 | ingredients=ingredients, 476 | equipment=[], 477 | offset=0, 478 | limit=limit, 479 | sort=None, 480 | domains=[], 481 | dietary_properties=dietary_properties, 482 | allow_refinement=False, 483 | suggest_products=True, 484 | ) 485 | -------------------------------------------------------------------------------- /reciperadar/templates/problem-report/correction.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

A possible correction has been reported for {{ recipe.title }}.

4 |
    5 |
  • Expected: {{ report.content_expected }}
  • 6 |
  • Found: {{ report.content_found }}
  • 7 |
8 |

Please inspect the recipe and determine whether the problem is from the origin recipe webpage or whether we have processed the data incorrectly.

9 |

File a data bugreport if the issue relates to the way we have processed the recipe.

10 | 11 | 12 | -------------------------------------------------------------------------------- /reciperadar/templates/problem-report/removal_request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

A recipe removal request has been received for {{ recipe.title }}.

4 | 5 | {% if report.content_owner_email %} 6 |

If in doubt, verify the identity of the requestor by contacting them.

7 | {% endif %} 8 | 9 | {% if report.content_reuse_policy %} 10 |

The reporter has provided a content usage policy covering the recipe. Confirm that the recipe to be removed is covered by the policy.

11 | {% endif %} 12 | 13 | {% if report.content_noindex_directive %} 14 |

The reporter also indicates that there is an HTML

noindex
directive that indicates that the content should not be included in search engine indices.

15 | {% endif %} 16 | 17 |

If you believe that the recipe should indeed be removed, delete it from the search engine index, database, and backups.

18 | 19 | {% if report.content_owner_email %} 20 |

Contact the content owner to confirm that their content has been removed.

21 | {% endif %} 22 | 23 | 24 | -------------------------------------------------------------------------------- /reciperadar/templates/problem-report/unsafe_content.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Unsafe content has been reported for {{ recipe.title }}.

4 |

Please remove the recipe from our search engine index, database, and backups.

5 |

If the problem occurs for all recipe webpages on {{ recipe.domain }}, then disable indexing for that domain, and remove all recipes from our search engine index, database and backups.

6 | 7 | 8 | -------------------------------------------------------------------------------- /reciperadar/utils/bots.py: -------------------------------------------------------------------------------- 1 | from user_agents import parse as ua_parser 2 | 3 | 4 | def is_suspected_bot(user_agent): 5 | user_agent = user_agent or "" 6 | # ref: https://github.com/ua-parser/uap-core/issues/554 7 | if "HeadlessChrome" in user_agent: 8 | return True 9 | return ua_parser(user_agent).is_bot 10 | -------------------------------------------------------------------------------- /reciperadar/workers/__init__.py: -------------------------------------------------------------------------------- 1 | from reciperadar.workers.broker import celery 2 | import reciperadar.workers.events 3 | import reciperadar.workers.recipes 4 | import reciperadar.workers.searches 5 | -------------------------------------------------------------------------------- /reciperadar/workers/broker.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | celery = Celery("reciperadar", broker="pyamqp://guest@rabbitmq") 4 | -------------------------------------------------------------------------------- /reciperadar/workers/events.py: -------------------------------------------------------------------------------- 1 | from reciperadar.workers.broker import celery 2 | 3 | 4 | @celery.task 5 | def store_event(event_table, event_data): 6 | pass 7 | -------------------------------------------------------------------------------- /reciperadar/workers/recipes.py: -------------------------------------------------------------------------------- 1 | from reciperadar.workers.broker import celery 2 | 3 | 4 | @celery.task(queue="index_recipe") 5 | def index_recipe(recipe_id): 6 | pass 7 | 8 | 9 | @celery.task(queue="crawl_recipe") 10 | def crawl_recipe(url): 11 | pass 12 | 13 | 14 | @celery.task(queue="crawl_url") 15 | def crawl_url(url): 16 | pass 17 | -------------------------------------------------------------------------------- /reciperadar/workers/searches.py: -------------------------------------------------------------------------------- 1 | from reciperadar.workers.broker import celery 2 | 3 | 4 | @celery.task(queue="recrawl_search") 5 | def recrawl_search(include, exclude, equipment, dietary_properties, offset): 6 | pass 7 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | pytest 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | amqp==5.3.1 \ 2 | --hash=sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2 \ 3 | --hash=sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432 4 | # via kombu 5 | billiard==4.2.1 \ 6 | --hash=sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f \ 7 | --hash=sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb 8 | # via celery 9 | black==25.1.0 \ 10 | --hash=sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171 \ 11 | --hash=sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7 \ 12 | --hash=sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da \ 13 | --hash=sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2 \ 14 | --hash=sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc \ 15 | --hash=sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666 \ 16 | --hash=sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f \ 17 | --hash=sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b \ 18 | --hash=sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32 \ 19 | --hash=sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f \ 20 | --hash=sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717 \ 21 | --hash=sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299 \ 22 | --hash=sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0 \ 23 | --hash=sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18 \ 24 | --hash=sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0 \ 25 | --hash=sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3 \ 26 | --hash=sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355 \ 27 | --hash=sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096 \ 28 | --hash=sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e \ 29 | --hash=sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9 \ 30 | --hash=sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba \ 31 | --hash=sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f 32 | # via -r requirements-dev.in 33 | blinker==1.9.0 \ 34 | --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ 35 | --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc 36 | # via 37 | # flask 38 | # flask-mail 39 | celery==5.5.2 \ 40 | --hash=sha256:4d6930f354f9d29295425d7a37261245c74a32807c45d764bedc286afd0e724e \ 41 | --hash=sha256:54425a067afdc88b57cd8d94ed4af2ffaf13ab8c7680041ac2c4ac44357bdf4c 42 | # via -r requirements.in 43 | certifi==2025.4.26 \ 44 | --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ 45 | --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 46 | # via 47 | # opensearch-py 48 | # requests 49 | charset-normalizer==3.4.2 \ 50 | --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ 51 | --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ 52 | --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ 53 | --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ 54 | --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ 55 | --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ 56 | --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ 57 | --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ 58 | --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ 59 | --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ 60 | --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ 61 | --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ 62 | --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ 63 | --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ 64 | --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ 65 | --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ 66 | --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ 67 | --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ 68 | --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ 69 | --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ 70 | --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ 71 | --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ 72 | --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ 73 | --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ 74 | --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ 75 | --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ 76 | --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ 77 | --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ 78 | --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ 79 | --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ 80 | --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ 81 | --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ 82 | --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ 83 | --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ 84 | --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ 85 | --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ 86 | --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ 87 | --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ 88 | --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ 89 | --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ 90 | --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ 91 | --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ 92 | --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ 93 | --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ 94 | --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ 95 | --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ 96 | --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ 97 | --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ 98 | --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ 99 | --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ 100 | --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ 101 | --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ 102 | --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ 103 | --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ 104 | --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ 105 | --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ 106 | --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ 107 | --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ 108 | --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ 109 | --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ 110 | --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ 111 | --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ 112 | --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ 113 | --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ 114 | --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ 115 | --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ 116 | --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ 117 | --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ 118 | --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ 119 | --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ 120 | --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ 121 | --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ 122 | --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ 123 | --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ 124 | --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ 125 | --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ 126 | --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ 127 | --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ 128 | --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ 129 | --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ 130 | --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ 131 | --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ 132 | --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ 133 | --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ 134 | --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ 135 | --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ 136 | --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ 137 | --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ 138 | --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ 139 | --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ 140 | --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ 141 | --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f 142 | # via requests 143 | click==8.1.8 \ 144 | --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ 145 | --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a 146 | # via 147 | # black 148 | # celery 149 | # click-didyoumean 150 | # click-plugins 151 | # click-repl 152 | # flask 153 | click-didyoumean==0.3.1 \ 154 | --hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \ 155 | --hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c 156 | # via celery 157 | click-plugins==1.1.1 \ 158 | --hash=sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b \ 159 | --hash=sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8 160 | # via celery 161 | click-repl==0.3.0 \ 162 | --hash=sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9 \ 163 | --hash=sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812 164 | # via celery 165 | events==0.5 \ 166 | --hash=sha256:a7286af378ba3e46640ac9825156c93bdba7502174dd696090fdfcd4d80a1abd 167 | # via opensearch-py 168 | flake8==7.2.0 \ 169 | --hash=sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343 \ 170 | --hash=sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426 171 | # via -r requirements-dev.in 172 | flask==3.1.1 \ 173 | --hash=sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c \ 174 | --hash=sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e 175 | # via 176 | # -r requirements.in 177 | # flask-mail 178 | # flask-sqlalchemy 179 | flask-mail==0.10.0 \ 180 | --hash=sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d \ 181 | --hash=sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7 182 | # via -r requirements.in 183 | flask-sqlalchemy==3.1.1 \ 184 | --hash=sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0 \ 185 | --hash=sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312 186 | # via -r requirements.in 187 | greenlet==3.2.1 \ 188 | --hash=sha256:04b4ec7f65f0e4a1500ac475c9343f6cc022b2363ebfb6e94f416085e40dea15 \ 189 | --hash=sha256:05a7490f74e8aabc5f29256765a99577ffde979920a2db1f3676d265a3adba41 \ 190 | --hash=sha256:063bcf7f8ee28eb91e7f7a8148c65a43b73fbdc0064ab693e024b5a940070145 \ 191 | --hash=sha256:0ba2811509a30e5f943be048895a983a8daf0b9aa0ac0ead526dfb5d987d80ea \ 192 | --hash=sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8 \ 193 | --hash=sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c \ 194 | --hash=sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526 \ 195 | --hash=sha256:17964c246d4f6e1327edd95e2008988a8995ae3a7732be2f9fc1efed1f1cdf8c \ 196 | --hash=sha256:1a750f1046994b9e038b45ae237d68153c29a3a783075211fb1414a180c8324b \ 197 | --hash=sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123 \ 198 | --hash=sha256:2273586879affca2d1f414709bb1f61f0770adcabf9eda8ef48fd90b36f15d12 \ 199 | --hash=sha256:24a496479bc8bd01c39aa6516a43c717b4cee7196573c47b1f8e1011f7c12495 \ 200 | --hash=sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95 \ 201 | --hash=sha256:3059c6f286b53ea4711745146ffe5a5c5ff801f62f6c56949446e0f6461f8157 \ 202 | --hash=sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a \ 203 | --hash=sha256:374ffebaa5fbd10919cd599e5cf8ee18bae70c11f9d61e73db79826c8c93d6f9 \ 204 | --hash=sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5 \ 205 | --hash=sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32 \ 206 | --hash=sha256:4245246e72352b150a1588d43ddc8ab5e306bef924c26571aafafa5d1aaae4e8 \ 207 | --hash=sha256:4339b202ac20a89ccd5bde0663b4d00dc62dd25cb3fb14f7f3034dec1b0d9ece \ 208 | --hash=sha256:4818116e75a0dd52cdcf40ca4b419e8ce5cb6669630cb4f13a6c384307c9543f \ 209 | --hash=sha256:5193135b3a8d0017cb438de0d49e92bf2f6c1c770331d24aa7500866f4db4017 \ 210 | --hash=sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb \ 211 | --hash=sha256:5c12f0d17a88664757e81a6e3fc7c2452568cf460a2f8fb44f90536b2614000b \ 212 | --hash=sha256:6079ae990bbf944cf66bea64a09dcb56085815630955109ffa98984810d71565 \ 213 | --hash=sha256:639a94d001fe874675b553f28a9d44faed90f9864dc57ba0afef3f8d76a18b04 \ 214 | --hash=sha256:64a4d0052de53ab3ad83ba86de5ada6aeea8f099b4e6c9ccce70fb29bc02c6a2 \ 215 | --hash=sha256:6dcc6d604a6575c6225ac0da39df9335cc0c6ac50725063fa90f104f3dbdb2c9 \ 216 | --hash=sha256:7132e024ebeeeabbe661cf8878aac5d2e643975c4feae833142592ec2f03263d \ 217 | --hash=sha256:72c9b668454e816b5ece25daac1a42c94d1c116d5401399a11b77ce8d883110c \ 218 | --hash=sha256:777c1281aa7c786738683e302db0f55eb4b0077c20f1dc53db8852ffaea0a6b0 \ 219 | --hash=sha256:7abc0545d8e880779f0c7ce665a1afc3f72f0ca0d5815e2b006cafc4c1cc5840 \ 220 | --hash=sha256:7b0f3a0a67786facf3b907a25db80efe74310f9d63cc30869e49c79ee3fcef7e \ 221 | --hash=sha256:852ef432919830022f71a040ff7ba3f25ceb9fe8f3ab784befd747856ee58530 \ 222 | --hash=sha256:8b89e5d44f55372efc6072f59ced5ed1efb7b44213dab5ad7e0caba0232c6545 \ 223 | --hash=sha256:8fe303381e7e909e42fb23e191fc69659910909fdcd056b92f6473f80ef18543 \ 224 | --hash=sha256:9afa05fe6557bce1642d8131f87ae9462e2a8e8c46f7ed7929360616088a3975 \ 225 | --hash=sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7 \ 226 | --hash=sha256:aa30066fd6862e1153eaae9b51b449a6356dcdb505169647f69e6ce315b9468b \ 227 | --hash=sha256:b38d53cf268da963869aa25a6e4cc84c1c69afc1ae3391738b2603d110749d01 \ 228 | --hash=sha256:b7503d6b8bbdac6bbacf5a8c094f18eab7553481a1830975799042f26c9e101b \ 229 | --hash=sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07 \ 230 | --hash=sha256:cb5ee928ce5fedf9a4b0ccdc547f7887136c4af6109d8f2fe8e00f90c0db47f5 \ 231 | --hash=sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc \ 232 | --hash=sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189 \ 233 | --hash=sha256:dbb4e1aa2000852937dd8f4357fb73e3911da426df8ca9b8df5db231922da474 \ 234 | --hash=sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982 \ 235 | --hash=sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac \ 236 | --hash=sha256:e1a40a17e2c7348f5eee5d8e1b4fa6a937f0587eba89411885a36a8e1fc29bd2 \ 237 | --hash=sha256:e63cd2035f49376a23611fbb1643f78f8246e9d4dfd607534ec81b175ce582c2 \ 238 | --hash=sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d \ 239 | --hash=sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437 \ 240 | --hash=sha256:e934591a7a4084fa10ee5ef50eb9d2ac8c4075d5c9cf91128116b5dca49d43b1 \ 241 | --hash=sha256:e98328b8b8f160925d6b1c5b1879d8e64f6bd8cf11472b7127d579da575b77d9 \ 242 | --hash=sha256:ff38c869ed30fff07f1452d9a204ece1ec6d3c0870e0ba6e478ce7c1515acf22 243 | # via sqlalchemy 244 | gunicorn==23.0.0 \ 245 | --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \ 246 | --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec 247 | # via -r requirements.in 248 | idna==3.10 \ 249 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 250 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 251 | # via requests 252 | iniconfig==2.1.0 \ 253 | --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ 254 | --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 255 | # via pytest 256 | itsdangerous==2.2.0 \ 257 | --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ 258 | --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 259 | # via flask 260 | jinja2==3.1.6 \ 261 | --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ 262 | --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 263 | # via 264 | # -r requirements.in 265 | # flask 266 | kombu==5.5.3 \ 267 | --hash=sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2 \ 268 | --hash=sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b 269 | # via celery 270 | markupsafe==3.0.2 \ 271 | --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ 272 | --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ 273 | --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ 274 | --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ 275 | --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ 276 | --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ 277 | --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ 278 | --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ 279 | --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ 280 | --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ 281 | --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ 282 | --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ 283 | --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ 284 | --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ 285 | --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ 286 | --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ 287 | --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ 288 | --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ 289 | --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ 290 | --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ 291 | --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ 292 | --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ 293 | --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ 294 | --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ 295 | --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ 296 | --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ 297 | --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ 298 | --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ 299 | --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ 300 | --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ 301 | --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ 302 | --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ 303 | --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ 304 | --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ 305 | --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ 306 | --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ 307 | --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ 308 | --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ 309 | --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ 310 | --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ 311 | --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ 312 | --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ 313 | --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ 314 | --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ 315 | --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ 316 | --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ 317 | --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ 318 | --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ 319 | --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ 320 | --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ 321 | --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ 322 | --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ 323 | --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ 324 | --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ 325 | --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ 326 | --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ 327 | --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ 328 | --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ 329 | --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ 330 | --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ 331 | --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 332 | # via 333 | # flask 334 | # jinja2 335 | # werkzeug 336 | mccabe==0.7.0 \ 337 | --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ 338 | --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e 339 | # via flake8 340 | mypy-extensions==1.1.0 \ 341 | --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ 342 | --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 343 | # via black 344 | opensearch-py==2.8.0 \ 345 | --hash=sha256:52c60fdb5d4dcf6cce3ee746c13b194529b0161e0f41268b98ab8f1624abe2fa \ 346 | --hash=sha256:6598df0bc7a003294edd0ba88a331e0793acbb8c910c43edf398791e3b2eccda 347 | # via -r requirements.in 348 | packaging==25.0 \ 349 | --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ 350 | --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f 351 | # via 352 | # black 353 | # gunicorn 354 | # pytest 355 | pathspec==0.12.1 \ 356 | --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ 357 | --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 358 | # via black 359 | platformdirs==4.3.8 \ 360 | --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ 361 | --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 362 | # via black 363 | pluggy==1.5.0 \ 364 | --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ 365 | --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 366 | # via pytest 367 | prompt-toolkit==3.0.51 \ 368 | --hash=sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07 \ 369 | --hash=sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed 370 | # via click-repl 371 | pycodestyle==2.13.0 \ 372 | --hash=sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9 \ 373 | --hash=sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae 374 | # via flake8 375 | pyflakes==3.3.2 \ 376 | --hash=sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a \ 377 | --hash=sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b 378 | # via flake8 379 | pytest==8.3.5 \ 380 | --hash=sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820 \ 381 | --hash=sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845 382 | # via -r requirements-dev.in 383 | python-dateutil==2.9.0.post0 \ 384 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 385 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 386 | # via 387 | # celery 388 | # opensearch-py 389 | requests==2.32.3 \ 390 | --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ 391 | --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 392 | # via opensearch-py 393 | six==1.17.0 \ 394 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 395 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 396 | # via python-dateutil 397 | sqlalchemy==2.0.40 \ 398 | --hash=sha256:00a494ea6f42a44c326477b5bee4e0fc75f6a80c01570a32b57e89cf0fbef85a \ 399 | --hash=sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d \ 400 | --hash=sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2 \ 401 | --hash=sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e \ 402 | --hash=sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26 \ 403 | --hash=sha256:1abb387710283fc5983d8a1209d9696a4eae9db8d7ac94b402981fe2fe2e39ad \ 404 | --hash=sha256:1ffdf9c91428e59744f8e6f98190516f8e1d05eec90e936eb08b257332c5e870 \ 405 | --hash=sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0 \ 406 | --hash=sha256:2cbafc8d39ff1abdfdda96435f38fab141892dc759a2165947d1a8fffa7ef596 \ 407 | --hash=sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a \ 408 | --hash=sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a \ 409 | --hash=sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4 \ 410 | --hash=sha256:37a5c21ab099a83d669ebb251fddf8f5cee4d75ea40a5a1653d9c43d60e20867 \ 411 | --hash=sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a \ 412 | --hash=sha256:46628ebcec4f23a1584fb52f2abe12ddb00f3bb3b7b337618b80fc1b51177aff \ 413 | --hash=sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705 \ 414 | --hash=sha256:4aeb939bcac234b88e2d25d5381655e8353fe06b4e50b1c55ecffe56951d18c2 \ 415 | --hash=sha256:50f5885bbed261fc97e2e66c5156244f9704083a674b8d17f24c72217d29baf5 \ 416 | --hash=sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51 \ 417 | --hash=sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00 \ 418 | --hash=sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364 \ 419 | --hash=sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011 \ 420 | --hash=sha256:574aea2c54d8f1dd1699449f332c7d9b71c339e04ae50163a3eb5ce4c4325ee4 \ 421 | --hash=sha256:5cfa124eda500ba4b0d3afc3e91ea27ed4754e727c7f025f293a22f512bcd4c9 \ 422 | --hash=sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1 \ 423 | --hash=sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad \ 424 | --hash=sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1 \ 425 | --hash=sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716 \ 426 | --hash=sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0 \ 427 | --hash=sha256:7e0505719939e52a7b0c65d20e84a6044eb3712bb6f239c6b1db77ba8e173a37 \ 428 | --hash=sha256:8b6b28d303b9d57c17a5164eb1fd2d5119bb6ff4413d5894e74873280483eeb5 \ 429 | --hash=sha256:8bb131ffd2165fae48162c7bbd0d97c84ab961deea9b8bab16366543deeab625 \ 430 | --hash=sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01 \ 431 | --hash=sha256:9408fd453d5f8990405cc9def9af46bfbe3183e6110401b407c2d073c3388f47 \ 432 | --hash=sha256:957f8d85d5e834397ef78a6109550aeb0d27a53b5032f7a57f2451e1adc37e98 \ 433 | --hash=sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1 \ 434 | --hash=sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d \ 435 | --hash=sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500 \ 436 | --hash=sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af \ 437 | --hash=sha256:ae9597cab738e7cc823f04a704fb754a9249f0b6695a6aeb63b74055cd417a96 \ 438 | --hash=sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758 \ 439 | --hash=sha256:b5a5bbe29c10c5bfd63893747a1bf6f8049df607638c786252cb9243b86b6706 \ 440 | --hash=sha256:baf7cee56bd552385c1ee39af360772fbfc2f43be005c78d1140204ad6148438 \ 441 | --hash=sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db \ 442 | --hash=sha256:bece9527f5a98466d67fb5d34dc560c4da964240d8b09024bb21c1246545e04e \ 443 | --hash=sha256:c0cae71e20e3c02c52f6b9e9722bca70e4a90a466d59477822739dc31ac18b4b \ 444 | --hash=sha256:c268b5100cfeaa222c40f55e169d484efa1384b44bf9ca415eae6d556f02cb08 \ 445 | --hash=sha256:c7b927155112ac858357ccf9d255dd8c044fd9ad2dc6ce4c4149527c901fa4c3 \ 446 | --hash=sha256:c884de19528e0fcd9dc34ee94c810581dd6e74aef75437ff17e696c2bfefae3e \ 447 | --hash=sha256:cd2f75598ae70bcfca9117d9e51a3b06fe29edd972fdd7fd57cc97b4dbf3b08a \ 448 | --hash=sha256:cf0e99cdb600eabcd1d65cdba0d3c91418fee21c4aa1d28db47d095b1064a7d8 \ 449 | --hash=sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00 \ 450 | --hash=sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191 \ 451 | --hash=sha256:f0fda83e113bb0fb27dc003685f32a5dcb99c9c4f41f4fa0838ac35265c23b5c \ 452 | --hash=sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7 \ 453 | --hash=sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e \ 454 | --hash=sha256:fe147fcd85aaed53ce90645c91ed5fca0cc88a797314c70dfd9d35925bd5d106 455 | # via 456 | # -r requirements.in 457 | # flask-sqlalchemy 458 | typing-extensions==4.13.2 \ 459 | --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ 460 | --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef 461 | # via sqlalchemy 462 | tzdata==2025.2 \ 463 | --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ 464 | --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 465 | # via kombu 466 | ua-parser==1.0.1 \ 467 | --hash=sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea \ 468 | --hash=sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d 469 | # via user-agents 470 | ua-parser-builtins==0.18.0.post1 \ 471 | --hash=sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d 472 | # via ua-parser 473 | urllib3==2.4.0 \ 474 | --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ 475 | --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 476 | # via 477 | # opensearch-py 478 | # requests 479 | user-agents==2.2.0 \ 480 | --hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7 \ 481 | --hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 482 | # via -r requirements.in 483 | vine==5.1.0 \ 484 | --hash=sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc \ 485 | --hash=sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0 486 | # via 487 | # amqp 488 | # celery 489 | # kombu 490 | wcwidth==0.2.13 \ 491 | --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \ 492 | --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5 493 | # via prompt-toolkit 494 | werkzeug==3.1.3 \ 495 | --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ 496 | --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 497 | # via flask 498 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | celery==5.5.2 2 | flask==3.1.1 3 | flask-mail==0.10.0 4 | flask-sqlalchemy==3.1.1 5 | gunicorn==23.0.0 6 | jinja2==3.1.6 7 | opensearch-py==2.8.0 8 | sqlalchemy==2.0.40 9 | user-agents==2.2.0 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | amqp==5.3.1 \ 2 | --hash=sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2 \ 3 | --hash=sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432 4 | # via kombu 5 | billiard==4.2.1 \ 6 | --hash=sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f \ 7 | --hash=sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb 8 | # via celery 9 | blinker==1.9.0 \ 10 | --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ 11 | --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc 12 | # via 13 | # flask 14 | # flask-mail 15 | celery==5.5.2 \ 16 | --hash=sha256:4d6930f354f9d29295425d7a37261245c74a32807c45d764bedc286afd0e724e \ 17 | --hash=sha256:54425a067afdc88b57cd8d94ed4af2ffaf13ab8c7680041ac2c4ac44357bdf4c 18 | # via -r requirements.in 19 | certifi==2025.4.26 \ 20 | --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ 21 | --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 22 | # via 23 | # opensearch-py 24 | # requests 25 | charset-normalizer==3.4.2 \ 26 | --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ 27 | --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ 28 | --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ 29 | --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ 30 | --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ 31 | --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ 32 | --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ 33 | --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ 34 | --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ 35 | --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ 36 | --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ 37 | --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ 38 | --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ 39 | --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ 40 | --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ 41 | --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ 42 | --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ 43 | --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ 44 | --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ 45 | --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ 46 | --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ 47 | --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ 48 | --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ 49 | --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ 50 | --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ 51 | --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ 52 | --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ 53 | --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ 54 | --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ 55 | --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ 56 | --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ 57 | --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ 58 | --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ 59 | --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ 60 | --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ 61 | --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ 62 | --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ 63 | --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ 64 | --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ 65 | --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ 66 | --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ 67 | --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ 68 | --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ 69 | --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ 70 | --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ 71 | --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ 72 | --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ 73 | --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ 74 | --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ 75 | --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ 76 | --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ 77 | --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ 78 | --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ 79 | --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ 80 | --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ 81 | --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ 82 | --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ 83 | --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ 84 | --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ 85 | --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ 86 | --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ 87 | --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ 88 | --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ 89 | --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ 90 | --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ 91 | --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ 92 | --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ 93 | --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ 94 | --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ 95 | --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ 96 | --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ 97 | --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ 98 | --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ 99 | --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ 100 | --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ 101 | --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ 102 | --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ 103 | --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ 104 | --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ 105 | --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ 106 | --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ 107 | --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ 108 | --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ 109 | --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ 110 | --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ 111 | --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ 112 | --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ 113 | --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ 114 | --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ 115 | --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ 116 | --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ 117 | --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f 118 | # via requests 119 | click==8.1.8 \ 120 | --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ 121 | --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a 122 | # via 123 | # celery 124 | # click-didyoumean 125 | # click-plugins 126 | # click-repl 127 | # flask 128 | click-didyoumean==0.3.1 \ 129 | --hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \ 130 | --hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c 131 | # via celery 132 | click-plugins==1.1.1 \ 133 | --hash=sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b \ 134 | --hash=sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8 135 | # via celery 136 | click-repl==0.3.0 \ 137 | --hash=sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9 \ 138 | --hash=sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812 139 | # via celery 140 | events==0.5 \ 141 | --hash=sha256:a7286af378ba3e46640ac9825156c93bdba7502174dd696090fdfcd4d80a1abd 142 | # via opensearch-py 143 | flask==3.1.1 \ 144 | --hash=sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c \ 145 | --hash=sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e 146 | # via 147 | # -r requirements.in 148 | # flask-mail 149 | # flask-sqlalchemy 150 | flask-mail==0.10.0 \ 151 | --hash=sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d \ 152 | --hash=sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7 153 | # via -r requirements.in 154 | flask-sqlalchemy==3.1.1 \ 155 | --hash=sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0 \ 156 | --hash=sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312 157 | # via -r requirements.in 158 | greenlet==3.2.1 \ 159 | --hash=sha256:04b4ec7f65f0e4a1500ac475c9343f6cc022b2363ebfb6e94f416085e40dea15 \ 160 | --hash=sha256:05a7490f74e8aabc5f29256765a99577ffde979920a2db1f3676d265a3adba41 \ 161 | --hash=sha256:063bcf7f8ee28eb91e7f7a8148c65a43b73fbdc0064ab693e024b5a940070145 \ 162 | --hash=sha256:0ba2811509a30e5f943be048895a983a8daf0b9aa0ac0ead526dfb5d987d80ea \ 163 | --hash=sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8 \ 164 | --hash=sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c \ 165 | --hash=sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526 \ 166 | --hash=sha256:17964c246d4f6e1327edd95e2008988a8995ae3a7732be2f9fc1efed1f1cdf8c \ 167 | --hash=sha256:1a750f1046994b9e038b45ae237d68153c29a3a783075211fb1414a180c8324b \ 168 | --hash=sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123 \ 169 | --hash=sha256:2273586879affca2d1f414709bb1f61f0770adcabf9eda8ef48fd90b36f15d12 \ 170 | --hash=sha256:24a496479bc8bd01c39aa6516a43c717b4cee7196573c47b1f8e1011f7c12495 \ 171 | --hash=sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95 \ 172 | --hash=sha256:3059c6f286b53ea4711745146ffe5a5c5ff801f62f6c56949446e0f6461f8157 \ 173 | --hash=sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a \ 174 | --hash=sha256:374ffebaa5fbd10919cd599e5cf8ee18bae70c11f9d61e73db79826c8c93d6f9 \ 175 | --hash=sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5 \ 176 | --hash=sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32 \ 177 | --hash=sha256:4245246e72352b150a1588d43ddc8ab5e306bef924c26571aafafa5d1aaae4e8 \ 178 | --hash=sha256:4339b202ac20a89ccd5bde0663b4d00dc62dd25cb3fb14f7f3034dec1b0d9ece \ 179 | --hash=sha256:4818116e75a0dd52cdcf40ca4b419e8ce5cb6669630cb4f13a6c384307c9543f \ 180 | --hash=sha256:5193135b3a8d0017cb438de0d49e92bf2f6c1c770331d24aa7500866f4db4017 \ 181 | --hash=sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb \ 182 | --hash=sha256:5c12f0d17a88664757e81a6e3fc7c2452568cf460a2f8fb44f90536b2614000b \ 183 | --hash=sha256:6079ae990bbf944cf66bea64a09dcb56085815630955109ffa98984810d71565 \ 184 | --hash=sha256:639a94d001fe874675b553f28a9d44faed90f9864dc57ba0afef3f8d76a18b04 \ 185 | --hash=sha256:64a4d0052de53ab3ad83ba86de5ada6aeea8f099b4e6c9ccce70fb29bc02c6a2 \ 186 | --hash=sha256:6dcc6d604a6575c6225ac0da39df9335cc0c6ac50725063fa90f104f3dbdb2c9 \ 187 | --hash=sha256:7132e024ebeeeabbe661cf8878aac5d2e643975c4feae833142592ec2f03263d \ 188 | --hash=sha256:72c9b668454e816b5ece25daac1a42c94d1c116d5401399a11b77ce8d883110c \ 189 | --hash=sha256:777c1281aa7c786738683e302db0f55eb4b0077c20f1dc53db8852ffaea0a6b0 \ 190 | --hash=sha256:7abc0545d8e880779f0c7ce665a1afc3f72f0ca0d5815e2b006cafc4c1cc5840 \ 191 | --hash=sha256:7b0f3a0a67786facf3b907a25db80efe74310f9d63cc30869e49c79ee3fcef7e \ 192 | --hash=sha256:852ef432919830022f71a040ff7ba3f25ceb9fe8f3ab784befd747856ee58530 \ 193 | --hash=sha256:8b89e5d44f55372efc6072f59ced5ed1efb7b44213dab5ad7e0caba0232c6545 \ 194 | --hash=sha256:8fe303381e7e909e42fb23e191fc69659910909fdcd056b92f6473f80ef18543 \ 195 | --hash=sha256:9afa05fe6557bce1642d8131f87ae9462e2a8e8c46f7ed7929360616088a3975 \ 196 | --hash=sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7 \ 197 | --hash=sha256:aa30066fd6862e1153eaae9b51b449a6356dcdb505169647f69e6ce315b9468b \ 198 | --hash=sha256:b38d53cf268da963869aa25a6e4cc84c1c69afc1ae3391738b2603d110749d01 \ 199 | --hash=sha256:b7503d6b8bbdac6bbacf5a8c094f18eab7553481a1830975799042f26c9e101b \ 200 | --hash=sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07 \ 201 | --hash=sha256:cb5ee928ce5fedf9a4b0ccdc547f7887136c4af6109d8f2fe8e00f90c0db47f5 \ 202 | --hash=sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc \ 203 | --hash=sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189 \ 204 | --hash=sha256:dbb4e1aa2000852937dd8f4357fb73e3911da426df8ca9b8df5db231922da474 \ 205 | --hash=sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982 \ 206 | --hash=sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac \ 207 | --hash=sha256:e1a40a17e2c7348f5eee5d8e1b4fa6a937f0587eba89411885a36a8e1fc29bd2 \ 208 | --hash=sha256:e63cd2035f49376a23611fbb1643f78f8246e9d4dfd607534ec81b175ce582c2 \ 209 | --hash=sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d \ 210 | --hash=sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437 \ 211 | --hash=sha256:e934591a7a4084fa10ee5ef50eb9d2ac8c4075d5c9cf91128116b5dca49d43b1 \ 212 | --hash=sha256:e98328b8b8f160925d6b1c5b1879d8e64f6bd8cf11472b7127d579da575b77d9 \ 213 | --hash=sha256:ff38c869ed30fff07f1452d9a204ece1ec6d3c0870e0ba6e478ce7c1515acf22 214 | # via sqlalchemy 215 | gunicorn==23.0.0 \ 216 | --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \ 217 | --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec 218 | # via -r requirements.in 219 | idna==3.10 \ 220 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 221 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 222 | # via requests 223 | itsdangerous==2.2.0 \ 224 | --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ 225 | --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 226 | # via flask 227 | jinja2==3.1.6 \ 228 | --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ 229 | --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 230 | # via 231 | # -r requirements.in 232 | # flask 233 | kombu==5.5.3 \ 234 | --hash=sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2 \ 235 | --hash=sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b 236 | # via celery 237 | markupsafe==3.0.2 \ 238 | --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ 239 | --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ 240 | --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ 241 | --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ 242 | --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ 243 | --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ 244 | --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ 245 | --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ 246 | --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ 247 | --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ 248 | --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ 249 | --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ 250 | --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ 251 | --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ 252 | --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ 253 | --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ 254 | --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ 255 | --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ 256 | --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ 257 | --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ 258 | --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ 259 | --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ 260 | --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ 261 | --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ 262 | --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ 263 | --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ 264 | --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ 265 | --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ 266 | --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ 267 | --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ 268 | --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ 269 | --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ 270 | --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ 271 | --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ 272 | --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ 273 | --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ 274 | --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ 275 | --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ 276 | --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ 277 | --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ 278 | --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ 279 | --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ 280 | --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ 281 | --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ 282 | --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ 283 | --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ 284 | --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ 285 | --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ 286 | --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ 287 | --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ 288 | --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ 289 | --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ 290 | --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ 291 | --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ 292 | --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ 293 | --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ 294 | --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ 295 | --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ 296 | --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ 297 | --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ 298 | --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 299 | # via 300 | # flask 301 | # jinja2 302 | # werkzeug 303 | opensearch-py==2.8.0 \ 304 | --hash=sha256:52c60fdb5d4dcf6cce3ee746c13b194529b0161e0f41268b98ab8f1624abe2fa \ 305 | --hash=sha256:6598df0bc7a003294edd0ba88a331e0793acbb8c910c43edf398791e3b2eccda 306 | # via -r requirements.in 307 | packaging==25.0 \ 308 | --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ 309 | --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f 310 | # via gunicorn 311 | prompt-toolkit==3.0.51 \ 312 | --hash=sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07 \ 313 | --hash=sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed 314 | # via click-repl 315 | python-dateutil==2.9.0.post0 \ 316 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 317 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 318 | # via 319 | # celery 320 | # opensearch-py 321 | requests==2.32.3 \ 322 | --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ 323 | --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 324 | # via opensearch-py 325 | six==1.17.0 \ 326 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 327 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 328 | # via python-dateutil 329 | sqlalchemy==2.0.40 \ 330 | --hash=sha256:00a494ea6f42a44c326477b5bee4e0fc75f6a80c01570a32b57e89cf0fbef85a \ 331 | --hash=sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d \ 332 | --hash=sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2 \ 333 | --hash=sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e \ 334 | --hash=sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26 \ 335 | --hash=sha256:1abb387710283fc5983d8a1209d9696a4eae9db8d7ac94b402981fe2fe2e39ad \ 336 | --hash=sha256:1ffdf9c91428e59744f8e6f98190516f8e1d05eec90e936eb08b257332c5e870 \ 337 | --hash=sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0 \ 338 | --hash=sha256:2cbafc8d39ff1abdfdda96435f38fab141892dc759a2165947d1a8fffa7ef596 \ 339 | --hash=sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a \ 340 | --hash=sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a \ 341 | --hash=sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4 \ 342 | --hash=sha256:37a5c21ab099a83d669ebb251fddf8f5cee4d75ea40a5a1653d9c43d60e20867 \ 343 | --hash=sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a \ 344 | --hash=sha256:46628ebcec4f23a1584fb52f2abe12ddb00f3bb3b7b337618b80fc1b51177aff \ 345 | --hash=sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705 \ 346 | --hash=sha256:4aeb939bcac234b88e2d25d5381655e8353fe06b4e50b1c55ecffe56951d18c2 \ 347 | --hash=sha256:50f5885bbed261fc97e2e66c5156244f9704083a674b8d17f24c72217d29baf5 \ 348 | --hash=sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51 \ 349 | --hash=sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00 \ 350 | --hash=sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364 \ 351 | --hash=sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011 \ 352 | --hash=sha256:574aea2c54d8f1dd1699449f332c7d9b71c339e04ae50163a3eb5ce4c4325ee4 \ 353 | --hash=sha256:5cfa124eda500ba4b0d3afc3e91ea27ed4754e727c7f025f293a22f512bcd4c9 \ 354 | --hash=sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1 \ 355 | --hash=sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad \ 356 | --hash=sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1 \ 357 | --hash=sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716 \ 358 | --hash=sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0 \ 359 | --hash=sha256:7e0505719939e52a7b0c65d20e84a6044eb3712bb6f239c6b1db77ba8e173a37 \ 360 | --hash=sha256:8b6b28d303b9d57c17a5164eb1fd2d5119bb6ff4413d5894e74873280483eeb5 \ 361 | --hash=sha256:8bb131ffd2165fae48162c7bbd0d97c84ab961deea9b8bab16366543deeab625 \ 362 | --hash=sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01 \ 363 | --hash=sha256:9408fd453d5f8990405cc9def9af46bfbe3183e6110401b407c2d073c3388f47 \ 364 | --hash=sha256:957f8d85d5e834397ef78a6109550aeb0d27a53b5032f7a57f2451e1adc37e98 \ 365 | --hash=sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1 \ 366 | --hash=sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d \ 367 | --hash=sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500 \ 368 | --hash=sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af \ 369 | --hash=sha256:ae9597cab738e7cc823f04a704fb754a9249f0b6695a6aeb63b74055cd417a96 \ 370 | --hash=sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758 \ 371 | --hash=sha256:b5a5bbe29c10c5bfd63893747a1bf6f8049df607638c786252cb9243b86b6706 \ 372 | --hash=sha256:baf7cee56bd552385c1ee39af360772fbfc2f43be005c78d1140204ad6148438 \ 373 | --hash=sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db \ 374 | --hash=sha256:bece9527f5a98466d67fb5d34dc560c4da964240d8b09024bb21c1246545e04e \ 375 | --hash=sha256:c0cae71e20e3c02c52f6b9e9722bca70e4a90a466d59477822739dc31ac18b4b \ 376 | --hash=sha256:c268b5100cfeaa222c40f55e169d484efa1384b44bf9ca415eae6d556f02cb08 \ 377 | --hash=sha256:c7b927155112ac858357ccf9d255dd8c044fd9ad2dc6ce4c4149527c901fa4c3 \ 378 | --hash=sha256:c884de19528e0fcd9dc34ee94c810581dd6e74aef75437ff17e696c2bfefae3e \ 379 | --hash=sha256:cd2f75598ae70bcfca9117d9e51a3b06fe29edd972fdd7fd57cc97b4dbf3b08a \ 380 | --hash=sha256:cf0e99cdb600eabcd1d65cdba0d3c91418fee21c4aa1d28db47d095b1064a7d8 \ 381 | --hash=sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00 \ 382 | --hash=sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191 \ 383 | --hash=sha256:f0fda83e113bb0fb27dc003685f32a5dcb99c9c4f41f4fa0838ac35265c23b5c \ 384 | --hash=sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7 \ 385 | --hash=sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e \ 386 | --hash=sha256:fe147fcd85aaed53ce90645c91ed5fca0cc88a797314c70dfd9d35925bd5d106 387 | # via 388 | # -r requirements.in 389 | # flask-sqlalchemy 390 | typing-extensions==4.13.2 \ 391 | --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ 392 | --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef 393 | # via sqlalchemy 394 | tzdata==2025.2 \ 395 | --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ 396 | --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 397 | # via kombu 398 | ua-parser==1.0.1 \ 399 | --hash=sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea \ 400 | --hash=sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d 401 | # via user-agents 402 | ua-parser-builtins==0.18.0.post1 \ 403 | --hash=sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d 404 | # via ua-parser 405 | urllib3==2.4.0 \ 406 | --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ 407 | --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 408 | # via 409 | # opensearch-py 410 | # requests 411 | user-agents==2.2.0 \ 412 | --hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7 \ 413 | --hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 414 | # via -r requirements.in 415 | vine==5.1.0 \ 416 | --hash=sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc \ 417 | --hash=sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0 418 | # via 419 | # amqp 420 | # celery 421 | # kombu 422 | wcwidth==0.2.13 \ 423 | --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \ 424 | --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5 425 | # via prompt-toolkit 426 | werkzeug==3.1.3 \ 427 | --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ 428 | --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 429 | # via flask 430 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openculinary/api/71c2adadc094f6e91ff19bc0d9341eb685dd3760/tests/__init__.py -------------------------------------------------------------------------------- /tests/api/test_autosuggest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "prefix", 6 | [ 7 | None, 8 | "", 9 | "a", 10 | "0", 11 | "1", 12 | "test" * 25, 13 | ], 14 | ) 15 | def test_autosuggest_equipment_invalid_prefix(client, prefix): 16 | url = "/autosuggest/equipment" + (f"?pre={prefix}" if prefix else "") 17 | response = client.get(url) 18 | assert response.status_code == 400 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "prefix", 23 | [ 24 | None, 25 | "", 26 | "a", 27 | "0", 28 | "1", 29 | "test" * 25, 30 | ], 31 | ) 32 | def test_autosuggest_ingredients_invalid_prefix(client, prefix): 33 | url = "/autosuggest/ingredients" + (f"?pre={prefix}" if prefix else "") 34 | response = client.get(url) 35 | assert response.status_code == 400 36 | -------------------------------------------------------------------------------- /tests/api/test_feedback.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from reciperadar.models.feedback import Feedback 4 | 5 | 6 | @patch.object(Feedback, "distribute") 7 | def test_feedback(distribute, client): 8 | response = client.post( 9 | path="/feedback", 10 | json=[ 11 | {}, 12 | "data:image/png;base64,", 13 | ], 14 | ) 15 | 16 | assert response.status_code == 200 17 | assert distribute.call_arg 18 | 19 | 20 | @patch.object(Feedback, "distribute") 21 | def test_feedback_invalid_uri(distribute, client): 22 | response = client.post( 23 | path="/feedback", 24 | json=[ 25 | {}, 26 | "http://example.test", 27 | ], 28 | ) 29 | 30 | assert response.status_code == 400 31 | assert not distribute.called 32 | -------------------------------------------------------------------------------- /tests/api/test_recipes.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from reciperadar.api.recipes import Feedback 6 | from reciperadar.models.recipes import Recipe 7 | from reciperadar.search.recipes import RecipeSearch 8 | from reciperadar.search.base import EntityClause 9 | 10 | 11 | @patch.object(RecipeSearch, "query") 12 | def test_search_invalid_sort(query, client): 13 | response = client.get( 14 | path="/recipes/search", 15 | query_string={"sort": "invalid"}, 16 | ) 17 | 18 | assert response.status_code == 400 19 | assert not query.called 20 | 21 | 22 | @patch("reciperadar.api.recipes.recrawl_search.delay") 23 | @patch("reciperadar.api.recipes.store_event") 24 | @patch("reciperadar.search.recipes.load_ingredient_synonyms") 25 | @patch("reciperadar.search.base.QueryRepository.es.search") 26 | def test_search_empty_query(search, synonyms, store, recrawl, client, raw_recipe_hit): 27 | hits = [raw_recipe_hit] 28 | total = len(hits) 29 | search.return_value = { 30 | "hits": {"hits": hits, "total": {"value": total}}, 31 | "aggregations": { 32 | "prefilter": { 33 | "doc_count": 0, 34 | "domains": {"buckets": []}, 35 | } 36 | }, 37 | } 38 | synonyms.return_value = {} 39 | 40 | response = client.get("/recipes/search") 41 | 42 | assert response.status_code == 200 43 | assert "refinements" in response.json 44 | assert "empty_query" in response.json["refinements"] 45 | assert "domains" in response.json["facets"] 46 | 47 | 48 | @patch("reciperadar.api.recipes.recrawl_search.delay") 49 | @patch("reciperadar.api.recipes.store_event") 50 | @patch("reciperadar.search.recipes.load_ingredient_synonyms") 51 | @patch("reciperadar.search.recipes.RecipeSearch.query") 52 | def test_search_simple_query(query, synonyms, store, recrawl, client, raw_recipe_hit): 53 | query.return_value = { 54 | "authority": "api", 55 | "total": 0, 56 | "results": [], 57 | "facets": {"domains": []}, 58 | "refinements": [], 59 | } 60 | synonyms.return_value = {} 61 | 62 | response = client.get("/recipes/search?ingredients[]=tomato&ingredients[]=-tomato") 63 | 64 | assert response.status_code == 200 65 | assert "refinements" in response.json 66 | assert "domains" in response.json["facets"] 67 | 68 | expected_clauses = [ 69 | EntityClause(term="tomato", positive=True), 70 | EntityClause(term="tomato", positive=False), 71 | ] 72 | assert query.call_args[1]["ingredients"] == expected_clauses 73 | 74 | 75 | @patch("werkzeug.datastructures.Headers.get") 76 | @patch("reciperadar.api.recipes.recrawl_search.delay") 77 | @patch("reciperadar.api.recipes.store_event") 78 | @patch.object(RecipeSearch, "query") 79 | def test_search_user_agent_optional(query, store, recrawl, get, client): 80 | query.return_value = {"results": [], "total": 0} 81 | get.return_value = None 82 | 83 | response = client.get("/recipes/search", headers={"user-agent": None}) 84 | 85 | assert response.status_code == 200 86 | 87 | 88 | @patch("reciperadar.api.recipes.recrawl_search.delay") 89 | @patch("reciperadar.api.recipes.store_event") 90 | @patch.object(RecipeSearch, "query") 91 | def test_search_recrawling(query, store, recrawl, client): 92 | query.return_value = {"results": [], "total": 0} 93 | 94 | response = client.get("/recipes/search", headers={"user-agent": None}) 95 | 96 | assert response.status_code == 200 97 | assert recrawl.called is True 98 | 99 | 100 | @patch("reciperadar.api.recipes.recrawl_search.delay") 101 | @patch("reciperadar.api.recipes.store_event.delay") 102 | @patch.object(RecipeSearch, "query") 103 | def test_bot_search(query, store, recrawl, client): 104 | query.return_value = {"results": [], "total": 0} 105 | 106 | user_agent = ( 107 | "Mozilla/5.0+", 108 | "(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)", 109 | ) 110 | client.get("/recipes/search", headers={"user-agent": user_agent}) 111 | 112 | assert store.called 113 | assert store.call_args[1]["event_data"]["suspected_bot"] is True 114 | 115 | 116 | @patch.object(Feedback, "register_report") 117 | @patch.object(Recipe, "get_by_id") 118 | @pytest.mark.parametrize( 119 | "report_data", 120 | [ 121 | # missing report type 122 | {"recipe-id": "recipe_id_0", "result-index": 0}, 123 | # non-integer index 124 | { 125 | "recipe-id": "recipe_id_0", 126 | "report-type": "unsafe-content", 127 | "result-index": "", 128 | }, 129 | # invalid report type 130 | {"recipe-id": "recipe_id_0", "report-type": "invalid", "result-index": 0}, 131 | ], 132 | ) 133 | def test_invalid_problem_report(get_recipe_by_id, report, client, report_data): 134 | recipe = Recipe(id="example_id", domain="example.test", dst="http://example.test") 135 | get_recipe_by_id.return_value = recipe 136 | 137 | response = client.post( 138 | path="/recipes/report", 139 | headers={"Content-Type": "application/json"}, 140 | data=report_data, 141 | ) 142 | 143 | assert not report.called 144 | assert response.status_code == 400 145 | 146 | 147 | @patch.object(Feedback, "register_report") 148 | @patch.object(Recipe, "get_by_id") 149 | def test_unsafe_content_report(get_recipe_by_id, register_report, client): 150 | recipe = Recipe(id="example_id", domain="example.test", dst="http://example.test") 151 | get_recipe_by_id.return_value = recipe 152 | 153 | report_data = { 154 | "recipe-id": "test_recipe_id", 155 | "report-type": "unsafe-content", 156 | "result-index": 0, 157 | } 158 | response = client.post( 159 | path="/recipes/report", 160 | data=report_data, 161 | ) 162 | 163 | assert register_report.called 164 | assert response.status_code == 200 165 | -------------------------------------------------------------------------------- /tests/api/test_redirect.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call, patch 2 | 3 | from reciperadar.models.recipes import Recipe 4 | 5 | 6 | def _expected_redirect_call(recipe, suspected_bot=False): 7 | return call( 8 | event_table="redirects", 9 | event_data={ 10 | "suspected_bot": suspected_bot, 11 | "recipe_id": recipe.id, 12 | "domain": recipe.domain, 13 | "dst": recipe.dst, 14 | }, 15 | ) 16 | 17 | 18 | @patch("reciperadar.api.recipes.store_event.delay") 19 | @patch.object(Recipe, "get_by_id") 20 | def test_redirect_retrieval(get_recipe_by_id, store_event, client): 21 | recipe = Recipe(id="example_id", domain="example.test", dst="http://example.test") 22 | get_recipe_by_id.return_value = recipe 23 | 24 | response = client.get( 25 | path="/redirect/recipe/example_id", 26 | headers={"Referer": "http://example.test/origin"}, 27 | ) 28 | 29 | assert response.status_code == 301 30 | assert response.location == recipe.dst 31 | assert store_event.call_args == _expected_redirect_call(recipe, suspected_bot=True) 32 | 33 | 34 | @patch("reciperadar.api.recipes.is_suspected_bot") 35 | @patch("reciperadar.api.recipes.store_event.delay") 36 | @patch.object(Recipe, "get_by_id") 37 | def test_redirect_ping(get_recipe_by_id, store_event, is_suspected_bot, client): 38 | recipe = Recipe(id="example_id", domain="example.test", dst="http://example.test") 39 | get_recipe_by_id.return_value = recipe 40 | is_suspected_bot.return_value = False 41 | 42 | response = client.post( 43 | path="/redirect/recipe/example_id", 44 | data={ 45 | "Ping-From": "http://example.test/origin", 46 | "Ping-To": recipe.dst, 47 | }, 48 | ) 49 | 50 | assert response.status_code == 200 51 | assert store_event.call_args == _expected_redirect_call(recipe) 52 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from reciperadar import app 4 | 5 | 6 | @pytest.fixture 7 | def client(): 8 | yield app.test_client() 9 | 10 | 11 | @pytest.fixture 12 | def raw_recipe_hit(): 13 | return { 14 | "_index": "recipes", 15 | "_type": "recipe", 16 | "_id": "random-id", 17 | "_score": 10.04635, 18 | "_source": { 19 | "id": "recipe_id_0", 20 | "title": "Test Recipe", 21 | "directions": [ 22 | { 23 | "id": "direction_id_0", 24 | "index": 0, 25 | "description": "place each skewer in the oven", 26 | "markup": ( 27 | "place each " 28 | # "skewer in the " 29 | # "oven" 30 | "skewer in the " 31 | "oven" 32 | ), 33 | } 34 | ], 35 | "ingredients": [ 36 | { 37 | "id": "ingredient_id_0", 38 | "index": 0, 39 | "description": "1 unit of test ingredient one", 40 | "product": { 41 | "singular": "one", 42 | "plural": "ones", 43 | "contents": [ 44 | "ancestor-of-one", 45 | "content-of-one", 46 | "one", 47 | ], 48 | }, 49 | "product_is_plural": False, 50 | "product_name": "one", 51 | "nutrition": { 52 | "id": "nutrition_id_0", 53 | "carbohydrates": 0, 54 | "carbohydrates_units": "g", 55 | "energy": 0, 56 | "energy_units": "cal", 57 | "fat": 0.01, 58 | "fat_units": "g", 59 | "fibre": 0.65, 60 | "fibre_units": "g", 61 | "protein": 0.05, 62 | "protein_units": "g", 63 | }, 64 | }, 65 | { 66 | "id": "ingredient_id_1", 67 | "index": 1, 68 | "description": "two units of test ingredient two", 69 | "product": {"singular": "two"}, 70 | "product_is_plural": False, 71 | "product_name": "two", 72 | }, 73 | ], 74 | "author": "example", 75 | "time": 30, 76 | "src": "http://www.example.test/recipes/test", 77 | "dst": "https://www.example.test/recipes/test", 78 | "domain": "example.test", 79 | "servings": 2, 80 | "rating": 4.5, 81 | "indexed_at": "1970-01-01T01:02:03.456789", 82 | "nutrition": { 83 | "carbohydrates": 0, 84 | "carbohydrates_units": "g", 85 | "energy": 0, 86 | "energy_units": "cal", 87 | "fat": 0.01, 88 | "fat_units": "g", 89 | "fibre": 0.65, 90 | "fibre_units": "g", 91 | "protein": 0.05, 92 | "protein_units": "g", 93 | }, 94 | "is_vegetarian": True, 95 | }, 96 | "inner_hits": {"ingredients": {"hits": {"hits": []}}}, 97 | } 98 | -------------------------------------------------------------------------------- /tests/models/recipes/test_recipe.py: -------------------------------------------------------------------------------- 1 | from reciperadar.models.recipes import Recipe 2 | 3 | 4 | def test_recipe_from_doc(raw_recipe_hit): 5 | recipe = Recipe().from_doc(raw_recipe_hit["_source"]) 6 | assert recipe.author == "example" 7 | assert recipe.is_vegetarian 8 | 9 | # assert recipe.directions[0].appliances[0].appliance == "oven" 10 | # assert recipe.directions[0].utensils[0].utensil == "skewer" 11 | 12 | assert recipe.ingredients[0].product.singular == "one" 13 | expected_contents = ["one", "content-of-one", "ancestor-of-one"] 14 | actual_contents = recipe.ingredients[0].product.contents 15 | 16 | assert all([content in actual_contents for content in expected_contents]) 17 | 18 | assert recipe.nutrition.carbohydrates == 0 19 | assert recipe.nutrition.fibre == 0.65 20 | 21 | assert "nutrition" not in recipe.ingredients[0].to_dict() 22 | assert "is_vegetarian" in recipe.to_dict() 23 | 24 | assert recipe.nutrition.to_dict() == { 25 | "carbohydrates": {"magnitude": 0, "units": "g"}, 26 | "energy": {"magnitude": 0, "units": "cal"}, 27 | "fat": {"magnitude": 0.01, "units": "g"}, 28 | "fibre": {"magnitude": 0.65, "units": "g"}, 29 | "protein": {"magnitude": 0.05, "units": "g"}, 30 | } 31 | 32 | assert not recipe.is_gluten_free 33 | assert not recipe.is_vegan 34 | assert recipe.is_vegetarian 35 | 36 | 37 | def test_nutrition_source(raw_recipe_hit): 38 | recipe = Recipe().from_doc(raw_recipe_hit["_source"]) 39 | doc = recipe.to_dict() 40 | 41 | assert recipe.nutrition is not None 42 | assert "nutrition" in doc 43 | assert doc["nutrition"] is None 44 | -------------------------------------------------------------------------------- /tests/models/test_feedback_model.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import pytest 4 | 5 | from reciperadar.models.feedback import Correction, Feedback, RemovalRequest 6 | from reciperadar.models.recipes import Recipe 7 | 8 | 9 | @pytest.fixture 10 | def reported_recipe(): 11 | return Recipe( 12 | id="test_recipe_id", 13 | title="marvellous recipe", 14 | domain="example.test", 15 | dst="http://example.test", 16 | ) 17 | 18 | 19 | @patch.object(Feedback, "_construct") 20 | def test_feedback(construct, reported_recipe): 21 | construct.return_value = MagicMock() 22 | report = RemovalRequest( 23 | recipe_id=reported_recipe.id, 24 | report_type="removal_request", 25 | result_index=0, 26 | content_owner_email="webmaster@example.test", 27 | ) 28 | Feedback.register_report(reported_recipe, report) 29 | 30 | subject, sender, recipients, html = ( 31 | construct.call_args.kwargs.get(field) 32 | for field in ("subject", "sender", "recipients", "html") 33 | ) 34 | assert subject == "Content report: removal_request: test_recipe_id" 35 | assert sender == "contact@reciperadar.com" 36 | assert recipients == ["content-reports@reciperadar.com"] 37 | assert "recipe removal request" in html 38 | assert "https://www.reciperadar.com/#action=view&id=test_recipe_id" in html 39 | assert '' in html 40 | 41 | 42 | @patch.object(Feedback, "_construct") 43 | def test_feedback_html_escaping(construct, reported_recipe): 44 | construct.return_value = MagicMock() 45 | report = Correction( 46 | recipe_id=reported_recipe.id, 47 | report_type="correction", 48 | result_index=0, 49 | content_expected="challenging input", 50 | content_found="", 51 | ) 52 | Feedback.register_report(reported_recipe, report) 53 | 54 | subject, sender, recipients, html = ( 55 | construct.call_args.kwargs.get(field) 56 | for field in ("subject", "sender", "recipients", "html") 57 | ) 58 | assert subject == "Content report: correction: test_recipe_id" 59 | assert sender == "contact@reciperadar.com" 60 | assert recipients == ["content-reports@reciperadar.com"] 61 | assert "