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.
@@ -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 };