├── .ebextensions └── 01_packages.config ├── .env.example ├── .gitignore ├── LICENSE ├── Pipfile ├── README.md ├── application.py ├── config.py ├── db.py ├── local-requirements.txt ├── requirements.txt ├── setup.sh ├── static ├── logo.svg ├── no-image-icon.png ├── script.js └── style.css ├── templates ├── homepage.html └── start.html └── yelp.py /.ebextensions/01_packages.config: -------------------------------------------------------------------------------- 1 | packages: 2 | yum: 3 | git: [] 4 | mariadb-devel: [] 5 | python3-devel: [] 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | YELP_AUTH_TOKEN= 2 | DATABASE_URL= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos,flask,python,vscode 3 | # Edit at https://www.gitignore.io/?templates=macos,flask,python,vscode 4 | 5 | ## Pipfile Lock 6 | Pipfile.lock 7 | 8 | ## (vsc) 9 | .vscode/* 10 | 11 | ### Flask ### 12 | instance/* 13 | !instance/.gitignore 14 | .webassets-cache 15 | 16 | ### Flask.Python Stack ### 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | wheels/ 39 | share/python-wheels/ 40 | *.egg-info/ 41 | .installed.cfg 42 | *.egg 43 | MANIFEST 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .nox/ 59 | .coverage 60 | .coverage.* 61 | .cache 62 | nosetests.xml 63 | coverage.xml 64 | *.cover 65 | .hypothesis/ 66 | .pytest_cache/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | db.sqlite3 76 | 77 | # Flask stuff: 78 | instance/ 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # celery beat schedule file 100 | celerybeat-schedule 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | ### macOS ### 133 | # General 134 | .DS_Store 135 | .AppleDouble 136 | .LSOverride 137 | 138 | ### Python Patch ### 139 | .venv/ 140 | 141 | #!! ERROR: vscode is undefined. Use list command to see defined gitignore types !!# 142 | 143 | # End of https://www.gitignore.io/api/macos,flask,python,vscode 144 | 145 | # Elastic Beanstalk Files 146 | .elasticbeanstalk/* 147 | !.elasticbeanstalk/*.cfg.yml 148 | !.elasticbeanstalk/*.global.yml 149 | .vscode/settings.json 150 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Major League Hacking (MLH) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | python-dotenv = "==0.10.1" 10 | mysqlclient = "==2.0" 11 | requests = "==2.24" 12 | Flask = "==1.0.2" 13 | SQLAlchemy = "==1.3.5" 14 | awsebcli = "*" 15 | 16 | [requires] 17 | python_version = "3.7" 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Localhost AWS 2 | 3 | ## Requirements and dependencies 4 | 5 | - [Python3](https://www.python.org/) 6 | - [Pip](https://pip.pypa.io/en/latest/installing/) - The python package manager. 7 | - [Flask](http://flask.pocoo.org/) - A simple and flexible Python Web Framework that provides with tools, libraries and technologies to build a web application. (Installed by pip) 8 | 9 | ## Database requirements 10 | 11 | - Locally, install [MariaDB](https://mariadb.org/download/). 12 | - On AWS, MySQL is used and the `mysqlclient` connector is used. 13 | 14 | ## Clone the project 15 | 16 | Use the command below: 17 | 18 | ```sh 19 | git clone https://github.com/MLH/mlh-localhost-build-and-deploy-aws.git 20 | ``` 21 | 22 | ## Set Up Environment variables 23 | 24 | To quickly set up environment variables, make a copy of the `.env.example` and rename it to `.env`. Then make sure to modify it following the instructions below. 25 | 26 | ### Yelp API Key 27 | 28 | 1. Create a Yelp account 29 | 2. Create an app at https://www.yelp.ca/developers/v3/manage_app 30 | 3. Copy the API key to the `.env` 31 | 32 | ``` 33 | YELP_AUTH_TOKEN= 34 | ``` 35 | 36 | ### Database URL 37 | 38 | This allows you to use a [custom database url](https://dev.mysql.com/doc/mysql-getting-started/en/) and will be useful for local tests (The app is currently configured to support a custom Postgres or Mysql database). This won't be necessary to deploy the app to AWS, as we will use an RDS instance that Elastic Beanstalk configures for us. See the "Adding a database to Your Elastic Beanstalk Environment" section below for more details. 39 | 40 | ``` 41 | DATABASE_URL= 42 | ``` 43 | 44 | The format should be something like: 45 | 46 | ``` 47 | DATABASE_URL=mysql://USER:PASSWORD@ENDPOINT/DATABASE_NAME 48 | ``` 49 | 50 | For a local development server, the url could look something like: 51 | 52 | ``` 53 | DATABASE_URL=mysql://littlejohnnydroptables:amaz1ngpa33word@localhost/events 54 | ``` 55 | 56 | ## Install dependencies 57 | 58 | The next step is to install the dependencies used by the project. Run the following command: 59 | 60 | ```sh 61 | pip install -r requirements.txt 62 | ``` 63 | 64 | ## Executing the application 65 | 66 | After having all the dependencies installed, you only need to execute the main application file. In this case it will be the file "main.py" 67 | 68 | ``` 69 | FLASK_APP=application.py FLASK_DEBUG=1 flask run 70 | ``` 71 | 72 | Then open [http://localhost:5000/](http://localhost:5000/) to see the application. 73 | 74 | ## Deploying to AWS Elastic Beanstalk 75 | 76 | We will use [awswebcli](https://pypi.org/project/awsebcli/3.7.4/) to deploy our app to AWS. 77 | 78 | ### Install awswebcli 79 | 80 | ```sh 81 | pip install awsebcli 82 | ``` 83 | 84 | ### Initialize your APP 85 | 86 | After installing `awswebcli`, the first thing we need to do is to initialize our app within AWS. 87 | 88 | Initialize your Elastic Beanstalk with: 89 | 90 | ```sh 91 | eb init 92 | ``` 93 | 94 | This will prompt you with a number of questions to help you configure your environment. 95 | 96 | #### Do you wish to continue with CodeCommit? 97 | 98 | Select No (N) 99 | 100 | #### Default region 101 | 102 | As this is an example application, we can choose keep the default option selected. 103 | 104 | #### Credentials 105 | 106 | Next, it’s going to ask for your AWS credentials. 107 | 108 | If needed, you can follow this guide to set up your [IAM account](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html) 109 | 110 | #### Application name 111 | 112 | This will default to the directory name. Just go with that. 113 | 114 | #### Python version 115 | 116 | Choose any Python 3+ version 117 | 118 | #### SSH 119 | 120 | Say yes to setting up SSH for your instances. 121 | 122 | #### RSA Keypair 123 | 124 | Next, you need to generate an RSA keypair, which will be added to your ~/.ssh folder. This keypair will also be uploaded to the EC2 public key for the region you specified in step one. This will allow you to SSH into your EC2 instance later in this tutorial. 125 | 126 | ### Adding a database to Your Elastic Beanstalk Environment 127 | 128 | Open your console management by running 129 | 130 | ```sh 131 | eb console 132 | ``` 133 | 134 | Then follow [this guide](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features.managing.db.html) to set up your Amazon RDS within your app. The app expects an RDS MySQL database. 135 | 136 | ### Create an environment 137 | 138 | ```sh 139 | eb create 140 | ``` 141 | 142 | Just like eb init, this command will prompt you with a series of questions. 143 | 144 | #### Environment Name 145 | 146 | Name your environment. `localhost-aws-test` for instance. 147 | 148 | #### DNS CNAME prefix 149 | 150 | This will be your subdomain. You can keep the default value, or use your environment name. 151 | 152 | ### Configuring YELP Auth Token 153 | 154 | Open your console management by running 155 | 156 | ```sh 157 | eb setenv YELP_AUTH_TOKEN={{YELP_AUTH_TOKEN_VALUE}} 158 | ``` 159 | 160 | ### Set up a different DB engine 161 | 162 | ```sh 163 | eb setenv RDS_ENGINE=mysql 164 | ``` 165 | 166 | ### Deploy the app 167 | 168 | ```sh 169 | eb deploy 170 | ``` 171 | 172 | ### Open your app 173 | 174 | To see your deployed app in the browser: 175 | 176 | ```sh 177 | eb open 178 | ``` 179 | -------------------------------------------------------------------------------- /application.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, jsonify, request, abort 2 | import config 3 | 4 | from yelp import get_businesses 5 | 6 | # from db import Favorite, get_session 7 | 8 | application = Flask(__name__) 9 | 10 | 11 | # Renders UI 12 | @application.route("/") 13 | def home(): 14 | # 💡 renders an html page with url parameters, if any 15 | # 🆘 1. We need to show the correct homepage! Change start.html to homepage.html 16 | return render_template("start.html", city=request.args.get("city", "")) 17 | 18 | 19 | # API Endpoints 20 | @application.route("/api/places/") # annotation for route 21 | def places(city): 22 | businesses = get_businesses(city) # getting list of businesses from Yelp 23 | return jsonify(businesses) # json rendered array 24 | 25 | # ================================================================================ 26 | # Code skeleton for favorites. Not covered in this workshop, but a fun next step! 27 | # ================================================================================ 28 | 29 | @application.route("/api/places//favorites", methods=["POST"]) 30 | def create_favorite_event(place_id): 31 | print("Should save a favorite", place_id) 32 | return abort(400) 33 | # user_id = request.headers.get("x-user-id") 34 | # session = get_session() 35 | 36 | # favorite = ( 37 | # session.query(Favorite).filter_by(place_id=place_id, user_id=user_id).first() 38 | # ) 39 | 40 | # if favorite: 41 | # return abort(400) 42 | 43 | # favorite = Favorite(place_id=place_id, user_id=user_id) 44 | 45 | # session.add(favorite) 46 | # session.commit() 47 | 48 | # return jsonify(favorite.to_dict()) 49 | 50 | 51 | # @application.route("/api/places//favorites", methods=["DELETE"]) 52 | # def delete_favorite_event(place_id): 53 | # user_id = request.headers.get("x-user-id") 54 | # session = get_session() 55 | 56 | # favorite = ( 57 | # session.query(Favorite).filter_by(place_id=place_id, user_id=user_id).first() 58 | # ) 59 | 60 | # if favorite: 61 | # session.delete(favorite) 62 | # session.commit() 63 | 64 | # return jsonify(favorite.to_dict()) 65 | 66 | # abort(404) 67 | 68 | 69 | @application.route("/api/places//favorites", methods=["GET"]) 70 | def get_favorites_event(place_id): 71 | print("Should get number of favourites for", place_id) 72 | return jsonify( 73 | { 74 | "count": 0, 75 | "results": [], 76 | } 77 | ) 78 | # session = get_session() 79 | # favorites_query = session.query(Favorite).filter_by(place_id=place_id) 80 | 81 | # return jsonify( 82 | # { 83 | # "count": favorites_query.count(), 84 | # "results": [x.to_dict() for x in favorites_query.all()], 85 | # } 86 | # ) 87 | 88 | 89 | if __name__ == "__main__": 90 | application.run() 91 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | FLASK_APP_SECRET_KEY = os.getenv("FLASK_APP_SECRET_KEY") 7 | YELP_AUTH_TOKEN = os.getenv("YELP_AUTH_TOKEN") 8 | 9 | # Reads the database config injected by AWS Elastic Beanstalk 10 | if "RDS_DB_NAME" in os.environ: 11 | # AWS Elastic Beanstalk doesn't set an RDS_ENGINE by default, 12 | # so we set this to fallback to mysql, and allow for overriding if needed 13 | rds_db_string = ( 14 | os.getenv("RDS_ENGINE", "mysql") 15 | + "://" 16 | + os.getenv("RDS_USERNAME") 17 | + ":" 18 | + os.getenv("RDS_PASSWORD") 19 | + "@" 20 | + os.getenv("RDS_HOSTNAME") 21 | + ":" 22 | + os.getenv("RDS_PORT") 23 | + "/" 24 | + os.getenv("RDS_DB_NAME") 25 | ) 26 | DATABASE_URL = rds_db_string 27 | else: 28 | DATABASE_URL = os.getenv("DATABASE_URL") 29 | -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | from sqlalchemy import Column, BigInteger 5 | 6 | import config 7 | 8 | database = create_engine(config.DATABASE_URL) 9 | base = declarative_base() 10 | 11 | 12 | class Favorite(base): 13 | __tablename__ = "favorites" 14 | 15 | id = Column(BigInteger, primary_key=True) 16 | user_id = Column(BigInteger, index=True) 17 | event_id = Column(BigInteger, index=True) 18 | 19 | def to_dict(self): 20 | return {c.name: getattr(self, c.name) for c in self.__table__.columns} 21 | 22 | 23 | base.metadata.create_all(database) 24 | Session = sessionmaker(database) 25 | 26 | 27 | def get_session(): 28 | return Session() 29 | -------------------------------------------------------------------------------- /local-requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0.2 2 | python-dotenv==0.10.1 3 | sqlalchemy==1.3.5 4 | pymysql==0.10 5 | requests==2.24 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0.2 2 | python-dotenv==0.10.1 3 | sqlalchemy==1.3.5 4 | mysqlclient==2.0 5 | requests==2.24 6 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # setup script for aws env 3 | 4 | # check for python 5 | # https://stackoverflow.com/questions/592620/how-to-check-if-a-program-exists-from-a-bash-script?page=1&tab=votes#tab-top 6 | echo "--- Checking for Python3 installation ---" 7 | 8 | if ! [ -x "$(command -v python3)" ]; then 9 | echo 'Error: python3 is not installed. You can install Python3 at https://www.python.org/downloads/' 10 | exit 1 11 | fi 12 | 13 | echo "--- Python3 successfully found! ---" 14 | 15 | # check for pip installation 16 | echo "--- Checking for pip installation ---" 17 | 18 | if ! [ -x "$(command -v pip)" ]; then 19 | echo 'Error: pip is not installed. You can install pip at https://pip.pypa.io/en/stable/installing/' 20 | exit 1 21 | fi 22 | 23 | echo "--- Pip successfully found! ---" 24 | 25 | # check for pipenv installation 26 | echo "--- Checking for pipenv installation ---" 27 | 28 | if ! [ -x "$(command -v pipenv)" ]; then 29 | echo 'Error: pipenv is not installed. You can install pipenv at https://github.com/pypa/pipenv' >&2 30 | exit 1 31 | fi 32 | 33 | echo "--- Pipenv successfully found! ---" 34 | 35 | # check if pipenv dir = project dir 36 | echo "--- Checking if our current directory is equal to pipenv directory ---" 37 | 38 | if [ `pipenv --where` != `pwd` ]; then 39 | PIPENV_LOC=`pipenv --where` 40 | CURRENT_DIR=`pwd` 41 | echo "pipenv and project not in same location. 42 | Pipenv location: ${PIPENV_LOC}. 43 | Current dir: ${CURRENT_DIR}." 44 | exit 1 45 | fi 46 | 47 | echo "--- pipenv agrees with pwd directory! ---" 48 | 49 | # check for running enviornments 50 | echo "--- Checking for existing virtual enviornments ---" 51 | PIPVENV=`pipenv --venv` 52 | if [[ $PIPVENV != *"No virtualenv has been created for this project yet!"* ]]; then 53 | echo '--- Found existing virtual env! ---' 54 | echo "--- Checking env for dependencies. This may take a while... ---" 55 | FLAG=0 56 | if [ `pipenv graph | grep -F "mysqlclient==1.4.4"` != "mysqlclient==1.4.4" ]; then 57 | FLAG=1 58 | echo '--- Could not find mysqlclient. If you are having trouble installing this package, please refer to the troubleshooting section on the README. ---' 59 | fi 60 | if [ `pipenv graph | grep -F "python-dotenv==0.10.1"` != "python-dotenv==0.10.1" ]; then 61 | FLAG=1 62 | echo '--- Could not find dotenv ---' 63 | fi 64 | if [ `pipenv graph | grep -F "SQLAlchemy==1.3.5"` != "SQLAlchemy==1.3.5" ]; then 65 | FLAG=1 66 | echo '--- Could not find SQLAlchemy ---' 67 | fi 68 | if [ `pipenv graph | grep -F "psycopg2==2.8.3"` != "psycopg2==2.8.3" ]; then 69 | FLAG=1 70 | echo '--- Could not find psycopg2 ---' 71 | fi 72 | if [ `pipenv graph | grep -F "Flask==1.0.2"` != "Flask==1.0.2" ]; then 73 | FLAG=1 74 | echo '--- Could not find Flask ---' 75 | fi 76 | 77 | if [ $FLAG == 1 ]; then 78 | echo '--- running pip install ---' 79 | eval `pipenv install -r requirements.txt` 80 | echo '--- pip install successfully run! Exiting. ---' 81 | exit 1 82 | fi 83 | 84 | echo '--- All dependencies successfully found! Exiting. ---' 85 | 86 | exit 1 87 | fi 88 | 89 | echo '--- No virtual env found! Installing dependencies now ---' 90 | INSTALL_RESULT=eval `pipenv install -r requirements.txt` 91 | 92 | if [[ $INSTALL_RESULT == *"Traceback"* ]]; then 93 | echo 'Unsuccessful install. Aborting! Ask for help from organizer.' 94 | exit 1 95 | fi 96 | 97 | echo '--- pip install successfully run! Exiting. ---' 98 | exit 0 99 | 100 | 101 | -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /static/no-image-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MLH/mlh-localhost-build-and-deploy-aws-starter/e0409822a90c2d596509a870cf0766aeaf9171e5/static/no-image-icon.png -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // Ensures we can safely retry failed requests and add default params 3 | class ApiCircuitBreaker { 4 | constructor(timeout = 5000, failureThreshold = 5, retryTimePeriod = 500) { 5 | // We start in a closed state hoping that everything is fine 6 | this.state = "CLOSED"; 7 | // Number of failures we receive from the depended service before we change the state to 'OPEN' 8 | this.failureThreshold = failureThreshold; 9 | // Timeout for the API request. 10 | this.timeout = timeout; 11 | // Time period after which a fresh request be made to the dependent 12 | // service to check if service is up. 13 | this.retryTimePeriod = retryTimePeriod; 14 | this.lastFailureTime = null; 15 | this.failureCount = 0; 16 | } 17 | 18 | async call(url, params = {}) { 19 | // Determine the current state of the circuit. 20 | this.setState(); 21 | switch (this.state) { 22 | case "OPEN": 23 | // return cached response if no the circuit is in OPEN state 24 | return { data: "this is stale response" }; 25 | // Make the API request if the circuit is not OPEN 26 | case "HALF-OPEN": 27 | case "CLOSED": 28 | try { 29 | const response = await fetch( 30 | url, 31 | Object.assign( 32 | { 33 | timeout: this.timeout, 34 | method: "GET", 35 | headers: { "x-user-id": UserApi.getUserId() } 36 | }, 37 | params 38 | ) 39 | ); 40 | 41 | this.reset(); 42 | return response.json(); 43 | } catch (err) { 44 | this.recordFailure(); 45 | throw new Error(err); 46 | } 47 | default: 48 | console.log("This state should never be reached"); 49 | return "unexpected state in the state machine"; 50 | } 51 | } 52 | 53 | reset() { 54 | this.failureCount = 0; 55 | this.lastFailureTime = null; 56 | this.state = "CLOSED"; 57 | } 58 | 59 | setState() { 60 | if (this.failureCount > this.failureThreshold) { 61 | if (Date.now() - this.lastFailureTime > this.retryTimePeriod) { 62 | this.state = "HALF-OPEN"; 63 | } else { 64 | this.state = "OPEN"; 65 | } 66 | } else { 67 | this.state = "CLOSED"; 68 | } 69 | } 70 | 71 | recordFailure() { 72 | this.failureCount += 1; 73 | this.lastFailureTime = Date.now(); 74 | } 75 | } 76 | 77 | // Simulate a user ID as we don't have sign up 78 | // It will be persisted as long as the user doesn't their browser storage 79 | const UserApi = { 80 | getUserId: () => localStorage.getItem("userId"), 81 | createUserId: () => localStorage.setItem("userId", Date.now()) 82 | }; 83 | 84 | // Abstracts communication with the API 85 | // Allows us to search for events and manage favorites 86 | const PlacesApi = { 87 | findPlaces: async city => 88 | new ApiCircuitBreaker().call(`/api/places/${city}`), 89 | createFavorite: async id => 90 | new ApiCircuitBreaker().call(`/api/places/${id}/favorites`, { 91 | method: "POST" 92 | }), 93 | deleteFavorite: async id => 94 | new ApiCircuitBreaker().call(`/api/places/${id}/favorites`, { 95 | method: "DELETE" 96 | }), 97 | getPlaceFavorites: async id => 98 | new ApiCircuitBreaker().call(`/api/places/${id}/favorites`) 99 | }; 100 | 101 | // Abstracts UI changes 102 | const UI = { 103 | getUrlParameter: name => { 104 | name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); 105 | var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"); 106 | var results = regex.exec(location.search); 107 | return results === null 108 | ? "" 109 | : decodeURIComponent(results[1].replace(/\+/g, " ")); 110 | }, 111 | getCityUrlParameter: () => UI.getUrlParameter("city"), 112 | getCityField: () => $("#city"), 113 | getPlacesList: () => $("#places"), 114 | getSubmitButton: () => $("#submit"), 115 | getPlacePreview: () => $("#placePreview"), 116 | disableSubmit: (text = "Find places") => { 117 | let submitButton = UI.getSubmitButton(); 118 | submitButton.attr("disabled", true); 119 | submitButton.text(text); 120 | }, 121 | enableSubmit: () => { 122 | let submitButton = UI.getSubmitButton(); 123 | submitButton.attr("disabled", false); 124 | submitButton.text("Find seafood"); 125 | }, 126 | getForm: () => $("form"), 127 | getLoadingHtml: (includeText = true) => 128 | `${ 129 | includeText ? " Loading..." : "" 130 | }`, 131 | toggleLoading: () => { 132 | const loadingContent = UI.getLoadingHtml(true); 133 | UI.getSubmitButton().html(loadingContent); 134 | }, 135 | validate: () => { 136 | const city = UI.getCityField().val(); 137 | 138 | return $.trim(city); 139 | }, 140 | buildPlaceHtml: place => 141 | $( 142 | ` 143 |
144 | ${place.name} 149 | 150 |
151 |
${place.name}
152 |
153 | 167 |
` 168 | ), 169 | getModal: () => $("#modal"), 170 | updateModalActions: async (placeFavorites, id, block = false) => { 171 | const hasFavorited = placeFavorites.results.find( 172 | event => event.user_id.toString() === UserApi.getUserId() 173 | ); 174 | 175 | const modal = UI.getModal(); 176 | const modalActionsContainer = modal.find(".modal-footer"); 177 | modalActionsContainer.show(); 178 | const favoriteActionButton = modalActionsContainer.find( 179 | "#subscribeButton" 180 | ); 181 | 182 | // Update styles 183 | favoriteActionButton 184 | .find("#subscribedButtonContent") 185 | .attr("class", `${hasFavorited ? "favorited" : ""}`); 186 | 187 | favoriteActionButton.attr( 188 | "class", 189 | `btn btn-light ${block ? "pulse" : ""}` 190 | ); 191 | 192 | // Update counter 193 | favoriteActionButton 194 | .find("#subscribedCount") 195 | .text( 196 | ` (${placeFavorites.count}) ` + 197 | `${hasFavorited ? "Remove from " : "Add to "} favorites` 198 | ); 199 | favoriteActionButton; 200 | 201 | // Update bindings 202 | favoriteActionButton 203 | .attr("disabled", block) 204 | .unbind() 205 | .on("click", async () => { 206 | if (hasFavorited) { 207 | // Optimistic update 208 | const newPlaceFavorites = { 209 | count: placeFavorites.count - 1, 210 | results: placeFavorites.results.filter( 211 | placeFavorite => placeFavorite.event_id.toString() !== id 212 | ) 213 | }; 214 | UI.updateModalActions(newPlaceFavorites, id, true); 215 | 216 | await PlacesApi.deleteFavorite(id); 217 | UI.updateModalActions(newPlaceFavorites, id); 218 | } else { 219 | // Optimistic update 220 | const newOptimisticPlaceFavorites = { 221 | count: placeFavorites.count + 1, 222 | results: [ 223 | ...placeFavorites.results, 224 | { event_id: id, user_id: UserApi.getUserId() } 225 | ] 226 | }; 227 | UI.updateModalActions(newOptimisticPlaceFavorites, id, true); 228 | 229 | const placeFavorite = await PlacesApi.createFavorite(id); 230 | const newPlacesFavorites = { 231 | count: placeFavorites.count + 1, 232 | results: [...placeFavorites.results, { ...placeFavorite }] 233 | }; 234 | UI.updateModalActions(newPlacesFavorites, id); 235 | } 236 | }); 237 | }, 238 | 239 | bindModalAction: () => { 240 | UI.getPlacePreview() 241 | .unbind() 242 | .on("show.bs.modal", async domEvent => { 243 | const button = $(domEvent.relatedTarget); // Button that triggered the modal 244 | const id = unescape(button.data("id")); // Extract info from data-* attributes 245 | const title = unescape(button.data("title")); // Extract info from data-* attributes 246 | const description = unescape(button.data("description")); // Extract info from data-* attributes 247 | const url = unescape(button.data("url")); // Extract info from data-* attributes 248 | const modal = UI.getModal(); 249 | modal.find(".modal-title").html(title); 250 | modal.find(".modal-body").html(description); 251 | modal.find(".modal-footer").hide(); 252 | modal.find(".modal-footer #eventUrl").prop("href", url); 253 | 254 | const placeFavorites = await PlacesApi.getPlaceFavorites(id); 255 | const hasFavorited = placeFavorites.results.find( 256 | event => event.user_id.toString() === UserApi.getUserId() 257 | ); 258 | 259 | UI.updateModalActions(placeFavorites, id); 260 | }); 261 | }, 262 | displayPlaces: places => { 263 | places.forEach(suggestion => { 264 | UI.getPlacesList().append(UI.buildPlaceHtml(suggestion)); 265 | }); 266 | }, 267 | fetchPlacesAndDisplay: async city => { 268 | UI.disableSubmit(); 269 | UI.toggleLoading(); 270 | UI.getPlacesList().html(""); 271 | 272 | const places = await PlacesApi.findPlaces(city); 273 | UI.enableSubmit(); 274 | UI.displayPlaces(places); 275 | }, 276 | bindFormActions: () => { 277 | const validate = e => { 278 | const code = e.keyCode ? e.keyCode : e.which; 279 | if (code == 13) { 280 | return; 281 | } 282 | 283 | if (UI.validate()) { 284 | UI.enableSubmit(); 285 | } else { 286 | UI.disableSubmit(); 287 | } 288 | }; 289 | 290 | UI.getCityField() 291 | .unbind() 292 | .on("change keyup paste focus", validate); 293 | UI.getForm() 294 | .unbind() 295 | .on("submit", event => { 296 | event.preventDefault(); 297 | const city = $.trim(UI.getCityField().val()); 298 | 299 | history.pushState({}, "", `/?city=${city}`); 300 | UI.fetchPlacesAndDisplay(city); 301 | }); 302 | }, 303 | init: () => { 304 | UI.bindModalAction(); 305 | UI.bindFormActions(); 306 | UI.getCityField().focus(); 307 | 308 | const city = UI.getCityUrlParameter(); 309 | 310 | if (city) { 311 | UI.getCityField().val(city); 312 | UI.fetchPlacesAndDisplay(city); 313 | } 314 | } 315 | }; 316 | 317 | const init = () => { 318 | if (!UserApi.getUserId()) { 319 | UserApi.createUserId(); 320 | } 321 | 322 | UI.init(); 323 | }; 324 | 325 | $(document).ready(init); 326 | $(window).on("popstate", init); 327 | })(); 328 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Montserrat", sans-serif; 3 | } 4 | 5 | .hidden { 6 | display: none; 7 | } 8 | 9 | .topbar-bot { 10 | border-bottom: 1px solid #ddd; 11 | margin-bottom: 40px; 12 | } 13 | 14 | .flex-topbar { 15 | width: 65%; 16 | display: flex; 17 | flex-direction: row; 18 | justify-content: space-between; 19 | align-items: baseline; 20 | } 21 | 22 | .flex-topbar .navbar-text { 23 | font-size: 21px; 24 | font-weight: bold; 25 | } 26 | 27 | .form-container { 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: center; 31 | align-items: center; 32 | } 33 | 34 | .event { 35 | margin: 0 auto; 36 | } 37 | 38 | /* Ensures event's content doesn't overflow */ 39 | .modal-body * { 40 | max-width: 100%; 41 | } 42 | 43 | .card-img-top.placeholder { 44 | display: block; 45 | margin: 0 auto; 46 | max-width: 150px; 47 | } 48 | 49 | .icon-favorite { 50 | width: 16px; 51 | transition: all 1s; 52 | } 53 | 54 | #subscribedButtonContent { 55 | display: flex; 56 | } 57 | 58 | #subscribedButtonContent.favorited { 59 | color: #dc3545; 60 | } 61 | 62 | .favorited .icon-favorite { 63 | font-weight: bold; 64 | filter: invert(16%) sepia(67%) saturate(5946%) hue-rotate(341deg) 65 | brightness(88%) contrast(94%); 66 | transition: all 1s; 67 | } 68 | 69 | .pulse { 70 | animation: pulse 0.8s infinite; 71 | animation-timing-function: linear; 72 | } 73 | 74 | @keyframes pulse { 75 | 0% { 76 | transform: scale(1); 77 | } 78 | 50% { 79 | transform: scale(1.1); 80 | } 81 | 100% { 82 | transform: scale(1); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /templates/homepage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 18 | 22 | 28 | 29 | MLH - Seafood Suggestion Generator 30 | 31 | 32 | 42 | 43 |
44 |
45 |
46 |
47 |
48 |
49 | 59 | 60 |
61 | 69 |
70 |
71 |
72 |
73 |
74 |
75 | 76 |
77 |
78 | 79 | 80 | 128 | 129 | 134 | 139 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /templates/start.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

8 | Welcome to the Seafood Suggester! Oh wait, this isn't right! Go to application.py in Visual Studio Code to fix the correct homepage. 9 |

10 | 11 | -------------------------------------------------------------------------------- /yelp.py: -------------------------------------------------------------------------------- 1 | import config # 💡importing our env variables from dotenv 2 | import requests # 💡open a web url 3 | import json # 💡json stands for Javascript Object Notation and is commonly used to transmit web data 4 | 5 | ### 6 | # 🆘 Help us fix this file!! 🆘 7 | ### 8 | 9 | # 1. 🆘✨ we want to get events for the city name a user types in. Replace the placeholder variable with city 🏙 as a parameter! 10 | def get_businesses( FIXME ): 11 | 12 | # 2. 🆘✨ use the dotenv file to find the correct variable for Yelp! 13 | # We need to use our key! Look in the .env file for the Yelp key name 14 | headers = { "Authorization": "Bearer " + config.FIXME} 15 | params = {"location": city, "limit": 5, "term": "seafood"} 16 | 17 | # 💡the Request() method calls an external URL from our Python server 18 | request = requests.get( 19 | "https://api.yelp.com/v3/businesses/search", 20 | params=params, # 💡parameters are passed via the URL 21 | headers=headers, # 💡headers are variables passed DIRECTLY to the server 22 | ) 23 | 24 | # 3. 🆘✨we want to get a JSON response from Yelp. 25 | # They keep the info we need in the response_body.businesses. 26 | # 💡returns a JSON array of businesses in a city 27 | return json.loads(request.text)["FIXME"] 28 | --------------------------------------------------------------------------------