create-odoo-module 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/bin/create-odoo-module.js +147 -0
- package/package.json +84 -0
- package/src/cli/args-parser.js +36 -0
- package/src/cli/index.js +272 -0
- package/src/cli/prompts.js +151 -0
- package/src/cli/validator.js +72 -0
- package/src/config/defaults.js +41 -0
- package/src/config/pro-config.js +55 -0
- package/src/generators/api-generator.js +340 -0
- package/src/generators/deploy-generator.js +390 -0
- package/src/generators/flutter-generator.js +1695 -0
- package/src/generators/odoo-generator.js +794 -0
- package/src/generators/ui-generator.js +329 -0
- package/src/utils/file-system.js +67 -0
- package/src/utils/license-check.js +57 -0
- package/src/utils/logger.js +32 -0
- package/src/utils/spinner.js +32 -0
- package/src/utils/string-utils.js +65 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { writeFile } = require('../utils/file-system');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate REST API controller for the Odoo module.
|
|
9
|
+
*/
|
|
10
|
+
async function generateApiLayer(targetDir, config) {
|
|
11
|
+
const odooDir = path.join(targetDir, 'odoo_module');
|
|
12
|
+
const s = config.moduleNameSnake;
|
|
13
|
+
const P = config.moduleNamePascal;
|
|
14
|
+
const M = config.moduleNameOdoo;
|
|
15
|
+
const L = config.moduleNameLabel;
|
|
16
|
+
|
|
17
|
+
logger.verbose('Generating REST API controller...');
|
|
18
|
+
|
|
19
|
+
await writeFile(
|
|
20
|
+
path.join(odooDir, 'controllers', `${s}_controller.py`),
|
|
21
|
+
genController(s, P, M, L, config)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Controller generator ────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function genController(s, P, M, L, config) {
|
|
28
|
+
const endpoint = s.replace(/_/g, '-');
|
|
29
|
+
|
|
30
|
+
return `# -*- coding: utf-8 -*-
|
|
31
|
+
"""
|
|
32
|
+
REST API Controller for ${L} (${M}).
|
|
33
|
+
Auto-generated by create-odoo-module — https://create-odoo-module.dev
|
|
34
|
+
|
|
35
|
+
Endpoints:
|
|
36
|
+
GET /api/${endpoint} → List all records (with pagination)
|
|
37
|
+
GET /api/${endpoint}/<id> → Get single record
|
|
38
|
+
POST /api/${endpoint} → Create record
|
|
39
|
+
PUT /api/${endpoint}/<id> → Update record
|
|
40
|
+
DELETE /api/${endpoint}/<id> → Delete record
|
|
41
|
+
POST /api/${endpoint}/<id>/action → Call business action
|
|
42
|
+
"""
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
|
|
46
|
+
from odoo import http
|
|
47
|
+
from odoo.http import request, Response
|
|
48
|
+
from odoo.exceptions import (
|
|
49
|
+
AccessDenied,
|
|
50
|
+
AccessError,
|
|
51
|
+
MissingError,
|
|
52
|
+
ValidationError,
|
|
53
|
+
UserError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
# Fields returned by default in list/detail endpoints
|
|
59
|
+
_DEFAULT_FIELDS = [
|
|
60
|
+
'id', 'name', 'reference', 'state', 'description',
|
|
61
|
+
'user_id', 'company_id', 'date_start', 'date_end',
|
|
62
|
+
'priority', 'active', 'create_date', 'write_date',
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
_LIST_FIELDS = [
|
|
66
|
+
'id', 'name', 'reference', 'state', 'user_id',
|
|
67
|
+
'date_start', 'date_end', 'priority',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ${P}RestController(http.Controller):
|
|
72
|
+
"""Full CRUD REST API for ${L}."""
|
|
73
|
+
|
|
74
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _ok(data=None, status=200, meta=None):
|
|
78
|
+
"""Build a standardised success JSON response."""
|
|
79
|
+
payload = {'status': 'success', 'data': data}
|
|
80
|
+
if meta:
|
|
81
|
+
payload['meta'] = meta
|
|
82
|
+
return Response(
|
|
83
|
+
json.dumps(payload, default=str),
|
|
84
|
+
status=status,
|
|
85
|
+
headers={'Content-Type': 'application/json; charset=utf-8'},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _err(message, status=400, code=None):
|
|
90
|
+
"""Build a standardised error JSON response."""
|
|
91
|
+
payload = {'status': 'error', 'message': str(message)}
|
|
92
|
+
if code:
|
|
93
|
+
payload['code'] = code
|
|
94
|
+
return Response(
|
|
95
|
+
json.dumps(payload),
|
|
96
|
+
status=status,
|
|
97
|
+
headers={'Content-Type': 'application/json; charset=utf-8'},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _get_record(self, record_id):
|
|
101
|
+
"""Fetch a record by ID, raise 404 if not found."""
|
|
102
|
+
try:
|
|
103
|
+
record = request.env['${M}'].browse(int(record_id))
|
|
104
|
+
if not record.exists():
|
|
105
|
+
return None, self._err(f'Record {record_id} not found', 404)
|
|
106
|
+
return record, None
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
return None, self._err('Invalid ID format', 400)
|
|
109
|
+
|
|
110
|
+
# ── LIST — GET /api/${endpoint} ──────────────────────────────────────────
|
|
111
|
+
@http.route(
|
|
112
|
+
'/api/${endpoint}',
|
|
113
|
+
auth='user',
|
|
114
|
+
methods=['GET'],
|
|
115
|
+
csrf=False,
|
|
116
|
+
cors='*',
|
|
117
|
+
)
|
|
118
|
+
def list_records(self, **kwargs):
|
|
119
|
+
"""
|
|
120
|
+
List ${L} records with optional filtering and pagination.
|
|
121
|
+
|
|
122
|
+
Query params:
|
|
123
|
+
limit (int) Max records to return. Default: 80
|
|
124
|
+
offset (int) Pagination offset. Default: 0
|
|
125
|
+
state (str) Filter by state (draft|confirmed|in_progress|done|cancelled)
|
|
126
|
+
search (str) Search in name field
|
|
127
|
+
order (str) Sort order e.g. "name asc". Default: "name asc"
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
limit = min(int(kwargs.get('limit', 80)), 500)
|
|
131
|
+
offset = max(int(kwargs.get('offset', 0)), 0)
|
|
132
|
+
order = kwargs.get('order', 'name asc')
|
|
133
|
+
|
|
134
|
+
domain = [('active', '=', True)]
|
|
135
|
+
if kwargs.get('state'):
|
|
136
|
+
domain.append(('state', '=', kwargs['state']))
|
|
137
|
+
if kwargs.get('search'):
|
|
138
|
+
domain.append(('name', 'ilike', kwargs['search']))
|
|
139
|
+
|
|
140
|
+
Model = request.env['${M}']
|
|
141
|
+
total = Model.search_count(domain)
|
|
142
|
+
records = Model.search_read(
|
|
143
|
+
domain=domain,
|
|
144
|
+
fields=_LIST_FIELDS,
|
|
145
|
+
limit=limit,
|
|
146
|
+
offset=offset,
|
|
147
|
+
order=order,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return self._ok(
|
|
151
|
+
data=records,
|
|
152
|
+
meta={
|
|
153
|
+
'total': total,
|
|
154
|
+
'limit': limit,
|
|
155
|
+
'offset': offset,
|
|
156
|
+
'returned': len(records),
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
except AccessError as e:
|
|
160
|
+
return self._err(str(e), 403)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
_logger.exception('Error listing ${M} records')
|
|
163
|
+
return self._err(str(e), 500)
|
|
164
|
+
|
|
165
|
+
# ── GET SINGLE — GET /api/${endpoint}/<id> ───────────────────────────────
|
|
166
|
+
@http.route(
|
|
167
|
+
'/api/${endpoint}/<int:record_id>',
|
|
168
|
+
auth='user',
|
|
169
|
+
methods=['GET'],
|
|
170
|
+
csrf=False,
|
|
171
|
+
cors='*',
|
|
172
|
+
)
|
|
173
|
+
def get_record(self, record_id, **kwargs):
|
|
174
|
+
"""Return a single ${L} record by ID."""
|
|
175
|
+
try:
|
|
176
|
+
record, err = self._get_record(record_id)
|
|
177
|
+
if err:
|
|
178
|
+
return err
|
|
179
|
+
fields = kwargs.get('fields', '').split(',') if kwargs.get('fields') else _DEFAULT_FIELDS
|
|
180
|
+
fields = [f.strip() for f in fields if f.strip()]
|
|
181
|
+
return self._ok(record.read(fields)[0])
|
|
182
|
+
except AccessError as e:
|
|
183
|
+
return self._err(str(e), 403)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
_logger.exception('Error reading ${M} record %s', record_id)
|
|
186
|
+
return self._err(str(e), 500)
|
|
187
|
+
|
|
188
|
+
# ── CREATE — POST /api/${endpoint} ───────────────────────────────────────
|
|
189
|
+
@http.route(
|
|
190
|
+
'/api/${endpoint}',
|
|
191
|
+
auth='user',
|
|
192
|
+
methods=['POST'],
|
|
193
|
+
csrf=False,
|
|
194
|
+
cors='*',
|
|
195
|
+
type='json',
|
|
196
|
+
)
|
|
197
|
+
def create_record(self, **kwargs):
|
|
198
|
+
"""
|
|
199
|
+
Create a new ${L} record.
|
|
200
|
+
Body (JSON): { name, description, date_start, date_end, priority, ... }
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
data = request.jsonrequest or {}
|
|
204
|
+
if not data.get('name'):
|
|
205
|
+
return {'status': 'error', 'message': "'name' field is required"}
|
|
206
|
+
record = request.env['${M}'].create(data)
|
|
207
|
+
return {
|
|
208
|
+
'status': 'success',
|
|
209
|
+
'data': {'id': record.id, 'reference': record.reference, 'name': record.name},
|
|
210
|
+
}
|
|
211
|
+
except ValidationError as e:
|
|
212
|
+
return {'status': 'error', 'message': str(e), 'code': 'validation_error'}
|
|
213
|
+
except AccessError as e:
|
|
214
|
+
return {'status': 'error', 'message': str(e), 'code': 'access_denied'}
|
|
215
|
+
except Exception as e:
|
|
216
|
+
_logger.exception('Error creating ${M} record')
|
|
217
|
+
return {'status': 'error', 'message': str(e)}
|
|
218
|
+
|
|
219
|
+
# ── UPDATE — PUT /api/${endpoint}/<id> ───────────────────────────────────
|
|
220
|
+
@http.route(
|
|
221
|
+
'/api/${endpoint}/<int:record_id>',
|
|
222
|
+
auth='user',
|
|
223
|
+
methods=['PUT'],
|
|
224
|
+
csrf=False,
|
|
225
|
+
cors='*',
|
|
226
|
+
type='json',
|
|
227
|
+
)
|
|
228
|
+
def update_record(self, record_id, **kwargs):
|
|
229
|
+
"""
|
|
230
|
+
Update an existing ${L} record.
|
|
231
|
+
Body (JSON): fields to update.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
record, err = self._get_record(record_id)
|
|
235
|
+
if err:
|
|
236
|
+
return {'status': 'error', 'message': f'Record {record_id} not found'}
|
|
237
|
+
data = request.jsonrequest or {}
|
|
238
|
+
# Protect immutable fields
|
|
239
|
+
for field in ('id', 'reference', 'create_date', 'write_date'):
|
|
240
|
+
data.pop(field, None)
|
|
241
|
+
record.write(data)
|
|
242
|
+
return {'status': 'success', 'data': {'id': record_id, 'updated': True}}
|
|
243
|
+
except ValidationError as e:
|
|
244
|
+
return {'status': 'error', 'message': str(e), 'code': 'validation_error'}
|
|
245
|
+
except AccessError as e:
|
|
246
|
+
return {'status': 'error', 'message': str(e), 'code': 'access_denied'}
|
|
247
|
+
except Exception as e:
|
|
248
|
+
_logger.exception('Error updating ${M} record %s', record_id)
|
|
249
|
+
return {'status': 'error', 'message': str(e)}
|
|
250
|
+
|
|
251
|
+
# ── DELETE — DELETE /api/${endpoint}/<id> ────────────────────────────────
|
|
252
|
+
@http.route(
|
|
253
|
+
'/api/${endpoint}/<int:record_id>',
|
|
254
|
+
auth='user',
|
|
255
|
+
methods=['DELETE'],
|
|
256
|
+
csrf=False,
|
|
257
|
+
cors='*',
|
|
258
|
+
)
|
|
259
|
+
def delete_record(self, record_id, **kwargs):
|
|
260
|
+
"""Delete a ${L} record."""
|
|
261
|
+
try:
|
|
262
|
+
record, err = self._get_record(record_id)
|
|
263
|
+
if err:
|
|
264
|
+
return err
|
|
265
|
+
if record.state not in ('draft', 'cancelled'):
|
|
266
|
+
return self._err(
|
|
267
|
+
f'Cannot delete record in state "{record.state}". Cancel it first.', 409
|
|
268
|
+
)
|
|
269
|
+
record.unlink()
|
|
270
|
+
return self._ok({'deleted': True, 'id': record_id})
|
|
271
|
+
except AccessError as e:
|
|
272
|
+
return self._err(str(e), 403)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
_logger.exception('Error deleting ${M} record %s', record_id)
|
|
275
|
+
return self._err(str(e), 500)
|
|
276
|
+
|
|
277
|
+
# ── ACTION — POST /api/${endpoint}/<id>/action ───────────────────────────
|
|
278
|
+
@http.route(
|
|
279
|
+
'/api/${endpoint}/<int:record_id>/action',
|
|
280
|
+
auth='user',
|
|
281
|
+
methods=['POST'],
|
|
282
|
+
csrf=False,
|
|
283
|
+
cors='*',
|
|
284
|
+
type='json',
|
|
285
|
+
)
|
|
286
|
+
def call_action(self, record_id, **kwargs):
|
|
287
|
+
"""
|
|
288
|
+
Call a business method on a record.
|
|
289
|
+
Body (JSON): { "action": "confirm" | "cancel" | "done" | "reset_draft" }
|
|
290
|
+
"""
|
|
291
|
+
ALLOWED_ACTIONS = {
|
|
292
|
+
'confirm': 'action_confirm',
|
|
293
|
+
'start': 'action_start',
|
|
294
|
+
'done': 'action_done',
|
|
295
|
+
'cancel': 'action_cancel',
|
|
296
|
+
'reset_draft': 'action_reset_draft',
|
|
297
|
+
}
|
|
298
|
+
try:
|
|
299
|
+
data = request.jsonrequest or {}
|
|
300
|
+
action = data.get('action', '').strip()
|
|
301
|
+
if action not in ALLOWED_ACTIONS:
|
|
302
|
+
return {
|
|
303
|
+
'status': 'error',
|
|
304
|
+
'message': f'Unknown action "{action}". Allowed: {list(ALLOWED_ACTIONS)}',
|
|
305
|
+
}
|
|
306
|
+
record, err = self._get_record(record_id)
|
|
307
|
+
if err:
|
|
308
|
+
return {'status': 'error', 'message': f'Record {record_id} not found'}
|
|
309
|
+
getattr(record, ALLOWED_ACTIONS[action])()
|
|
310
|
+
return {'status': 'success', 'data': {'id': record_id, 'state': record.state}}
|
|
311
|
+
except UserError as e:
|
|
312
|
+
return {'status': 'error', 'message': str(e), 'code': 'user_error'}
|
|
313
|
+
except AccessError as e:
|
|
314
|
+
return {'status': 'error', 'message': str(e), 'code': 'access_denied'}
|
|
315
|
+
except Exception as e:
|
|
316
|
+
_logger.exception('Error calling action on ${M} record %s', record_id)
|
|
317
|
+
return {'status': 'error', 'message': str(e)}
|
|
318
|
+
|
|
319
|
+
# ── STATS — GET /api/${endpoint}/stats ───────────────────────────────────
|
|
320
|
+
@http.route(
|
|
321
|
+
'/api/${endpoint}/stats',
|
|
322
|
+
auth='user',
|
|
323
|
+
methods=['GET'],
|
|
324
|
+
csrf=False,
|
|
325
|
+
cors='*',
|
|
326
|
+
)
|
|
327
|
+
def get_stats(self, **kwargs):
|
|
328
|
+
"""Return aggregate stats grouped by state."""
|
|
329
|
+
try:
|
|
330
|
+
Model = request.env['${M}']
|
|
331
|
+
states = ['draft', 'confirmed', 'in_progress', 'done', 'cancelled']
|
|
332
|
+
stats = {s: Model.search_count([('state', '=', s)]) for s in states}
|
|
333
|
+
stats['total'] = Model.search_count([])
|
|
334
|
+
return self._ok(stats)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
return self._err(str(e), 500)
|
|
337
|
+
`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = { generateApiLayer };
|