├── .gitignore ├── .test.env ├── Dockerfile ├── Dockerfile.dev ├── Dockerfile.test ├── Makefile ├── README.md ├── app.py ├── app_shopify ├── __init__.py ├── common.py ├── templates │ ├── 400.html │ ├── index.html │ ├── install.html │ └── static │ │ └── bootstrap-polaris.min.css ├── views.py └── webhooks.py ├── common ├── __init__.py ├── auth.py ├── const.py ├── extensions.py └── parsers.py ├── config.py ├── docker-compose-test.yml ├── docker-compose.yml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 3506c6a93ea6_initial_migration.py ├── models ├── __init__.py └── profile.py ├── requirements.txt ├── service ├── __init__.py └── profile.py ├── tests ├── __init__.py ├── base_case.py ├── const.py └── test_app_shopify.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .pyenv 4 | .flaskenv 5 | *.pyc 6 | *.pyo 7 | dist/ 8 | build/ 9 | *.egg 10 | *.egg-info/ 11 | _mailinglist 12 | .tox/ 13 | .cache/ 14 | .pytest_cache/ 15 | .idea/ 16 | docs/_build/ 17 | .vscode 18 | node_modules/ 19 | 20 | # Coverage reports 21 | htmlcov/ 22 | .coverage 23 | .coverage.* 24 | *,cover 25 | 26 | bundle.css 27 | bundle.js 28 | **/static/widget.css 29 | **/static/widget.js 30 | -------------------------------------------------------------------------------- /.test.env: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_KEY="abababababababbaba" 2 | SHOPIFY_SHARED_SECRET=shpss_abs 3 | DATABASE_URL="postgres:///unittestdb" 4 | TESTING=true -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest AS build 2 | ARG API_URL_FOR_SHOPIFY 3 | ENV API_URL_FOR_SHOPIFY=${API_URL_FOR_SHOPIFY} 4 | 5 | FROM python:3.9.0-slim-buster AS production 6 | WORKDIR /app 7 | COPY requirements.txt requirements.txt 8 | RUN python3 -m pip install -r requirements.txt --no-cache-dir 9 | COPY . . 10 | COPY --from=build /app/plugin_shopify/templates/*.html plugin_shopify/templates/static/ 11 | COPY --from=build /app/plugin_shopify/templates/static/ plugin_shopify/templates/static/ 12 | ENV PORT 5000 13 | 14 | COPY ./docker-entrypoint.sh /app/ 15 | RUN chmod +x /app/docker-entrypoint.sh 16 | CMD ["./docker-entrypoint.sh"] 17 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM python:3.9.0-slim-buster 2 | 3 | WORKDIR /app 4 | ADD requirements.txt /app/requirements.txt 5 | RUN python3 -m pip install -r requirements.txt --no-cache-dir 6 | 7 | CMD gunicorn -w 3 -b :5000 -t 30 --reload wsgi:app 8 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM python:3.9.0-slim-buster 2 | 3 | RUN python3 -m pip install nose2 4 | ADD requirements.txt requirements.txt 5 | RUN python3 -m pip install -r requirements.txt --no-cache-dir 6 | 7 | WORKDIR /app 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ENV ?= local 2 | 3 | run: 4 | docker compose up --build --remove-orphans 5 | 6 | test: 7 | docker compose \ 8 | -f docker-compose-test.yml \ 9 | up --build \ 10 | --remove-orphans \ 11 | --exit-code-from test-runner 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | What is this stuff? 2 | =================== 3 | This is a Flask/Python boilerplate Shopify backend app that you can use to kick start the development of your next Shopify App. Checkout [this blog post](https://www.tigersandtacos.dev/posts/create-a-shopify-backend-service-in-python-flask/) for more information on what it does and how you can get it up and running. 4 | 5 | The features: 6 | - Handle app installs from merchants 7 | - Subscribe and act on webhooks (especially the order/paid hook), each order triggers a usage charge on the merchants account. 8 | - Endpoint to receive merchant profile updates from your Shopify app 9 | - A starter for the Shopify admin page 10 | - Database migrations 11 | - Unit tests 12 | - Setup recurring and usage charge billing for your app (coming soon) 13 | - Examples for how to interact with other services (coming soon) 14 | 15 | 16 | ## Installation 17 | The app is dockerized and listening to port 5000, on Mac / Linux, to launch the service simply type `make run` 18 | 19 | ## Create the database 20 | When you run the service the database should be created in the first run, if not execute the following commands. 21 | 22 | First connect to the docker container 23 | `docker exec -it bash` 24 | 25 | Then initialize the database models and apply the migrations 26 | `flask db init` 27 | 28 | `flask db migrate -m "Initial migration."` 29 | 30 | `flask db upgrade` 31 | 32 | ## Launching the service 33 | Navigate to the backend directory of the application and execute `make run` 34 | 35 | ## Running tests 36 | All unit tests are located in the tests directory, to run them simply navigate to the backend 37 | directory of the application and execute: `make test` 38 | 39 | ## For more details 40 | Checkout the blog posts over at [Tigers and Tacos](https://tigersandtacos.dev) 41 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask 3 | from flask import jsonify 4 | from flask_cors import CORS 5 | import logging 6 | from app_shopify.views import shopify_bp 7 | from config import DefaultConfig 8 | from flask_migrate import Migrate 9 | from common.extensions import db 10 | 11 | logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s:%(lineno)d %(threadName)s : %(message)s") 12 | 13 | log_level = { 14 | 'DEBUG': logging.DEBUG, 15 | 'WARNING': logging.WARNING, 16 | 'INFO': logging.INFO 17 | } 18 | 19 | logging.getLogger('werkzeug').setLevel(log_level[os.getenv('LOG_LEVEL', 20 | 'INFO')]) 21 | 22 | app = Flask(__name__, instance_relative_config=False) 23 | app.config.from_object(DefaultConfig) 24 | CORS(app) 25 | 26 | # Blueprints 27 | app.register_blueprint(shopify_bp) 28 | 29 | # Liveness probe 30 | @app.route('/health') 31 | def health(): 32 | return jsonify({"status": 200, "message": "It's alive!"}) 33 | 34 | 35 | # Database 36 | db.init_app(app) 37 | migrate = Migrate(app, db) 38 | 39 | if __name__ == "__main__": 40 | app.run(use_reloader=True, debug=True) 41 | -------------------------------------------------------------------------------- /app_shopify/__init__.py: -------------------------------------------------------------------------------- 1 | from .views import shopify_bp -------------------------------------------------------------------------------- /app_shopify/common.py: -------------------------------------------------------------------------------- 1 | import hashlib, base64, hmac 2 | from flask import current_app 3 | from urllib.parse import urlencode 4 | import shopify 5 | from common import const 6 | 7 | 8 | def hmac_is_valid(query_dict): 9 | """Used in for example the index.html endpoint for the shopify app""" 10 | try: 11 | hmac_from_query_string = query_dict.pop('hmac') 12 | if 'charge_id' in query_dict.keys(): 13 | del query_dict['charge_id'] 14 | return True 15 | url_encoded = urlencode(query_dict) 16 | secret = current_app.config['SHOPIFY_SHARED_SECRET'].encode('utf-8') 17 | signature = hmac.new(secret, url_encoded.encode('utf-8'), hashlib.sha256).hexdigest() 18 | return hmac.compare_digest(hmac_from_query_string, signature) 19 | except KeyError as e: 20 | return False 21 | 22 | 23 | def verify_webhook(data, hmac_header): 24 | digest = hmac.new(current_app.config['SHOPIFY_SHARED_SECRET'].encode('utf-8'), 25 | data, 26 | hashlib.sha256).digest() 27 | computed_hmac = base64.b64encode(digest) 28 | return hmac.compare_digest(computed_hmac, hmac_header.encode('utf-8')) 29 | 30 | 31 | def add_webhooks(shop, token): 32 | session = shopify.Session(shop, current_app.config['SHOPIFY_API_VERSION'], token) 33 | shopify.ShopifyResource.activate_session(session) 34 | for topic in const.SHOPIFY_WEBHOOK_TOPICS: 35 | new_webhook = shopify.Webhook() 36 | new_webhook.address = current_app.config['HOSTNAME'] + "/shopify/webhook" 37 | new_webhook.format = 'json' 38 | new_webhook.topic = topic 39 | new_webhook.save() 40 | return True 41 | 42 | -------------------------------------------------------------------------------- /app_shopify/templates/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Unexpected Error 6 | 7 | 46 | 47 | 48 | 49 | 50 |
51 |

