Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .claude/skills/git-fork-workflow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: git-fork-workflow
description: Git workflow for contributing to this repository via fork. Use when creating commits, branches, or pull requests.
---

# Git Fork Workflow

This repository uses a fork-based contribution model:

- **origin**: Points to the contributor's fork (e.g., `username/commcare-sync`)
- **upstream**: Points to the main repository (`dimagi/commcare-sync`)

Contributors do NOT have write access to the main repository, only to their fork.

## Creating Pull Requests

1. Create a feature branch from the current branch
2. Make and commit changes
3. Push the branch to `origin` (your fork)
4. Create a PR targeting the upstream repo

```bash
git checkout -b fix/descriptive-branch-name
git add <files>
git commit -m "Commit message"
git push -u origin fix/descriptive-branch-name
gh pr create --repo dimagi/commcare-sync --title "PR Title" --body "Description"
```

Never attempt to push directly to `dimagi/commcare-sync` - use PRs from your fork.
15 changes: 15 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,18 @@ DJANGO_SETTINGS_MODULE=commcare_sync.settings_docker

# Use a Docker-specific virtualenv directory to avoid conflicts with host .venv
UV_PROJECT_ENVIRONMENT=.venv-docker

# CommCare OAuth Configuration
# See docs for setup instructions: create two OAuth apps in CommCare HQ
# 1. Web App (Confidential Client) - for browser-based OAuth flow
# 2. CLI Tool (Public Client) - for command-line token acquisition

# CommCare HQ URL (defaults to production if not set)
# COMMCARE_HQ_URL=https://www.commcarehq.org

# Web OAuth App (Confidential Client)
# COMMCARE_OAUTH_CLIENT_ID=your_web_client_id
# COMMCARE_OAUTH_CLIENT_SECRET=your_web_client_secret

# CLI OAuth App (Public Client - no secret needed, uses PKCE)
# COMMCARE_OAUTH_CLI_CLIENT_ID=your_cli_client_id
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ static_root/

# uv
.venv/

#configuration
.env
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ See [pyproject.toml](pyproject.toml).
## Coding Style

See [CONTRIBUTING.md](CONTRIBUTING.md).

## Skills

When creating AI skills for this project, add them to `.claude/skills/`. This format is compatible with both Cursor and Claude Code.
Empty file.
Empty file.
141 changes: 141 additions & 0 deletions apps/commcare/management/commands/get_commcare_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""
Django management command to obtain CommCare OAuth token via CLI flow.

Usage:
python manage.py get_commcare_token

Or with custom settings:
python manage.py get_commcare_token --port 8888

