Metadata-Version: 2.4
Name: django_shopify_app
Version: 2.9.0
Summary: A django app with all the tools required to make a Shopify app
Author: Santiago Fernandez
License-Expression: MIT
Project-URL: Homepage, http://pypi.python.org/pypi/django_shopify_app/
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: Django<7,>=5.2
Requires-Dist: djangorestframework>=3.14
Requires-Dist: shopifyapi>=12.7.0
Requires-Dist: django-crm-events
Requires-Dist: cryptography>=42.0.0
Requires-Dist: requests>=2.25

# 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
```

Supports Python 3.10+ and Django 5.2 LTS through 6.1. The floor tracks the oldest Django branch that still receives security fixes, so expect it to move when a branch reaches end of life.

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'
SHOPIFY_TOKEN_DB_ALIAS = 'shopify_tokens'       # DATABASES alias for token writes; see
                                               #   "Token database connection" below

# 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 = '2026-07'                # Pin this; see note below

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

# Expiring offline access tokens (Shopify, Dec 2025)
SHOPIFY_EXPIRING_TOKENS = False                # Default: False — opt into expiring
                                               #   offline tokens + refresh rotation
SHOPIFY_TOKEN_REFRESH_SKEW = 300               # Default: 300s — refresh this many
                                               #   seconds before access-token expiry
SHOPIFY_HTTP_TIMEOUT = (5, 30)                 # Default: (connect, read). Applies to ALL
                                               #   outbound Shopify calls. Read is generous so
                                               #   a processed refresh isn't abandoned.
SHOPIFY_TOKEN_ENCRYPTION_KEY = config('SHOPIFY_TOKEN_ENCRYPTION_KEY')
                                               # Fernet key encrypting refresh_token at
                                               #   rest. Required once a refresh token is
                                               #   stored (i.e. when expiring tokens are on).
                                               #   May be a list of keys for rotation.
                                               # Generate: python -c "from cryptography.fernet
                                               #   import Fernet; print(Fernet.generate_key().decode())"

# Per-shop credential overrides (replaces DB overwrite fields)
SHOPIFY_CREDENTIALS_OVERRIDES = {              # Default: {}
    'shop.myshopify.com': {
        'api_key': '...',
        'api_secret': '...',
    },
}

# 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'
```

**API version.** Shopify supports each stable API version for 12 months. Set `SHOPIFY_API_VERSION` explicitly and bump it on a schedule; the package default (`ShopifyAppConfig.DEFAULT_API_VERSION`) is only updated with package releases. The `shopify_app.W001` system check, run by `manage.py check`, `runserver` and `migrate`, warns when the configured version has reached end of life.

## 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 token_reacquired(self, request=None):
        """Called instead of installed() when a shop whose rejected token was cleared
        gets a new one: the store reopened, or the app was reinstalled after a lost
        app/uninstalled webhook. Defaults to installed(); override it to skip install
        work that shouldn't run twice."""
        self.update_webhooks()

    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: ...}}` |
| `refresh_token` | EncryptedCharField | Refresh token for expiring offline tokens (encrypted at rest). Empty unless `SHOPIFY_EXPIRING_TOKENS` is on |
| `token_expires_at` | DateTimeField | When the offline access token expires (expiring tokens only) |
| `refresh_token_expires_at` | DateTimeField | When the refresh token expires — 90-day window (expiring tokens only) |
| `refresh_token_rejected_at` | DateTimeField | When Shopify first rejected the shop's stored token: the refresh token (dead token or app uninstalled; the token is kept), or a permanent token it refused to migrate (the token is cleared). Cleared when a new token is stored. Read it through `shop.reauth_required` |

> **Note:** `refresh_token`, `token_expires_at` and `refresh_token_expires_at` were added in 2.4.0, `refresh_token_rejected_at` in 2.7.0. Because `ShopBase` is abstract, run `makemigrations` in your app to add the columns to your concrete Shop model.

### 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 per-shop override or global setting
shop.shopify_app_api_secret   # Returns per-shop override 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
shop.valid_access_token()     # Access token, auto-rotated if expiring + stale
shop.reauth_required          # True when Shopify rejected the refresh token (see below)
```