400

52 |

{{ message }}

53 |
54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app_shopify/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | Tigers & Tacos 15 | 16 | 17 | 18 | 19 | 20 | 52 | 57 | 58 | 59 | 60 |
61 |
62 |
You made it! 🥳
63 |

This page is using the Bootstrap Polaris theme which gives you the things you need to adhere to the Polaris style guide required by Shopify.

64 |

You can use whatever tools you like to create the app, there are some decent guides out there for React and other frameworks.

65 |
66 |
67 |
68 |
69 |

Find me a taco

70 |

We love tacos and believe your should eat them all the time.

71 |

72 |

73 |
74 |
75 |
76 |
77 | 78 | -------------------------------------------------------------------------------- /app_shopify/templates/install.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /app_shopify/views.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | import logging 3 | from service import profile as profile_service 4 | import random 5 | 6 | import shopify 7 | from flask import ( 8 | Blueprint, render_template, current_app, request, redirect, 9 | url_for, make_response, abort) 10 | 11 | from common import const, parsers 12 | from common.auth import token_auth 13 | from app_shopify.common import hmac_is_valid, \ 14 | add_webhooks, verify_webhook 15 | from app_shopify import webhooks 16 | 17 | log = logging.getLogger('werkzeug') 18 | 19 | shopify_bp = Blueprint('plugin_shopify', __name__, 20 | url_prefix='/shopify', 21 | static_folder='templates/static', 22 | template_folder='templates') 23 | 24 | 25 | @shopify_bp.route('/profile', methods=["GET", "POST"]) 26 | @token_auth.login_required 27 | def profile_update(): 28 | """ 29 | This is how you can take a request from the Shopify admin side of things, provide the auth_token, 30 | make sure that the user is authenticated and then perform stuff with the data provided. 31 | For example update a profile. 32 | """ 33 | merchant = token_auth.current_user() 34 | if request.method == "GET": 35 | return make_response(jsonify({"message": "OK", "data": _setup_profile_data(merchant.__dict__)})) 36 | if request.is_json and request.method == "POST": 37 | data = request.get_json() 38 | try: 39 | profile_data = _parse_profile_data(data) 40 | except KeyError as e: 41 | log.error(f'Issue with parsing profile data {e} ') 42 | return make_response(jsonify({"message": "Could not process provided data"}), 400) 43 | try: 44 | profile_service.update(merchant.shop_unique_id, 45 | profile_data) 46 | except Exception as e: # NOQA 47 | log.exception(f'failed to persist profile: {e}') 48 | return make_response(jsonify({"message": "Failed to persist profile"}), 400) 49 | 50 | profile = profile_service.get(merchant.shop_unique_id) 51 | return make_response(jsonify({"message": "OK", "data": _setup_profile_data(profile)})) 52 | return jsonify({"message": "Invalid request"}) 53 | 54 | 55 | @shopify_bp.route('/install') 56 | def install(): 57 | """ 58 | Redirect user to the Shopify permission authorization page where they can give their access for our app. 59 | """ 60 | shop_url = request.args.get("shop") 61 | if not shop_url: 62 | return render_template("400.html", message="No shop in query params"), 400 63 | shopify.Session.setup( 64 | api_key=current_app.config['SHOPIFY_API_KEY'], 65 | secret=current_app.config['SHOPIFY_SHARED_SECRET']) 66 | session = shopify.Session(shop_url, current_app.config['SHOPIFY_API_VERSION']) 67 | 68 | permission_url = session.create_permission_url( 69 | const.SHOPIFY_OAUTH_SCOPES, url_for("plugin_shopify.finalize", 70 | _external=True, 71 | _scheme='https')) 72 | return render_template('install.html', 73 | permission_url=permission_url) 74 | 75 | 76 | @shopify_bp.route('/finalize') 77 | def finalize(): 78 | """ 79 | Generate shop token, store the shop information and show app dashboard page 80 | """ 81 | shop_url = request.args.get("shop") 82 | shopify.Session.setup( 83 | api_key=current_app.config['SHOPIFY_API_KEY'], 84 | secret=current_app.config['SHOPIFY_SHARED_SECRET']) 85 | shopify_session = shopify.Session(shop_url, current_app.config['SHOPIFY_API_VERSION']) 86 | 87 | token = shopify_session.request_token(request.args) 88 | try: 89 | session = shopify.Session(shop_url, 90 | current_app.config['SHOPIFY_API_VERSION'], 91 | token) 92 | shopify.ShopifyResource.activate_session(session) 93 | shop = shopify.Shop.current() 94 | profile_service.create(shop_unique_id=shop_url, 95 | shopify_access_token=token, 96 | contact_email=shop.email, 97 | name=shop.shop_owner) 98 | except Exception as e: # NOQA 99 | log.error('Something went wrong when trying to crete the profile') 100 | return render_template("400.html", message="Failed to save profile"), 400 101 | profile = profile_service.get(shop_url) 102 | if not profile[const.PROFILE_MODEL_FIELD_ONBOARDING_SHOPIFY_ADDED_WEBHOOK_UNINSTALL]: 103 | add_webhooks(shop_url, token) 104 | profile_service.update(shop_url, {const.PROFILE_MODEL_FIELD_ONBOARDING_SHOPIFY_ADDED_WEBHOOK_UNINSTALL: True}) 105 | return_url = "{}?{}".format(url_for('plugin_shopify.index'), request.query_string.decode('utf-8')) 106 | return redirect(return_url) 107 | 108 | 109 | def _merchant_is_installed(profile): 110 | if not profile['shopify_access_token'] or not profile['status']: 111 | return False 112 | return True 113 | 114 | 115 | @shopify_bp.route('/') 116 | def index(): 117 | """ 118 | Render the index page of our application. 119 | """ 120 | query_dict = request.args.to_dict() 121 | if not hmac_is_valid(query_dict): 122 | return render_template("400.html", message="Could not verify request"), 400 123 | tokens = profile_service.get_tokens(query_dict.get('shop')) 124 | profile = profile_service.get(query_dict.get('shop')) 125 | if not _merchant_is_installed(profile): 126 | return install() 127 | return render_template('index.html', 128 | shopify_api_key=current_app.config['SHOPIFY_API_KEY'], 129 | shop=query_dict.get('shop'), 130 | backend=current_app.config["HOSTNAME"], 131 | auth_token=tokens.get('auth_token')) 132 | 133 | 134 | @shopify_bp.route('/webhook', methods=['POST']) 135 | def parse_webhook(): 136 | """ 137 | Receive and process Shopify webhooks. 138 | This is a simple implementation that keeps the webhook in the request / response cycle. 139 | If we fail to handle the webhook then we return a http 500 to Shopify which means that they will resend the webhook. 140 | 141 | A more appropriate way to implement this would be to accept the webhook from Shopify and then process it in a 142 | queue / worker pattern or similar. 143 | """ 144 | data = request.get_data() 145 | verified = verify_webhook(data, request.headers.get('X-SHOPIFY_HMAC_SHA256')) 146 | if not verified and current_app.config["WEBHOOK_TEST_MODE"] is True: 147 | log.warning('got webhook that failed hmac verification') 148 | abort(401) 149 | event = request.headers.get('X_SHOPIFY_TOPIC') 150 | shop_url = request.headers.get('X-SHOPIFY_SHOP_DOMAIN') 151 | try: 152 | webhooks.handler[event](data, shop_url) 153 | except parsers.ParserException: 154 | log.exception("Failed to parse order for shop: {}, {}".format(shop_url, data)) 155 | abort(500) 156 | except Exception as e: # NOQA 157 | log.exception("unhandled exception when parsing webhook for: {}, {}".format(shop_url, data)) 158 | abort(500) 159 | return jsonify({"message": "Nom nom nom"}) 160 | 161 | 162 | @shopify_bp.route('/demo-post-request', methods=['POST']) 163 | @token_auth.login_required 164 | def demo_post_request(): 165 | tacos = ["carne Asada", "shrimp taco", "fish Taco", "barbacoa", "tacos de Birria", "tacos Al Pastor", "carnitas", "nopales"] 166 | if request.is_json: 167 | data = request.get_json() 168 | log.info(f'post received: {data}') 169 | msg = f"I think you should try a {random.choice(tacos)}" 170 | return jsonify({ 171 | "message": msg, 172 | }) 173 | 174 | 175 | def _parse_profile_data(data): 176 | return_data = { 177 | const.PROFILE_MODEL_FIELD_NAME: data[const.PROFILE_NAME], 178 | const.PROFILE_MODEL_FIELD_CONTACT_EMAIL: data[const.PROFILE_EMAIL], 179 | const.PROFILE_SHOP: data["shop"] 180 | } 181 | return return_data 182 | 183 | 184 | def _setup_profile_data(profile): 185 | data = { 186 | const.PROFILE_NAME: profile[const.PROFILE_MODEL_FIELD_NAME], 187 | const.PROFILE_EMAIL: profile[const.PROFILE_MODEL_FIELD_CONTACT_EMAIL], 188 | } 189 | return data 190 | -------------------------------------------------------------------------------- /app_shopify/webhooks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from flask import current_app 3 | import json 4 | import logging 5 | from common import const, parsers 6 | from service import profile as profile_service 7 | 8 | log = logging.getLogger('werkzeug') 9 | 10 | 11 | def uninstall(data, shop_unique_url): 12 | profile_service.update(shop_unique_url, {'status': 0, 13 | 'timestamp_uninstall': datetime.datetime.utcnow(), 14 | const.PROFILE_MODEL_FIELD_SHOPIFY_RECURRING_SUBSCRIPTION_ID: None, 15 | const.PROFILE_MODEL_FIELD_ONBOARDING_SHOPIFY_ADDED_WEBHOOK_UNINSTALL: False 16 | }) 17 | 18 | 19 | def order_paid(data, shop_url): 20 | log.info('got order paid webhook') 21 | try: 22 | parsed = parsers.shopify_order(json.loads(data), shop_url) 23 | except KeyError as e: 24 | raise parsers.ParserException 25 | log.info(f'this is the parsed order: {parsed}') 26 | 27 | 28 | def order_fulfilled(data, shop_url): 29 | log.info(f'order is fulfilled') 30 | 31 | 32 | def gdpr_data_request(data, shop_url): 33 | log.info(f'GDPR data_access_request: {data}') 34 | 35 | 36 | def gdpr_customer_data_erasure(data, shop_url): 37 | log.info(f'GDPR customer_data_erasure_request: {data}') 38 | 39 | 40 | def gdpr_shop_data_erasure(data, shop_url): 41 | log.info(f'GDPR shop_data_erasure_request: {data}') 42 | 43 | 44 | # Extend this to enable handling of other webhook topics that you subscribe to 45 | handler = { 46 | 'app/uninstalled': uninstall, 47 | 'orders/paid': order_paid, 48 | 'orders/fulfilled': order_fulfilled, 49 | 'customers/redact': gdpr_customer_data_erasure, 50 | 'shop/redact': gdpr_shop_data_erasure, 51 | 'customers/data_request': gdpr_data_request 52 | } 53 | 54 | -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburman/shopify-flask-backend/6c47465c4b594807d29be0aca1398f9bad9e6302/common/__init__.py -------------------------------------------------------------------------------- /common/auth.py: -------------------------------------------------------------------------------- 1 | 2 | from flask import jsonify 3 | from werkzeug.http import HTTP_STATUS_CODES 4 | from flask_httpauth import HTTPTokenAuth 5 | from models.profile import Profile 6 | token_auth = HTTPTokenAuth() 7 | 8 | 9 | def error_response(status_code, message=None): 10 | payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} 11 | if message: 12 | payload['message'] = message 13 | response = jsonify(payload) 14 | response.status_code = status_code 15 | return response 16 | 17 | 18 | @token_auth.verify_token 19 | def verify_token(token): 20 | return Profile.check_token(token) if token else None 21 | 22 | 23 | @token_auth.error_handler 24 | def token_auth_error(status): 25 | return error_response(status) -------------------------------------------------------------------------------- /common/const.py: -------------------------------------------------------------------------------- 1 | # This is a good place to put all your constants 2 | PROFILE_NAME = "nameOfUser" 3 | PROFILE_EMAIL = "contactEmail" 4 | PROFILE_SHOP = "shop" 5 | 6 | PROFILE_MODEL_FIELD_AUTH_TOKEN = "auth_token" 7 | PROFILE_MODEL_FIELD_ACCESS_TOKEN = "access_token" 8 | PROFILE_MODEL_FIELD_ACCEPTED_TOC = "accepted_tos" 9 | PROFILE_MODEL_FIELD_NAME = "name" 10 | PROFILE_MODEL_FIELD_CONTACT_EMAIL = "contact_email" 11 | PROFILE_MODEL_FIELD_SHOPIFY_RECURRING_SUBSCRIPTION_ID = "shopify_recurring_subscription_id" 12 | PROFILE_MODEL_FIELD_ONBOARDING_SHOPIFY_ADDED_WEBHOOK_UNINSTALL = "onboarding_shopify_added_webhooks" 13 | 14 | SHOPIFY_WEBHOOK_TOPICS = ["app/uninstalled", "orders/paid"] 15 | SHOPIFY_OAUTH_SCOPES = [ 16 | "write_products", 17 | "read_products", 18 | "read_orders", 19 | "read_script_tags", 20 | "write_script_tags"] 21 | 22 | # Blacklist 23 | BLACKLIST_CACHE_AGE = 3600 24 | 25 | # The order data 26 | ORDER_SHOP_UNIQUE_ID = "shop_id" 27 | ORDER_ID = "order_id" 28 | -------------------------------------------------------------------------------- /common/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | db = SQLAlchemy() 3 | 4 | -------------------------------------------------------------------------------- /common/parsers.py: -------------------------------------------------------------------------------- 1 | from common import const 2 | 3 | 4 | class ParserException(Exception): 5 | """When we fail to parse data""" 6 | 7 | 8 | def shopify_order(data, shop_url): 9 | """ 10 | It's always a good idea to map the Shopify order to a data structure that you decide what it looks like 11 | The reason is simple, you should only try to collect and persist data that you know will be used for your business. 12 | Feel free to extend this structure, the Shopify order event can be [found here](https://shopify.dev/api/admin/rest/reference/events/webhook) 13 | """ 14 | return_data = { 15 | const.ORDER_SHOP_UNIQUE_ID: shop_url, 16 | const.ORDER_ID: str(data["id"]), 17 | "order": { 18 | "created_at": data["created_at"], 19 | "total_price": data["total_price"], 20 | "total_weight": data["total_weight"], 21 | "currency": data["currency"], 22 | "financial_status": data["financial_status"], 23 | "order_number": data["order_number"], 24 | "order_status_url": data["order_status_url"], 25 | "line_items": data["line_items"], 26 | } 27 | } 28 | if data.get("billing_address"): 29 | return_data["order"]["billing_address"] = { 30 | "city": data["billing_address"]["city"], 31 | "country": data["billing_address"]["country"], 32 | "country_code": data["billing_address"]["country_code"], 33 | } 34 | if data.get("shipping_address"): 35 | return_data["order"]["shipping_address"] = { 36 | "city": data["shipping_address"]["city"], 37 | "country": data["shipping_address"]["country"], 38 | "country_code": data["shipping_address"]["country_code"], 39 | } 40 | 41 | return return_data 42 | 43 | 44 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import multiprocessing 3 | 4 | PORT = int(os.getenv("PORT", 5000)) 5 | DEBUG_MODE = int(os.getenv("DEBUG_MODE", 0)) 6 | 7 | # Gunicorn config 8 | bind = ":" + str(PORT) 9 | workers = multiprocessing.cpu_count() * 2 + 1 10 | threads = 2 * multiprocessing.cpu_count() 11 | 12 | 13 | class DefaultConfig(object): 14 | 15 | PROJECT = "Tigers & Tacos for Shopify" 16 | PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 17 | DEBUG = False 18 | TESTING = False 19 | SECRET_KEY = os.getenv("SECRET_KEY", "super-random-key") 20 | PREFERRED_URL_SCHEME = "https" 21 | 22 | SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") 23 | SQLALCHEMY_TRACK_MODIFICATIONS = True 24 | SQLALCHEMY_ECHO = True 25 | SQLALCHEMY_ENGINE_OPTIONS = { 26 | "pool_pre_ping": True, 27 | "pool_recycle": 300, 28 | } 29 | SHOPIFY_API_KEY = os.getenv("SHOPIFY_API_KEY") 30 | SHOPIFY_SHARED_SECRET = os.getenv("SHOPIFY_SHARED_SECRET") 31 | SHOPIFY_API_VERSION = '2020-07' 32 | HOSTNAME = os.getenv("HOSTNAME_FOR_SHOPIFY", None) 33 | WEBHOOK_TEST_MODE = os.getenv("WEBHOOK_TEST_MODE", False).lower() in ('true', '1', 't') 34 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | test-db: 4 | image: postgres:12.0 5 | environment: 6 | POSTGRES_DB: tacos 7 | POSTGRES_USER: testuser 8 | POSTGRES_PASSWORD: testpass 9 | test-runner: 10 | build: 11 | dockerfile: Dockerfile.test 12 | context: . 13 | depends_on: 14 | - test-db 15 | environment: 16 | DATABASE_URL: postgresql://testuser:testpass@test-db/tacos 17 | WEBHOOK_TEST_MODE: "true" 18 | SHOPIFY_BILLING_TEST_MODE: "true" 19 | volumes: 20 | - .:/app 21 | command: nose2 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | taco-db: 5 | image: postgres:12.0 6 | environment: 7 | POSTGRES_DB: taco 8 | POSTGRES_USER: taco-api 9 | POSTGRES_PASSWORD: dbpassword 10 | taco-api: 11 | env_file: 12 | - ./.env 13 | build: 14 | dockerfile: Dockerfile.dev 15 | context: . 16 | depends_on: 17 | - taco-db 18 | environment: 19 | DATABASE_URL: postgresql://taco-api:dbpassword@taco-db/taco 20 | FLASK_ENV: dev 21 | LOG_LEVEL: DEBUG 22 | SHOPIFY_BILLING_TEST_MODE: "true" 23 | volumes: 24 | - .:/app 25 | networks: 26 | - default 27 | ports: 28 | - 5000:5000 29 | command: sh -c 'flask db upgrade && gunicorn -w 3 -b :5000 -t 30 --reload wsgi:app' -------------------------------------------------------------------------------- /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/3506c6a93ea6_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration. 2 | 3 | Revision ID: 3506c6a93ea6 4 | Revises: 5 | Create Date: 2021-08-03 14:49:12.957790 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 = '3506c6a93ea6' 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('profile', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), 24 | sa.Column('timestamp_uninstall', sa.DateTime(timezone=True), nullable=True), 25 | sa.Column('timestamp_blacklisted', sa.DateTime(timezone=True), nullable=True), 26 | sa.Column('name', sa.String(length=128), nullable=True), 27 | sa.Column('contact_email', sa.String(length=128), nullable=True), 28 | sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=True), 29 | sa.Column('status', sa.SmallInteger(), nullable=True), 30 | sa.Column('auth_token', sa.String(length=256), nullable=True), 31 | sa.Column('shop_unique_id', sa.String(length=256), nullable=True), 32 | sa.Column('shop_url', sa.String(length=256), nullable=True), 33 | sa.Column('shopify_access_token', sa.String(length=256), nullable=True), 34 | sa.Column('shopify_recurring_subscription_id', sa.String(length=256), nullable=True), 35 | sa.Column('onboarding_shopify_added_webhooks', sa.Boolean(), nullable=True), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table('profile') 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburman/shopify-flask-backend/6c47465c4b594807d29be0aca1398f9bad9e6302/models/__init__.py -------------------------------------------------------------------------------- /models/profile.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column 2 | from sqlalchemy.dialects.postgresql import UUID 3 | import datetime 4 | import secrets 5 | 6 | from common.extensions import db 7 | 8 | 9 | def _generate_shared_secret(): 10 | return secrets.token_urlsafe(16) 11 | 12 | 13 | class Profile(db.Model): 14 | __tablename__ = "profile" 15 | id = Column(db.Integer, 16 | primary_key=True) 17 | created_on = db.Column( 18 | "created_on", 19 | db.DateTime(timezone=True), 20 | default=datetime.datetime.utcnow, 21 | server_default=db.func.now(), 22 | nullable=False, 23 | ) 24 | timestamp_uninstall = db.Column(db.DateTime(timezone=True), 25 | nullable=True) 26 | timestamp_blacklisted = db.Column(db.DateTime(timezone=True), 27 | nullable=True) 28 | name = db.Column(db.String(128), 29 | nullable=True) 30 | contact_email = db.Column(db.String(128), 31 | nullable=True) 32 | customer_id = db.Column(UUID(as_uuid=True), 33 | nullable=True) 34 | # status of profile, currently not used anywhere but maybe one day? ;) 35 | status = Column(db.SmallInteger, default=1) 36 | # Used to validate api requests from for example Shopify 37 | auth_token = Column(db.String(256), 38 | default=_generate_shared_secret) 39 | 40 | shop_unique_id = Column(db.String(256)) # For shopify this is the same as the shop url 41 | shop_url = Column(db.String(256)) 42 | # The Shopify access token 43 | shopify_access_token = Column(db.String(256), 44 | nullable=True) 45 | shopify_recurring_subscription_id = db.Column(db.String(256), 46 | nullable=True) 47 | onboarding_shopify_added_webhooks = db.Column(db.Boolean, 48 | default=False) 49 | 50 | @staticmethod 51 | def check_token(token): 52 | profile = Profile.query.filter_by(auth_token=token).first() 53 | if profile is None: 54 | return None 55 | return profile 56 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask >= 1.1.1 2 | ShopifyAPI >= 5.1.2 3 | python-dotenv >= 0.10.3 4 | Flask-SQLAlchemy==2.4.4 5 | Flask-Migrate==2.5.3 6 | gunicorn==20.0.4 7 | psycopg2-binary==2.8.6 8 | Flask-Cors==3.0.9 9 | simplejson==3.17.2 10 | Flask-HTTPAuth==4.2.0 11 | requests==2.25.0 12 | stripe==2.55.1 13 | -------------------------------------------------------------------------------- /service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburman/shopify-flask-backend/6c47465c4b594807d29be0aca1398f9bad9e6302/service/__init__.py -------------------------------------------------------------------------------- /service/profile.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from common.extensions import db 3 | from models.profile import Profile 4 | 5 | log = logging.getLogger('werkzeug') 6 | 7 | 8 | def create(shop_unique_id, 9 | contact_email=None, 10 | shop_url=None, 11 | shopify_access_token=None, 12 | name=None, 13 | accepted_terms_and_conditions=None, 14 | customer_id=None, 15 | platform="SHOPIFY"): 16 | """Create a profile""" 17 | profile = Profile.query.filter_by(shop_unique_id=shop_unique_id).first() 18 | if not profile: 19 | profile = Profile(shop_unique_id=shop_unique_id, 20 | contact_email=contact_email, 21 | name=name, 22 | shop_url=shop_url, 23 | shopify_access_token=shopify_access_token, 24 | customer_id=customer_id) 25 | db.session.add(profile) 26 | else: 27 | profile.status = 1 28 | profile.shopify_access_token = shopify_access_token 29 | profile.name = name 30 | profile.contact_email = contact_email 31 | profile.shop_url = shop_url 32 | profile.timestamp_uninstall = None 33 | profile.platform = platform 34 | if accepted_terms_and_conditions: 35 | profile.accepted_tos = True 36 | try: 37 | db.session.commit() 38 | except Exception as e: # NOQA 39 | log.exception(f'Failed to persist profile:') 40 | return profile 41 | 42 | 43 | def update(shop_unique_id, data): 44 | profile = Profile.query.filter_by(shop_unique_id=shop_unique_id).update(data) 45 | db.session.commit() 46 | return profile 47 | 48 | 49 | def get_tokens(shop_unique_id): 50 | profile = Profile.query.filter_by(shop_unique_id=shop_unique_id).first() 51 | if profile: 52 | return {"shopify_access_token": profile.shopify_access_token, "auth_token": profile.auth_token} 53 | return {} 54 | 55 | 56 | def get(shop_unique_id): 57 | profile = Profile.query.filter_by(shop_unique_id=shop_unique_id).first() 58 | if profile: 59 | return profile.__dict__ 60 | else: 61 | return {'shopify_access_token': None, 62 | 'status': None, 63 | 'shop_unique_id': None} 64 | 65 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredrikburman/shopify-flask-backend/6c47465c4b594807d29be0aca1398f9bad9e6302/tests/__init__.py -------------------------------------------------------------------------------- /tests/base_case.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import app 3 | from common.extensions import db 4 | from models.profile import Profile 5 | from tests import const 6 | 7 | 8 | class BaseCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.db = db 12 | app.config['TESTING'] = True 13 | app.config['WTF_CSRF_ENABLED'] = False 14 | app.config['SQLALCHEMY_DATABASE_URI'] = const.DB_URI 15 | self.app = app.test_client() 16 | with app.app_context(): 17 | db.create_all() 18 | self.setup_shops() 19 | 20 | def tearDown(self): 21 | # Delete Database collections after the test is complete 22 | with app.app_context(): 23 | self.db.drop_all() 24 | 25 | def setup_shops(self): 26 | profile_shopify_not_installed = Profile( 27 | shop_unique_id=const.SHOP_SHOPIFY_NOT_INSTALLED, 28 | contact_email='not-installed@shopify.com', 29 | name="Shopify Not installed", 30 | shop_url="https://not-installed-store.myshopify.com", 31 | ) 32 | profile_shopify = Profile( 33 | shop_unique_id=const.SHOP_SHOPIFY, 34 | contact_email='shopify@hello.com', 35 | name="Shopify Name", 36 | shop_url="https://super-store.myshopify.com", 37 | customer_id=const.CUSTOMER_ID_SHOPIFY, 38 | ) 39 | profile_shopify_blacklisted = Profile( 40 | shop_unique_id=const.SHOP_SHOPIFY_BLACKLISTED, 41 | contact_email='shopify2@hello.com', 42 | name="Shopify 2 Name", 43 | shop_url="https://blacklisted.myshopify.com", 44 | customer_id=const.CUSTOMER_ID_SHOPIFY_BLACKLISTED, 45 | ) 46 | 47 | with app.app_context(): 48 | db.session.add(profile_shopify_not_installed) 49 | db.session.add(profile_shopify) 50 | db.session.add(profile_shopify_blacklisted) 51 | db.session.commit() 52 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | import os 2 | SHOP_SHOPIFY = "some-random-shopify-store.myshopify.com" 3 | SHOP_SHOPIFY_BLACKLISTED = "blacklisted.myshopify.com" 4 | SHOP_SHOPIFY_NOT_INSTALLED = "not-installed-shopify-store.myshopify.com" 5 | 6 | CUSTOMER_ID_SHOPIFY = "d0e60383-1746-4089-bc86-4d238eb2012d" 7 | CUSTOMER_ID_SHOPIFY_BLACKLISTED = "d0e60383-1746-4089-bc86-4d238eb2012a" 8 | CUSTOMER_ID_WOOCOMMERCE = "a6e1acfc-53ac-4922-8411-15d594d4ded8" 9 | 10 | DB_URI = os.getenv("DATABASE_URL", "postgresql+psycopg2://test_user:password@localhost/testdb") 11 | -------------------------------------------------------------------------------- /tests/test_app_shopify.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | from unittest.mock import patch 4 | 5 | from app import app 6 | from models.profile import Profile 7 | 8 | from tests import const 9 | from tests.base_case import BaseCase 10 | 11 | 12 | class ShopifyTest(BaseCase): 13 | 14 | @patch('app_shopify.views.customer_service.create_customer') 15 | def test_to_complete_onboarding(self, mock_tx_service): 16 | with app.app_context(): 17 | profile = Profile.query.filter_by( 18 | shop_unique_id=const.SHOP_SHOPIFY).first() 19 | 20 | name_of_user = "kg" 21 | contact_email = "new_email@demo.com" 22 | 23 | payload = { 24 | "nameOfUser": name_of_user, 25 | "contactEmail": contact_email, 26 | "shop": profile.shop_unique_id, 27 | } 28 | mocked_customer_id = "b6c9a69e-1b6e-4a5e-9787-b9574e5f3aaa" 29 | mock_tx_service.return_value = mocked_customer_id 30 | response_for_unauthorized = self.app.post('/shopify/profile', 31 | headers={ 32 | "Content-Type": "application/json" 33 | }, data=json.dumps(payload)) 34 | self.assertEqual(401, response_for_unauthorized.status_code) 35 | 36 | response = self.app.post('/shopify/profile', 37 | headers={ 38 | "Content-Type": "application/json", 39 | "Authorization": "Bearer {}".format(profile.auth_token) 40 | }, data=json.dumps(payload)) 41 | self.assertEqual(200, response.status_code) 42 | with app.app_context(): 43 | profile_after = Profile.query.filter_by( 44 | shop_unique_id=const.SHOP_SHOPIFY).first() 45 | self.assertEqual(name_of_user, profile_after.name, 46 | msg="Failed to update name") 47 | self.assertEqual(contact_email, profile_after.contact_email, 48 | msg="Failed to update contact email") 49 | 50 | @patch('app_shopify.views.verify_webhook') 51 | @patch('app_shopify.webhooks.tx_service') 52 | def test_order_paid_webook_no_compensation(self, mock_tx, mock_hmac): 53 | mock_tx.create_tx.return_value = "core-transaction-id" 54 | order = get_order_paid_webhook_data() 55 | del order['line_items'][0] 56 | 57 | order_id = str(order['id']) 58 | response = self.app.post('/shopify/webhook', headers={ 59 | "Content-Type": "application/json", 60 | "X_SHOPIFY_TOPIC": "orders/paid", 61 | "X-SHOPIFY-HMAC-SHA256": "bla-bla-bla", 62 | "X-SHOPIFY_SHOP_DOMAIN": const.SHOP_SHOPIFY 63 | }, data=json.dumps(order)) 64 | self.assertEqual(200, response.status_code) 65 | 66 | @patch('app_shopify.views.verify_webhook') 67 | @patch('app_shopify.webhooks.tx_service') 68 | def test_order_paid_webook_failed_charge(self, mock_tx, mock_hmac): 69 | mock_tx.create_tx.return_value = "core-transaction-id" 70 | mock_tx.create_usage_charge.side_effect = Exception("failed!") 71 | order = get_order_paid_webhook_data() 72 | 73 | order_id = str(order['id']) 74 | response = self.app.post('/shopify/webhook', headers={ 75 | "Content-Type": "application/json", 76 | "X_SHOPIFY_TOPIC": "orders/paid", 77 | "X-SHOPIFY-HMAC-SHA256": "bla-bla-bla", 78 | "X-SHOPIFY_SHOP_DOMAIN": const.SHOP_SHOPIFY 79 | }, data=json.dumps(order)) 80 | self.assertEqual(200, response.status_code) 81 | 82 | mock_tx.fail_order.assert_called_once_with(const.SHOP_SHOPIFY, order_id) 83 | 84 | @patch('app_shopify.views.verify_webhook') 85 | def test_uninstall_webhook(self, mock_webhook_hmac): 86 | response = self.app.post('/shopify/webhook', headers={ 87 | "Content-Type": "application/json", 88 | "X_SHOPIFY_TOPIC": "app/uninstalled", 89 | "X-SHOPIFY-HMAC-SHA256": "bla-bla-bla", 90 | "X-SHOPIFY_SHOP_DOMAIN": const.SHOP_SHOPIFY 91 | }, data=json.dumps({})) 92 | self.assertEqual(200, response.status_code) 93 | with app.app_context(): 94 | profile = Profile.query.filter_by( 95 | shop_unique_id=const.SHOP_SHOPIFY).first() 96 | self.assertFalse(profile.status, 97 | msg="Profile wasnt uninstalled properly") 98 | 99 | 100 | def get_order_paid_webhook_data(): 101 | return deepcopy({ 102 | "id": 2704107995290, 103 | "email": "this-should-be-removed@demo.com", 104 | "closed_at": "None", 105 | "created_at": "2020-09-16T02:32:46-04:00", 106 | "updated_at": "2020-09-16T02:32:47-04:00", 107 | "number": 8, 108 | "note": "None", 109 | "token": "82c7ea520c11c086a5659e6b03aa226a", 110 | "gateway": "bogus", 111 | "test": True, 112 | "total_price": "32.46", 113 | "subtotal_price": "15.20", 114 | "total_weight": 10000, 115 | "total_tax": "0.00", 116 | "taxes_included": True, 117 | "currency": "EUR", 118 | "financial_status": "paid", 119 | "confirmed": True, 120 | "total_discounts": "0.00", 121 | "total_line_items_price": "15.20", 122 | "cart_token": "a7927b2d5ee021b2d4f54225ab09c901", 123 | "buyer_accepts_marketing": False, 124 | "name": "#1008", 125 | "referring_site": "", 126 | "landing_site": "/", 127 | "cancelled_at": "None", 128 | "cancel_reason": "None", 129 | "total_price_usd": "38.52", 130 | "checkout_token": "68c914958c57087ccc66a9c89b41f439", 131 | "reference": "None", 132 | "user_id": "None", 133 | "location_id": "None", 134 | "source_identifier": "None", 135 | "source_url": "None", 136 | "processed_at": "2020-09-16T02:32:45-04:00", 137 | "device_id": "None", 138 | "phone": "None", 139 | "customer_locale": "en", 140 | "app_id": 580111, 141 | "browser_ip": "this.should.also.be.removed", 142 | "landing_site_ref": "None", 143 | "order_number": 1008, 144 | "discount_applications": [ 145 | 146 | ], 147 | "discount_codes": [ 148 | 149 | ], 150 | "note_attributes": [ 151 | 152 | ], 153 | "payment_gateway_names": [ 154 | "bogus" 155 | ], 156 | "processing_method": "direct", 157 | "checkout_id": 14907243888794, 158 | "source_name": "web", 159 | "fulfillment_status": "None", 160 | "tax_lines": [ 161 | ], 162 | "tags": "", 163 | "contact_email": "this-should-be-removed@demo.com", 164 | "order_status_url": "https://some-random-store-about-nachos.myshopify.com/44984500378/orders/82c7ea520c11c086a5659e6b03aa226a/authenticate?key=99bc4875c9e777b68f971f90fe549f9b", 165 | "presentment_currency": "SEK", 166 | "total_line_items_price_set": { 167 | "shop_money": { 168 | "amount": "15.20", 169 | "currency_code": "EUR" 170 | }, 171 | "presentment_money": { 172 | "amount": "158.37", 173 | "currency_code": "SEK" 174 | } 175 | }, 176 | "total_discounts_set": { 177 | "shop_money": { 178 | "amount": "0.00", 179 | "currency_code": "EUR" 180 | }, 181 | "presentment_money": { 182 | "amount": "0.00", 183 | "currency_code": "SEK" 184 | } 185 | }, 186 | "total_shipping_price_set": { 187 | "shop_money": { 188 | "amount": "17.26", 189 | "currency_code": "EUR" 190 | }, 191 | "presentment_money": { 192 | "amount": "179.72", 193 | "currency_code": "SEK" 194 | } 195 | }, 196 | "subtotal_price_set": { 197 | "shop_money": { 198 | "amount": "15.20", 199 | "currency_code": "EUR" 200 | }, 201 | "presentment_money": { 202 | "amount": "158.37", 203 | "currency_code": "SEK" 204 | } 205 | }, 206 | "total_price_set": { 207 | "shop_money": { 208 | "amount": "32.46", 209 | "currency_code": "EUR" 210 | }, 211 | "presentment_money": { 212 | "amount": "338.09", 213 | "currency_code": "SEK" 214 | } 215 | }, 216 | "total_tax_set": { 217 | "shop_money": { 218 | "amount": "0.00", 219 | "currency_code": "EUR" 220 | }, 221 | "presentment_money": { 222 | "amount": "0.00", 223 | "currency_code": "SEK" 224 | } 225 | }, 226 | "line_items": [ 227 | { 228 | "id": 5886999888026, 229 | "variant_id": 36237290471578, 230 | "title": "Tigers & Tacos", 231 | "quantity": 1, 232 | "sku": "", 233 | "variant_title": "e54cfba7-ce6f-4de1-a94b-7facca8a96ab", 234 | "vendor": "Tigers & Tacos", 235 | "fulfillment_service": "manual", 236 | "product_id": 5718277390490, 237 | "requires_shipping": True, 238 | "taxable": True, 239 | "gift_card": False, 240 | "name": "Nacho - e54cfba7-ce6f-4de1-a94b-7facca8a96ab", 241 | "variant_inventory_management": "shopify", 242 | "properties": [ 243 | 244 | ], 245 | "product_exists": True, 246 | "fulfillable_quantity": 1, 247 | "grams": 0, 248 | "price": "5.05", 249 | "total_discount": "0.00", 250 | "fulfillment_status": "None", 251 | "price_set": { 252 | "shop_money": { 253 | "amount": "5.05", 254 | "currency_code": "EUR" 255 | }, 256 | "presentment_money": { 257 | "amount": "52.65", 258 | "currency_code": "SEK" 259 | } 260 | }, 261 | "total_discount_set": { 262 | "shop_money": { 263 | "amount": "0.00", 264 | "currency_code": "EUR" 265 | }, 266 | "presentment_money": { 267 | "amount": "0.00", 268 | "currency_code": "SEK" 269 | } 270 | }, 271 | "discount_allocations": [ 272 | 273 | ], 274 | "duties": [ 275 | 276 | ], 277 | "admin_graphql_api_id": "gid://shopify/LineItem/5886999888026", 278 | "tax_lines": [ 279 | 280 | ], 281 | "origin_location": { 282 | "id": 2300131606682, 283 | "country_code": "SE", 284 | "province_code": "", 285 | "name": "some-random-store-about-nachos", 286 | "address1": "Vasagatan 47", 287 | "address2": "", 288 | "city": "Helsinki", 289 | "zip": "00130" 290 | } 291 | }, 292 | { 293 | "id": 5886999953562, 294 | "variant_id": 35562416668826, 295 | "title": "Widgets", 296 | "quantity": 1, 297 | "sku": "100", 298 | "variant_title": "", 299 | "vendor": "some-random-store-about-nachos", 300 | "fulfillment_service": "manual", 301 | "product_id": 5578484154522, 302 | "requires_shipping": True, 303 | "taxable": True, 304 | "gift_card": False, 305 | "name": "Widgets", 306 | "variant_inventory_management": "None", 307 | "properties": [ 308 | 309 | ], 310 | "product_exists": True, 311 | "fulfillable_quantity": 1, 312 | "grams": 10000, 313 | "price": "10.15", 314 | "total_discount": "0.00", 315 | "fulfillment_status": "None", 316 | "price_set": { 317 | "shop_money": { 318 | "amount": "10.15", 319 | "currency_code": "EUR" 320 | }, 321 | "presentment_money": { 322 | "amount": "105.72", 323 | "currency_code": "SEK" 324 | } 325 | }, 326 | "total_discount_set": { 327 | "shop_money": { 328 | "amount": "0.00", 329 | "currency_code": "EUR" 330 | }, 331 | "presentment_money": { 332 | "amount": "0.00", 333 | "currency_code": "SEK" 334 | } 335 | }, 336 | "discount_allocations": [ 337 | 338 | ], 339 | "duties": [ 340 | 341 | ], 342 | "admin_graphql_api_id": "gid://shopify/LineItem/5886999953562", 343 | "tax_lines": [ 344 | 345 | ], 346 | "origin_location": { 347 | "id": 2300131606682, 348 | "country_code": "SE", 349 | "province_code": "", 350 | "name": "tigers & tacos", 351 | "address1": "Vasagatan 47", 352 | "address2": "", 353 | "city": "Stockholm", 354 | "zip": "00130" 355 | } 356 | } 357 | ], 358 | "fulfillments": [ 359 | 360 | ], 361 | "refunds": [ 362 | 363 | ], 364 | "total_tip_received": "0.0", 365 | "original_total_duties_set": "None", 366 | "current_total_duties_set": "None", 367 | "admin_graphql_api_id": "gid://shopify/Order/2704107995290", 368 | "shipping_lines": [ 369 | { 370 | "id": 2246394249370, 371 | "title": "Standard", 372 | "price": "17.26", 373 | "code": "Standard", 374 | "source": "shopify", 375 | "phone": "None", 376 | "requested_fulfillment_service_id": "None", 377 | "delivery_category": "None", 378 | "carrier_identifier": "None", 379 | "discounted_price": "17.26", 380 | "price_set": { 381 | "shop_money": { 382 | "amount": "17.26", 383 | "currency_code": "EUR" 384 | }, 385 | "presentment_money": { 386 | "amount": "179.72", 387 | "currency_code": "SEK" 388 | } 389 | }, 390 | "discounted_price_set": { 391 | "shop_money": { 392 | "amount": "17.26", 393 | "currency_code": "EUR" 394 | }, 395 | "presentment_money": { 396 | "amount": "179.72", 397 | "currency_code": "SEK" 398 | } 399 | }, 400 | "discount_allocations": [ 401 | 402 | ], 403 | "tax_lines": [ 404 | 405 | ] 406 | } 407 | ], 408 | "billing_address": { 409 | "first_name": "Tiger", 410 | "address1": "Engelbrektsgatan 7", 411 | "phone": "None", 412 | "city": "Stockholm", 413 | "zip": "10500", 414 | "province": "None", 415 | "country": "Sweden", 416 | "last_name": "b", 417 | "address2": "", 418 | "company": "None", 419 | "latitude": "None", 420 | "longitude": "None", 421 | "name": "Tiger b", 422 | "country_code": "SE", 423 | "province_code": "None" 424 | }, 425 | "shipping_address": { 426 | "first_name": "Tiger", 427 | "address1": "Engelbrektsgatan 7", 428 | "phone": "None", 429 | "city": "Stockholm", 430 | "zip": "10500", 431 | "province": "None", 432 | "country": "Sweden", 433 | "last_name": "b", 434 | "address2": "", 435 | 436 | 437 | "company": "None", 438 | "latitude": "None", 439 | "longitude": "None", 440 | "name": "Tiger b", 441 | "country_code": "SE", 442 | "province_code": "None" 443 | }, 444 | "client_details": { 445 | "browser_ip": "this.should.also.be.removed", 446 | "accept_language": "en-GB,en-US;q=0.9,en;q=0.8", 447 | "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36", 448 | "session_hash": "79191efdcc6b235e4cba38ff21ce9d8f", 449 | "browser_width": 1266, 450 | "browser_height": 483 451 | }, 452 | "payment_details": { 453 | "credit_card_bin": "1", 454 | "avs_result_code": "None", 455 | "cvv_result_code": "None", 456 | "credit_card_number": "•••• •••• •••• 1", 457 | "credit_card_company": "Bogus" 458 | }, 459 | "customer": { 460 | "id": 3856580083866, 461 | "email": "this-should-be-removed@demo.com", 462 | "accepts_marketing": False, 463 | "created_at": "2020-08-13T16:07:09-04:00", 464 | "updated_at": "2020-09-16T02:32:46-04:00", 465 | "first_name": "Tiger", 466 | "last_name": "b", 467 | "orders_count": 0, 468 | "state": "disabled", 469 | "total_spent": "0.00", 470 | "last_order_id": "None", 471 | "note": "None", 472 | "verified_email": True, 473 | "multipass_identifier": "None", 474 | "tax_exempt": False, 475 | "phone": "None", 476 | "tags": "", 477 | "last_order_name": "None", 478 | "currency": "EUR", 479 | "accepts_marketing_updated_at": "2020-08-13T16:07:09-04:00", 480 | "marketing_opt_in_level": "None", 481 | "admin_graphql_api_id": "gid://shopify/Customer/3856580083866", 482 | "default_address": { 483 | "id": 4582489161882, 484 | "customer_id": 3856580083866, 485 | "first_name": "Tiger", 486 | "last_name": "b", 487 | "company": "None", 488 | "address1": "Engelbrektsgatan 7", 489 | "address2": "", 490 | "city": "Stockholm", 491 | "province": "None", 492 | "country": "Sweden", 493 | "zip": "10500", 494 | "phone": "None", 495 | "name": "Tiger b", 496 | "province_code": "None", 497 | "country_code": "SE", 498 | "country_name": "Sweden", 499 | "default": True 500 | } 501 | } 502 | }) 503 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | 3 | if __name__ == "__main__": 4 | app.run() 5 | --------------------------------------------------------------------------------