This uses a public OAuth client (no secret) with PKCE for security.
Tokens are saved to ~/.commcare-sync/commcare_token.json
"""

from django.conf import settings
from django.core.management.base import BaseCommand, CommandError

from apps.commcare.oauth.cli import TokenManager, get_oauth_token
from apps.commcare.oauth.utils import fetch_user_identity


class Command(BaseCommand):
help = 'Obtain a CommCare OAuth access token for CLI/script usage via browser authorization'

def add_arguments(self, parser):
parser.add_argument(
'--client-id',
type=str,
help='OAuth client ID (defaults to COMMCARE_OAUTH_CLI_CLIENT_ID from settings)',
)
parser.add_argument(
'--commcare-url',
type=str,
help='CommCare HQ URL (defaults to COMMCARE_HQ_URL from settings)',
)
parser.add_argument(
'--port',
type=int,
default=8765,
help='Local port for OAuth callback (default: 8765)',
)
parser.add_argument(
'--scope',
type=str,
default='access_apis',
help='OAuth scopes to request (default: "access_apis")',
)
parser.add_argument(
'--save-to',
type=str,
help='Save token to specified file instead of default location',
)
parser.add_argument(
'--quiet',
action='store_true',
help='Suppress output (only print token)',
)

def handle(self, *args, **options):
# Get configuration from options or settings
client_id = options.get('client_id') or getattr(
settings, 'COMMCARE_OAUTH_CLI_CLIENT_ID', None
)
commcare_url = options.get('commcare_url') or getattr(
settings, 'COMMCARE_HQ_URL', 'https://www.commcarehq.org'
)

if not client_id:
raise CommandError(
'OAuth client ID not provided.\n'
'Set COMMCARE_OAUTH_CLI_CLIENT_ID in settings/environment or use --client-id\n\n'
'To set up OAuth:\n'
'1. Create a Public OAuth application in CommCare HQ\n'
'2. Set redirect URI to: http://localhost:8765/callback\n'
'3. Add COMMCARE_OAUTH_CLI_CLIENT_ID to your .env file'
)

if not options['quiet']:
self.stdout.write(
self.style.SUCCESS('\nCommCare OAuth Token Setup')
)
self.stdout.write('=' * 70)
self.stdout.write(f'CommCare URL: {commcare_url}')
self.stdout.write(f'Client ID: {client_id}')
self.stdout.write(f'Scope: {options["scope"]}')
self.stdout.write(f'Callback Port: {options["port"]}\n')

# Get OAuth token
token_data = get_oauth_token(
client_id=client_id,
commcare_url=commcare_url,
port=options['port'],
scope=options['scope'],
verbose=not options['quiet'],
)

if not token_data:
raise CommandError('Failed to obtain OAuth token')

# Fetch user identity
access_token = token_data.get('access_token')
user_identity = None
if access_token:
user_identity = fetch_user_identity(access_token)
if user_identity and not options['quiet']:
self.stdout.write(
self.style.SUCCESS(
f'Authenticated as: {user_identity.get("username", "unknown")}'
)
)

# Save to token manager
if options.get('save_to'):
token_manager = TokenManager(token_file=options['save_to'])
else:
token_manager = TokenManager()

if token_manager.save_token(token_data, user_identity):
if not options['quiet']:
self.stdout.write(
self.style.SUCCESS(
f'\nToken saved to: {token_manager.token_file}'
)
)
else:
self.stderr.write(self.style.ERROR('Failed to save token'))

# Show token info
info = token_manager.get_token_info()
if info and 'expires_in_seconds' in info and not options['quiet']:
minutes = info['expires_in_seconds'] // 60
self.stdout.write(f'Expires in: {minutes} minutes\n')

if not options['quiet']:
self.stdout.write(self.style.SUCCESS('Setup Complete!'))
self.stdout.write(
'You can now run: python manage.py test_oauth_connection\n'
)

# In quiet mode, just print the token
if options['quiet']:
self.stdout.write(access_token)
160 changes: 160 additions & 0 deletions apps/commcare/management/commands/test_oauth_connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
Django management command to test CommCare OAuth connection.

Usage:
python manage.py test_oauth_connection

Tests:
1. Token file exists and is readable
2. Token is not expired
3. Token can authenticate with CommCare identity API
4. User has access to at least one domain
"""

from django.core.management.base import BaseCommand, CommandError

from apps.commcare.oauth.cli import TokenManager
from apps.commcare.oauth.utils import (
fetch_user_domains,
fetch_user_identity,
get_commcare_hq_url,
)


class Command(BaseCommand):
help = 'Test CommCare OAuth connection and token validity'

def add_arguments(self, parser):
parser.add_argument(
'--token-file',
type=str,
help='Path to token file (defaults to ~/.commcare-sync/commcare_token.json)',
)
parser.add_argument(
'--verbose',
action='store_true',
help='Show detailed output',
)

def handle(self, *args, **options):
verbose = options.get('verbose', False)
token_file = options.get('token_file')

self.stdout.write('\nCommCare OAuth Connection Test')
self.stdout.write('=' * 70)

# Initialize token manager
token_manager = TokenManager(token_file=token_file)