All REST/GraphQL helper methods send `valid_access_token()`. With `SHOPIFY_EXPIRING_TOKENS` enabled, that proactively rotates a near-expiry token, and the `requests`-based helpers (`get`/`post`/`put`/`patch`/`delete_request`/`graphql`) additionally refresh-and-retry once on a `401`. With the flag off, the token is returned as-is.

### 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, forwarding only the `shop`, `host`, `locale` and `embedded` query parameters. The `id_token`, `hmac`, `signature`, `session` and `timestamp` values Shopify appends to the app URL are dropped so they never appear in the dashboard URL or in access logs. 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. The `shop` query parameter is validated as a `*.myshopify.com` domain before the OAuth URL is built; anything else gets a 400. If you override the `shopify_app/index.html` template, keep the `redirect` value escaped with `escapejs`.

### Expiring offline access tokens

As of December 2025 Shopify supports expiring offline access tokens (60-minute access token + a 90-day refresh token), and requires them for new public apps from April 1, 2026. Enable them with:

```python
SHOPIFY_EXPIRING_TOKENS = True
SHOPIFY_TOKEN_ENCRYPTION_KEY = config('SHOPIFY_TOKEN_ENCRYPTION_KEY')  # required
SHOPIFY_TOKEN_REFRESH_SKEW = 300  # optional, seconds before expiry to refresh
```

When enabled:

- **Acquisition** — token exchange and the authorization code grant request `expiring=1`, so the response includes a `refresh_token`. Tokens are stored via `refresh_token` / `token_expires_at` / `refresh_token_expires_at`.
- **Rotation** — `valid_access_token()` refreshes a token within `SHOPIFY_TOKEN_REFRESH_SKEW` seconds of expiry; REST/GraphQL helpers also refresh-and-retry once on a `401`. Refresh is concurrency-safe (`select_for_update`), so parallel workers never double-spend the one-time-use refresh token. A failed refresh raises; see "Refresh failures" below.
- **Migration** — a legacy non-expiring token is replaced by an expiring one the next time a merchant opens the app (the session token is exchanged; the permanent token is never sent), or in bulk:

```bash
python manage.py migrate_offline_tokens            # migrate all non-expiring shops
python manage.py migrate_offline_tokens --shop example.myshopify.com
python manage.py migrate_offline_tokens --dry-run  # preview only
```

> Migration is **irreversible** — Shopify revokes the original non-expiring token on success.

When Shopify rejects the stored token (`400 invalid_subject_token`) or the store is unavailable (`404` closed or deleted, `402` frozen, `423` locked), the bulk migration clears the token and sets `refresh_token_rejected_at`, logging a WARNING. A permanent token is never kept for a store that might reopen: from January 1, 2027, Shopify delists public apps that still use them. The cleared shop drops out of later runs and gets a new token when a merchant opens the app again, which calls `shop.token_reacquired()` instead of `installed()`. Other failures (`429`, `5xx`, credential errors) keep the token and log an ERROR.

With `SHOPIFY_EXPIRING_TOKENS = False` (default), behaviour is unchanged: non-expiring offline tokens, no refresh, no encryption key required.

#### Refresh failures

A failed refresh raises one exception per response type, all in `shopify_app.exceptions`. Nothing is logged alongside them; the exception message carries the shop domain, status code and response body. Because `valid_access_token()` raises, the API helpers (`get`/`post`/…/`graphql`/`graph`) raise **before** sending a request with an expired token. The `401` retry raises the same way.

| Response from Shopify's token endpoint | Exception | What it means |
|---|---|---|
| no refresh token stored | `RefreshTokenMissing` | Nothing to refresh with |
| `401 {"error": "invalid_request"}` | `RefreshTokenInvalid` | The refresh token is dead: expired, replaced, revoked, or the app was uninstalled. Shopify doesn't say which. Sets `refresh_token_rejected_at` |
| `404` (any body) | `ShopNotFound` | Usually a closed or deleted store |
| `429` | `TokenRefreshRateLimited` | Rate limited |
| `5xx` | `TokenRefreshUnavailable` | Shopify is unavailable; safe to retry later. Also a `requests.RequestException`, so `autoretry_for=RequestException` retries it |
| any other non-200 | `UnexpectedTokenRefreshResponse` | e.g. a `401` with a different `error`, a `400` or a `403` |
| shop row stayed locked (Postgres, 10 s) | `TokenRefreshLockTimeout` | The calling transaction most likely holds the shop row; see "Token database connection" |

