# django-shopify-app

A reusable Django package for building Shopify apps. Handles OAuth, token exchange, webhook management, session token validation (JWT), and Shopify API interactions.

## Installation

```bash
pip install django-shopify-app
```

Add the app in settings.py:

```python
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'shopify_app',
    'shops',
]
```

## Settings

```python
# Required
SHOPIFY_API_KEY = config('SHOPIFY_API_KEY')
SHOPIFY_API_SECRET = config('SHOPIFY_API_SECRET')
SHOPIFY_APP_HOST = 'https://your-app.com'
SHOPIFY_SHOP_MODEL = 'shops.Shop'              # Must inherit from ShopBase
SHOPIFY_WEBHOOK_CALLBACK = 'shops.webhooks.webhook_entry'
SHOPIFY_GDPR_WEBHOOK_CALLBACK = 'shops.webhooks.gdpr_webhook_entry'

# Optional
SHOPIFY_APP_SCOPES = ['read_products', 'read_orders']
SHOPIFY_WEBHOOK_TOPICS = ['products/update', 'app/uninstalled']
SHOPIFY_WEBHOOK_HOST = 'https://your-app.com'  # Defaults to SHOPIFY_APP_HOST
SHOPIFY_API_VERSION = '2025-04'                # Defaults to '2022-04'

# Token exchange (recommended for embedded apps)
SHOPIFY_TOKEN_EXCHANGE = False                 # Default: False
SHOPIFY_DASHBOARD_PATH = '/dashboard'          # Default: '/dashboard'

# Staff bypass
SHOPIFY_STAFF_BYPASS = False                   # Default: False
SHOPIFY_STAFF_BYPASS_METHODS = None            # Default: None (all methods)
SHOPIFY_STAFF_SHOP_ATTR = 'admin_shop'         # Default: 'admin_shop'
```

## ShopBase model

Your app must define a Shop model that inherits from `ShopBase`:

```python
from shopify_app.models import ShopBase

class Shop(ShopBase):
    # Add your custom fields
    plan_name = models.CharField(max_length=50, default='')

    def installed(self, request=None):
        """Called when the app is installed on a shop for the first time."""
        self.update_webhooks()
        self.crm_on_install()

    def on_user_login(self, user_data, request=None):
        """Called when a Shopify user opens the app.

        user_data contains: id, first_name, last_name, email,
        email_verified, account_owner, locale, collaborator
        """
        self.crm_on_login(user_data)
```

### Fields

| Field | Type | Description |
|-------|------|-------------|
| `shopify_domain` | CharField | The shop's `.myshopify.com` domain |
| `shopify_token` | CharField | Offline access token |
| `access_scopes` | CharField | Granted OAuth scopes |
| `seen_users` | JSONField | Tracks user logins `{user_id: {last_login: ...}}` |
| `shopify_app_api_key_overwrite` | CharField | Per-shop API key override |
| `shopify_app_api_secret_overwrite` | CharField | Per-shop API secret override |

### API methods

All REST methods replace `api_version` in the path automatically:

```python
# REST API
shop.get('/admin/api/api_version/products.json')
shop.post('/admin/api/api_version/products.json', data={...})
shop.put('/admin/api/api_version/products/123.json', data={...})
shop.patch('/admin/api/api_version/products/123.json', data={...})
shop.delete_request('/admin/api/api_version/products/123.json')

# GraphQL
response = shop.graphql(query='{ shop { name } }')
response = shop.graphql(query=mutation, variables={...}, api_version='2025-04')

# GraphQL via shopify library (uses session context manager)
result = shop.graph(operation_name='GetShop', variables={}, operations_document=query)
```

### Webhook management

```python
shop.update_webhooks()     # Deactivate all + reactivate from SHOPIFY_WEBHOOK_TOPICS
shop.activate_webhooks()   # Alias for update_webhooks()
shop.deactivate_webhooks() # Remove all registered webhooks
```

### CRM events

Built-in hooks for CRM lifecycle events (via `django-crm-events`):

```python
shop.crm_on_install()
shop.crm_on_uninstall(users=[])
shop.crm_on_login(user_data)
shop.crm_on_billing_plan_change(plan_price)
```

### Other properties

```python
shop.shopify_app_api_key      # Returns overwrite or global setting
shop.shopify_app_api_secret   # Returns overwrite or global setting
shop.api_version              # From SHOPIFY_API_VERSION setting
shop.host                     # Base64-encoded admin URL for App Bridge
shop.get_shop_data()          # Fetch shop details from Shopify REST API
```

### HMAC validation

```python
shop.app_proxy_request_is_valid(request)     # Validate app proxy HMAC
shop.request_shopify_hmac_is_valid(request)   # Validate standard Shopify HMAC
```

## Authorization

The package supports two authorization flows: **token exchange** (recommended for embedded apps) and **authorization code grant** (legacy / non-embedded apps).

### Token exchange (recommended)

Token exchange eliminates OAuth redirects. The backend exchanges the session token from App Bridge for an access token via a server-side POST to Shopify. No page reloads or flicker.

Scopes are managed via `shopify.app.toml` and deployed with Shopify CLI (`shopify app deploy`). Shopify handles installation and scope updates automatically.

Add to settings.py:

```python
SHOPIFY_TOKEN_EXCHANGE = True           # Enable token exchange
SHOPIFY_DASHBOARD_PATH = '/dashboard'   # Where to redirect from app root
```

Set up your URLs:

```python
from django.urls import path, include
from shopify_app.views import AppRootView

urlpatterns = [
    path('', AppRootView.as_view()),
    path('shopify/', include('shopify_app.urls')),
    # your dashboard urls...
]
```

When a merchant opens your app, `AppRootView` redirects to the dashboard. The first API request from the dashboard triggers token exchange automatically via `ShopSessionMixin` / `shop_session`, storing the access token for subsequent requests.

User login tracking is handled automatically: on the first request from a new user (or after 24 hours), an online token exchange fetches user details and fires `shop.on_user_login()`.

### Authorization code grant (legacy)

For non-embedded apps or apps that don't use Shopify managed installation.

```python
from django.urls import path
from shopify_app.views import AppRootView, EndTokenRequestView

app_name = 'my_shopify_app'

urlpatterns = [
    path(
        '',
        AppRootView.as_view(
            redirect_path_name='my_shopify_app:end-token-request',
        ),
    ),
    path(
        'confirm/',
        EndTokenRequestView.as_view(
            redirect_path_name='embed_admin:dashboard',
        ),
        name='end-token-request'
    ),
]
```

With `SHOPIFY_TOKEN_EXCHANGE = False` (default), `AppRootView` falls back to the OAuth authorization code grant flow.

### Webhook URLs

Include the package URLs for webhook and GDPR endpoints:

```python
from django.urls import path, include

urlpatterns = [
    path('shopify/', include('shopify_app.urls')),
]
```

This registers:
- `shopify/webhooks` — Main webhook receiver
- `shopify/gdpr-webhooks/customer-data-request` — GDPR customer data request
- `shopify/gdpr-webhooks/customer-data-erasure` — GDPR customer data erasure
- `shopify/gdpr-webhooks/shop-data-erasure` — GDPR shop data erasure

## ShopSessionMixin

A mixin that authenticates requests against a valid Shopify shop session (JWT). Use it with any `APIView` or DRF generic view:

```python
from rest_framework.views import APIView
from shopify_app.mixins import ShopSessionMixin

class MyView(ShopSessionMixin, APIView):
    def get(self, request, *args, **kwargs):
        shop = request.shop
        ...
```

### Staff bypass

Staff users can skip Shopify JWT validation if they have a shop associated with their user model. Enable it globally in settings:

```python
SHOPIFY_STAFF_BYPASS = True  # Default: False
SHOPIFY_STAFF_BYPASS_METHODS = ['GET', 'HEAD', 'OPTIONS']  # Default: None (all methods)
SHOPIFY_STAFF_SHOP_ATTR = 'admin_shop'  # Default: 'admin_shop'
```

`SHOPIFY_STAFF_BYPASS_METHODS` restricts which HTTP methods are allowed through the bypass. When `None` (default), all methods are allowed. When set, unlisted methods (e.g. POST, PUT, DELETE) will require Shopify JWT validation even for staff users.

Or per-view:

```python
class MyView(ShopSessionMixin, APIView):
    allow_staff_bypass = True  # Overrides the global setting
```

When enabled, if the request user is authenticated, is staff, and has a truthy value on the configured attribute (`admin_shop` by default), the mixin sets `request.shop` from that attribute and skips JWT validation.

## Decorators

### @shop_session

Validates the Shopify session token (JWT) from the request header. Injects `shop`, `shopify_domain`, and `shopify_user_id` into the view kwargs:

```python
from shopify_app.decorators import shop_session

@shop_session
def my_view(request, *args, **kwargs):
    shop = kwargs['shop']
    user_id = kwargs['shopify_user_id']
```

### @shopify_embed

Adds Content-Security-Policy `frame-ancestors` header for embedded app views:

```python
from shopify_app.decorators import shopify_embed

@shopify_embed
def my_view(request, **kwargs):
    ...
```

### @known_shop_required

Validates that a `shop` query parameter is present and the shop exists in the database. Returns 401 if invalid:

```python
from shopify_app.decorators import known_shop_required

@known_shop_required
def my_view(request, *args, **kwargs):
    shop = kwargs['shop']
```

### @app_proxy_view

Validates HMAC for Shopify app proxy requests:

```python
from shopify_app.decorators import app_proxy_view

@app_proxy_view
def my_proxy_view(request, *args, **kwargs):
    shop = kwargs['shop']
```

### @latest_access_scopes_required

Checks if the shop's stored scopes match the configured scopes. Sets `scope_changes_required=True` in kwargs if they differ:

```python
from shopify_app.decorators import latest_access_scopes_required

@shop_session
@latest_access_scopes_required
def my_view(request, *args, **kwargs):
    if kwargs.get('scope_changes_required'):
        # handle scope update
```

## Utilities

```python
from shopify_app.utils import (
    get_shop_model,          # Resolve shop model from SHOPIFY_SHOP_MODEL setting
    get_shop,                # Get shop by domain (raises DoesNotExist)
    get_auth_shop,           # Get shop or unsaved instance for auth lookup
    webhook_request_is_valid, # Validate webhook HMAC
    liquid_render,           # Render template with Liquid content-type
    app_proxy_url_builder,   # Build full URL for app proxy requests
    app_proxy_redirect,      # Redirect to app proxy URL
)
```

## Management commands

```bash
# Activate webhooks for a shop by subdomain
python manage.py activate_shop_webhooks <shopify_subdomain>
# e.g. activate_shop_webhooks example-store → targets example-store.myshopify.com
```
