Architecture¶
Technical architecture of the JDX Field PWA.
System Architecture¶
flowchart TB
subgraph "Client Layer"
Browser[Mobile Browser]
SW[Service Worker]
Cache[(Browser Cache)]
end
subgraph "Application Layer"
Flask[Flask App]
BP1[auth Blueprint]
BP2[jobs Blueprint]
BP3[sales Blueprint]
BP4[crm Blueprint]
BP5[inventory Blueprint]
BP6[sms Blueprint]
end
subgraph "Service Layer"
OdooAPI[OdooAPI Service]
S3Service[S3Service]
TZService[Timezone Service]
end
subgraph "External Services"
Odoo[Odoo 15 ERP]
S3[AWS S3]
end
Browser --> SW
SW --> Cache
Browser --> Flask
Flask --> BP1 & BP2 & BP3 & BP4 & BP5 & BP6
BP1 & BP2 & BP3 & BP4 & BP5 & BP6 --> OdooAPI
BP2 & BP3 --> S3Service
OdooAPI --> Odoo
S3Service --> S3
Directory Structure¶
pwa/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── config.py # Configuration class
│ ├── routes/
│ │ ├── auth.py # Authentication routes
│ │ ├── jobs.py # FSM job routes
│ │ ├── sales.py # Sales & quotes routes
│ │ ├── crm.py # CRM pipeline routes
│ │ ├── inventory.py # Stock picking routes
│ │ ├── dashboard.py # Dashboard routes
│ │ ├── customers.py # Customer routes
│ │ └── sms.py # SMS API routes
│ ├── services/
│ │ ├── odoo_api.py # Odoo JSON-RPC/REST client
│ │ ├── s3_service.py # AWS S3 client
│ │ └── timezone.py # Timezone utilities
│ ├── templates/ # Jinja2 templates
│ └── static/
│ ├── css/app.css # Application styles
│ ├── js/
│ │ ├── app.js # Main JavaScript
│ │ ├── signature.js # Signature canvas
│ │ ├── sms.js # SMS modal
│ │ └── form-draft.js # Form draft saving
│ ├── sw.js # Service Worker
│ ├── manifest.json # PWA manifest
│ └── icons/ # App icons
├── run.py # Application entry point
├── requirements.txt # Python dependencies
├── Dockerfile # Container definition
└── .env.example # Environment template
Flask Blueprints¶
The application uses Flask Blueprints for modular organization:
| Blueprint | URL Prefix | File | Description |
|---|---|---|---|
| auth | / |
routes/auth.py |
Login, logout, session |
| jobs | / |
routes/jobs.py |
FSM orders, signatures |
| sales | / |
routes/sales.py |
Sales orders, quotes |
| crm | / |
routes/crm.py |
CRM pipeline |
| inventory | /inventory |
routes/inventory.py |
Stock pickings |
| dashboard | / |
routes/dashboard.py |
Admin dashboard |
| customers | / |
routes/customers.py |
Customer lookup |
| sms | / |
routes/sms.py |
SMS API |
Odoo API Integration¶
The PWA communicates with Odoo using two methods:
1. REST API (with JWT/API Key)¶
Used for simple CRUD operations:
# GET request with API Key
headers = {'X-API-Key': api_key}
response = requests.get(f"{base_url}/restapi/1.0/object/fsm.order", headers=headers)
2. JSON-RPC (Session-based)¶
Used for complex operations and custom field updates:
# JSON-RPC call
payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "fsm.order",
"method": "write",
"args": [[record_id], {"x_custom_field": value}],
"kwargs": {}
}
}
response = session.post(f"{base_url}/web/dataset/call_kw/fsm.order/write", json=payload)
Authentication Flow¶
sequenceDiagram
participant User
participant Flask
participant Odoo
User->>Flask: POST /login (username, password)
Flask->>Odoo: /web/session/authenticate
Odoo-->>Flask: {uid, session_id, name}
Flask->>Flask: Store in Flask session
Flask-->>User: Redirect to /jobs/active
Service Worker¶
The PWA uses a service worker (sw.js) for offline support with intelligent caching:
Caching Strategies¶
| Resource Type | Strategy | TTL | Description |
|---|---|---|---|
Static files (/static/*) |
Cache-first | 30 days | CSS, JS, icons |
API GET (/api/*) |
Network-first | 1 hour | Data requests |
| HTML pages | Network-first | - | Falls back to offline page |
| POST/PUT/DELETE | Network-only | - | Never cached |
Cache Version Management¶
const CACHE_VERSION = 'v3';
const STATIC_CACHE = `jdx-static-${CACHE_VERSION}`;
const API_CACHE = `jdx-api-${CACHE_VERSION}`;
Pre-cached Assets¶
const STATIC_ASSETS = [
'/',
'/static/css/app.css',
'/static/js/app.js',
'/static/manifest.json',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png',
'/offline'
];
Offline Fallback¶
When network is unavailable for HTML pages:
- Service worker intercepts the request
- Returns cached
/offlinepage - User sees friendly offline message
S3 Integration¶
AWS S3 is used for storing:
- Signatures:
signatures/fsm_{id}_{timestamp}.png - Quote Photos:
quotes/{order_id}/photos/{filename}.jpg - Quote Videos:
quotes/{order_id}/videos/{filename}.mp4
Upload Flow¶
sequenceDiagram
participant User
participant Flask
participant S3
participant Odoo
User->>Flask: POST signature (base64)
Flask->>S3: put_object()
S3-->>Flask: Success
Flask->>Odoo: Update x_signature_url
Odoo-->>Flask: OK
Flask-->>User: {success: true}
Pre-signed URLs¶
For secure access to S3 objects:
url = s3_client.generate_presigned_url(
'get_object',
Params={'Bucket': bucket, 'Key': key},
ExpiresIn=3600 # 1 hour
)
Session Management¶
Flask Session Configuration¶
class Config:
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
SESSION_COOKIE_SECURE = True # Production only
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
Session Data Stored¶
| Key | Description |
|---|---|
user_id |
Odoo user ID (res.users) |
username |
Login username |
user_name |
Display name |
partner_id |
Partner ID for activity attribution |
CSRF Protection¶
The app uses Flask-WTF for CSRF protection:
API endpoints that need to bypass CSRF (called from JavaScript):
@bp.route('/api/jobs/<int:fsm_id>/signature', methods=['POST'])
@csrf.exempt
@login_required
def submit_signature(fsm_id):
...
Activity Attribution¶
PWA actions are properly attributed to users in Odoo's activity log:
def write_as_user(self, model, record_id, values, author_partner_id):
# Get message IDs before write
before_ids = self.get_record_message_ids(model, record_id)
# Perform write
self._jsonrpc_call(model, 'write', [[record_id], values], {})
# Fix author on tracking messages
self.fix_tracking_author(model, record_id, before_ids, author_partner_id)
Error Handling¶
All routes follow a consistent error handling pattern:
@bp.route('/jobs/<int:fsm_id>')
@login_required
def detail(fsm_id):
try:
job = odoo_api.get_fsm_order(fsm_id)
if not job:
abort(404)
return render_template('job_detail.html', job=job)
except Exception as e:
return render_template('jobs_active.html', jobs=[], error=str(e))