# Test 1: Token file exists
self.stdout.write('\n1. Checking token file...')
token_data = token_manager.load_token()
if not token_data:
self.stdout.write(
self.style.ERROR(
f' [FAIL] No token found at {token_manager.token_file}'
)
)
self.stdout.write('\n Run: python manage.py get_commcare_token')
raise CommandError('No token file found')
self.stdout.write(
self.style.SUCCESS(
f' [OK] Token loaded from {token_manager.token_file}'
)
)

# Test 2: Token not expired
self.stdout.write('\n2. Checking token expiration...')
info = token_manager.get_token_info()
if not info:
self.stdout.write(
self.style.ERROR(' [FAIL] Could not read token info')
)
raise CommandError('Invalid token data')

if not info.get('is_valid'):
self.stdout.write(self.style.ERROR(' [FAIL] Token is expired'))
self.stdout.write('\n Run: python manage.py get_commcare_token')
raise CommandError('Token expired')

expires_in = info.get('expires_in_seconds', 0)
minutes = expires_in // 60
self.stdout.write(
self.style.SUCCESS(
f' [OK] Token is valid (expires in {minutes} minutes)'
)
)

if verbose:
self.stdout.write(
f' Saved at: {info.get("saved_at", "unknown")}'
)
self.stdout.write(
f' Expires at: {info.get("expires_at", "unknown")}'
)
self.stdout.write(
f' Has refresh token: {info.get("has_refresh_token", False)}'
)

# Test 3: Token authenticates with CommCare
self.stdout.write('\n3. Testing CommCare identity API...')
access_token = token_manager.get_valid_token()
if not access_token:
self.stdout.write(
self.style.ERROR(' [FAIL] Could not get valid access token')
)
raise CommandError('Invalid access token')

commcare_url = get_commcare_hq_url()
identity = fetch_user_identity(access_token)
if not identity:
self.stdout.write(
self.style.ERROR(' [FAIL] Identity API request failed')
)
self.stdout.write(f' CommCare URL: {commcare_url}')
raise CommandError('Identity API failed')

username = identity.get('username', 'unknown')
self.stdout.write(
self.style.SUCCESS(f' [OK] Authenticated as: {username}')
)

if verbose:
self.stdout.write(f' CommCare URL: {commcare_url}')

# Test 4: Check domain access
self.stdout.write('\n4. Checking domain access...')
domains = fetch_user_domains(access_token)
if domains is None:
self.stdout.write(
self.style.ERROR(' [FAIL] Could not fetch user domains')
)
raise CommandError('User domains API failed')
elif not domains:
self.stdout.write(
self.style.WARNING(' [WARN] User has no domain access')
)
else:
self.stdout.write(
self.style.SUCCESS(
f' [OK] User has access to {len(domains)} domain(s)'
)
)
if verbose or len(domains) <= 5:
for domain_info in domains[:10]:
domain_name = domain_info.get('domain_name', 'unknown')
project_name = domain_info.get('project_name', domain_name)
if domain_name != project_name:
self.stdout.write(f' - {domain_name} ({project_name})')
else:
self.stdout.write(f' - {domain_name}')
if len(domains) > 10:
self.stdout.write(
f' ... and {len(domains) - 10} more'
)

# Summary
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('All tests passed!'))
self.stdout.write(f'\nOAuth connection is working. User: {username}')
self.stdout.write(f'Token expires in {minutes} minutes.\n')
22 changes: 22 additions & 0 deletions apps/commcare/oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
CommCare OAuth submodule for configuration assistance.

This module provides OAuth integration with CommCare HQ to enable
easier configuration of CommCare Sync. OAuth tokens are stored in
the session (not database) since they're only used for interactive
user-driven configuration, not for production exports.

Production exports continue to use API keys stored in CommCareAccount.
"""

from apps.commcare.oauth.utils import (
generate_pkce_pair,
get_commcare_oauth_session,
has_valid_oauth_token,
)

__all__ = [
'generate_pkce_pair',
'get_commcare_oauth_session',
'has_valid_oauth_token',
]
Loading