Django Integration
Complete example of MONCRENEAU API integration with Django 4.x and Django REST Framework.
Project Structure
moncreneau-django/
├── requirements.txt
├── .env.example
├── manage.py
├── moncreneau_project/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── appointments/
│ ├── __init__.py
│ ├── apps.py
│ ├── models.py
│ ├── views.py
│ ├── serializers.py
│ ├── urls.py
│ └── services/
│ ├── __init__.py
│ ├── moncreneau_client.py
│ └── webhook_handler.py
├── webhooks/
│ ├── __init__.py
│ ├── middleware.py
│ ├── views.py
│ └── urls.py
└── tests/
├── __init__.py
├── test_appointments.py
└── test_webhooks.py
Configuration
requirements.txt
Django==4.2.8
djangorestframework==3.14.0
requests==2.31.0
python-dotenv==1.0.0
celery==5.3.4
redis==5.0.1
django-cors-headers==4.3.1
# Testing
pytest==7.4.3
pytest-django==4.7.0
pytest-cov==4.1.0
.env.example
# Django
SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# MONCRENEAU API
MONCRENEAU_API_URL=https://mc-prd.duckdns.org/api/v1
MONCRENEAU_API_KEY=your_api_key_here
MONCRENEAU_WEBHOOK_SECRET=your_webhook_secret_here
# Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
Settings
moncreneau_project/settings.py (excerpt)
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('SECRET_KEY')
DEBUG = os.getenv('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'appointments',
'webhooks',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
# Note: CSRF disabled for webhooks, handle differently in production
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'moncreneau_project.urls'
# MONCRENEAU Configuration
MONCRENEAU = {
'API_URL': os.getenv('MONCRENEAU_API_URL'),
'API_KEY': os.getenv('MONCRENEAU_API_KEY'),
'WEBHOOK_SECRET': os.getenv('MONCRENEAU_WEBHOOK_SECRET'),
'TIMEOUT': 30,
}
# Celery Configuration
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND')
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.FileHandler',
'filename': 'moncreneau.log',
'formatter': 'verbose',
},
},
'loggers': {
'appointments': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
'webhooks': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
},
}
Services
appointments/services/moncreneau_client.py
import logging
import requests
from typing import Dict, Any
from django.conf import settings
logger = logging.getLogger(__name__)
class MonCreneauClient:
def __init__(self):
self.api_url = settings.MONCRENEAU['API_URL']
self.api_key = settings.MONCRENEAU['API_KEY']
self.timeout = settings.MONCRENEAU['TIMEOUT']
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
})
def create_appointment(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new appointment."""
try:
logger.info(
f"Creating appointment for organization {data.get('organizationId')}"
)
response = self.session.post(
f'{self.api_url}/appointments',
json=data,
timeout=self.timeout,
)
response.raise_for_status()
appointment = response.json()
logger.info(f"Appointment created: {appointment['id']}")
return appointment
except requests.exceptions.HTTPError as e:
return self._handle_http_error(e)
except requests.exceptions.RequestException as e:
logger.error(f"Request failed: {str(e)}")
raise
def get_appointment(self, appointment_id: int) -> Dict[str, Any]:
"""Get appointment by ID."""
try:
response = self.session.get(
f'{self.api_url}/appointments/{appointment_id}',
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get appointment {appointment_id}: {str(e)}")
raise
def cancel_appointment(self, appointment_id: int) -> None:
"""Cancel an appointment."""
try:
logger.info(f"Cancelling appointment {appointment_id}")
response = self.session.delete(
f'{self.api_url}/appointments/{appointment_id}',
timeout=self.timeout,
)
response.raise_for_status()
logger.info(f"Appointment {appointment_id} cancelled")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to cancel appointment {appointment_id}: {str(e)}")
raise
def _handle_http_error(self, error: requests.exceptions.HTTPError):
"""Handle HTTP errors from the API."""
status_code = error.response.status_code
if status_code == 402:
logger.error("Insufficient credits")
raise ValueError("Insufficient credits")
elif status_code == 400:
error_data = error.response.json()
logger.error(f"Validation error: {error_data}")
raise ValueError(f"Invalid data: {error_data}")
elif status_code == 429:
logger.error("Rate limit exceeded")
raise ValueError("Rate limit exceeded")
else:
logger.error(f"API error: {error.response.text}")
raise
appointments/services/webhook_handler.py
import logging
from typing import Dict, Any
from celery import shared_task
logger = logging.getLogger(__name__)
class WebhookEventHandler:
@staticmethod
def process_event(event_type: str, event_data: Dict[str, Any]) -> None:
"""Route webhook events to appropriate handlers."""
logger.info(f"Processing webhook event: {event_type}")
handlers = {
'appointment.created': WebhookEventHandler._handle_appointment_created,
'appointment.updated': WebhookEventHandler._handle_appointment_updated,
'appointment.cancelled': WebhookEventHandler._handle_appointment_cancelled,
'appointment.validated': WebhookEventHandler._handle_appointment_validated,
'appointment.completed': WebhookEventHandler._handle_appointment_completed,
}
handler = handlers.get(event_type)
if handler:
handler(event_data)
else:
logger.warning(f"Unknown event type: {event_type}")
@staticmethod
def _handle_appointment_created(event_data: Dict[str, Any]) -> None:
logger.info(f"Handling appointment.created: {event_data['object']['id']}")
# Send confirmation email
# Send SMS
# Update local database
@staticmethod
def _handle_appointment_updated(event_data: Dict[str, Any]) -> None:
logger.info(f"Handling appointment.updated: {event_data['object']['id']}")
# Notify user of changes
@staticmethod
def _handle_appointment_cancelled(event_data: Dict[str, Any]) -> None:
logger.info(f"Handling appointment.cancelled: {event_data['object']['id']}")
# Send cancellation email
# Release resources
@staticmethod
def _handle_appointment_validated(event_data: Dict[str, Any]) -> None:
logger.info(f"Handling appointment.validated: {event_data['object']['id']}")
# Confirm with staff
@staticmethod
def _handle_appointment_completed(event_data: Dict[str, Any]) -> None:
logger.info(f"Handling appointment.completed: {event_data['object']['id']}")
# Send satisfaction survey
@shared_task
def process_webhook_event(event_type: str, event_data: Dict[str, Any]):
"""Celery task for async webhook processing."""
WebhookEventHandler.process_event(event_type, event_data)
Views
appointments/views.py
import logging
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .services.moncreneau_client import MonCreneauClient
logger = logging.getLogger(__name__)
client = MonCreneauClient()
@api_view(['POST'])
def create_appointment(request):
"""Create a new appointment."""
try:
appointment = client.create_appointment(request.data)
return Response(appointment, status=status.HTTP_201_CREATED)
except ValueError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
logger.error(f"Error creating appointment: {str(e)}")
return Response(
{'error': 'Internal server error'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['GET'])
def get_appointment(request, appointment_id):
"""Get appointment by ID."""
try:
appointment = client.get_appointment(appointment_id)
return Response(appointment)
except Exception as e:
logger.error(f"Error getting appointment: {str(e)}")
return Response(
{'error': 'Appointment not found'},
status=status.HTTP_404_NOT_FOUND
)
@api_view(['DELETE'])
def cancel_appointment(request, appointment_id):
"""Cancel an appointment."""
try:
client.cancel_appointment(appointment_id)
return Response({'message': 'Appointment cancelled'})
except Exception as e:
logger.error(f"Error cancelling appointment: {str(e)}")
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
webhooks/views.py
import logging
import hmac
import hashlib
import json
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.conf import settings
from appointments.services.webhook_handler import process_webhook_event
logger = logging.getLogger(__name__)
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
"""Verify HMAC-SHA256 signature."""
if not signature or not signature.startswith('sha256='):
logger.warning('Invalid signature format')
return False
received_hash = signature[7:]
secret = settings.MONCRENEAU['WEBHOOK_SECRET']
expected_hash = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(expected_hash, received_hash)
@csrf_exempt
@require_http_methods(["POST"])
def handle_webhook(request):
"""Handle incoming webhooks from MONCRENEAU."""
signature = request.headers.get('X-Moncreneau-Signature')
payload = request.body
# Verify signature
if not verify_webhook_signature(payload, signature):
logger.warning('Invalid webhook signature')
return HttpResponse('Invalid signature', status=401)
try:
# Parse event
event = json.loads(payload.decode('utf-8'))
event_type = event.get('type')
event_data = event.get('data')
# Process asynchronously with Celery
process_webhook_event.delay(event_type, event_data)
return HttpResponse('OK', status=200)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON: {str(e)}")
return HttpResponse('Invalid JSON', status=400)
except Exception as e:
logger.error(f"Error processing webhook: {str(e)}")
return HttpResponse('Internal server error', status=500)
URLs
appointments/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('appointments/', views.create_appointment, name='create_appointment'),
path('appointments/<int:appointment_id>/', views.get_appointment, name='get_appointment'),
path('appointments/<int:appointment_id>/cancel/', views.cancel_appointment, name='cancel_appointment'),
]
webhooks/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('moncreneau/', views.handle_webhook, name='webhook'),
]
moncreneau_project/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('appointments.urls')),
path('webhooks/', include('webhooks.urls')),
]
Tests
tests/test_webhooks.py
import json
import hmac
import hashlib
from django.test import TestCase, Client
from django.conf import settings
class WebhookTestCase(TestCase):
def setUp(self):
self.client = Client()
self.webhook_url = '/webhooks/moncreneau/'
self.secret = settings.MONCRENEAU['WEBHOOK_SECRET']
def sign_payload(self, payload: str) -> str:
"""Generate HMAC signature."""
signature = hmac.new(
self.secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return f'sha256={signature}'
def test_webhook_accepts_valid_signature(self):
"""Test webhook accepts valid signature."""
payload = json.dumps({
'id': 'evt_test_123',
'type': 'appointment.created',
'data': {'object': {'id': 123}}
})
signature = self.sign_payload(payload)
response = self.client.post(
self.webhook_url,
data=payload,
content_type='application/json',
HTTP_X_MONCRENEAU_SIGNATURE=signature
)
self.assertEqual(response.status_code, 200)
def test_webhook_rejects_invalid_signature(self):
"""Test webhook rejects invalid signature."""
payload = json.dumps({'id': 'evt_test'})
response = self.client.post(
self.webhook_url,
data=payload,
content_type='application/json',
HTTP_X_MONCRENEAU_SIGNATURE='sha256=invalid'
)
self.assertEqual(response.status_code, 401)
Getting Started
# 1. Installation
pip install -r requirements.txt
# 2. Configuration
cp .env.example .env
# Edit .env with your API keys
# 3. Migrations
python manage.py migrate
# 4. Development
python manage.py runserver
# 5. Celery worker (separate terminal)
celery -A moncreneau_project worker -l info
Testing
# Run tests
pytest
# With coverage
pytest --cov=. --cov-report=html
# Specific tests
pytest tests/test_webhooks.py
Celery Configuration
moncreneau_project/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'moncreneau_project.settings')
app = Celery('moncreneau_project')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
Docker (optional)
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "moncreneau_project.wsgi:application", "--bind", "0.0.0.0:8000"]