- **Shared base class.** All of them subclass `TokenRefreshError`, which subclasses `ShopifyRequestException`, so existing `except ShopifyRequestException` handlers keep working. Timeouts and connection errors still raise the `requests` exceptions.
- **`shop.reauth_required`** is `True` once Shopify has rejected the shop's refresh token. Refreshes are still attempted on later calls. The flag is cleared when a new token is stored: the merchant opening the app (token exchange) or reinstalling. Use it to find shops that need the merchant to open the app.

#### Token database connection

Shopify rotates the token as soon as it answers a refresh or token exchange. If the new pair were saved inside your `transaction.atomic()` block and the block rolled back (say, the GraphQL call that follows fails), it would be lost from your database. So token refreshes, self-heals and migrations lock the shop row and save the new tokens through their own database connection, named by `SHOPIFY_TOKEN_DB_ALIAS`. That connection commits on its own, whatever your transaction does. It's required from 2.8.0 (system check `shopify_app.E001`):

```python
# settings.py
DATABASES["shopify_tokens"] = {
    **DATABASES["default"],
    "CONN_MAX_AGE": 0,
    "TEST": {"MIRROR": "default"},  # no separate test database
}
SHOPIFY_TOKEN_DB_ALIAS = "shopify_tokens"

# test settings
SHOPIFY_TOKEN_DB_ALIAS = "default"
```

- **Connections:** the package opens the alias connection only for a token write and closes it right after. Workers that aren't refreshing hold no extra connection, and at most one is open per refresh in flight.
- **Your shop object** stays on its own database. The new token values are copied onto it, and your later `shop.save()` calls stay in your transaction.
- **Tests:** a separate connection can't see rows your tests create inside their transaction, so point token writes back at `default` in test settings. `"default"` means token writes share the caller's transaction (the behaviour before 2.8.0).
- **Don't save or lock the shop and then call Shopify in the same transaction.** The token connection would wait for a row lock that your transaction holds until it ends. On Postgres it gives up after 10 seconds with `TokenRefreshLockTimeout` instead of hanging. Save the shop after the API call, or in a separate transaction.
- **First install and the OAuth code grant** still save on `default`: they run in views, outside any transaction.

### 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. 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']
```

The token is read from, in order: the `Authorization: Bearer <token>` header (preferred), the `ShopifyAuth: Bearer <token>` header (deprecated, kept for older frontends), or the `id_token` query parameter that App Bridge adds to the first page load. New frontends should send the standard `Authorization` header.

#### Error tracking

Session tokens are short-lived but replayable, so keep them out of your error tracker. Sentry and similar tools scrub `Authorization` and `Cookie` headers by default but capture custom headers and raw query strings verbatim. If you still send `ShopifyAuth`, or you enable `send_default_pii`, add a scrubber for the `ShopifyAuth` header and the `id_token` query parameter. With `sentry_sdk`:

```python
def _strip_session_tokens(event, hint):
    request = event.get("request") or {}
    request.get("headers", {}).pop("ShopifyAuth", None)
    if "id_token=" in request.get("query_string", ""):
        request["query_string"] = "[Filtered]"
    return event

sentry_sdk.init(..., send_default_pii=False, before_send=_strip_session_tokens)
```

### @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. Fetches the shop from the database:

```python
from shopify_app.decorators import app_proxy_view

@app_proxy_view
def my_proxy_view(request, *args, **kwargs):
    shop = kwargs['shop']                          # Shop model instance (from DB)
    domain = kwargs['shopify_domain']              # e.g. 'example.myshopify.com'
    customer_id = kwargs['logged_in_customer_id']  # Customer ID or None
```

### @app_proxy_view_lite

Validates HMAC without hitting the database. Use this when you don't need the full shop record:

```python
from shopify_app.decorators import app_proxy_view_lite

@app_proxy_view_lite
def my_proxy_view(request, *args, **kwargs):
    domain = kwargs['shopify_domain']              # e.g. 'example.myshopify.com'
    customer_id = kwargs['logged_in_customer_id']  # Customer ID or None
    # No kwargs['shop'] — use get_shop() if you need the DB record
```

### @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

# Migrate shops from non-expiring to expiring offline tokens (irreversible).
# Tokens Shopify rejects (closed, deleted or uninstalled stores) are cleared.
python manage.py migrate_offline_tokens [--shop <domain>] [--dry-run]
```
