├── .env.example ├── .gitignore ├── Procfile ├── admin.py ├── api-documentation.md ├── app.py ├── blog ├── __init__.py ├── forms.py ├── models.py └── views.py ├── calculator ├── __init__.py ├── forms.py └── views.py ├── config.py ├── create_elastic_index.ipynb ├── extentions.py ├── foods ├── __init__.py ├── diet.py ├── models.py ├── utils.py └── views.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 00490a5b5d9b_.py │ ├── 2b7041d2221f_.py │ ├── 34508ec90fd5_.py │ ├── 6a93bd633b3a_.py │ ├── 7bb12babf968_.py │ ├── 7dab2a62f67b_.py │ ├── 9d0a2766befe_.py │ └── ace46fc2b3ce_.py ├── readme.md ├── requirements.txt ├── search.py ├── templates └── users │ └── activate.html ├── tests ├── __init__.py ├── api_test.py ├── diet_test.py └── user_test.py ├── users ├── __init__.py ├── email.py ├── forms.py ├── models.py ├── token.py └── views.py └── utils └── decorators.py /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 2 | FLASK_ENV = 3 | JWT_SECRET_KEY = 4 | SECURITY_PASSWORD_SALT = 5 | SQLALCHEMY_DATABASE_URI = 6 | SQLALCHEMY_TRACK_MODIFICATIONS = 7 | MAIL_USERNAME = 8 | MAIL_PASSWORD = 9 | MAIL_DEFAULT_SENDER = -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .idea 3 | .env 4 | felan.txt 5 | .vscode/ 6 | __pycache__ 7 | .DS_Store 8 | DATABASE_CREDENTIALS.txt 9 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn app:app 2 | -------------------------------------------------------------------------------- /admin.py: -------------------------------------------------------------------------------- 1 | from flask_admin import Admin 2 | from extentions import db 3 | from users.models import User, UserModelView 4 | from foods.models import Food, FoodModelView,DietRecord,DietRecordModelView 5 | from blog.models import Post,PostModelView 6 | 7 | admin = Admin(name='Dailydiet', template_mode='bootstrap3', url='/admin') 8 | # adding models to admin 9 | admin.add_view(UserModelView(User, db.session, endpoint='user_admin')) 10 | admin.add_view(FoodModelView(Food, db.session, endpoint='food_admin')) 11 | admin.add_view(PostModelView(Post, db.session, endpoint='post_admin')) 12 | admin.add_view(DietRecordModelView(DietRecord,db.session,endpoint='diet_record_admin')) 13 | -------------------------------------------------------------------------------- /api-documentation.md: -------------------------------------------------------------------------------- 1 | # DailyDiet API Documentation 2 | 3 | ## v1.0 4 | 5 | ### `/calculate/bmi` 6 | 7 | method: `POST` 8 | 9 | *input*: 10 | 11 | - height in centimeters 12 | - weight in kilograms 13 | 14 | ```json 15 | { 16 | "height":180, 17 | "weight":80 18 | } 19 | ``` 20 | 21 | *output*: 22 | 23 | - `bmi_value` rounded up to 2 decimal points 24 | - `bmi_status` one of **Underweight**, **Normal weight**, **Overweight** or **Obesity** 25 | 26 | ```json 27 | { 28 | "bmi_status": "Normal weight", 29 | "bmi_value": 24.69 30 | } 31 | 32 | ``` 33 | 34 | *in case of errors*: 35 | 36 | response code will be **400** 37 | 38 | ```json 39 | { 40 | "errors": { 41 | "height": [ 42 | "This field is required." 43 | ], 44 | "weight": [ 45 | "Number must be between 20 and 150." 46 | ] 47 | } 48 | } 49 | ``` 50 | 51 | ---------- 52 | 53 | ### `/calculate/calorie` 54 | 55 | method: `POST` 56 | 57 | *input*: 58 | 59 | - goal one of **lose_weight**, **maintain** or **build_muscle** 60 | - gender one of **male** or **female** 61 | - height in centimiters 62 | - weight in kilograms 63 | - age in years 64 | - activity level one of **sedentary**, **lightly**, **moderately**, **very** or **extra** 65 | 66 | ```json 67 | { 68 | "goal":"maintain", 69 | "gender":"male", 70 | "height":180, 71 | "weight":80, 72 | "age":21, 73 | "activity":"lightly" 74 | } 75 | ``` 76 | 77 | *output*: 78 | 79 | - `calorie` is integer number 80 | 81 | ```json 82 | { 83 | "calorie": 2638 84 | } 85 | 86 | ``` 87 | 88 | *in case of errors*: 89 | 90 | response code will be **400** 91 | 92 | ```json 93 | { 94 | "errors": { 95 | "activity": [ 96 | "Invalid value, must be one of: sedentary, lightly, moderately, very, extra." 97 | ], 98 | "age": [ 99 | "This field is required." 100 | ], 101 | "gender": [ 102 | "Invalid value, must be one of: male, female." 103 | ], 104 | "goal": [ 105 | "Invalid value, must be one of: lose_weight, maintain, build_muscle." 106 | ], 107 | "height": [ 108 | "Number must be between 50 and 210." 109 | ], 110 | "weight": [ 111 | "Number must be between 20 and 150." 112 | ] 113 | } 114 | } 115 | ``` 116 | 117 | ---------- 118 | 119 | ### `/users/signup` 120 | 121 | method: `POST` 122 | 123 | *input*: 124 | 125 | - full_name 126 | - email 127 | - password 128 | - confirm_password 129 | 130 | ```json 131 | { 132 | "full_name": "Arnold Schwarzenegger", 133 | "email": "arnold.schwarzenegger@gmail.com", 134 | "password": "p@$$word123", 135 | "confirm_password": "p@$$word123" 136 | } 137 | ``` 138 | 139 | *output*: 140 | 141 | response code will be **201** 142 | 143 | - `msg` is string 144 | 145 | ```json 146 | { 147 | "msg": "Account created successfully!" 148 | } 149 | 150 | ``` 151 | 152 | *in case of errors*: 153 | 154 | response code will be **400** 155 | 156 | ```json 157 | { 158 | "errors": { 159 | "email": [ 160 | "Email already registered." 161 | ] 162 | } 163 | } 164 | ``` 165 | 166 | or 167 | 168 | ```json 169 | { 170 | "errors": { 171 | "confirm_password": [ 172 | "Passwords must match." 173 | ], 174 | "email": [ 175 | "Invalid email address." 176 | ], 177 | "password": [ 178 | "Field must be between 6 and 25 characters long." 179 | ] 180 | } 181 | } 182 | ``` 183 | 184 | ---------- 185 | 186 | ### `/users/signup/confirmation/` 187 | 188 | method: `GET` 189 | 190 | *input*: **NONE** 191 | 192 | *output*: 193 | 194 | response code will be **204** 195 | 196 | - The server successfully processed the request and is not returning any content. 197 | 198 | *in case of errors*: 199 | 200 | 1- response code will be **404** 201 | 202 | ```json 203 | { 204 | "error": "User not found." 205 | } 206 | ``` 207 | 208 | 2- response code will be **400** 209 | 210 | ```json 211 | { 212 | "error": "The confirmation link is invalid or has expired." 213 | } 214 | ``` 215 | 216 | or 217 | 218 | ```json 219 | { 220 | "error": "Account already confirmed. Please login." 221 | } 222 | ``` 223 | 224 | ---------- 225 | 226 | ### `/users/signin` 227 | 228 | method: `POST` 229 | 230 | *input*: 231 | 232 | - email 233 | - password 234 | 235 | ```json 236 | { 237 | "email": "arnold.schwarzenegger@gmail.com", 238 | "password": "p@$$word123" 239 | } 240 | ``` 241 | 242 | *output*: 243 | 244 | response code will be **200** 245 | 246 | - is_active 247 | - access_token 248 | - refresh_token 249 | 250 | ```json 251 | { 252 | "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1OTEzODEwOTYsIm5iZiI6MTU5MTM4MTA5NiwianRpIjoiYWViMjY5MDktYzUxZS00NTM0LTk0NWEtMzZkYzEwZjNiMjdhIiwiZXhwIjoxNTkxMzgxOTk2LCJpZGVudGl0eSI6Im1vaGFtbWFkaG9zc2Vpbi5tYWxla3BvdXJAZ21haWwuY29tIiwiZnJlc2giOnRydWUsInR5cGUiOiJhY2Nlc3MifQ.8iSlZyW2pQN-OzDiSUe7LKbgX6iS6CNOsPMUGZfhf-s", 253 | "is_active": true, 254 | "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1OTEzODEwOTYsIm5iZiI6MTU5MTM4MTA5NiwianRpIjoiMDA5N2E1M2ItYWI4Yi00YzAwLTkxZjUtZTgwNmNkNWFjNTRmIiwiZXhwIjoxNTkzOTczMDk2LCJpZGVudGl0eSI6Im1vaGFtbWFkaG9zc2Vpbi5tYWxla3BvdXJAZ21haWwuY29tIiwidHlwZSI6InJlZnJlc2gifQ.TpuHN33fO66LWVZktvYr10VGoDWwONJkPaC6WgywgQM" 255 | } 256 | ``` 257 | 258 | *in case of errors*: 259 | 260 | 1- response code will be **403** 261 | 262 | ```json 263 | { 264 | "error": "Email or Password does not match." 265 | } 266 | ``` 267 | 268 | 2- response code will be **400** 269 | 270 | ```json 271 | { 272 | "errors": { 273 | "email": [ 274 | "Invalid email address." 275 | ] 276 | } 277 | } 278 | ``` 279 | 280 | ---------- 281 | 282 | ### `/users/auth` 283 | 284 | method: `PUT` 285 | 286 | *input*: 287 | 288 | Authorization Header: 289 | 290 | - Bearer \ 291 | 292 | Body: 293 | 294 | - None 295 | 296 | *output*: 297 | 298 | response code will be **200** 299 | 300 | - access_token 301 | 302 | ```json 303 | { 304 | "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODc5OTQ1NDYsIm5iZiI6MTU4Nzk5NDU0NiwianRpIjoiZmU0YTE2MjUtNDFkMi00MjljLTlhZDItNmFlZDYyM2MzZDUwIiwiZXhwIjoxNTg3OTk1NDQ2LCJpZGVudGl0eSI6Im1vaGFtbWFkaG9zc2Vpbi5tYWxla3BvdXJAZ21haWwuY29tIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIn0.9Cybw84QcwAuxxPGoEhURAHFUTQpIK3A9N8TxE38NMw" 305 | } 306 | ``` 307 | 308 | *in case of errors*: 309 | 310 | 1- response code will be **422** 311 | 312 | ```json 313 | { 314 | "msg": "Signature verification failed" 315 | } 316 | ``` 317 | 318 | or 319 | 320 | ```json 321 | { 322 | "msg": "Invalid header string: 'utf-8' codec can't decode byte 0x9a in position 15: invalid start byte" 323 | } 324 | ``` 325 | 326 | or 327 | 328 | ```json 329 | { 330 | "msg": "Not enough segments" 331 | } 332 | ``` 333 | 334 | 2- response code will be **401** 335 | 336 | ```json 337 | { 338 | "msg": "Token has expired" 339 | } 340 | ``` 341 | 342 | ---------- 343 | 344 | ### `/users/signup/resendConfrimation` 345 | 346 | method: `GET` 347 | 348 | *input*: 349 | 350 | Authorization Header: 351 | 352 | - Bearer \ 353 | 354 | Body: 355 | 356 | - None 357 | 358 | *output*: 359 | 360 | response code will be **200** 361 | 362 | - `msg` is string 363 | 364 | ```json 365 | { 366 | "msg": "A new confirmation email has been sent." 367 | } 368 | 369 | ``` 370 | 371 | *in case of errors*: 372 | 373 | 1- response code will be **422** 374 | 375 | ```json 376 | { 377 | "msg": "Signature verification failed" 378 | } 379 | ``` 380 | 381 | or 382 | 383 | ```json 384 | { 385 | "msg": "Invalid header string: 'utf-8' codec can't decode byte 0x9a in position 15: invalid start byte" 386 | } 387 | ``` 388 | 389 | or 390 | 391 | ```json 392 | { 393 | "msg": "Not enough segments" 394 | } 395 | ``` 396 | 397 | 2- response code will be **401** 398 | 399 | ```json 400 | { 401 | "msg": "Token has expired" 402 | } 403 | ``` 404 | 405 | ---------- 406 | 407 | ### `/users/signup/modify` 408 | 409 | method: `PATCH` 410 | 411 | *input*: 412 | Authorization Header: 413 | 414 | - Bearer \ 415 | 416 | Body: 417 | 418 | - old_password 419 | - new_password 420 | - confirm_password 421 | 422 | ```json 423 | { 424 | "old_password": "p@$$word123", 425 | "new_password": "123456", 426 | "confirm_password": "123456" 427 | } 428 | ``` 429 | 430 | *output*: 431 | 432 | response code will be **204** 433 | 434 | - The server successfully processed the request and is not returning any content. 435 | 436 | *in case of errors*: 437 | 438 | 1- response code will be **401** 439 | 440 | ```json 441 | { 442 | "msg": "Token has expired" 443 | } 444 | ``` 445 | 446 | 2t- response code will be **422** 447 | 448 | ```json 449 | { 450 | "msg": "Signature verification failed" 451 | } 452 | ``` 453 | 454 | or 455 | 456 | ```json 457 | { 458 | "msg": "Invalid header string: 'utf-8' codec can't decode byte 0x9a in position 15: invalid start byte" 459 | } 460 | ``` 461 | 462 | or 463 | 464 | ```json 465 | { 466 | "msg": "Not enough segments" 467 | } 468 | ``` 469 | 470 | ---------- 471 | 472 | ### `/users/signout` 473 | 474 | method: `PATCH` 475 | 476 | *input*: 477 | 478 | - None 479 | 480 | *output*: 481 | 482 | response code will be **204** 483 | 484 | - The server successfully processed the request and is not returning any content. 485 | 486 | ---------- 487 | 488 | ### `/users/get_user 489 | 490 | method: `GET` 491 | 492 | *input*: 493 | Authorization Header: 494 | 495 | - Bearer \ 496 | 497 | Body: 498 | 499 | - None 500 | 501 | *output*: 502 | 503 | response code will be **200** 504 | 505 | ```json 506 | { 507 | "confirmed": "False", 508 | "email": "mohammadhossein.malekpour@gmail.com", 509 | "full_name": "Mohammad Hossein Malekpour" 510 | } 511 | ``` 512 | 513 | *in case of errors*: 514 | 515 | 1- response code will be **401** 516 | 517 | ```json 518 | { 519 | "msg": "Token has expired" 520 | } 521 | ``` 522 | 523 | ---------- 524 | 525 | ### `/foods/recipe/` 526 | 527 | method: `GET` 528 | 529 | recipe and foods detailed information 530 | 531 | *input*: 532 | URL: 533 | 534 | - food id 535 | 536 | *sample input*: 537 | 538 | ``` 539 | /foods/recipe/33480 540 | ``` 541 | 542 | *output*: 543 | 544 | response code will be **200** 545 | 546 | - The server successfully processed the request and it's returning the recipe in this format. 547 | 548 | - image id is **0** if image is a placeholder 549 | 550 | ```json 551 | { 552 | "author_id": 1, 553 | "category": "sandwich", 554 | "cook_time": 0, 555 | "date_created": "2020-05-07 15:10:07", 556 | "description": "Peanut butter + jelly", 557 | "directions": [], 558 | "food_name": "Big PB&J Sandwich", 559 | "id": 33482, 560 | "images": [ 561 | { 562 | "id": 7, 563 | "image": "https://images.eatthismuch.com/site_media/img/33482_ldementhon_aa377c57-35b0-42c5-8c8e-930bd04c1fd3.png", 564 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_ldementhon_aa377c57-35b0-42c5-8c8e-930bd04c1fd3.png" 565 | }, 566 | { 567 | "id": 2665, 568 | "image": "https://images.eatthismuch.com/site_media/img/33482_Mirkatt_572cebe5-c1b7-441e-af28-0530524b3039.png", 569 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_Mirkatt_572cebe5-c1b7-441e-af28-0530524b3039.png" 570 | }, 571 | { 572 | "id": 7744, 573 | "image": "https://images.eatthismuch.com/site_media/img/33482_larrystylinson_e7a30dfb-b79e-417a-94ca-bde7a294fb54.png", 574 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_larrystylinson_e7a30dfb-b79e-417a-94ca-bde7a294fb54.png" 575 | }, 576 | { 577 | "id": 10535, 578 | "image": "https://images.eatthismuch.com/site_media/img/33482_FoodWorks_0b2e4aa6-9ff6-4d87-9a18-5a594126ed96.png", 579 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_FoodWorks_0b2e4aa6-9ff6-4d87-9a18-5a594126ed96.png" 580 | }, 581 | { 582 | "id": 10833, 583 | "image": "https://images.eatthismuch.com/site_media/img/33482_AziyaAlen_5fc51b0a-5f53-4067-bc31-1fb2aaa7db10.png", 584 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_AziyaAlen_5fc51b0a-5f53-4067-bc31-1fb2aaa7db10.png" 585 | }, 586 | { 587 | "id": 10932, 588 | "image": "https://images.eatthismuch.com/site_media/img/33482_LuizDGarcia_4de5ff13-1b71-4950-a0e7-d82f3bc502b4.png", 589 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_LuizDGarcia_4de5ff13-1b71-4950-a0e7-d82f3bc502b4.png" 590 | }, 591 | { 592 | "id": 10933, 593 | "image": "https://images.eatthismuch.com/site_media/img/33482_LuizDGarcia_e2a6df38-a718-4ab3-bf84-a7c9a6bda253.png", 594 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_LuizDGarcia_e2a6df38-a718-4ab3-bf84-a7c9a6bda253.png" 595 | }, 596 | { 597 | "id": 13194, 598 | "image": "https://images.eatthismuch.com/site_media/img/33482_jennmrqz_cdebca3a-ec99-447b-ad97-150d5fc4f03a.png", 599 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_jennmrqz_cdebca3a-ec99-447b-ad97-150d5fc4f03a.png" 600 | }, 601 | { 602 | "id": 48032, 603 | "image": "https://images.eatthismuch.com/site_media/img/33482_NarendrasinhChimansinhVadajiya_7c880813-aafa-4d4e-8ac7-850da878c56f.png", 604 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_NarendrasinhChimansinhVadajiya_7c880813-aafa-4d4e-8ac7-850da878c56f.png" 605 | }, 606 | { 607 | "id": 73768, 608 | "image": "https://images.eatthismuch.com/site_media/img/33482_%D8%BA%D8%B1%D9%83%D8%B2%D9%85%D8%A7%D9%86%D9%83_d02706f3-e94c-415e-ba9c-bf78dc7e1b9c.png", 609 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_%D8%BA%D8%B1%D9%83%D8%B2%D9%85%D8%A7%D9%86%D9%83_d02706f3-e94c-415e-ba9c-bf78dc7e1b9c.png" 610 | }, 611 | { 612 | "id": 73769, 613 | "image": "https://images.eatthismuch.com/site_media/img/33482_%D8%BA%D8%B1%D9%83%D8%B2%D9%85%D8%A7%D9%86%D9%83_ad5fac6b-64a8-4309-9ba3-71e3e5e8dcc0.png", 614 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_%D8%BA%D8%B1%D9%83%D8%B2%D9%85%D8%A7%D9%86%D9%83_ad5fac6b-64a8-4309-9ba3-71e3e5e8dcc0.png" 615 | }, 616 | { 617 | "id": 160621, 618 | "image": "https://images.eatthismuch.com/site_media/img/33482_JunidAli_d79d7a2d-d532-4e9a-9072-4f7075105c9b.png", 619 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_JunidAli_d79d7a2d-d532-4e9a-9072-4f7075105c9b.png" 620 | }, 621 | { 622 | "id": 270080, 623 | "image": "https://images.eatthismuch.com/site_media/img/33482_tabitharwheeler_6cd5f22c-a4fa-476b-abba-c9c9a42c3c3c.jpg", 624 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_tabitharwheeler_6cd5f22c-a4fa-476b-abba-c9c9a42c3c3c.jpg" 625 | } 626 | ], 627 | "ingredients": [ 628 | { 629 | "amount": 2.0, 630 | "food": { 631 | "food_name": "Whole-wheat bread", 632 | "id": 4025, 633 | "nutrition": { 634 | "calories": 70.56, 635 | "carbs": 11.9, 636 | "fats": 0.98, 637 | "proteins": 3.49 638 | }, 639 | "primary_thumbnail": "https://images.eatthismuch.com/site_media/thmb/4025_erin_m_a7dde43d-5764-4eca-aaab-6446ec28f15e.png" 640 | }, 641 | "grams": 56.0, 642 | "preparation": null, 643 | "units": "slice" 644 | }, 645 | { 646 | "amount": 4.0, 647 | "food": { 648 | "food_name": "Peanut butter", 649 | "id": 3594, 650 | "nutrition": { 651 | "calories": 188.48, 652 | "carbs": 6.9, 653 | "fats": 15.98, 654 | "proteins": 7.7 655 | }, 656 | "primary_thumbnail": "https://images.eatthismuch.com/site_media/thmb/3594_ldementhon_05f99dd7-43d4-4f2a-8127-789b8d75ecfc.png" 657 | }, 658 | "grams": 64.0, 659 | "preparation": null, 660 | "units": "tbsp" 661 | }, 662 | { 663 | "amount": 2.0, 664 | "food": { 665 | "food_name": "Apricot jam", 666 | "id": 4715, 667 | "nutrition": { 668 | "calories": 48.4, 669 | "carbs": 12.88, 670 | "fats": 0.04, 671 | "proteins": 0.14 672 | }, 673 | "primary_thumbnail": "https://images.eatthismuch.com/site_media/thmb/4715_erin_m_8a36f44a-cae6-4617-987a-f4bacbb45bea.png" 674 | }, 675 | "grams": 40.0, 676 | "preparation": null, 677 | "units": "tbsp" 678 | } 679 | ], 680 | "nutrition": { 681 | "calories": 614.88, 682 | "carbs": 63.49, 683 | "fats": 34.02, 684 | "proteins": 22.66 685 | }, 686 | "prep_time": 5, 687 | "primary_image": "https://images.eatthismuch.com/site_media/img/33482_tabitharwheeler_6cd5f22c-a4fa-476b-abba-c9c9a42c3c3c.jpg", 688 | "primary_thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_tabitharwheeler_6cd5f22c-a4fa-476b-abba-c9c9a42c3c3c.jpg", 689 | "servings": 1, 690 | "source": "eatthismuch.com", 691 | "tag_cloud": "gluten Apricot jam Whole-wheat bread Sweets \"Big PB&J Sandwich\" Soy and Legume Products Baked Products Peanut butter" 692 | } 693 | ``` 694 | 695 | *in case of errors*: 696 | 697 | 1- if food doesn't exist in the database, response code will be **404** 698 | 699 | ```json 700 | { 701 | "error": "food not found." 702 | } 703 | ``` 704 | 705 | 2- if recipe doesn't exist, response code will be **404** 706 | 707 | ```json 708 | { 709 | "error": "recipe not found." 710 | } 711 | ``` 712 | 713 | ---------- 714 | 715 | ### `/foods/` 716 | 717 | method: `GET` 718 | 719 | food summarized information 720 | 721 | *input*: 722 | URL: 723 | 724 | - food id 725 | 726 | *sample input*: 727 | 728 | ``` 729 | /foods/33480 730 | ``` 731 | 732 | *output*: 733 | 734 | response code will be **200** 735 | 736 | - The server successfully processed the request and it's returning food summary in this format. 737 | 738 | ```json 739 | { 740 | "id": 33482, 741 | "category": "sandwich", 742 | "image": "https://images.eatthismuch.com/site_media/img/33482_tabitharwheeler_6cd5f22c-a4fa-476b-abba-c9c9a42c3c3c.jpg", 743 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/33482_tabitharwheeler_6cd5f22c-a4fa-476b-abba-c9c9a42c3c3c.jpg", 744 | "title": "Big PB&J Sandwich", 745 | "nutrition": { 746 | "calories": 615, 747 | "fat": 34.0, 748 | "fiber": 8.6, 749 | "protein": 22.7} 750 | } 751 | ``` 752 | 753 | *in case of errors*: 754 | 755 | 1- if food doesn't exist in the database, response code will be **404** 756 | 757 | ```json 758 | { 759 | "error": "food not found." 760 | } 761 | ``` 762 | 763 | ---------- 764 | 765 | ### `/foods/search` 766 | 767 | method: `GET` 768 | 769 | recipe and foods detailed information 770 | 771 | *input*: 772 | GET method parameters: 773 | 774 | - query: text to search 775 | - page:pagination page, default value is 1 776 | - page:items per page, default value is 10 777 | 778 | *sample input*: 779 | 780 | ``` 781 | /foods/search?query=pasta&page=1&per_page=5 782 | ``` 783 | 784 | *output*: 785 | 786 | response code will be **200** 787 | 788 | - total results count in the elasticsearch 789 | - food sample view of foods found int the search ordered by relevance 790 | 791 | ```json 792 | { 793 | "results": [ "list of simple views.."], 794 | "total_results_count": "count..." 795 | } 796 | ``` 797 | 798 | *sample output* 799 | 800 | ```json 801 | { 802 | "results": [ 803 | { 804 | "category": "pasta", 805 | "id": 384279, 806 | "image": "https://images.eatthismuch.com/site_media/img/384279_erin_m_77a48297-f148-454d-aa02-fdd277e70edf.png", 807 | "nutrition": { 808 | "calories": 476, 809 | "fat": 8.6, 810 | "fiber": 1.6, 811 | "protein": 17.7 812 | }, 813 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/384279_erin_m_77a48297-f148-454d-aa02-fdd277e70edf.png", 814 | "title": "Pasta, Corn & Artichoke Bowl" 815 | }, 816 | { 817 | "category": "pasta", 818 | "id": 1493432, 819 | "image": "https://images.eatthismuch.com/site_media/img/1093241_Billie7_1975_f6db1d3f-2bed-4c82-bf10-e343b9dc8314.jpeg", 820 | "nutrition": { 821 | "calories": 591, 822 | "fat": 15.8, 823 | "fiber": 4.7, 824 | "protein": 16.7 825 | }, 826 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/1093241_Billie7_1975_f6db1d3f-2bed-4c82-bf10-e343b9dc8314.jpeg", 827 | "title": "Spaghetti with Mushrooms, Garlic and Oil" 828 | }, 829 | { 830 | "category": "other", 831 | "id": 907167, 832 | "image": "https://images.eatthismuch.com/site_media/img/907167_tabitharwheeler_915ad93b-213d-4b3d-bcc2-e0570b833af3.jpg", 833 | "nutrition": { 834 | "calories": 309, 835 | "fat": 7.2, 836 | "fiber": 8.6, 837 | "protein": 16.1 838 | }, 839 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/907167_tabitharwheeler_915ad93b-213d-4b3d-bcc2-e0570b833af3.jpg", 840 | "title": "Pasta with Red Sauce and Mozzarella" 841 | }, 842 | { 843 | "category": "pasta", 844 | "id": 905979, 845 | "image": "https://images.eatthismuch.com/site_media/img/905979_tabitharwheeler_82334d46-99b8-428d-aa16-4bdd9c3008cd.jpg", 846 | "nutrition": { 847 | "calories": 423, 848 | "fat": 12.3, 849 | "fiber": 4.0, 850 | "protein": 24.2 851 | }, 852 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/905979_tabitharwheeler_82334d46-99b8-428d-aa16-4bdd9c3008cd.jpg", 853 | "title": "Spaghetti with Meat Sauce" 854 | }, 855 | { 856 | "category": "pasta", 857 | "id": 45500, 858 | "image": "https://images.eatthismuch.com/site_media/img/45500_simmyras_43adc56f-d597-4778-a682-4ddfa9f394a3.png", 859 | "nutrition": { 860 | "calories": 285, 861 | "fat": 18.0, 862 | "fiber": 0.9, 863 | "protein": 15.4 864 | }, 865 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/45500_simmyras_43adc56f-d597-4778-a682-4ddfa9f394a3.png", 866 | "title": "Rigatoni with Brie, Grape Tomatoes, Olives, and Basil" 867 | } 868 | ], 869 | "total_results_count": 1211 870 | } 871 | ``` 872 | 873 | *in case of errors*: 874 | 875 | 1- if you don't pass query parameter in the url 876 | 877 | response code will be **422** 878 | 879 | ```json 880 | { 881 | "error": "query should exist in the request" 882 | } 883 | ``` 884 | 885 | 2- if per_page value is more than 50 886 | 887 | response code will be **422** 888 | 889 | ```json 890 | { 891 | "error": "per_page should not be more than 50" 892 | } 893 | ``` 894 | 895 | 3- if search is timed out. 896 | 897 | status code will be **408** 898 | ```json 899 | { 900 | "error": "search request timed out." 901 | } 902 | ``` 903 | 904 | ---------- 905 | 906 | ### `/blog/` 907 | 908 | method: `GET` 909 | 910 | *input*: **None** 911 | 912 | *output*: 913 | 914 | response code will be **200** 915 | 916 | - return all posts 917 | 918 | ```json 919 | { 920 | "1": { 921 | "author_email": "yasi_ommi@yahoo.com", 922 | "author_fullname": "Ken Adams", 923 | "category": "category", 924 | "content": "post content", 925 | "current_user_mail": "mohammadhossein.malekpour@gmail.com", 926 | "slug": "some-slug", 927 | "summary": "post summery", 928 | "title": "sample post" 929 | }, 930 | "10": { 931 | "author_email": "imanmalekian31@gmail.com", 932 | "author_fullname": "iman123", 933 | "category": "asd", 934 | "content": "asd", 935 | "current_user_mail": "mohammadhossein.malekpour@gmail.com", 936 | "slug": "asdss", 937 | "summary": "asdsss", 938 | "title": "asdss" 939 | }, 940 | "11": { 941 | "author_email": "imanmalekian31@gmail.com", 942 | "author_fullname": "iman123", 943 | "category": "asd", 944 | "content": "asdasd", 945 | "current_user_mail": "mohammadhossein.malekpour@gmail.com", 946 | "slug": "asdasd", 947 | "summary": "asdas", 948 | "title": "asdasd" 949 | } 950 | } 951 | ``` 952 | 953 | ---------- 954 | 955 | ### `/blog/` 956 | 957 | method: `GET` 958 | 959 | *input*: 960 | 961 | pass query parametr in URL 962 | 963 | *output*: 964 | 965 | response code will be **200** 966 | 967 | ```json 968 | { 969 | "author_email": "mohammadhossein.malekpour@gmail.com", 970 | "author_fullname": "Mohammad Hossein Malekpour", 971 | "category": "recepie", 972 | "content": "who konws!", 973 | "post_id": 2, 974 | "slug": "avaliwern-post-dailywrdiet", 975 | "summary": "pooof", 976 | "title": "How To Get Diet?" 977 | } 978 | ``` 979 | 980 | *in case of errors*: 981 | 982 | response code will be **404** 983 | 984 | ```json 985 | { 986 | "error": "post not exist!" 987 | } 988 | ``` 989 | 990 | ---------- 991 | 992 | ### `/blog/posts/new/` 993 | 994 | method: `POST` 995 | 996 | *input*: 997 | 998 | Authorization Header: 999 | 1000 | - Bearer \ 1001 | 1002 | Body: 1003 | 1004 | - category 1005 | - content 1006 | - slug 1007 | - summary 1008 | - title 1009 | 1010 | ```json 1011 | { 1012 | "category": "recepie", 1013 | "content": "who konws!", 1014 | "slug": "dovomi-podsasdt-daasdilywrdiet", 1015 | "summary": "pooof", 1016 | "title": "How asdToqwewdasde Get Diet?" 1017 | } 1018 | ``` 1019 | 1020 | *output*: 1021 | 1022 | response code will be **200** 1023 | 1024 | ```json 1025 | { 1026 | "msg": "Post created successfully" 1027 | } 1028 | ``` 1029 | 1030 | *in case of errors*: 1031 | 1032 | response code will be **400** 1033 | 1034 | ```json 1035 | { 1036 | "error": { 1037 | "title": [ 1038 | "This field is required." 1039 | ] 1040 | } 1041 | } 1042 | ``` 1043 | 1044 | ---------- 1045 | 1046 | ### `/posts/delete//` 1047 | 1048 | method: `DELETE` 1049 | 1050 | *input*: 1051 | 1052 | pass query parametr in URL 1053 | 1054 | Authorization Header: 1055 | 1056 | - Bearer \ 1057 | 1058 | *output*: 1059 | 1060 | response code will be **204** 1061 | 1062 | - No content 1063 | 1064 | *in case of errors*: 1065 | 1066 | response code will be **403** 1067 | 1068 | ```json 1069 | { 1070 | "error": "access denied!" 1071 | } 1072 | ``` 1073 | 1074 | ---------- 1075 | 1076 | ### `/blog/posts/user` 1077 | 1078 | method: `GET` 1079 | 1080 | *input*: 1081 | 1082 | Authorization Header: 1083 | 1084 | - Bearer \ 1085 | 1086 | *output*: 1087 | 1088 | response code will be **200** 1089 | 1090 | ```json 1091 | { 1092 | "2": { 1093 | "author_email": "mohammadhossein.malekpour@gmail.com", 1094 | "author_fullname": "Mohammad Hossein Malekpour", 1095 | "category": "recepie", 1096 | "content": "who konws!", 1097 | "current_user_mail": "mohammadhossein.malekpour@gmail.com", 1098 | "slug": "avaliwern-post-dailywrdiet", 1099 | "summary": "pooof", 1100 | "title": "How To Get Diet?" 1101 | }, 1102 | "3": { 1103 | "author_email": "mohammadhossein.malekpour@gmail.com", 1104 | "author_fullname": "Mohammad Hossein Malekpour", 1105 | "category": "recepie", 1106 | "content": "who konws!", 1107 | "current_user_mail": "mohammadhossein.malekpour@gmail.com", 1108 | "slug": "dovomi-post-dailywrdiet", 1109 | "summary": "pooof", 1110 | "title": "How Toqwewe Get Diet?" 1111 | }, 1112 | "4": { 1113 | "author_email": "mohammadhossein.malekpour@gmail.com", 1114 | "author_fullname": "Mohammad Hossein Malekpour", 1115 | "category": "recepie", 1116 | "content": "who konws!", 1117 | "current_user_mail": "mohammadhossein.malekpour@gmail.com", 1118 | "slug": "dovomi-post-daasdilywrdiet", 1119 | "summary": "pooof", 1120 | "title": "How Toqwewdasde Get Diet?" 1121 | } 1122 | } 1123 | ``` 1124 | 1125 | ---------- 1126 | 1127 | ### `/foods/diets` 1128 | 1129 | method: `GET` 1130 | 1131 | *input*: 1132 | 1133 | GET parameters: 1134 | 1135 | - page 1136 | - per_page 1137 | 1138 | Authorization Header: 1139 | 1140 | - Bearer \ 1141 | 1142 | *sample input*: 1143 | ``` 1144 | localhost:5000/foods/diets?page=1&per_page=2 1145 | ``` 1146 | 1147 | *output*: 1148 | 1149 | response code will be **200** 1150 | 1151 | *sample output*: 1152 | ```json 1153 | [ 1154 | { 1155 | "diet": [ 1156 | { 1157 | "category": "breakfast", 1158 | "id": 905755, 1159 | "image": "https://images.eatthismuch.com/site_media/img/905755_Shamarie84_a50c2b94-934f-4326-9af3-0e0f04d7b10f.png", 1160 | "nutrition": { 1161 | "calories": 443, 1162 | "fat": 18.6, 1163 | "fiber": 6.2, 1164 | "protein": 17.9 1165 | }, 1166 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/905755_Shamarie84_a50c2b94-934f-4326-9af3-0e0f04d7b10f.png", 1167 | "title": "Peach Yogurt Parfait" 1168 | }, 1169 | { 1170 | "category": "mostly_meat", 1171 | "id": 940743, 1172 | "image": "https://images.eatthismuch.com/site_media/img/325467_simmyras_cbf011a4-a8ef-4fac-b4bc-bcdd1f8770d4.png", 1173 | "nutrition": { 1174 | "calories": 1633, 1175 | "fat": 84.7, 1176 | "fiber": 8.8, 1177 | "protein": 112.4 1178 | }, 1179 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/325467_simmyras_cbf011a4-a8ef-4fac-b4bc-bcdd1f8770d4.png", 1180 | "title": "Ham and Cheese Chicken Roll-ups" 1181 | }, 1182 | { 1183 | "category": "breakfast", 1184 | "id": 983905, 1185 | "image": "https://images.eatthismuch.com/site_media/img/233507_ashleigh_c_hughes_9eab40e4-c8ad-488f-a381-f6e9342ed72d.png", 1186 | "nutrition": { 1187 | "calories": 214, 1188 | "fat": 18.7, 1189 | "fiber": 1.4, 1190 | "protein": 9.3 1191 | }, 1192 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/233507_ashleigh_c_hughes_9eab40e4-c8ad-488f-a381-f6e9342ed72d.png", 1193 | "title": "Eggs & Greens" 1194 | } 1195 | ], 1196 | "time": "Fri, 12 Jun 2020 13:56:25 GMT" 1197 | } 1198 | ] 1199 | ``` 1200 | 1201 | in case of errors: 1202 | 1203 | - Logged in user is `NULL` 1204 | 1205 | *output*: 1206 | 1207 | response code will be **404** 1208 | 1209 | ```json 1210 | { 1211 | "error": "user not found" 1212 | } 1213 | ``` 1214 | 1215 | ------------ 1216 | 1217 | ### `/foods/search/ingredient` 1218 | text-search between ingredients in order to choose some of them to include in a recipe. 1219 | (but it's not excluded to this) 1220 | 1221 | method: `GET` 1222 | 1223 | *input*: 1224 | GET method parameters: 1225 | 1226 | - query: text to search 1227 | - page:pagination page, default value is 1 1228 | - page:items per page, default value is 10 1229 | 1230 | *sample input*: 1231 | 1232 | ``` 1233 | /foods/search/ingredient?query=Mango 1234 | ``` 1235 | 1236 | *output*: 1237 | 1238 | response code will be **200** 1239 | 1240 | - total results count in the elasticsearch 1241 | - ingredients info ordered by relevance 1242 | 1243 | ```json 1244 | { 1245 | "results": [ "list of simple views.."], 1246 | "total_results_count": "count..." 1247 | } 1248 | ``` 1249 | 1250 | *sample output*: 1251 | 1252 | ```json 1253 | { 1254 | "results": [ 1255 | { 1256 | "food_name": "Frozen Mango", 1257 | "id": 163245, 1258 | "nutrition": { 1259 | "calories": 70.0, 1260 | "carbs": 19.0, 1261 | "fats": 0.0, 1262 | "proteins": 1.0 1263 | }, 1264 | "primary_thumbnail": "https://images.eatthismuch.com/site_media/thmb/163245_simmyras_81adf045-0657-4bd9-8603-af3740b0e540.png" 1265 | }, 1266 | { 1267 | "food_name": "Mango Chutney", 1268 | "id": 448267, 1269 | "nutrition": { 1270 | "calories": 25.0, 1271 | "carbs": 7.0, 1272 | "fats": 0.0, 1273 | "proteins": 0.0 1274 | }, 1275 | "primary_thumbnail": "https://images.eatthismuch.com/site_media/thmb/448267_RedHawk5_6af04abe-c233-4908-83ed-6891e6306fc6.png" 1276 | }, 1277 | { 1278 | "food_name": "Mango Juice", 1279 | "id": 85585, 1280 | "nutrition": { 1281 | "calories": 30.0, 1282 | "carbs": 7.0, 1283 | "fats": 0.0, 1284 | "proteins": 0.0 1285 | }, 1286 | "primary_thumbnail": "https://img.icons8.com/color/2x/grocery-bag.png" 1287 | } 1288 | ], 1289 | "total_results_count": 3 1290 | } 1291 | ``` 1292 | 1293 | 1294 | *in case of errors*: 1295 | 1296 | 1- if you don't pass query parameter in the url 1297 | 1298 | response code will be **422** 1299 | 1300 | ```json 1301 | { 1302 | "error": "query should exist in the request" 1303 | } 1304 | ``` 1305 | 1306 | 2- if per_page value is more than 50 1307 | 1308 | response code will be **422** 1309 | 1310 | ```json 1311 | { 1312 | "error": "per_page should not be more than 50" 1313 | } 1314 | ``` 1315 | 1316 | 3- if search is timed out. 1317 | 1318 | status code will be **408** 1319 | ```json 1320 | { 1321 | "error": "search request timed out." 1322 | } 1323 | ``` 1324 | 1325 | --------- 1326 | 1327 | ### `/foods/search` 1328 | advanced search in foods 1329 | 1330 | method: `POST` 1331 | 1332 | *input*: 1333 | 1334 | ```json 1335 | { 1336 | "text": "text_to_search", 1337 | "category" : "one of [mostly_meat, appetizers,drink,main_dish,sandwich,dessert,breakfast,protein_shake,salad,pasta,other]" , 1338 | "ingredients": ["list of ingredient ids"], 1339 | "calories":{ 1340 | "min":"optional", 1341 | "max":"optional" 1342 | }, 1343 | "carbs": { 1344 | "min":"optional", 1345 | "max":"optional" 1346 | } , 1347 | "fats": { 1348 | "min":"optional", 1349 | "max":"optional" 1350 | }, 1351 | "proteins": { 1352 | "min":"optional", 1353 | "max":"optional" 1354 | } , 1355 | "cook_time":{ 1356 | "min":"optional", 1357 | "max":"optional" 1358 | } , 1359 | "prep_time": { 1360 | "min":"optional", 1361 | "max":"optional" 1362 | } , 1363 | "total_time": { 1364 | "min":"optional", 1365 | "max":"optional" 1366 | }, 1367 | "page": "pagination page number (default is 1)" , 1368 | "per_page": "pagination per_page count (default is 10)" 1369 | } 1370 | ``` 1371 | *output*: 1372 | response code will be **200** 1373 | 1374 | - total results count in the elasticsearch 1375 | - food sample view of foods found int the search ordered by relevance 1376 | 1377 | ```json 1378 | { 1379 | "results": [ "list of simple views.."], 1380 | "total_results_count": "count..." 1381 | } 1382 | ``` 1383 | *in case of errors*: 1384 | 1385 | 1- if you don't pass query parameter in the url 1386 | 1387 | response code will be **422** 1388 | 1389 | ```json 1390 | { 1391 | "error": "some query should exist in the request" 1392 | } 1393 | ``` 1394 | 1395 | 2- if per_page value is more than 50 1396 | 1397 | response code will be **422** 1398 | 1399 | ```json 1400 | { 1401 | "error": "per_page should not be more than 50" 1402 | } 1403 | ``` 1404 | 1405 | 3- if search is timed out. 1406 | 1407 | status code will be **408** 1408 | ```json 1409 | { 1410 | "error": "search request timed out." 1411 | } 1412 | ``` 1413 | 1414 | *sample input 1*: 1415 | ```json 1416 | { 1417 | "text":"avocado", 1418 | "calories":{ 1419 | "min":200 1420 | }, 1421 | "cook_time":{ 1422 | "min":20, 1423 | "max":30 1424 | } 1425 | } 1426 | ``` 1427 | 1428 | *sample output 1*: 1429 | ```json 1430 | { 1431 | "results": [ 1432 | { 1433 | "category": "breakfast", 1434 | "id": 906721, 1435 | "image": "https://images.eatthismuch.com/site_media/img/270860_mensuramjr_cd47fe9f-edb6-42cd-bcb0-766f8eaa3914.png", 1436 | "nutrition": { 1437 | "calories": 393, 1438 | "fat": 34.2, 1439 | "fiber": 13.5, 1440 | "protein": 10.3 1441 | }, 1442 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/270860_mensuramjr_cd47fe9f-edb6-42cd-bcb0-766f8eaa3914.png", 1443 | "title": "Avocado Egg Bake" 1444 | }, 1445 | { 1446 | "category": "pasta", 1447 | "id": 1015979, 1448 | "image": "https://img.icons8.com/color/7x/spaghetti.png", 1449 | "nutrition": { 1450 | "calories": 935, 1451 | "fat": 41.7, 1452 | "fiber": 9.7, 1453 | "protein": 49.5 1454 | }, 1455 | "thumbnail": "https://img.icons8.com/color/2x/spaghetti.png", 1456 | "title": "Creamy Chicken Avocado Pasta" 1457 | }, 1458 | { 1459 | "category": "sandwich", 1460 | "id": 906763, 1461 | "image": "https://images.eatthismuch.com/site_media/img/906763_JoseBaltazar_b00f5694-fd97-4f90-bc91-290a5d72257d.jpg", 1462 | "nutrition": { 1463 | "calories": 675, 1464 | "fat": 36.1, 1465 | "fiber": 16.9, 1466 | "protein": 50.8 1467 | }, 1468 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/906763_JoseBaltazar_b00f5694-fd97-4f90-bc91-290a5d72257d.jpg", 1469 | "title": "Chicken and Avocado Sandwich" 1470 | }, 1471 | { 1472 | "category": "mostly_meat", 1473 | "id": 905623, 1474 | "image": "https://images.eatthismuch.com/site_media/img/264808_tharwood_e5e0c43d-bbf4-4006-875f-a0e48fef3302.png", 1475 | "nutrition": { 1476 | "calories": 260, 1477 | "fat": 18.1, 1478 | "fiber": 3.4, 1479 | "protein": 19.5 1480 | }, 1481 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/264808_tharwood_e5e0c43d-bbf4-4006-875f-a0e48fef3302.png", 1482 | "title": "Ham, Egg, and Cheese Cupcake" 1483 | }, 1484 | { 1485 | "category": "sandwich", 1486 | "id": 211831, 1487 | "image": "https://images.eatthismuch.com/site_media/img/211831_ZenKari_967deee5-b5c2-4cf1-8544-9ff7757b0931.png", 1488 | "nutrition": { 1489 | "calories": 1184, 1490 | "fat": 54.3, 1491 | "fiber": 29, 1492 | "protein": 33.6 1493 | }, 1494 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/211831_ZenKari_967deee5-b5c2-4cf1-8544-9ff7757b0931.png", 1495 | "title": "Nutribullet Portabella Burgers" 1496 | } 1497 | ], 1498 | "total_results_count": 10 1499 | } 1500 | ``` 1501 | 1502 | *sample input 2*: 1503 | ```json 1504 | { 1505 | "text":"avocado" 1506 | } 1507 | ``` 1508 | 1509 | *sample output 2*: 1510 | ```json 1511 | { 1512 | "results": [ 1513 | { 1514 | "category": "other", 1515 | "id": 390740, 1516 | "image": "https://images.eatthismuch.com/site_media/img/390740_erin_m_11094712-ba5d-4c9b-b0ad-278907f8d1e5.png", 1517 | "nutrition": { 1518 | "calories": 541, 1519 | "fat": 42.2, 1520 | "fiber": 13.9, 1521 | "protein": 26.1 1522 | }, 1523 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/390740_erin_m_11094712-ba5d-4c9b-b0ad-278907f8d1e5.png", 1524 | "title": "Baked Seafood Stuffed Avocados" 1525 | }, 1526 | { 1527 | "category": "main_dish", 1528 | "id": 943329, 1529 | "image": "https://images.eatthismuch.com/site_media/img/331372_bbebber_dacfd09e-5d58-4e24-bdeb-1dc6ce82b16c.png", 1530 | "nutrition": { 1531 | "calories": 145, 1532 | "fat": 10.8, 1533 | "fiber": 6.8, 1534 | "protein": 2.1 1535 | }, 1536 | "thumbnail": "https://images.eatthismuch.com/site_media/thmb/331372_bbebber_dacfd09e-5d58-4e24-bdeb-1dc6ce82b16c.png", 1537 | "title": "Strawberry Salsa Stuffed Avocados" 1538 | } 1539 | ], 1540 | "total_results_count": 418 1541 | } 1542 | ``` 1543 | 1544 | *sample input 3*: 1545 | ```json 1546 | { 1547 | "ingredients":[4914 ,2057,2042] 1548 | } 1549 | ``` 1550 | 1551 | *sample output 3*: 1552 | found foods contain all the ingredients that we have given ids. 1553 | ```json 1554 | { 1555 | "results": [ 1556 | { 1557 | "category": "pasta", 1558 | "id": 57154, 1559 | "image": "https://img.icons8.com/color/7x/spaghetti.png", 1560 | "nutrition": { 1561 | "calories": 2701, 1562 | "fat": 69.7, 1563 | "fiber": 39.3, 1564 | "protein": 102.8 1565 | }, 1566 | "thumbnail": "https://img.icons8.com/color/2x/spaghetti.png", 1567 | "title": "Pasta Puttanesca" 1568 | } 1569 | ], 1570 | "total_results_count": 1 1571 | } 1572 | ``` -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from flask import Flask, send_from_directory 4 | from flask_cors import CORS 5 | from flask_migrate import Migrate 6 | from flask_sqlalchemy import SQLAlchemy 7 | from werkzeug.debug import DebuggedApplication 8 | from whitenoise import WhiteNoise 9 | 10 | from calculator import calculator 11 | from extentions import db, elastic, jwt, mail, migrate 12 | from foods import foods 13 | from users import models as user_models 14 | from foods import models as food_models 15 | from blog import models as blog_models 16 | from users import users 17 | from blog import blog 18 | from admin import admin 19 | 20 | 21 | def create_app(environment='Development'): 22 | """ 23 | :param environment: is either Development/Production/Testing 24 | """ 25 | if environment is None: 26 | environment = 'Development' 27 | 28 | app = Flask(__name__) 29 | 30 | app.config.from_object(f'config.{environment}Config') 31 | 32 | @app.route('/', methods=['GET']) 33 | def temp_main_function(): 34 | """ 35 | temporary main function to test app, debug and testing status 36 | todo:move it to another endpoint 37 | :return: status:dict 38 | """ 39 | return { 40 | 'status': 'API is up and running:))', 41 | 'ENV': app.config['ENV'], 42 | 'DEBUG': app.config['DEBUG'], 43 | 'TESTING': app.config['TESTING'], 44 | 'elasticsearch_status': 'ready' if elastic.ping() else 'broken' 45 | } 46 | 47 | app.register_blueprint(calculator) 48 | app.register_blueprint(users) 49 | app.register_blueprint(foods) 50 | app.register_blueprint(blog) 51 | 52 | 53 | db.init_app(app) 54 | migrate.init_app(app) 55 | jwt.init_app(app) 56 | mail.init_app(app) 57 | admin.init_app(app) 58 | 59 | # configuring CORS settings 60 | CORS(app, resources=app.config['CORS_RESOURCES']) 61 | 62 | if app.debug: 63 | app.wsgi_app = DebuggedApplication(app.wsgi_app, evalex=True) 64 | 65 | # enabling whitenoise 66 | app.wsgi_app = WhiteNoise(app.wsgi_app) 67 | for static_folder in app.config['STATIC_FOLDERS']: 68 | app.wsgi_app.add_files(static_folder) 69 | 70 | return app 71 | 72 | 73 | app = create_app(environment=getenv('DAILYDIET_ENV')) 74 | 75 | if __name__ == '__main__': 76 | app.run() 77 | -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | 4 | blog = Blueprint('blog', __name__, url_prefix='/blog/') 5 | 6 | 7 | from . import views 8 | -------------------------------------------------------------------------------- /blog/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import TextField, TextAreaField 3 | from wtforms.validators import DataRequired 4 | from blog.models import Post 5 | 6 | 7 | class PostForm(FlaskForm): 8 | class Meta: 9 | csrf = False 10 | 11 | title = TextField(validators=[DataRequired()]) 12 | summary = TextAreaField() 13 | content = TextAreaField(validators=[DataRequired()]) 14 | slug = TextField(validators=[DataRequired()]) 15 | category = TextField(validators=[DataRequired()]) 16 | 17 | def validate(self): 18 | initial_validation = super(PostForm, self).validate() 19 | if not initial_validation: 20 | return False 21 | slug = Post.query.filter_by(slug=self.slug.data).first() 22 | title = Post.query.filter_by(title=self.title.data).first() 23 | if slug: 24 | self.slug.errors.append("slug has already been taken.") 25 | return False 26 | if title: 27 | self.title.errors.append("title has already been taken.") 28 | return False 29 | return True -------------------------------------------------------------------------------- /blog/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Text, Table, ForeignKey 2 | from extentions import db 3 | 4 | from flask_admin.contrib.sqla import ModelView 5 | 6 | class Post(db.Model): 7 | __tablename__ = 'posts' 8 | 9 | id = Column(Integer, primary_key=True) 10 | title = Column(String(128), nullable=False, unique=True) 11 | summary = Column(String(256), nullable=True, unique=False) 12 | content = Column(Text, nullable=False, unique=False) 13 | slug = Column(String(128), nullable=False, unique=True) 14 | category = Column(String(256), nullable=True, unique=False) 15 | authorId = Column('authorid', Integer(), ForeignKey('users.id'), nullable=False) 16 | 17 | class PostModelView(ModelView): 18 | can_edit = True 19 | column_display_pk = True 20 | column_searchable_list = ['title'] 21 | -------------------------------------------------------------------------------- /blog/views.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt_identity 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | from extentions import db 6 | 7 | from . import blog 8 | from .forms import PostForm 9 | from .models import Post 10 | from users.models import User 11 | 12 | 13 | @blog.route('/', methods=['GET']) 14 | def list_posts(): 15 | posts = Post.query.order_by(Post.id.desc()).all() 16 | result = dict() 17 | for p in posts: 18 | tmp = dict() 19 | tmp['id'] = p.id 20 | slug = p.slug.replace(' ', '-') 21 | print(p.writer.FullName) 22 | tmp = {'slug': slug, 'title': p.title, 'summary': p.summary, 'content': p.content, 'category': p.category, 'author_fullname': p.writer.FullName, 'author_email': p.writer.Email} 23 | result.update({f'{p.id}': tmp}) 24 | return jsonify(result), 200 25 | 26 | 27 | @blog.route('/', methods=['GET']) 28 | def single_post(slug): 29 | post = Post.query.filter(Post.slug == slug).first() 30 | if not post: 31 | return {'error': 'post not exist!'}, 404 32 | result = {'post_id':post.id, 'slug': post.slug, 'title': post.title, 'summary': post.summary, 'content': post.content, 'category': post.category, 'author_fullname': post.writer.FullName, 'author_email': post.writer.Email} 33 | return jsonify(result), 200 34 | 35 | 36 | @blog.route('/posts/user', methods=['GET']) 37 | @jwt_required 38 | def get_user_posts(): 39 | ID = User.query.filter_by(Email=get_jwt_identity()).first().id 40 | posts = Post.query.filter(Post.authorId == ID).all() 41 | result = dict() 42 | for p in posts: 43 | tmp = dict() 44 | tmp['id'] = p.id 45 | slug = p.slug.replace(' ', '-') 46 | print(p.writer.FullName) 47 | tmp = {'slug': slug, 'title': p.title, 'summary': p.summary, 'content': p.content, 'category': p.category, 'author_fullname': p.writer.FullName, 'author_email': p.writer.Email, 'current_user_mail': get_jwt_identity()} 48 | result.update({f'{p.id}': tmp}) 49 | return jsonify(result), 200 50 | 51 | 52 | @blog.route('/posts/new/', methods=['POST']) 53 | @jwt_required 54 | def create_post(): 55 | ID = User.query.filter_by(Email=get_jwt_identity()).first().id 56 | form = PostForm() 57 | if not form.validate_on_submit(): 58 | return {'error': form.errors}, 400 59 | new_post = Post() 60 | new_post.title = form.title.data 61 | new_post.content = form.content.data 62 | new_post.slug = form.slug.data 63 | new_post.summary = form.summary.data 64 | new_post.category = form.category.data 65 | new_post.authorId = ID 66 | db.session.add(new_post) 67 | db.session.commit() 68 | return {'msg': 'Post created successfully'}, 201 69 | 70 | 71 | @blog.route('/posts/delete//', methods=['DELETE']) 72 | @jwt_required 73 | def delete_post(post_id): 74 | ID = User.query.filter_by(Email=get_jwt_identity()).first().id 75 | if not ID == Post.query.get(post_id).authorId: 76 | return {'error': 'access denied!'}, 403 77 | post = Post.query.get_or_404(post_id) 78 | db.session.delete(post) 79 | db.session.commit() 80 | return {}, 204 81 | 82 | 83 | 84 | # @blog.route('/posts/modify//', methods=['PATCH']) 85 | # def modify_post(post_id): 86 | # post = Post.query.get_or_404(post_id) 87 | # form = PostForm() 88 | # if not form.validate_on_submit(): 89 | # return {'error': form.errors}, 400 90 | # new_post.title = form.title.data 91 | # post.content = form.content.data 92 | # post.slug = form.slug.data 93 | # post.summary = form.summary.data 94 | # post.category = form.category.data 95 | # db.session.commit() 96 | # return {'msg': 'Post Modified!'}, 201 97 | -------------------------------------------------------------------------------- /calculator/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | 4 | calculator = Blueprint('calculator', __name__, url_prefix='/calculate/') 5 | 6 | 7 | from calculator import views 8 | -------------------------------------------------------------------------------- /calculator/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import DecimalField, IntegerField, StringField, FloatField 3 | from wtforms.validators import AnyOf, DataRequired, NumberRange 4 | 5 | 6 | class BMIForm(FlaskForm): 7 | class Meta: 8 | csrf = False 9 | 10 | height = IntegerField('Height', validators=[DataRequired(), NumberRange(50, 210)]) # height in centimeter 11 | weight = DecimalField('Weight', validators=[DataRequired(), NumberRange(20, 150)]) # weight in kilograms 12 | 13 | 14 | class CalorieForm(FlaskForm): 15 | class Meta: 16 | csrf = False 17 | 18 | goal = StringField('Goal', validators=[DataRequired(), AnyOf(['lose_weight', 'maintain', 'build_muscle'])]) 19 | gender = StringField('Gender', validators=[DataRequired(), AnyOf(['male', 'female'])]) 20 | height = IntegerField('Height', validators=[DataRequired(), NumberRange(50, 210)]) # height in centimeter 21 | weight = DecimalField('Weight', validators=[DataRequired(), NumberRange(20, 150)]) # weight in kilograms 22 | age = IntegerField('Age', validators=[DataRequired(), NumberRange(1, 120)]) # age in years 23 | activity = StringField('Activity Level', validators=[DataRequired(), AnyOf(['sedentary', 'lightly', 'moderately', 'very', 'extra'])]) 24 | -------------------------------------------------------------------------------- /calculator/views.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | 3 | from flask import jsonify 4 | 5 | from calculator import calculator 6 | from calculator.forms import BMIForm, CalorieForm 7 | 8 | 9 | @calculator.route('/bmi', methods=['POST']) 10 | def calculate_bmi(): 11 | form = BMIForm() 12 | if not form.validate_on_submit(): 13 | return jsonify({"errors": form.errors}), 400 14 | 15 | height_in_meters = form.height.data / 100 16 | bmi_value = float(form.weight.data) / (height_in_meters ** 2) 17 | bmi_status = None 18 | if bmi_value < 18.5: 19 | bmi_status = 'Underweight' 20 | elif bmi_value < 25: 21 | bmi_status = 'Normal weight' 22 | elif bmi_value < 30: 23 | bmi_status = 'Overweight' 24 | else: 25 | bmi_status = 'Obesity' 26 | 27 | result = { 28 | "bmi_value": round(bmi_value, 2), 29 | "bmi_status": bmi_status 30 | } 31 | return jsonify(result) 32 | 33 | 34 | @calculator.route('/calorie', methods=['POST']) 35 | def calculate_calorie(): 36 | form = CalorieForm() 37 | if not form.validate_on_submit(): 38 | return jsonify({"errors": form.errors}), 400 39 | 40 | bmr = None 41 | if form.gender.data == 'male': 42 | bmr = 66 + (13.7 * float(form.weight.data)) + (5 * form.height.data) - (6.8 * form.age.data) 43 | elif form.gender.data == 'female': 44 | bmr = 655 + (9.6 * float(form.weight.data)) + (1.8 * form.height.data) - (4.7 * form.age.data) 45 | 46 | calorie = None 47 | activity = form.activity.data 48 | if activity == 'sedentary': 49 | calorie = bmr * 1.2 50 | elif activity == 'lightly': 51 | calorie = bmr * 1.375 52 | elif activity == 'moderately': 53 | calorie = bmr * 1.55 54 | elif activity == 'very': 55 | calorie = bmr * 1.725 56 | elif activity == 'extra': 57 | calorie = bmr * 1.9 58 | 59 | goal = form.goal.data 60 | if goal == 'lose_weight': 61 | calorie = calorie - randrange(500, 750) 62 | elif goal == 'build_muscle': 63 | calorie = calorie + randrange(500, 750) 64 | elif goal == 'maintain': 65 | pass 66 | 67 | result = { 68 | "calorie": int(calorie) 69 | } 70 | 71 | return jsonify(result) 72 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | import os 3 | 4 | load_dotenv() 5 | 6 | 7 | class Config(object): 8 | SECRET_KEY = os.getenv('SECRET_KEY') 9 | SECURITY_PASSWORD_SALT = os.getenv('SECURITY_PASSWORD_SALT') 10 | 11 | # database 12 | SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI') 13 | SQLALCHEMY_TRACK_MODIFICATIONS = os.getenv('SQLALCHEMY_TRACK_MODIFICATIONS', False) 14 | 15 | # mail settings 16 | MAIL_SERVER = 'smtp.googlemail.com' 17 | MAIL_PORT = 465 18 | MAIL_USE_TLS = False 19 | MAIL_USE_SSL = True 20 | MAIL_MAX_EMAILS = None 21 | 22 | # gmail authentication 23 | MAIL_USERNAME = os.getenv('MAIL_USERNAME') 24 | MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') 25 | 26 | # mail accounts 27 | MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER') 28 | 29 | # admin settings 30 | 31 | # static file settings 32 | STATIC_FOLDERS = ( 33 | 'admin/static/', 34 | 'static/' 35 | ) 36 | 37 | # elasticsearch settings 38 | ELASTICSEARCH_URL = os.getenv('ELASTICSEARCH_URL') 39 | 40 | PLACEHOLDERS = { 41 | "image": { 42 | "mostly_meat": "https://img.icons8.com/color/7x/steak.png", 43 | "appetizers": "https://img.icons8.com/color/7x/nachos.png", 44 | "drink": "https://img.icons8.com/color/7x/rice-vinegar.png", 45 | "main_dish": "https://img.icons8.com/color/7x/real-food-for-meals.png", 46 | "sandwich": "https://img.icons8.com/color/7x/sandwich.png", 47 | "dessert": "https://img.icons8.com/color/7x/dessert.png", 48 | "breakfast": "https://img.icons8.com/color/7x/breakfast.png", 49 | "protein_shake": "https://img.icons8.com/color/7x/protein.png", 50 | "salad": "https://img.icons8.com/color/7x/salad.png", 51 | "pasta": "https://img.icons8.com/color/7x/spaghetti.png", 52 | "other": "https://img.icons8.com/color/7x/cookbook.png", 53 | "ingredient": "https://img.icons8.com/color/7x/grocery-bag.png" 54 | }, 55 | "thumbnail": { 56 | "mostly_meat": "https://img.icons8.com/color/2x/steak.png", 57 | "appetizers": "https://img.icons8.com/color/2x/nachos.png", 58 | "drink": "https://img.icons8.com/color/2x/rice-vinegar.png", 59 | "main_dish": "https://img.icons8.com/color/2x/real-food-for-meals.png", 60 | "sandwich": "https://img.icons8.com/color/2x/sandwich.png", 61 | "dessert": "https://img.icons8.com/color/2x/dessert.png", 62 | "breakfast": "https://img.icons8.com/color/2x/breakfast.png", 63 | "protein_shake": "https://img.icons8.com/color/2x/protein.png", 64 | "salad": "https://img.icons8.com/color/2x/salad.png", 65 | "pasta": "https://img.icons8.com/color/2x/spaghetti.png", 66 | "other": "https://img.icons8.com/color/2x/cookbook.png", 67 | "ingredient":"https://img.icons8.com/color/2x/grocery-bag.png" 68 | } 69 | } 70 | 71 | 72 | class DevelopmentConfig(Config): 73 | FLASK_DEBUG = True 74 | CORS_RESOURCES = {'*': {'origins': '*'}} 75 | 76 | 77 | class TestingConfig(Config): 78 | TESTING = True 79 | FLASK_DEBUG = True 80 | 81 | 82 | class ProductionConfig(Config): 83 | FLASK_DEBUG = True 84 | CORS_RESOURCES = {'https://daily-diet-aut.herokuapp.com/': {'origins': '*'}} 85 | -------------------------------------------------------------------------------- /create_elastic_index.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "pycharm": { 8 | "is_executing": false 9 | } 10 | }, 11 | "outputs": [ 12 | { 13 | "name": "stderr", 14 | "output_type": "stream", 15 | "text": [ 16 | "d:\\dailydiet\\dailydiet-api\\venv\\lib\\site-packages\\whitenoise\\base.py:115: UserWarning: No directory at: D:\\dailydiet\\dailydiet-api\\admin\\static\\\n", 17 | " warnings.warn(u\"No directory at: {}\".format(root))\n" 18 | ] 19 | } 20 | ], 21 | "source": [ 22 | "import app\n", 23 | "instance = app.create_app()\n", 24 | "instance.app_context().push()" 25 | ] 26 | }, 27 | { 28 | "cell_type": "code", 29 | "execution_count": 2, 30 | "metadata": { 31 | "pycharm": { 32 | "is_executing": false, 33 | "name": "#%%\n" 34 | } 35 | }, 36 | "outputs": [], 37 | "source": [ 38 | "from foods.models import Food,DietRecord\n", 39 | "from extentions import elastic\n", 40 | "from elasticsearch_dsl import Q,Search" 41 | ] 42 | }, 43 | { 44 | "cell_type": "code", 45 | "execution_count": null, 46 | "metadata": { 47 | "pycharm": { 48 | "is_executing": false, 49 | "name": "#%%\n" 50 | } 51 | }, 52 | "outputs": [], 53 | "source": [ 54 | "page = Food.query.paginate(page=1,per_page=100)" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": { 61 | "pycharm": { 62 | "is_executing": false, 63 | "name": "#%%\n" 64 | } 65 | }, 66 | "outputs": [], 67 | "source": [ 68 | "page.next()" 69 | ] 70 | }, 71 | { 72 | "cell_type": "code", 73 | "execution_count": null, 74 | "metadata": { 75 | "pycharm": { 76 | "is_executing": false 77 | } 78 | }, 79 | "outputs": [], 80 | "source": [ 81 | "page.pages" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": { 88 | "pycharm": { 89 | "is_executing": false, 90 | "name": "#%%\n" 91 | } 92 | }, 93 | "outputs": [], 94 | "source": [ 95 | "page = Food.query.paginate(page=26,per_page=100)\n", 96 | "while page.has_next:\n", 97 | " for item in page.items:\n", 98 | " Food.add_to_index(item)\n", 99 | " print(f'page {page.next_num - 1} indexed.')\n", 100 | " page = page.next()" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": null, 106 | "metadata": { 107 | "pycharm": { 108 | "is_executing": false, 109 | "name": "#%%\n" 110 | } 111 | }, 112 | "outputs": [], 113 | "source": [ 114 | "page = Food.query.paginate(page=51,per_page=100)" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": null, 120 | "metadata": { 121 | "pycharm": { 122 | "is_executing": false, 123 | "name": "#%%\n" 124 | } 125 | }, 126 | "outputs": [], 127 | "source": [ 128 | "#inserting last page\n", 129 | "page = Food.query.paginate(page=60,per_page=100)\n", 130 | "for item in page.items:\n", 131 | " Food.add_to_index(item)\n", 132 | "print(f'page {page.pages} indexed.')" 133 | ] 134 | }, 135 | { 136 | "cell_type": "code", 137 | "execution_count": null, 138 | "metadata": { 139 | "pycharm": { 140 | "is_executing": false, 141 | "name": "#%%\n" 142 | } 143 | }, 144 | "outputs": [], 145 | "source": [ 146 | "query = \"Mangos\" \n", 147 | "#elastic ingredient search\n", 148 | "search = elastic.search(\n", 149 | " index='ingredients',\n", 150 | " body={'query': {'multi_match': {'query': query, 'fields': ['food_name']}},\n", 151 | " 'from': 0, 'size': 20})\n" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": null, 157 | "metadata": { 158 | "pycharm": { 159 | "is_executing": false, 160 | "name": "#%%\n" 161 | } 162 | }, 163 | "outputs": [], 164 | "source": [ 165 | "search" 166 | ] 167 | }, 168 | { 169 | "cell_type": "code", 170 | "execution_count": 7, 171 | "metadata": { 172 | "pycharm": { 173 | "is_executing": false, 174 | "name": "#%%\n" 175 | } 176 | }, 177 | "outputs": [], 178 | "source": [ 179 | "q = Q('bool',\n", 180 | "# should=[\n", 181 | "# {\n", 182 | "# \"multi_match\": {\n", 183 | "# \"query\": query,\n", 184 | "# \"fields\": [\n", 185 | "# \"name^6.0\",\n", 186 | "# \"category^1.0\",\n", 187 | "# \"description^3.0\",\n", 188 | "# \"tag_cloud^3.0\",\n", 189 | "# \"ingredients^2.0\",\n", 190 | "# \"directions^1.5\",\n", 191 | "# \"author^1.0\"\n", 192 | "# ],\n", 193 | "# \"type\": \"phrase_prefix\",\n", 194 | "# \"lenient\": \"true\"\n", 195 | "# }\n", 196 | "# },\n", 197 | " \n", 198 | "# ],\n", 199 | " must=[\n", 200 | " {\n", 201 | " \"range\":{\n", 202 | " \"nutrition.calories\":{\n", 203 | " \"gte\":300\n", 204 | " }\n", 205 | " }\n", 206 | " }\n", 207 | " ,\n", 208 | " {\n", 209 | " \"match\":{\n", 210 | " \"category\":\"sandwich\"\n", 211 | " }\n", 212 | " }\n", 213 | " ],\n", 214 | "# boost=1,\n", 215 | "# minimum_should_match=1\n", 216 | " )\n", 217 | "\n", 218 | " \n", 219 | "# category [one of 13 categories]\n", 220 | "# calories [min:max]\n", 221 | "# carbs [min:max]\n", 222 | "# fats [min:max]\n", 223 | "# proteins (per serving) [min:max]\n", 224 | "# cook_time [min:max]\n", 225 | "# prep_time [min:max]\n", 226 | "# total_time [min:max]\n", 227 | "# ingredients [list of ids]" 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": null, 233 | "metadata": { 234 | "pycharm": { 235 | "name": "#%%\n" 236 | } 237 | }, 238 | "outputs": [], 239 | "source": [] 240 | }, 241 | { 242 | "cell_type": "code", 243 | "execution_count": null, 244 | "metadata": { 245 | "pycharm": { 246 | "is_executing": false 247 | } 248 | }, 249 | "outputs": [], 250 | "source": [ 251 | "\n" 252 | ] 253 | }, 254 | { 255 | "cell_type": "code", 256 | "execution_count": 10, 257 | "metadata": { 258 | "pycharm": { 259 | "is_executing": false 260 | } 261 | }, 262 | "outputs": [ 263 | { 264 | "data": { 265 | "text/plain": [ 266 | "{'took': 4,\n", 267 | " 'timed_out': False,\n", 268 | " '_shards': {'total': 16, 'successful': 16, 'skipped': 0, 'failed': 0},\n", 269 | " 'hits': {'total': {'value': 107, 'relation': 'eq'},\n", 270 | " 'max_score': 4.080478,\n", 271 | " 'hits': [{'_index': 'foods_new',\n", 272 | " '_type': '_doc',\n", 273 | " '_id': '927307',\n", 274 | " '_score': 4.080478,\n", 275 | " '_source': {'author': 'Ken Adams',\n", 276 | " 'name': 'English Muffin Ham Breakfast Sandwich',\n", 277 | " 'description': '',\n", 278 | " 'category': 'sandwich',\n", 279 | " 'nutrition': {'calories': 513.17,\n", 280 | " 'carbs': 30.88,\n", 281 | " 'fats': 27.58,\n", 282 | " 'proteins': 36.13},\n", 283 | " 'tag_cloud': 'English muffins Pepper Cheddar cheese Dairy Products Sliced ham Egg Sausages and Luncheon Meats Spices and Herbs realegg \"English Muffin Ham Breakfast Sandwich\" breakfast Salt Baked Products gluten',\n", 284 | " 'ingredients': ['Egg',\n", 285 | " 'Salt',\n", 286 | " 'Pepper',\n", 287 | " 'English muffins',\n", 288 | " 'Sliced ham',\n", 289 | " 'Cheddar cheese'],\n", 290 | " 'ingredient_ids': [103, 221, 205, 4185, 905, 9],\n", 291 | " 'directions': ['Preheat oven to 350 degrees F. Spray a jumbo muffin tin or small ramekins with nonstick spray.',\n", 292 | " 'Use the large muffin tin or small ramekins, crack an egg into each vessel for each serving you intend to make. (E.g. for one muffin use one, for six muffins, cook six.) Use a sharp knife to gently pierce each yolk.',\n", 293 | " 'Bake the eggs 12-18 minutes, until set. Slide eggs out of ramekins and cool slightly. Sprinkle with salt and pepper, if desired.',\n", 294 | " 'Meanwhile, slice English muffins. Layer one slice of cheese on each English muffin, then 1-3 slices of ham.',\n", 295 | " 'Finally, layer on the egg and top of the English muffin.',\n", 296 | " 'If not eating immediately, wrap in plastic wrap and freeze.',\n", 297 | " 'To eat after frozen: remove plastic wrap and place in a bowl or on a plate. Cook in microwave for 2 minutes, then turn over and microwave for another 1.5 minutes. Enjoy!'],\n", 298 | " 'cook_time': 18,\n", 299 | " 'prep_time': 10,\n", 300 | " 'total_time': 28}},\n", 301 | " {'_index': 'foods_new',\n", 302 | " '_type': '_doc',\n", 303 | " '_id': '907190',\n", 304 | " '_score': 4.080478,\n", 305 | " '_source': {'author': 'Ken Adams',\n", 306 | " 'name': 'Buffalo Chicken Grilled Cheese Sandwich',\n", 307 | " 'description': '',\n", 308 | " 'category': 'sandwich',\n", 309 | " 'nutrition': {'calories': 843.17,\n", 310 | " 'carbs': 28.11,\n", 311 | " 'fats': 47.17,\n", 312 | " 'proteins': 73.37},\n", 313 | " 'tag_cloud': 'Dairy Products Blue cheese Soups, Sauces, and Gravies White bread Fats and Oils Baked Products Pepper Spices and Herbs Vegetables and Vegetable Products \"Buffalo Chicken Grilled Cheese Sandwich\" Light mayonnaise Butter Cheddar cheese gluten Chicken breast Salt Poultry Products Olive oil Celery dairybutter Carrots Pepper or hot sauce Onions',\n", 314 | " 'ingredients': ['Chicken breast',\n", 315 | " 'Olive oil',\n", 316 | " 'Pepper',\n", 317 | " 'Salt',\n", 318 | " 'Pepper or hot sauce',\n", 319 | " 'Light mayonnaise',\n", 320 | " 'Carrots',\n", 321 | " 'Celery',\n", 322 | " 'Onions',\n", 323 | " 'White bread',\n", 324 | " 'Butter',\n", 325 | " 'Blue cheese',\n", 326 | " 'Cheddar cheese'],\n", 327 | " 'ingredient_ids': [451,\n", 328 | " 266,\n", 329 | " 205,\n", 330 | " 221,\n", 331 | " 739,\n", 332 | " 367,\n", 333 | " 1914,\n", 334 | " 1927,\n", 335 | " 2052,\n", 336 | " 4021,\n", 337 | " 121,\n", 338 | " 4,\n", 339 | " 9],\n", 340 | " 'directions': ['Preheat oven to 400 degrees F. ',\n", 341 | " 'Coat chicken breast with oil and season with salt and pepper. Place on a baking sheet and bake for 15 minutes; flip the chicken and bake for another 10 minutes until chicken is cooked through and juices run clear. ',\n", 342 | " 'Allow chicken to rest 10 minutes before handling. Set aside 1/4 of the cooked breast and wrap the remaining chicken to save for later use. Shred the 1/4 piece of chicken with two forks and set aside.',\n", 343 | " 'In a small bowl mix the chicken, hot sauce, mayo, carrot, celery, and onion; set aside.',\n", 344 | " 'Butter the outside of each slice of bread, sprinkle half of the cheeses on the inside of one slice of bread, top with the buffalo chicken salad, the remaining cheese, and finally the other slice of bread',\n", 345 | " 'Heat a non-stick pan over medium heat.',\n", 346 | " 'Add the sandwich and grill until golden brown and the cheese has melted, about 2-4 minutes per side.',\n", 347 | " 'Serve hot and enjoy!'],\n", 348 | " 'cook_time': 25,\n", 349 | " 'prep_time': 15,\n", 350 | " 'total_time': 40}}]}}" 351 | ] 352 | }, 353 | "execution_count": 10, 354 | "metadata": {}, 355 | "output_type": "execute_result" 356 | } 357 | ], 358 | "source": [ 359 | "s = Search(using=elastic)\n", 360 | "s = s.query(q)\n", 361 | "# for idd in [4914 ,2057,2042]:\n", 362 | "# s = s.filter('term',**{\n", 363 | "# \"ingredient_ids\": idd\n", 364 | "# })\n", 365 | "s[0:2].execute().to_dict()" 366 | ] 367 | }, 368 | { 369 | "cell_type": "code", 370 | "execution_count": null, 371 | "metadata": { 372 | "pycharm": { 373 | "is_executing": false 374 | } 375 | }, 376 | "outputs": [], 377 | "source": [ 378 | "DietRecord.query.filter(DietRecord.ownerId == 1).order_by(DietRecord.generatedAt.desc()) \\\n", 379 | " .limit(10).all()" 380 | ] 381 | }, 382 | { 383 | "cell_type": "code", 384 | "execution_count": null, 385 | "metadata": { 386 | "pycharm": { 387 | "is_executing": false, 388 | "name": "#%%\n" 389 | } 390 | }, 391 | "outputs": [], 392 | "source": [ 393 | "query= 'roast beef'\n", 394 | "page=1\n", 395 | "per_page=5\n", 396 | "search = Search(using=elastic, index='foods_new')\n", 397 | "elastic_query = Q('bool',\n", 398 | " should=[\n", 399 | " {\n", 400 | " \"multi_match\": {\n", 401 | " \"query\": query,\n", 402 | " \"fields\": [\n", 403 | " \"name^6.0\",\n", 404 | " \"category^1.0\",\n", 405 | " \"description^3.0\",\n", 406 | " \"tag_cloud^3.0\",\n", 407 | " \"ingredients^2.0\",\n", 408 | " \"directions^1.5\",\n", 409 | " \"author^1.0\"\n", 410 | " ],\n", 411 | " \"type\": \"phrase_prefix\",\n", 412 | " \"lenient\": \"true\"\n", 413 | " }\n", 414 | " },\n", 415 | "\n", 416 | " ],\n", 417 | " boost=1,\n", 418 | " minimum_should_match=1)\n", 419 | "from_index = (page - 1) * per_page\n", 420 | "size = per_page\n", 421 | "search = search.query(elastic_query)\n", 422 | "search_results = search[from_index: from_index + size].execute()\n" 423 | ] 424 | }, 425 | { 426 | "cell_type": "code", 427 | "execution_count": null, 428 | "metadata": { 429 | "pycharm": { 430 | "is_executing": false, 431 | "name": "#%%\n" 432 | } 433 | }, 434 | "outputs": [], 435 | "source": [ 436 | "search_results.to_dict()" 437 | ] 438 | }, 439 | { 440 | "cell_type": "code", 441 | "execution_count": null, 442 | "metadata": { 443 | "pycharm": { 444 | "name": "#%%\n" 445 | } 446 | }, 447 | "outputs": [], 448 | "source": [ 449 | "\n" 450 | ] 451 | } 452 | ], 453 | "metadata": { 454 | "kernelspec": { 455 | "display_name": "Python 3", 456 | "language": "python", 457 | "name": "python3" 458 | }, 459 | "language_info": { 460 | "codemirror_mode": { 461 | "name": "ipython", 462 | "version": 3 463 | }, 464 | "file_extension": ".py", 465 | "mimetype": "text/x-python", 466 | "name": "python", 467 | "nbconvert_exporter": "python", 468 | "pygments_lexer": "ipython3", 469 | "version": "3.7.0" 470 | }, 471 | "pycharm": { 472 | "stem_cell": { 473 | "cell_type": "raw", 474 | "metadata": { 475 | "collapsed": false 476 | }, 477 | "source": [] 478 | } 479 | } 480 | }, 481 | "nbformat": 4, 482 | "nbformat_minor": 1 483 | } 484 | -------------------------------------------------------------------------------- /extentions.py: -------------------------------------------------------------------------------- 1 | """add extensions here""" 2 | from flask_jwt_extended import JWTManager 3 | from flask_mail import Mail 4 | from flask_migrate import Migrate 5 | from flask_sqlalchemy import SQLAlchemy 6 | from elasticsearch import Elasticsearch 7 | from config import Config 8 | 9 | db = SQLAlchemy() 10 | migrate = Migrate(db=db) 11 | jwt = JWTManager() 12 | mail = Mail() 13 | elastic = Elasticsearch(Config.ELASTICSEARCH_URL) if Config.ELASTICSEARCH_URL else None 14 | -------------------------------------------------------------------------------- /foods/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | 4 | foods = Blueprint('foods', __name__, url_prefix='/foods/') 5 | 6 | 7 | from foods import views 8 | -------------------------------------------------------------------------------- /foods/diet.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | def yevade(list1, calorie): 4 | i = 0 5 | rand = random.randint(1,30) 6 | while i < len(list1): 7 | if float(list1[i].get_calorie()) in range (calorie-rand, calorie+rand, 1): 8 | return list1[i], float(list1[i].get_calorie()) 9 | i += 1 10 | return None 11 | 12 | def dovade(list1, list2, calorie): 13 | i = 0 14 | j = len(list2) - 1 15 | rand = random.randint(1,30) 16 | while i < len(list1) and j>=0: 17 | if float(list1[i].get_calorie()) + float(list2[j].get_calorie()) in range(calorie-rand, calorie+rand, 1): 18 | return list1[i], list2[j], float(list1[i].get_calorie()) + float(list2[j].get_calorie()) 19 | elif float(list1[i].get_calorie()) + float(list2[j].get_calorie()) < calorie - 10: 20 | j -= 1 21 | i += 1 22 | return None 23 | 24 | 25 | def sevade(list1, list2, list3, calorie): 26 | i = 0 27 | j = 0 28 | k = len(list3) - 1 29 | rand = random.randint(1,30) 30 | while i < len(list1): 31 | while j < len(list2) and k >= 0: 32 | if float(list1[i].get_calorie()) + float(list2[j].get_calorie()) + float(list3[k].get_calorie()) in range(calorie-rand, calorie+rand, 1): 33 | return list1[i], list2[j], list3[k], float(list1[i].get_calorie()) + float(list2[j].get_calorie()) + float(list3[k].get_calorie()) 34 | elif float(list1[i].get_calorie()) + float(list2[j].get_calorie()) + float(list3[k].get_calorie()) < calorie-10: 35 | j += 1 36 | else: 37 | k -= 1 38 | i += 1 39 | return None 40 | -------------------------------------------------------------------------------- /foods/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from sqlalchemy.ext.hybrid import hybrid_property 3 | from sqlalchemy import Column, Integer, REAL, CHAR, VARCHAR, TIMESTAMP, TEXT, ForeignKey, JSON 4 | import json 5 | from flask_admin.contrib.sqla import ModelView 6 | from flask import jsonify 7 | from config import Config 8 | from extentions import db, elastic 9 | from wtforms import SelectField 10 | from elasticsearch_dsl import Q, Search 11 | 12 | 13 | def build_query(input_json): 14 | query = {} 15 | if input_json.get('text') is not None and input_json['text'] != '': 16 | query['should'] = [ 17 | { 18 | "multi_match": { 19 | "query": input_json['text'], 20 | "fields": [ 21 | "name^6.0", 22 | "category^1.0", 23 | "description^3.0", 24 | "tag_cloud^3.0", 25 | "ingredients^2.0", 26 | "directions^1.5", 27 | "author^1.0" 28 | ], 29 | "type": "phrase_prefix", 30 | "lenient": "true" 31 | } 32 | }, 33 | ] 34 | query['boost'] = 1 35 | query['minimum_should_match'] = 1 36 | 37 | # building must query 38 | if input_json.get('category') is not None: 39 | if query.get('must') is None: 40 | query['must'] = [] 41 | query['must'].append({ 42 | "match": { 43 | "category": input_json['category'] 44 | } 45 | }) 46 | 47 | for feature in ["calories", "carbs", "fats", "proteins"]: 48 | if input_json.get(feature) is not None: 49 | if query.get('must') is None: 50 | query['must'] = [] 51 | part = { 52 | 'range':{} 53 | } 54 | if input_json[feature].get('min') is not None or input_json[feature].get('max') is not None: 55 | if input_json[feature].get('min') is not None: 56 | if part['range'].get(f'nutrition.{feature}') is None: 57 | part['range'][f'nutrition.{feature}'] = {} 58 | part['range'][f'nutrition.{feature}']['gte'] = input_json[feature]['min'] 59 | if input_json[feature].get('max') is not None: 60 | if part['range'].get(f'nutrition.{feature}') is None: 61 | part['range'][f'nutrition.{feature}'] = {} 62 | part['range'][f'nutrition.{feature}']['lte'] = input_json[feature]['min'] 63 | query['must'].append(part) 64 | 65 | for feature in ["cook_time", "prep_time", "total_time"]: 66 | if input_json.get(feature) is not None: 67 | if query.get('must') is None: 68 | query['must'] = [] 69 | part = { 70 | 'range': {} 71 | } 72 | if input_json[feature].get('min') is not None or input_json[feature].get('max') is not None: 73 | if input_json[feature].get('min') is not None: 74 | if part['range'].get(feature) is None: 75 | part['range'][feature] = {} 76 | part['range'][feature]['gte'] = input_json[feature]['min'] 77 | if input_json[feature].get('max') is not None: 78 | if part['range'].get(feature) is None: 79 | part['range'][feature] = {} 80 | part['range'][feature]['lte'] = input_json[feature]['min'] 81 | query['must'].append(part) 82 | 83 | return query 84 | 85 | 86 | class SearchTimedOutException(Exception): 87 | pass 88 | 89 | 90 | class SearchableMixin(object): 91 | 92 | @classmethod 93 | def add_to_index(cls, instance): 94 | if elastic is None: 95 | return 96 | if not hasattr(instance, 'elastic_document'): 97 | raise Exception("model doesn't have 'elastic_document' attribute") 98 | 99 | payload = instance.elastic_document 100 | 101 | if not hasattr(instance, 'ingredients_summery'): 102 | raise Exception("model doesn't have 'ingredients_summery' attribute") 103 | ingredients = instance.ingredients_summery 104 | 105 | if not hasattr(cls, '__ingredients_index__'): 106 | raise Exception("class doesn't have '__ingredients_index__' attribute") 107 | 108 | for ingredient_id, ingredient in ingredients.items(): 109 | elastic.index(index=cls.__ingredients_index__, body=ingredient, id=ingredient_id, doc_type='ingredient') 110 | 111 | if not hasattr(cls, '__indexname__'): 112 | raise Exception("class doesn't have '__indexname__' attribute") 113 | elastic.index(index=cls.__indexname__, body=payload, id=instance.id) 114 | 115 | @classmethod 116 | def remove_from_index(cls, instance): 117 | if elastic is None: 118 | return 119 | if not hasattr(cls, '__indexname__'): 120 | raise Exception("class doesn't have '__indexname__' attribute") 121 | 122 | elastic.delete(index=cls.__indexname__, id=instance.id) 123 | 124 | @classmethod 125 | def query_index(cls, query, page=1, per_page=10): 126 | if elastic is None: 127 | return [], 0 128 | search = Search(using=elastic, index=cls.__indexname__) 129 | elastic_query = Q('bool', 130 | should=[ 131 | { 132 | "multi_match": { 133 | "query": query, 134 | "fields": [ 135 | "name^6.0", 136 | "category^1.0", 137 | "description^3.0", 138 | "tag_cloud^3.0", 139 | "ingredients^2.0", 140 | "directions^1.5", 141 | "author^1.0" 142 | ], 143 | "type": "phrase_prefix", 144 | "lenient": "true" 145 | } 146 | }, 147 | 148 | ], 149 | boost=1, 150 | minimum_should_match=1) 151 | from_index = (page - 1) * per_page 152 | size = per_page 153 | search = search.query(elastic_query) 154 | search_results = search[from_index: from_index + size].execute().to_dict() 155 | if search_results['timed_out']: 156 | raise SearchTimedOutException() 157 | 158 | ids = [int(hit['_id']) for hit in search_results['hits']['hits']] 159 | return ids, search_results['hits']['total']['value'] 160 | 161 | @classmethod 162 | def search(cls, expression, page=1, per_page=10): 163 | ids, total = cls.query_index(expression, page, per_page) 164 | if total == 0: 165 | return cls.query.filter_by(id=0), 0 # just returning nothing 166 | when = [] 167 | for i in range(len(ids)): 168 | when.append((ids[i], i)) 169 | return cls.query.filter(cls.id.in_(ids)).order_by( 170 | db.case(when, value=cls.id)), total 171 | 172 | @classmethod 173 | def before_commit(cls, session): 174 | session._changes = { 175 | 'add': list(session.new), 176 | 'update': list(session.dirty), 177 | 'delete': list(session.deleted) 178 | } 179 | 180 | @classmethod 181 | def after_commit(cls, session): 182 | for obj in session._changes['add']: 183 | if isinstance(obj, SearchableMixin): 184 | cls.add_to_index(obj) 185 | for obj in session._changes['update']: 186 | if isinstance(obj, SearchableMixin): 187 | cls.add_to_index(obj) 188 | for obj in session._changes['delete']: 189 | if isinstance(obj, SearchableMixin): 190 | cls.remove_from_index(obj) 191 | session._changes = None 192 | 193 | @classmethod 194 | def ingredient_search(cls, expression, page=1, per_page=10): 195 | if elastic is None: 196 | return [], 0 197 | search_results = elastic.search( 198 | index='ingredients', 199 | body={'query': {'multi_match': {'query': expression, 'fields': ['food_name']}}, 200 | 'from': (page - 1) * per_page, 'size': per_page}) 201 | if search_results['timed_out']: 202 | raise SearchTimedOutException() 203 | total = search_results['hits']['total']['value'] 204 | return [hit['_source'] for hit in search_results['hits']['hits']], total 205 | 206 | @classmethod 207 | def advanced_query(cls, input_json): 208 | if elastic is None: 209 | return [], 0 210 | page = 1 if input_json.get('page') is None else input_json['page'] 211 | per_page = 10 if input_json.get('per_page') is None else input_json['per_page'] 212 | search = Search(using=elastic, index=cls.__indexname__) 213 | elastic_query = Q('bool',**build_query(input_json)) 214 | from_index = (page - 1) * per_page 215 | size = per_page 216 | search = search.query(elastic_query) 217 | if input_json.get('ingredients') is not None: 218 | for ing_id in input_json['ingredients']: 219 | search = search.filter('term', ingredient_ids=ing_id) 220 | 221 | search_results = search[from_index: from_index + size].execute().to_dict() 222 | 223 | if search_results['timed_out']: 224 | raise SearchTimedOutException() 225 | ids = [int(hit['_id']) for hit in search_results['hits']['hits']] 226 | return ids, search_results['hits']['total']['value'] 227 | 228 | @classmethod 229 | def advanced_search(cls, input_json): 230 | ids, total = cls.advanced_query(input_json) 231 | if total == 0: 232 | return cls.query.filter_by(id=0), 0 # just returning nothing 233 | when = [] 234 | for i in range(len(ids)): 235 | when.append((ids[i], i)) 236 | return cls.query.filter(cls.id.in_(ids)).order_by( 237 | db.case(when, value=cls.id)), total 238 | 239 | @classmethod 240 | def reindex(cls): 241 | for obj in cls.query: 242 | cls.add_to_index(obj) 243 | 244 | 245 | class Food(db.Model, SearchableMixin): 246 | __tablename__ = 'foods' 247 | __indexname__ = 'foods_new' 248 | __ingredients_index__ = 'ingredients' 249 | 250 | id = Column('id', Integer(), primary_key=True) 251 | Calories = Column('calories', Integer()) 252 | Fat = Column('fat', REAL()) 253 | Fiber = Column('fiber', REAL()) 254 | Protein = Column('protein', REAL()) 255 | Category = Column('category', VARCHAR(20), nullable=False) 256 | Image = Column('image', VARCHAR(400), default=None) 257 | Thumbnail = Column('thumbnail', VARCHAR(400), default=None) 258 | Title = Column('title', VARCHAR(200), nullable=False) 259 | CreatedAt = Column('created_at', TIMESTAMP()) 260 | AuthorId = Column('author', Integer(), ForeignKey('users.id'), nullable=False) 261 | 262 | # should not be accessed directly, use `recipe` property insted 263 | # this column will be deprecated soon 264 | Recipe = Column('recipe', TEXT()) 265 | 266 | # @hybrid_property 267 | # def Category(self): 268 | # return self._Category.strip() 269 | # 270 | # @Category.setter 271 | # def category_setter(self, category): 272 | # self._Category = category 273 | 274 | @property 275 | def recipe(self) -> dict: 276 | if self.Recipe is None or self.Recipe == '': 277 | return None 278 | else: 279 | payload = json.loads(self.Recipe) 280 | payload['category'] = self.get_category() 281 | payload['date_created'] = self.CreatedAt.strftime('%Y-%m-%d %H:%M:%S') 282 | 283 | # fixing image loss 284 | if len(payload['images']) != 0 and payload['primary_image'] is None: 285 | payload['primary_image'] = payload['images'][0]['image'] 286 | payload['primary_thumbnail'] = payload['images'][0]['thumbnail'] 287 | 288 | return payload 289 | 290 | def __repr__(self): 291 | return f"" 292 | 293 | @property 294 | def simple_view(self) -> dict: 295 | """ 296 | a simple view of food model 297 | """ 298 | payload = { 299 | 'id': self.id, 300 | 'category': self.get_category(), 301 | 'image': self.Image, 302 | 'thumbnail': self.Thumbnail, 303 | 'title': self.Title, 304 | 'nutrition': {'calories': self.Calories, 305 | 'fat': self.Fat, 306 | 'fiber': self.Fiber, 307 | 'protein': self.Protein} 308 | } 309 | if payload['image'] is None: 310 | recipe = self.recipe 311 | payload['image'] = recipe['primary_image'] 312 | payload['thumbnail'] = recipe['primary_thumbnail'] 313 | 314 | return payload 315 | 316 | def __str__(self): 317 | return json.dumps(self.simple_view) 318 | 319 | def get_calorie(self) -> int: 320 | return self.Calories 321 | 322 | def get_category(self) -> str: 323 | return self.Category.strip().lower() 324 | 325 | @property 326 | def elastic_document(self): 327 | """ 328 | :return: elastic search index document 329 | """ 330 | recipe = self.recipe 331 | payload = { 332 | 'author': self.author.FullName, 333 | 'name': recipe['food_name'], 334 | 'description': recipe['description'], 335 | 'category': recipe['category'], 336 | 'nutrition': {key: value / recipe['servings'] for key, value in recipe['nutrition'].items()}, 337 | 'tag_cloud': recipe['tag_cloud'], 338 | 'ingredients': [ingredient['food']['food_name'] for ingredient in recipe['ingredients']], 339 | 'ingredient_ids': [ingredient['food']['id'] for ingredient in recipe['ingredients']], 340 | 'directions': [direction['text'] for direction in recipe['directions']], 341 | 'cook_time': recipe['cook_time'], 342 | 'prep_time': recipe['prep_time'], 343 | 'total_time': recipe['cook_time'] + recipe['prep_time'] 344 | } 345 | 346 | return payload 347 | 348 | @property 349 | def ingredients_summery(self): 350 | payload = {} 351 | recipe = self.recipe 352 | for ingredient in recipe['ingredients']: 353 | payload[ingredient['food']['id']] = ingredient['food'] 354 | return payload 355 | 356 | 357 | # for admin integration 358 | class FoodModelView(ModelView): 359 | column_display_pk = True 360 | can_view_details = True 361 | column_exclude_list = ['Recipe'] 362 | column_searchable_list = ['Title', 'Category'] 363 | column_filters = ['Calories', 'Fiber', 'Fat', 'Protein'] 364 | create_modal = True 365 | edit_modal = True 366 | 367 | form_choices = { 368 | 'Category': [(item, item.replace('_', ' ')) for item in [ 369 | 'mostly_meat', 370 | 'appetizers', 371 | 'drink', 372 | 'main_dish', 373 | 'sandwich', 374 | 'dessert', 375 | 'breakfast', 376 | 'protein_shake', 377 | 'salad', 378 | 'pasta' 379 | ]] 380 | } 381 | 382 | 383 | class DietRecord(db.Model): 384 | id = Column(Integer(), primary_key=True) 385 | generatedAt = Column('generated_at', TIMESTAMP(), nullable=False, default=datetime.datetime.now) 386 | ownerId = Column('owner_id', Integer(), ForeignKey('users.id'), nullable=False) 387 | diet = Column('diet', JSON(), nullable=False) 388 | 389 | 390 | class DietRecordModelView(ModelView): 391 | can_edit = True 392 | column_display_pk = True 393 | create_modal = True 394 | edit_modal = True 395 | column_labels = {'generatedAt': 'Generated At', 'diet': 'Diet ids'} 396 | 397 | 398 | db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) 399 | db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) 400 | -------------------------------------------------------------------------------- /foods/utils.py: -------------------------------------------------------------------------------- 1 | from extentions import db 2 | from foods.models import Food 3 | from config import Config 4 | 5 | 6 | def get_foods_with_categories(categories): 7 | foods = [] 8 | for cat in categories: 9 | foods += Food.query.filter_by(Category=cat).all() 10 | return foods 11 | 12 | 13 | def set_placeholder(recipe): 14 | category = recipe['category'] 15 | 16 | if 'images' in recipe: 17 | # its a recipe dict 18 | recipe['images'].append({ 19 | "id": 0, 20 | "image": Config.PLACEHOLDERS['image'][category], 21 | "thumbnail": Config.PLACEHOLDERS['thumbnail'][category] 22 | }) 23 | if recipe['primary_image'] is None: 24 | recipe['primary_image'] = Config.PLACEHOLDERS['image'][category] 25 | recipe['primary_thumbnail'] = Config.PLACEHOLDERS['thumbnail'][category] 26 | else: 27 | # its a small view dict 28 | if recipe['image'] is None: 29 | recipe['image'] = Config.PLACEHOLDERS['image'][category] 30 | recipe['thumbnail'] = Config.PLACEHOLDERS['thumbnail'][category] 31 | 32 | return recipe 33 | 34 | 35 | def beautify_category(category: str): 36 | return category.replace('_', ' ').lower() 37 | 38 | 39 | def uglify_category(category: str): 40 | return category.replace(' ', '_').lower() 41 | -------------------------------------------------------------------------------- /foods/views.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request 2 | from flask_jwt_extended import jwt_required, get_jwt_identity 3 | 4 | from config import Config 5 | from foods import foods 6 | from foods.diet import dovade, sevade, yevade 7 | from foods.utils import (beautify_category, get_foods_with_categories, 8 | set_placeholder) 9 | from utils.decorators import confirmed_only 10 | from users.models import User 11 | 12 | from .models import Food, DietRecord, SearchTimedOutException 13 | from extentions import db 14 | 15 | 16 | def submit_diet_record(food_ids, jwt_identity): 17 | user = User.query.filter_by(Email=jwt_identity).first() 18 | if user is None: 19 | return 20 | diet_record = DietRecord(ownerId=user.id, diet=food_ids) 21 | db.session.add(diet_record) 22 | db.session.commit() 23 | 24 | 25 | @foods.route('/yevade/', methods=['GET']) 26 | @jwt_required 27 | def get_yevade(calorie): 28 | if calorie > 0: 29 | cat = ['breakfast', 'mostly_meat', 'pasta', 'main_dish', 'sandwich', 'appetizers', 'drink'] 30 | dog = get_foods_with_categories(cat) 31 | 32 | catdog = yevade(dog, calorie) 33 | 34 | if catdog is None: 35 | return jsonify({'error': 'Not Found'}), 404 36 | else: 37 | submit_diet_record([catdog[0].id], get_jwt_identity()) 38 | return jsonify({'diet': [catdog[0].simple_view, catdog[1]]}), 200 39 | else: 40 | return jsonify({'error': 'Calorie value can\'t be zero'}), 418 41 | 42 | 43 | @foods.route('/dovade/', methods=['GET']) 44 | @jwt_required 45 | def get_dovade(calorie): 46 | if calorie > 0: 47 | cats1 = ['breakfast', 'sandwich', 'pasta', 'appetizers', 'drink', 'mostly_meat', 'appetizers', 'mostly_meat'] 48 | cats2 = ['mostly_meat', 'pasta', 'main_dish', 'sandwich', 'appetizers', 'breakfast', 'drink'] 49 | 50 | dogs1 = get_foods_with_categories(cats1) 51 | dogs2 = get_foods_with_categories(cats2) 52 | 53 | catdog = dovade(dogs1, dogs2, calorie) 54 | 55 | if catdog is None: 56 | return jsonify({'error': 'Not Found'}), 404 57 | else: 58 | submit_diet_record([catdog[0].id, catdog[1].id], get_jwt_identity()) 59 | return jsonify({'diet': [catdog[0].simple_view, catdog[1].simple_view, catdog[2]]}), 200 60 | else: 61 | return jsonify({'error': 'Calorie value can\'t be zero'}), 418 62 | 63 | 64 | @foods.route('/sevade/', methods=['GET']) 65 | @jwt_required 66 | def get_sevade(calorie): 67 | if calorie > 0: 68 | cats1 = ['breakfast', 'pasta', 'salad', 'sandwich', 'appetizers', 'drink'] 69 | cats2 = ['mostly_meat', 'pasta', 'main_dish', 'sandwich', 'breakfast', 'drink', 'salad', 'breakfast'] 70 | cats3 = ['dessert', 'other', 'salad', 'side_dish', 'drink', 'main_dish', 'pasta', 'breakfast'] 71 | 72 | dogs1 = get_foods_with_categories(cats1) 73 | dogs2 = get_foods_with_categories(cats2) 74 | dogs3 = get_foods_with_categories(cats3) 75 | 76 | catdog = sevade(dogs1, dogs2, dogs3, calorie) 77 | 78 | if catdog is None: 79 | return jsonify({'error': 'Not Found'}), 404 80 | else: 81 | submit_diet_record([catdog[0].id, catdog[1].id, catdog[2].id], get_jwt_identity()) 82 | return jsonify( 83 | {'diet': [catdog[0].simple_view, catdog[1].simple_view, catdog[2].simple_view, catdog[3]]}), 200 84 | else: 85 | return jsonify({'error': 'Calorie value can\'t be zero'}), 418 86 | 87 | 88 | @foods.route('/recipe/', methods=['GET']) 89 | def get_recipe(id): 90 | food = Food.query.get(id) 91 | if food is None: 92 | return jsonify({"error": "food not found."}), 404 93 | 94 | recipe = food.recipe 95 | if recipe is None: 96 | return jsonify({"error": "recipe not found."}), 404 97 | 98 | if recipe['primary_image'] is None: 99 | recipe = set_placeholder(recipe) 100 | 101 | recipe['category'] = beautify_category(recipe['category']) 102 | return jsonify(recipe) 103 | 104 | 105 | @foods.route('/', methods=['GET']) 106 | def get_food(id): 107 | food = Food.query.get(id) 108 | if food is None: 109 | return jsonify({"error": "food not found."}), 404 110 | 111 | payload = food.simple_view 112 | if payload['image'] is None: 113 | payload = set_placeholder(payload) 114 | return jsonify(payload) 115 | 116 | 117 | @foods.route('/search', methods=['GET']) 118 | def food_search(): 119 | """ 120 | food full text search using elasticsearch 121 | http parameters: 122 | query: text to search 123 | page: pagination page number 124 | per_page: pagination per_page count 125 | :return: 126 | """ 127 | query = request.args.get('query') 128 | page = int(request.args.get('page', 1)) 129 | per_page = int(request.args.get('per_page', 10)) 130 | 131 | if query == "" or query is None: 132 | return jsonify({ 133 | 'error': "query should exist in the request" 134 | }), 422 # invalid input error 135 | 136 | if per_page > 50: 137 | return jsonify({ 138 | 'error': 'per_page should not be more than 50' 139 | }), 422 140 | 141 | try: 142 | results, count = Food.search(query, page, per_page) 143 | except SearchTimedOutException as e: 144 | return jsonify({ 145 | "error": "search request timed out." 146 | }), 408 147 | 148 | return jsonify({ 149 | 'results': [set_placeholder(result.simple_view) for result in results.all()], 150 | 'total_results_count': count 151 | }) 152 | 153 | 154 | @foods.route('/search', methods=['POST']) 155 | def food_advanced_search(): 156 | """ 157 | advanced + full text search using elasticsearch 158 | http post content json parameters: 159 | query: query to search with below format 160 | { 161 | "text": text_to_search | null | "", 162 | "category" : cat (one of 13 categories), 163 | "ingredients": [list of ingredient ids] 164 | "calories, carbs , fats ,proteins , cook_time ,prep_time , total_time, :{ 165 | "min":optional, 166 | "max":optional 167 | }, 168 | "page": pagination page number 169 | "per_page": pagination per_page count 170 | } 171 | 172 | :return: 173 | """ 174 | input_json = request.get_json() 175 | page = 1 if input_json.get('page') is None else input_json['page'] 176 | per_page = 10 if input_json.get('per_page') is None else input_json['per_page'] 177 | 178 | if len(input_json.items()) == 0: 179 | return jsonify({ 180 | 'error': "some query should exist in the request" 181 | }), 422 # invalid input error 182 | 183 | if per_page > 50: 184 | return jsonify({ 185 | 'error': 'per_page should not be more than 50' 186 | }), 422 187 | 188 | try: 189 | results, count = Food.advanced_search(input_json) 190 | except SearchTimedOutException as e: 191 | return jsonify({ 192 | "error": "search request timed out." 193 | }), 408 194 | 195 | return jsonify({ 196 | 'results': [set_placeholder(result.simple_view) for result in results.all()], 197 | 'total_results_count': count 198 | }) 199 | 200 | 201 | @foods.route('/search/ingredient', methods=['GET']) 202 | def ingredient_search(): 203 | """ 204 | ingredient full text search using elasticsearch 205 | http parameters: 206 | query: text to search 207 | page: pagination page number 208 | per_page: pagination per_page count 209 | :return: 210 | """ 211 | query = request.args.get('query') 212 | page = int(request.args.get('page', 1)) 213 | per_page = int(request.args.get('per_page', 10)) 214 | 215 | if query == "" or query is None: 216 | return jsonify({ 217 | 'error': "query should exist in the request" 218 | }), 422 # invalid input error 219 | 220 | if per_page > 50: 221 | return jsonify({ 222 | 'error': 'per_page should not be more than 50' 223 | }), 422 224 | 225 | try: 226 | results, count = Food.ingredient_search(query, page, per_page) 227 | except SearchTimedOutException as e: 228 | return jsonify({ 229 | "error": "search request timed out." 230 | }), 408 231 | 232 | # setting placeholder 233 | for result in results: 234 | if result['primary_thumbnail'] is None: 235 | result['primary_thumbnail'] = Config.PLACEHOLDERS['thumbnail']['ingredient'] 236 | 237 | return jsonify({ 238 | 'results': results, 239 | 'total_results_count': count 240 | }) 241 | 242 | 243 | @foods.route('/diets', methods=['GET']) 244 | @jwt_required 245 | def get_diet_records(): 246 | page = int(request.args.get('page', 1)) 247 | per_page = int(request.args.get('per_page', 10)) 248 | user = User.query.filter_by(Email=get_jwt_identity()).first() 249 | if user is None: 250 | return { 251 | "error": "user not found" 252 | }, 404 253 | results = DietRecord.query.filter(DietRecord.ownerId == user.id).order_by(DietRecord.generatedAt.desc()) \ 254 | .limit(per_page) \ 255 | .offset((page - 1) * per_page).all() 256 | 257 | payload = [] 258 | for diet_record in results: 259 | payload.append({ 260 | "diet": [Food.query.get_or_404(food_id).simple_view for food_id in diet_record.diet], 261 | "time": diet_record.generatedAt 262 | }) 263 | 264 | return jsonify(payload) 265 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', 27 | str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions['migrate'].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/00490a5b5d9b_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 00490a5b5d9b 4 | Revises: ace46fc2b3ce 5 | Create Date: 2020-06-05 20:33:12.836728 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '00490a5b5d9b' 14 | down_revision = 'ace46fc2b3ce' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('posts', sa.Column('author', sa.Integer(), nullable=False)) 22 | op.create_foreign_key(None, 'posts', 'users', ['author'], ['id']) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_constraint(None, 'posts', type_='foreignkey') 29 | op.drop_column('posts', 'author') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/2b7041d2221f_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2b7041d2221f 4 | Revises: 7bb12babf968 5 | Create Date: 2020-06-03 19:31:38.587951 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2b7041d2221f' 14 | down_revision = '7bb12babf968' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('posts', sa.Column('categories', sa.String(length=256), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('posts', 'categories') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/34508ec90fd5_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 34508ec90fd5 4 | Revises: 00490a5b5d9b 5 | Create Date: 2020-06-08 20:43:46.046465 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '34508ec90fd5' 14 | down_revision = '00490a5b5d9b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_constraint('posts_author_fkey', 'posts', type_='foreignkey') 22 | op.drop_column('posts', 'author') 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.add_column('posts', sa.Column('author', sa.INTEGER(), autoincrement=False, nullable=False)) 29 | op.create_foreign_key('posts_author_fkey', 'posts', 'users', ['author'], ['id']) 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/6a93bd633b3a_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 6a93bd633b3a 4 | Revises: 5 | Create Date: 2020-04-25 11:40:31.221870 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6a93bd633b3a' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('FullName', sa.String(), nullable=False), 24 | sa.Column('Email', sa.String(), nullable=False), 25 | sa.Column('Password', sa.String(), nullable=False), 26 | sa.Column('Admin', sa.Boolean(), nullable=False), 27 | sa.Column('RegisteredOn', sa.DateTime(), nullable=False), 28 | sa.Column('Confirmed', sa.Boolean(), nullable=False), 29 | sa.Column('ConfirmedOn', sa.DateTime(), nullable=True), 30 | sa.PrimaryKeyConstraint('id'), 31 | sa.UniqueConstraint('Email') 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table('users') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /migrations/versions/7bb12babf968_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 7bb12babf968 4 | Revises: 7dab2a62f67b 5 | Create Date: 2020-06-03 16:40:07.893902 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '7bb12babf968' 14 | down_revision = '7dab2a62f67b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('categories', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=128), nullable=False), 24 | sa.Column('description', sa.String(length=256), nullable=True), 25 | sa.Column('slug', sa.String(length=128), nullable=False), 26 | sa.PrimaryKeyConstraint('id'), 27 | sa.UniqueConstraint('name'), 28 | sa.UniqueConstraint('slug') 29 | ) 30 | op.create_table('posts', 31 | sa.Column('id', sa.Integer(), nullable=False), 32 | sa.Column('title', sa.String(length=128), nullable=False), 33 | sa.Column('summary', sa.String(length=256), nullable=True), 34 | sa.Column('content', sa.Text(), nullable=False), 35 | sa.Column('slug', sa.String(length=128), nullable=False), 36 | sa.PrimaryKeyConstraint('id'), 37 | sa.UniqueConstraint('slug'), 38 | sa.UniqueConstraint('title') 39 | ) 40 | op.create_table('posts_categories', 41 | sa.Column('post_id', sa.Integer(), nullable=True), 42 | sa.Column('categorie_id', sa.Integer(), nullable=True), 43 | sa.ForeignKeyConstraint(['categorie_id'], ['categories.id'], ondelete='cascade'), 44 | sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='cascade') 45 | ) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_table('posts_categories') 52 | op.drop_table('posts') 53 | op.drop_table('categories') 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /migrations/versions/7dab2a62f67b_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 7dab2a62f67b 4 | Revises: 6a93bd633b3a 5 | Create Date: 2020-06-03 16:36:11.006097 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '7dab2a62f67b' 14 | down_revision = '6a93bd633b3a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | pass 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | # op.create_table('foods', 23 | # sa.Column('id', sa.Integer(), nullable=False), 24 | # sa.Column('calories', sa.Integer(), nullable=True), 25 | # sa.Column('fat', sa.REAL(), nullable=True), 26 | # sa.Column('fiber', sa.REAL(), nullable=True), 27 | # sa.Column('protein', sa.REAL(), nullable=True), 28 | # sa.Column('category', sa.VARCHAR(length=20), nullable=False), 29 | # sa.Column('image', sa.VARCHAR(length=400), nullable=True), 30 | # sa.Column('thumbnail', sa.VARCHAR(length=400), nullable=True), 31 | # sa.Column('title', sa.VARCHAR(length=200), nullable=False), 32 | # sa.Column('created_at', sa.TIMESTAMP(), nullable=True), 33 | # sa.Column('author', sa.Integer(), nullable=False), 34 | # sa.Column('recipe', sa.TEXT(), nullable=True), 35 | # sa.ForeignKeyConstraint(['author'], ['users.id'], ), 36 | # sa.PrimaryKeyConstraint('id') 37 | # ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | pass 43 | # ### commands auto generated by Alembic - please adjust! ### 44 | # op.drop_table('foods') 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /migrations/versions/9d0a2766befe_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 9d0a2766befe 4 | Revises: 34508ec90fd5 5 | Create Date: 2020-06-12 03:36:00.842824 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9d0a2766befe' 14 | down_revision = '34508ec90fd5' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('diet_record', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('generated_at', sa.TIMESTAMP(), nullable=False), 24 | sa.Column('owner_id', sa.Integer(), nullable=False), 25 | sa.Column('diet', sa.JSON(), nullable=False), 26 | sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.alter_column('foods', 'author', 30 | existing_type=sa.INTEGER(), 31 | nullable=False, 32 | existing_server_default=sa.text('1')) 33 | op.alter_column('foods', 'created_at', 34 | existing_type=postgresql.TIMESTAMP(), 35 | nullable=True, 36 | existing_server_default=sa.text("timezone('Asia/Tehran'::text, CURRENT_TIMESTAMP)")) 37 | op.alter_column('posts', 'authorid', 38 | existing_type=sa.INTEGER(), 39 | nullable=False) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade(): 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.alter_column('posts', 'authorid', 46 | existing_type=sa.INTEGER(), 47 | nullable=True) 48 | op.alter_column('foods', 'created_at', 49 | existing_type=postgresql.TIMESTAMP(), 50 | nullable=False, 51 | existing_server_default=sa.text("timezone('Asia/Tehran'::text, CURRENT_TIMESTAMP)")) 52 | op.alter_column('foods', 'author', 53 | existing_type=sa.INTEGER(), 54 | nullable=True, 55 | existing_server_default=sa.text('1')) 56 | op.drop_table('diet_record') 57 | # ### end Alembic commands ### 58 | -------------------------------------------------------------------------------- /migrations/versions/ace46fc2b3ce_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: ace46fc2b3ce 4 | Revises: 2b7041d2221f 5 | Create Date: 2020-06-03 20:00:16.862683 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ace46fc2b3ce' 14 | down_revision = '2b7041d2221f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('posts', sa.Column('category', sa.String(length=256), nullable=True)) 22 | op.drop_column('posts', 'categories') 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.add_column('posts', sa.Column('categories', sa.VARCHAR(length=256), autoincrement=False, nullable=True)) 29 | op.drop_column('posts', 'category') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # DailyDiet 2 | 3 | You can access the website at [daily-diet-aut.herokuapp.com](https://daily-diet-aut.herokuapp.com/) 4 | and the API is deployed on Heroku at [dailydiet-api.herokuapp.com](https://dailydiet-api.herokuapp.com/). 5 | 6 | We're a small team focused on providing tools and support for people who want to take control of their nutrition. Given the saturation of information in the diet industry, we focus on more pragmatic elements of healthy eating such as planning and cooking. 7 | 8 | ## Features 9 | 10 | 1. **BMI and Daily Needed Calorie Calculator:** 11 | Body Mass Index (BMI) is a value derived from the mass and height of a person. It can be an important thing to consider in someone's diet since it describes your current body situation (if you need to lose or gain weight). 12 | A normal person's daily needed calorie can be calculated using personal data and it's the most important thing in a healthy diet. 13 | 14 | 2. **Receiving Diet Plans:** 15 | We have implemented a dynamic-programming algorithm for constructing a relevant diet based on the user's needed calorie. The algorithm can be improved using more complex concepts (genetic, etc.). 16 | The plan will mostly contain a breakfast, main dish, and a simpler meal. The number of meals can be selected by the user. 17 | 18 | 3. **Accounts Managing:** 19 | We used JWT to create and manage users' accounts. Users must sign up and confirm their email in order to use our main features. 20 | Users can also use a dashboard to review their daily diets for the past 5 days, or manage other things about their accounts. 21 | 22 | 4. **(Advanced) Searching Recipes:** 23 | We have implemented Elasticsearch on our database. Our Elasticsearch allows users to not only search by the name of the foods but also provide the feature of an advanced search. The search can be expanded on food nutrition, cooking time, and ingredients in various categories. You can search for recipes you can make with the ingredients you already have at home. 24 | 25 | 5. **Blog:** 26 | Our blog is where authenticated users and nutrition experts can publish their posts. 27 | Posts are displayed in the blog timeline and can be accessed by category or author. 28 | 29 | 6. **Admin Panel:** 30 | An admin is needed to maintain and modify our content and users. 31 | Admins can edit (create/delete/edit) blog posts, recipes, and users. 32 | 33 | ## Technologies 34 | 35 | 1. **Back-end:** 36 | - FLASK 37 | - PostgreSQL (on AWS cluster) 38 | - JSON Web Tokens 39 | - Elasticsearch 40 | 41 | 2. **Front-end:** 42 | - Vue.JS 43 | - NUXT 44 | - BootstrapVue 45 | 46 | 3. **Deployment:** 47 | - We have deployed our project on every stage to maintain a stable product. 48 | - DailyDiet is deployed on Heroku, which is a cloud platform as a service supporting several programming languages for easy deployment of applications. 49 | 50 | 4. **iOS Application:** 51 | - Swift 52 | - Auto Layout 53 | - Fastlane 54 | - GitHub Action 55 | 56 | ## Setup and Run on local machine 57 | 58 | 1. Install packages with `pip install -r requirements.txt` 59 | 60 | 2. Set environment variable `DAILYDIET_ENV` to `Development`/`Testing`/`Production` 61 | (default value is `Development` if you set nothing) 62 | 63 | 3. Copy `.env.example` to `.env` and fill in the keys 64 | 65 | 4. Run with `python app.py` 66 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DailyDiet/DailyDiet-API/e603caff460251c90a5e0a6a6648e0af603ba075/requirements.txt -------------------------------------------------------------------------------- /search.py: -------------------------------------------------------------------------------- 1 | from extentions import elastic 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/users/activate.html: -------------------------------------------------------------------------------- 1 | Welcome! Thanks for signing up. Please follow this link to activate your account: 2 |

{{ confirm_url }}

3 | Cheers! -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DailyDiet/DailyDiet-API/e603caff460251c90a5e0a6a6648e0af603ba075/tests/__init__.py -------------------------------------------------------------------------------- /tests/api_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import requests 3 | from os import getenv 4 | 5 | ip = 'https://dailydiet-api.herokuapp.com/' 6 | 7 | class TestAPI(unittest.TestCase): 8 | 9 | def test_bmi(self): 10 | r = requests.post(ip + '/calculate/bmi', json={'height': 158, 'weight': 39.4}) 11 | result = r.json() 12 | # print(result) 13 | assert result['bmi_value'] == 15.78 and result['bmi_status'] == 'Underweight', "Test Failed :(" 14 | 15 | def test_calorie(self): 16 | r = requests.post(ip + '/calculate/calorie', json={"goal": "maintain", "gender": "female", 17 | "height": 158, "weight": 39.4, "age": 21, 18 | "activity": "sedentary"}) 19 | result = r.json() 20 | # print(result) 21 | assert result['calorie'] == 1462, "Test Failed :(" 22 | 23 | 24 | if __name__ == '__main__': 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /tests/diet_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import requests 3 | import json 4 | 5 | ip = 'https://dailydiet-api.herokuapp.com/' 6 | # ip = 'http://127.0.0.1:5000' 7 | class TestDiet(unittest.TestCase): 8 | 9 | def test_sevade(self): 10 | r = requests.post(ip + '/food/sevade/2300') 11 | result = r.json() 12 | assert result['diet'][3] in range(2990, 2310, 1), 'Test Failed :(' 13 | 14 | def test_zero_sevade(self): 15 | r = requests.post(ip + '/food/sevade/0') 16 | assert r.status_code == 418, 'Khak bar saret :(' 17 | 18 | def test_dovade(self): 19 | r = requests.post(ip + '/food/dovade/4300') 20 | result = r.json() 21 | assert result['diet'][2] in range(2990, 2310, 1), 'Test Failed :(' 22 | 23 | def test_zero_dovade(self): 24 | r = requests.post(ip + '/food/dovade/0') 25 | assert r.status_code == 418, 'Khak bar saret :(' 26 | 27 | def test_yevade(self): 28 | r = requests.post(ip + '/food/yevade/400') 29 | result = r.json() 30 | assert result['diet'][1] in range(390, 410, 1), 'Test Failed :(' 31 | 32 | def test_zero_yevade(self): 33 | r = requests.post(ip + '/food/yevade/0') 34 | assert r.status_code == 418, 'Khak bar saret :(' 35 | 36 | if __name__ == '__main__': 37 | unittest.main() 38 | -------------------------------------------------------------------------------- /tests/user_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import requests 3 | from os import getenv 4 | 5 | ip = 'https://dailydiet-api.herokuapp.com/' 6 | 7 | 8 | class TestUser(unittest.TestCase): 9 | 10 | def test_create1(self): 11 | # correct things :D 12 | r = requests.post(ip + '/users/signup', 13 | json={'full_name': 'Ken Adams', 'email': 'nimaafshar79@gmail.com', 14 | 'password': 'Audio123', 'confirm_password': 'Audio123'}) 15 | assert r.status_code == 201, r.content 16 | 17 | def test_create2(self): 18 | # mismatching passwords 19 | r = requests.post(ip + '/users/signup', 20 | json={'full_name': 'Ken Adams', 'email': 'yasi_ommi@yahoo.com', 21 | 'password': 'Audio123', 'confirm_password': 'Audio456'}) 22 | assert r.status_code == 400, 'Test Failed :(' 23 | 24 | def test_create3(self): 25 | # invalid email 26 | r = requests.post(ip + '/users/signup', 27 | json={'full_name': 'Ken Adams', 'email': 'chertopert@chert.con', 28 | 'password': 'Audio123', 'confirm_password': 'Audio123'}) 29 | assert r.status_code == 400, 'Test Failed :(' 30 | 31 | def test_signin1(self): 32 | # correct 33 | r = requests.post(ip + '/users/signin', 34 | json={'email': 'arnold.schwarzenegger@gmail.com', 'password': 'p@$$word123'}) 35 | assert r.status_code == 200, r.content 36 | 37 | def test_signin2(self): 38 | # mismatching email and passwords 39 | r = requests.post(ip + '/users/signin', 40 | json={'email': 'arnold.schwarzenegger@gmail.com', 'password': 'probablywrong'}) 41 | assert r.status_code == 403, 'Test Failed :(' 42 | 43 | def test_signin3(self): 44 | # invalid email 45 | r = requests.post(ip + '/users/signin', 46 | json={'email': 'chertopert@chert.con', 'password': 'p@$$word123'}) 47 | assert r.status_code == 400, 'Test Failed :(' 48 | 49 | def test_modify1(self): 50 | # change password 51 | r = requests.post(ip + '/users/signup/modify', 52 | json={'old_password': 'p@$$word123', 'new_password': '123456', 'confirm_password': '123456'}) 53 | assert r.status_code == 204, 'Test Failed :(' 54 | 55 | def test_modify2(self): 56 | # mismtach passwords 57 | r = requests.post(ip + '/users/signup/modify', 58 | json={'old_password': 'p@$$word123', 'new_password': '123456', 'confirm_password': '7891011'}) 59 | assert r.status_code == 400, 'Test Failed :(' 60 | 61 | 62 | if __name__ == '__main__': 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | 4 | users = Blueprint('users', __name__, url_prefix='/users/') 5 | 6 | 7 | from users import views -------------------------------------------------------------------------------- /users/email.py: -------------------------------------------------------------------------------- 1 | from flask_mail import Message 2 | 3 | from extentions import mail 4 | 5 | 6 | def send_email(to, subject, template): 7 | msg = Message( 8 | subject=subject, 9 | recipients=[to], 10 | html=template 11 | ) 12 | mail.send(msg) 13 | -------------------------------------------------------------------------------- /users/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import PasswordField, TextField 3 | from wtforms.validators import DataRequired, Email, EqualTo, Length 4 | 5 | from users.models import User 6 | 7 | 8 | class RegisterForm(FlaskForm): 9 | class Meta: 10 | csrf = False 11 | 12 | full_name = TextField('full name', validators=[DataRequired(), Length(max=70)]) 13 | email = TextField('email', validators=[DataRequired(), Email(message=None), Length(min=6, max=40)]) 14 | password = PasswordField( 'password', validators=[DataRequired(), Length(min=6, max=25)]) 15 | confirm_password = PasswordField('repeat password', validators=[DataRequired(), EqualTo('password', message='Passwords must match.')]) 16 | 17 | def validate(self): 18 | initial_validation = super(RegisterForm, self).validate() 19 | if not initial_validation: 20 | return False 21 | user = User.query.filter_by(Email=self.email.data).first() 22 | if user: 23 | self.email.errors.append("Email already registered.") 24 | return False 25 | return True 26 | 27 | 28 | class LoginForm(FlaskForm): 29 | class Meta: 30 | csrf = False 31 | 32 | email = TextField('email', validators=[DataRequired(), Email()]) 33 | password = PasswordField('password', validators=[DataRequired()]) 34 | 35 | 36 | class ChangePasswordForm(FlaskForm): 37 | class Meta: 38 | csrf = False 39 | 40 | old_password = PasswordField('old password', validators=[DataRequired(), Length(min=6, max=25)]) 41 | new_password = PasswordField('new password', validators=[DataRequired(), Length(min=6, max=25)]) 42 | confirm_password = PasswordField('repeat password', validators=[DataRequired(), EqualTo('new_password', message='passwords must match')]) 43 | -------------------------------------------------------------------------------- /users/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from sqlalchemy import Boolean, Column, DateTime, Integer, String, REAL, CHAR, VARCHAR, TIMESTAMP 4 | from werkzeug.security import check_password_hash, generate_password_hash 5 | 6 | from extentions import db 7 | from flask_admin.contrib.sqla import ModelView 8 | 9 | 10 | class User(db.Model): 11 | __tablename__ = "users" 12 | 13 | id = Column(Integer(), primary_key=True) 14 | FullName = Column(String(), unique=False, nullable=False) 15 | Email = Column(String(), unique=True, nullable=False) 16 | Password = Column(String(), nullable=False) 17 | Admin = Column(Boolean(), nullable=False, default=False) 18 | RegisteredOn = Column(DateTime(), nullable=False) 19 | Confirmed = Column(Boolean(), nullable=False, default=False) # Confirmed Email Address Or Not 20 | ConfirmedOn = Column(DateTime(), nullable=True) 21 | FoodSet = db.relationship('Food', backref='author', lazy=True) 22 | PostSet = db.relationship('Post', backref='writer', lazy=True) 23 | DietSet = db.relationship('DietRecord', backref='owner', lazy=True) 24 | 25 | def __init__(self, full_name, email, password, admin=False, 26 | registerd_on=None, confirmed=False, confirmed_on=None): 27 | self.FullName = full_name 28 | self.Email = email 29 | self.Password = generate_password_hash(password) 30 | self.Admin = admin 31 | self.RegisteredOn = registerd_on or datetime.datetime.now() 32 | self.Confirmed = confirmed 33 | self.ConfirmedOn = confirmed_on 34 | 35 | def set_password(self, password): 36 | self.Password = generate_password_hash(password) 37 | 38 | def check_password(self, password): 39 | return check_password_hash(self.Password, password) 40 | 41 | def __repr__(self): 42 | return f'' 43 | 44 | def get_calorie(self): 45 | return self.Calories 46 | 47 | 48 | class UserModelView(ModelView): 49 | can_edit = True 50 | column_display_pk = True 51 | can_view_details = True 52 | column_exclude_list = ['Password'] 53 | column_searchable_list = ['FullName', 'Email'] 54 | column_filters = ['Admin', 'RegisteredOn', 'Confirmed', 'ConfirmedOn'] 55 | edit_modal = False # i don't know but it didn't work for true 56 | column_editable_list = ['Admin', 'Confirmed'] 57 | form_excluded_columns = ('FoodSet',) 58 | -------------------------------------------------------------------------------- /users/token.py: -------------------------------------------------------------------------------- 1 | from itsdangerous import URLSafeTimedSerializer 2 | 3 | from config import Config 4 | 5 | 6 | def generate_confirmation_token(email): 7 | serializer = URLSafeTimedSerializer(Config.SECRET_KEY) 8 | return serializer.dumps(email, salt=Config.SECURITY_PASSWORD_SALT) 9 | 10 | 11 | def confirm_token(token, expiration=3600): 12 | serializer = URLSafeTimedSerializer(Config.SECRET_KEY) 13 | try: 14 | email = serializer.loads( 15 | token, 16 | salt=Config.SECURITY_PASSWORD_SALT, 17 | max_age=expiration 18 | ) 19 | except: 20 | return False 21 | return email 22 | -------------------------------------------------------------------------------- /users/views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from flask import jsonify, render_template, request, url_for 4 | from flask_jwt_extended import (create_access_token, create_refresh_token, 5 | get_jwt_identity, jwt_refresh_token_required, 6 | jwt_required) 7 | 8 | from extentions import db 9 | from users import users 10 | from users.email import send_email 11 | from users.forms import ChangePasswordForm, LoginForm, RegisterForm 12 | from users.models import User 13 | from users.token import confirm_token, generate_confirmation_token 14 | 15 | 16 | @users.route('/signup', methods=['POST']) 17 | def create_user(): 18 | form = RegisterForm() 19 | if not form.validate_on_submit(): 20 | return {'errors': form.errors}, 400 21 | new_user = User(form.full_name.data, form.email.data, form.password.data) 22 | db.session.add(new_user) 23 | db.session.commit() 24 | token = generate_confirmation_token(new_user.Email) 25 | subject = 'DailyDiet | Email Confirmation' 26 | confirm_url = url_for('users.confirm_email', token=token, _external=True) 27 | html = html = render_template('users/activate.html', confirm_url=confirm_url) 28 | send_email(new_user.Email, subject, html) 29 | return {'msg': 'Account created successfully!'}, 201 30 | 31 | 32 | @users.route('/signup/confirmation/', methods=['GET']) 33 | def confirm_email(token): 34 | try: 35 | email = confirm_token(token) 36 | except: 37 | return {'error': 'The confirmation link is invalid or has expired.'}, 400 38 | user = User.query.filter_by(Email=email).first() 39 | if not user: 40 | return {'error': 'User not found.'}, 404 41 | if user.Confirmed: 42 | return {'error': 'Account already confirmed. Please login.'}, 400 43 | else: 44 | user.Confirmed = True 45 | user.ConfirmedOn = datetime.datetime.now() 46 | db.session.add(user) 47 | db.session.commit() 48 | return {}, 204 49 | 50 | 51 | @users.route('/signin', methods=['POST']) 52 | def login(): 53 | form = LoginForm() 54 | if not form.validate_on_submit(): 55 | return {'errors': form.errors}, 400 56 | email = form.email.data 57 | password = form.password.data 58 | user = User.query.filter_by(Email=email).first() 59 | if not user: 60 | return {'error': 'Email or Password does not match.'}, 403 61 | if not user.check_password(password): 62 | return {'error': 'Email or Password does not match.'}, 403 63 | isActive = user.Confirmed 64 | access_token = create_access_token(identity=user.Email, fresh=True) 65 | refresh_token = create_refresh_token(identity=user.Email) 66 | return {'is_active': isActive, 'access_token': access_token, 'refresh_token': refresh_token}, 200 67 | 68 | 69 | @users.route('/auth', methods=['PUT']) 70 | @jwt_refresh_token_required 71 | def get_new_access_token(): 72 | identity = get_jwt_identity() 73 | return {'access_token': f'{create_access_token(identity=identity)}'}, 200 74 | 75 | 76 | @users.route('/signup/resendConfrimation', methods=['GET']) 77 | @jwt_required 78 | def resend_confirmation(): 79 | identity = get_jwt_identity() 80 | token = generate_confirmation_token(identity) 81 | confirm_url = url_for('users.confirm_email', token=token, _external=True) 82 | html = render_template('users/activate.html', confirm_url=confirm_url) 83 | subject = 'DailyDiet | Email Confirmation' 84 | send_email(identity, subject, html) 85 | return {'msg': 'A new confirmation email has been sent.'}, 200 86 | 87 | 88 | @users.route('/signup/modify', methods=['PATCH']) 89 | @jwt_required 90 | def change_password(): 91 | identity = get_jwt_identity() 92 | user = User.query.filter_by(Email=identity).first() 93 | form = ChangePasswordForm() 94 | if not user.check_password(form.old_password.data): 95 | return {'error': 'Old password does not match.'}, 403 96 | if not form.validate_on_submit(): 97 | return {'errors': form.errors}, 400 98 | user.set_password(form.new_password.data) 99 | db.session.commit() 100 | return {}, 204 101 | 102 | 103 | @users.route('/signout', methods=['PATCH']) 104 | def log_out(): 105 | identity = get_jwt_identity() 106 | user = User.query.filter_by(Email=identity).first() 107 | return {}, 204 108 | 109 | 110 | @users.route('/get_user', methods=['GET']) 111 | @jwt_required 112 | def get_user(): 113 | identity = get_jwt_identity() 114 | user = User.query.filter_by(Email=identity).first() 115 | return {'full_name': f'{user.FullName}', 116 | 'email': f'{user.Email}', 117 | 'confirmed': f'{user.Confirmed}'}, 200 118 | -------------------------------------------------------------------------------- /utils/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import request 4 | from flask_jwt_extended import get_jwt_identity 5 | 6 | from users.models import User 7 | 8 | 9 | def json_only(function): 10 | @wraps(function) 11 | def decorator(*args, **kwargs): 12 | if not request.is_json: 13 | return {'error': 'Missing JSON in request.'}, 400 14 | return function(*args, **kwargs) 15 | return decorator 16 | 17 | 18 | def confirmed_only(function): 19 | @wraps(function) 20 | def decorator(*args, **kwargs): 21 | identity = get_jwt_identity() 22 | user = User.query.filter_by(Email=identity).first() 23 | if not user: 24 | return {'error': 'User not found.'}, 404 25 | if not user.Confirmed: 26 | return {'error': 'Your account is not activated.'}, 400 27 | return function(*args, **kwargs) 28 | return decorator 29 | --------------------------------------------------------------------------------