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,794 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { writeFile, mkdirp } = require('../utils/file-system');
|
|
5
|
+
const { CORE_DEPENDS, COMPATIBILITY_DATES } = require('../config/defaults');
|
|
6
|
+
const { toSnakeCase } = require('../utils/string-utils');
|
|
7
|
+
const logger = require('../utils/logger');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate the complete Odoo Python module directory.
|
|
11
|
+
*/
|
|
12
|
+
async function generateOdooModule(targetDir, config) {
|
|
13
|
+
const odooDir = path.join(targetDir, 'odoo_module');
|
|
14
|
+
const s = config.moduleNameSnake; // fleet_manager
|
|
15
|
+
const P = config.moduleNamePascal; // FleetManager
|
|
16
|
+
const M = config.moduleNameOdoo; // fleet.manager
|
|
17
|
+
const L = config.moduleNameLabel; // Fleet Manager
|
|
18
|
+
|
|
19
|
+
logger.verbose(`Generating Odoo module at ${odooDir}`);
|
|
20
|
+
|
|
21
|
+
await mkdirp(odooDir);
|
|
22
|
+
|
|
23
|
+
// ── Directory skeleton ────────────────────────────────────────────────────
|
|
24
|
+
const dirs = [
|
|
25
|
+
'models', 'views', 'controllers', 'security',
|
|
26
|
+
'data', 'report', 'tests', 'i18n',
|
|
27
|
+
`static/src/js`, `static/src/scss`, `static/description`,
|
|
28
|
+
];
|
|
29
|
+
if (config.withWizard) dirs.push('wizard');
|
|
30
|
+
for (const d of dirs) await mkdirp(path.join(odooDir, d));
|
|
31
|
+
|
|
32
|
+
// ── Build depends list ────────────────────────────────────────────────────
|
|
33
|
+
const depends = [...CORE_DEPENDS, ...config.extraDepends];
|
|
34
|
+
const compatDate = COMPATIBILITY_DATES[config.odooVersion] || '2024-01-01';
|
|
35
|
+
|
|
36
|
+
// ── File generation ───────────────────────────────────────────────────────
|
|
37
|
+
await Promise.all([
|
|
38
|
+
writeFile(path.join(odooDir, '__init__.py'), genRootInit(s)),
|
|
39
|
+
writeFile(path.join(odooDir, '__manifest__.py'), genManifest({ s, P, L, M, depends, compatDate, config })),
|
|
40
|
+
writeFile(path.join(odooDir, 'models', '__init__.py'), genModelsInit(s, config)),
|
|
41
|
+
writeFile(path.join(odooDir, 'models', `${s}.py`), genModel(s, P, M, L, config)),
|
|
42
|
+
writeFile(path.join(odooDir, 'controllers', '__init__.py'), genControllersInit(s)),
|
|
43
|
+
writeFile(path.join(odooDir, 'security', 'ir.model.access.csv'), genSecurityCsv(s, M)),
|
|
44
|
+
writeFile(path.join(odooDir, 'security', `${s}_security.xml`), genSecurityXml(s, L)),
|
|
45
|
+
writeFile(path.join(odooDir, 'data', `${s}_data.xml`), genDataXml(s, L)),
|
|
46
|
+
writeFile(path.join(odooDir, 'i18n', `${s}.pot`), genPot(s, L)),
|
|
47
|
+
writeFile(path.join(odooDir, 'static', 'description', 'index.html'), genDescription(L, config)),
|
|
48
|
+
writeFile(path.join(odooDir, 'static', 'src', 'js', `${s}.js`), genJs(s, P, config)),
|
|
49
|
+
writeFile(path.join(odooDir, 'static', 'src', 'scss', `${s}.scss`), genScss(s)),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
if (config.withTests) {
|
|
53
|
+
await Promise.all([
|
|
54
|
+
writeFile(path.join(odooDir, 'tests', '__init__.py'), genTestsInit(s)),
|
|
55
|
+
writeFile(path.join(odooDir, 'tests', `test_${s}.py`), genTests(s, P, M, L)),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (config.withWizard) {
|
|
60
|
+
await Promise.all([
|
|
61
|
+
writeFile(path.join(odooDir, 'wizard', '__init__.py'), `from . import ${s}_wizard\n`),
|
|
62
|
+
writeFile(path.join(odooDir, 'wizard', `${s}_wizard.py`), genWizard(s, P, M, L)),
|
|
63
|
+
writeFile(path.join(odooDir, 'wizard', `${s}_wizard_views.xml`), genWizardView(s, P, L)),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config.withReports) {
|
|
68
|
+
await writeFile(path.join(odooDir, 'report', `${s}_report.xml`), genReport(s, P, M, L));
|
|
69
|
+
await writeFile(path.join(odooDir, 'report', `${s}_report_template.xml`), genReportTemplate(s, P, M, L));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Root package.json for npm scripts ─────────────────────────────────────
|
|
73
|
+
await writeFile(path.join(targetDir, 'package.json'), genPackageJson(s, L, config));
|
|
74
|
+
await writeFile(path.join(targetDir, '.env.example'), genEnvExample(s, config));
|
|
75
|
+
await writeFile(path.join(targetDir, '.gitignore'), genGitignore());
|
|
76
|
+
await writeFile(path.join(targetDir, 'README.md'), genReadme(s, P, L, M, config));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
// Python / Odoo file generators
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
function genRootInit(s) {
|
|
84
|
+
return `# -*- coding: utf-8 -*-
|
|
85
|
+
from . import models
|
|
86
|
+
from . import controllers
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function genManifest({ s, P, L, M, depends, compatDate, config }) {
|
|
91
|
+
const dependsList = depends.map(d => `'${d}'`).join(', ');
|
|
92
|
+
const dataFiles = [
|
|
93
|
+
`'security/ir.model.access.csv'`,
|
|
94
|
+
`'security/${s}_security.xml'`,
|
|
95
|
+
`'views/${s}_views.xml'`,
|
|
96
|
+
`'views/${s}_menus.xml'`,
|
|
97
|
+
`'data/${s}_data.xml'`,
|
|
98
|
+
];
|
|
99
|
+
if (config.withReports) dataFiles.push(`'report/${s}_report.xml'`, `'report/${s}_report_template.xml'`);
|
|
100
|
+
if (config.withWizard) dataFiles.push(`'wizard/${s}_wizard_views.xml'`);
|
|
101
|
+
|
|
102
|
+
return `# -*- coding: utf-8 -*-
|
|
103
|
+
{
|
|
104
|
+
'name': '${L}',
|
|
105
|
+
'version': '${config.odooVersion}.0.1.0.0',
|
|
106
|
+
'category': '${config.category}',
|
|
107
|
+
'summary': 'Auto-generated by create-odoo-module CLI',
|
|
108
|
+
'description': """
|
|
109
|
+
${L} Module
|
|
110
|
+
${'='.repeat(L.length + 7)}
|
|
111
|
+
Full-stack Odoo module with CRUD${config.withApi ? ' + REST API' : ''}.
|
|
112
|
+
Generated by create-odoo-module — https://create-odoo-module.dev
|
|
113
|
+
""",
|
|
114
|
+
'author': '${config.author}',
|
|
115
|
+
'website': '${config.website}',
|
|
116
|
+
'depends': [${dependsList}],
|
|
117
|
+
'data': [
|
|
118
|
+
${dataFiles.join(',\n ')},
|
|
119
|
+
],
|
|
120
|
+
'assets': {
|
|
121
|
+
'web.assets_backend': [
|
|
122
|
+
'${s}/static/src/js/${s}.js',
|
|
123
|
+
'${s}/static/src/scss/${s}.scss',
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
'demo': [],
|
|
127
|
+
'installable': True,
|
|
128
|
+
'application': True,
|
|
129
|
+
'auto_install': False,
|
|
130
|
+
'license': '${config.license || 'LGPL-3'}',
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function genModelsInit(s, config) {
|
|
136
|
+
let out = `# -*- coding: utf-8 -*-\nfrom . import ${s}\n`;
|
|
137
|
+
if (config.withWizard) out += `from . import ${s}_wizard\n`;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function genControllersInit(s) {
|
|
142
|
+
return `# -*- coding: utf-8 -*-\nfrom . import ${s}_controller\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function genModel(s, P, M, L, config) {
|
|
146
|
+
return `# -*- coding: utf-8 -*-
|
|
147
|
+
from odoo import api, fields, models, _
|
|
148
|
+
from odoo.exceptions import UserError, ValidationError
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ${P}(models.Model):
|
|
152
|
+
"""
|
|
153
|
+
${L} — Main model.
|
|
154
|
+
Generated by create-odoo-module (https://create-odoo-module.dev)
|
|
155
|
+
"""
|
|
156
|
+
_name = '${M}'
|
|
157
|
+
_description = '${L}'
|
|
158
|
+
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
159
|
+
_order = 'name asc'
|
|
160
|
+
_rec_name = 'name'
|
|
161
|
+
|
|
162
|
+
# ── Identity ──────────────────────────────────────────────────────────────
|
|
163
|
+
name = fields.Char(
|
|
164
|
+
string='Name',
|
|
165
|
+
required=True,
|
|
166
|
+
tracking=True,
|
|
167
|
+
index=True,
|
|
168
|
+
copy=False,
|
|
169
|
+
)
|
|
170
|
+
reference = fields.Char(
|
|
171
|
+
string='Reference',
|
|
172
|
+
copy=False,
|
|
173
|
+
readonly=True,
|
|
174
|
+
default='New',
|
|
175
|
+
)
|
|
176
|
+
description = fields.Text(string='Description')
|
|
177
|
+
active = fields.Boolean(default=True, tracking=True)
|
|
178
|
+
color = fields.Integer(string='Color')
|
|
179
|
+
|
|
180
|
+
# ── Status ───────────────────────────────────────────────────────────────
|
|
181
|
+
state = fields.Selection([
|
|
182
|
+
('draft', 'Draft'),
|
|
183
|
+
('confirmed', 'Confirmed'),
|
|
184
|
+
('in_progress', 'In Progress'),
|
|
185
|
+
('done', 'Done'),
|
|
186
|
+
('cancelled', 'Cancelled'),
|
|
187
|
+
], default='draft', tracking=True, string='Status', required=True)
|
|
188
|
+
|
|
189
|
+
# ── Relations ─────────────────────────────────────────────────────────────
|
|
190
|
+
user_id = fields.Many2one(
|
|
191
|
+
'res.users',
|
|
192
|
+
string='Responsible',
|
|
193
|
+
default=lambda self: self.env.user,
|
|
194
|
+
tracking=True,
|
|
195
|
+
index=True,
|
|
196
|
+
)
|
|
197
|
+
company_id = fields.Many2one(
|
|
198
|
+
'res.company',
|
|
199
|
+
string='Company',
|
|
200
|
+
default=lambda self: self.env.company,
|
|
201
|
+
required=True,
|
|
202
|
+
index=True,
|
|
203
|
+
)
|
|
204
|
+
tag_ids = fields.Many2many(
|
|
205
|
+
'res.partner.category',
|
|
206
|
+
string='Tags',
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# ── Dates ────────────────────────────────────────────────────────────────
|
|
210
|
+
date_start = fields.Date(string='Start Date')
|
|
211
|
+
date_end = fields.Date(string='End Date')
|
|
212
|
+
create_date = fields.Datetime(readonly=True)
|
|
213
|
+
write_date = fields.Datetime(readonly=True)
|
|
214
|
+
|
|
215
|
+
# ── Priority / KPIs ───────────────────────────────────────────────────────
|
|
216
|
+
priority = fields.Selection([
|
|
217
|
+
('0', 'Normal'),
|
|
218
|
+
('1', 'Low'),
|
|
219
|
+
('2', 'High'),
|
|
220
|
+
('3', 'Very High'),
|
|
221
|
+
], default='0', string='Priority')
|
|
222
|
+
|
|
223
|
+
# ── Computed ─────────────────────────────────────────────────────────────
|
|
224
|
+
display_name = fields.Char(compute='_compute_display_name', store=True)
|
|
225
|
+
duration_days = fields.Integer(compute='_compute_duration', store=True, string='Duration (Days)')
|
|
226
|
+
|
|
227
|
+
@api.depends('name', 'reference', 'state')
|
|
228
|
+
def _compute_display_name(self):
|
|
229
|
+
for rec in self:
|
|
230
|
+
ref = rec.reference if rec.reference != 'New' else ''
|
|
231
|
+
rec.display_name = f"[{rec.state.upper()}] {rec.name}" + (f" ({ref})" if ref else '')
|
|
232
|
+
|
|
233
|
+
@api.depends('date_start', 'date_end')
|
|
234
|
+
def _compute_duration(self):
|
|
235
|
+
for rec in self:
|
|
236
|
+
if rec.date_start and rec.date_end:
|
|
237
|
+
rec.duration_days = (rec.date_end - rec.date_start).days
|
|
238
|
+
else:
|
|
239
|
+
rec.duration_days = 0
|
|
240
|
+
|
|
241
|
+
# ── Constraints ───────────────────────────────────────────────────────────
|
|
242
|
+
@api.constrains('date_start', 'date_end')
|
|
243
|
+
def _check_dates(self):
|
|
244
|
+
for rec in self:
|
|
245
|
+
if rec.date_start and rec.date_end and rec.date_start > rec.date_end:
|
|
246
|
+
raise ValidationError(_('Start date must be before end date.'))
|
|
247
|
+
|
|
248
|
+
_sql_constraints = [
|
|
249
|
+
('reference_unique', 'UNIQUE(reference, company_id)',
|
|
250
|
+
'Reference must be unique per company.'),
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
# ── Sequence ──────────────────────────────────────────────────────────────
|
|
254
|
+
@api.model_create_multi
|
|
255
|
+
def create(self, vals_list):
|
|
256
|
+
for vals in vals_list:
|
|
257
|
+
if vals.get('reference', 'New') == 'New':
|
|
258
|
+
vals['reference'] = self.env['ir.sequence'].next_by_code('${M}') or 'New'
|
|
259
|
+
return super().create(vals_list)
|
|
260
|
+
|
|
261
|
+
# ── Business Logic ────────────────────────────────────────────────────────
|
|
262
|
+
def action_confirm(self):
|
|
263
|
+
for rec in self:
|
|
264
|
+
if rec.state != 'draft':
|
|
265
|
+
raise UserError(_('Only draft records can be confirmed.'))
|
|
266
|
+
self.write({'state': 'confirmed'})
|
|
267
|
+
|
|
268
|
+
def action_start(self):
|
|
269
|
+
self.filtered(lambda r: r.state == 'confirmed').write({'state': 'in_progress'})
|
|
270
|
+
|
|
271
|
+
def action_done(self):
|
|
272
|
+
self.write({'state': 'done'})
|
|
273
|
+
|
|
274
|
+
def action_cancel(self):
|
|
275
|
+
if any(r.state == 'done' for r in self):
|
|
276
|
+
raise UserError(_('Cannot cancel a completed record.'))
|
|
277
|
+
self.write({'state': 'cancelled'})
|
|
278
|
+
|
|
279
|
+
def action_reset_draft(self):
|
|
280
|
+
self.write({'state': 'draft'})
|
|
281
|
+
|
|
282
|
+
# ── Smart button actions ───────────────────────────────────────────────────
|
|
283
|
+
def action_open_form(self):
|
|
284
|
+
self.ensure_one()
|
|
285
|
+
return {
|
|
286
|
+
'type': 'ir.actions.act_window',
|
|
287
|
+
'res_model': self._name,
|
|
288
|
+
'res_id': self.id,
|
|
289
|
+
'view_mode': 'form',
|
|
290
|
+
'target': 'current',
|
|
291
|
+
}
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function genSecurityCsv(s, M) {
|
|
296
|
+
const s_ = s.replace(/\./g, '_');
|
|
297
|
+
return `id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
|
298
|
+
access_${s_}_user,${M} User,model_${s_.replace(/-/g, '_')},base.group_user,1,0,0,0
|
|
299
|
+
access_${s_}_manager,${M} Manager,model_${s_.replace(/-/g, '_')},base.group_system,1,1,1,1
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function genSecurityXml(s, L) {
|
|
304
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
305
|
+
<odoo>
|
|
306
|
+
<data noupdate="1">
|
|
307
|
+
<!-- Groups -->
|
|
308
|
+
<record id="group_${s}_user" model="res.groups">
|
|
309
|
+
<field name="name">${L} / User</field>
|
|
310
|
+
<field name="category_id" ref="base.module_category_hidden"/>
|
|
311
|
+
</record>
|
|
312
|
+
<record id="group_${s}_manager" model="res.groups">
|
|
313
|
+
<field name="name">${L} / Manager</field>
|
|
314
|
+
<field name="category_id" ref="base.module_category_hidden"/>
|
|
315
|
+
<field name="implied_ids" eval="[(4, ref('group_${s}_user'))]"/>
|
|
316
|
+
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
|
317
|
+
</record>
|
|
318
|
+
</data>
|
|
319
|
+
</odoo>
|
|
320
|
+
`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function genDataXml(s, L) {
|
|
324
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
325
|
+
<odoo>
|
|
326
|
+
<data noupdate="1">
|
|
327
|
+
<!-- Sequence for ${L} -->
|
|
328
|
+
<record id="seq_${s}" model="ir.sequence">
|
|
329
|
+
<field name="name">${L} Sequence</field>
|
|
330
|
+
<field name="code">${s.replace(/_/g, '.')}</field>
|
|
331
|
+
<field name="prefix">${s.toUpperCase().slice(0,3)}/%(year)s/</field>
|
|
332
|
+
<field name="padding">5</field>
|
|
333
|
+
<field name="company_id" eval="False"/>
|
|
334
|
+
</record>
|
|
335
|
+
</data>
|
|
336
|
+
</odoo>
|
|
337
|
+
`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function genPot(s, L) {
|
|
341
|
+
return `# Translation of ${L} module
|
|
342
|
+
# Copyright (C) ${new Date().getFullYear()} - Your Company
|
|
343
|
+
# This file is distributed under the same license as the ${L} package.
|
|
344
|
+
#, fuzzy
|
|
345
|
+
msgid ""
|
|
346
|
+
msgstr ""
|
|
347
|
+
"Project-Id-Version: ${s} 1.0\\n"
|
|
348
|
+
"Report-Msgid-Bugs-To: \\n"
|
|
349
|
+
"POT-Creation-Date: ${new Date().toISOString()}\\n"
|
|
350
|
+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
|
|
351
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
|
|
352
|
+
"Language-Team: LANGUAGE <LL@li.org>\\n"
|
|
353
|
+
"MIME-Version: 1.0\\n"
|
|
354
|
+
"Content-Type: text/plain; charset=UTF-8\\n"
|
|
355
|
+
"Content-Transfer-Encoding: 8bit\\n"
|
|
356
|
+
`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function genDescription(L, config) {
|
|
360
|
+
return `<section class="oe_container">
|
|
361
|
+
<div class="oe_row oe_spaced">
|
|
362
|
+
<h2 class="oe_slogan">${L}</h2>
|
|
363
|
+
<h3 class="oe_slogan">Generated by create-odoo-module</h3>
|
|
364
|
+
<p class="oe_mt32 text-center">
|
|
365
|
+
Full-stack Odoo module with CRUD operations${config.withApi ? ', REST API' : ''}
|
|
366
|
+
${config.withUi ? ', and Flutter mobile app' : ''}.
|
|
367
|
+
</p>
|
|
368
|
+
</div>
|
|
369
|
+
</section>
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function genJs(s, P, config) {
|
|
374
|
+
if (!config.withOwl) {
|
|
375
|
+
return `/** @odoo-module **/
|
|
376
|
+
// ${P} — static JS
|
|
377
|
+
// Generated by create-odoo-module (https://create-odoo-module.dev)
|
|
378
|
+
console.log('[${s}] module loaded');
|
|
379
|
+
`;
|
|
380
|
+
}
|
|
381
|
+
return `/** @odoo-module **/
|
|
382
|
+
|
|
383
|
+
import { Component, useState, onWillStart } from "@odoo/owl";
|
|
384
|
+
import { registry } from "@web/core/registry";
|
|
385
|
+
import { useService } from "@web/core/utils/hooks";
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* ${P} OWL Component
|
|
389
|
+
* Renders inside the Odoo webclient.
|
|
390
|
+
*/
|
|
391
|
+
class ${P}Widget extends Component {
|
|
392
|
+
static template = \`
|
|
393
|
+
<div class="${s}-widget">
|
|
394
|
+
<h3>{{ state.title }}</h3>
|
|
395
|
+
<p>Records loaded: {{ state.count }}</p>
|
|
396
|
+
</div>
|
|
397
|
+
\`;
|
|
398
|
+
|
|
399
|
+
setup() {
|
|
400
|
+
this.orm = useService("orm");
|
|
401
|
+
this.state = useState({ title: "${P}", count: 0 });
|
|
402
|
+
|
|
403
|
+
onWillStart(async () => {
|
|
404
|
+
const count = await this.orm.searchCount("${s.replace(/_/g, '.')}", []);
|
|
405
|
+
this.state.count = count;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Register as a systray item or action component
|
|
411
|
+
registry.category("actions").add("${s}_action_widget", ${P}Widget);
|
|
412
|
+
`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function genScss(s) {
|
|
416
|
+
return `.o_${s} {
|
|
417
|
+
// Module root class — applied to form views
|
|
418
|
+
.o_field_widget {
|
|
419
|
+
// Custom field styling
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.o_statusbar_status {
|
|
423
|
+
// Custom statusbar
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function genTestsInit(s) {
|
|
430
|
+
return `# -*- coding: utf-8 -*-
|
|
431
|
+
from . import test_${s}
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function genTests(s, P, M, L) {
|
|
436
|
+
return `# -*- coding: utf-8 -*-
|
|
437
|
+
from odoo.tests import common, tagged
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@tagged('post_install', '-at_install', '${s}')
|
|
441
|
+
class Test${P}(common.TransactionCase):
|
|
442
|
+
"""
|
|
443
|
+
Unit tests for ${L} (${M}).
|
|
444
|
+
Run with: python -m pytest addons/${s}/tests/ -v
|
|
445
|
+
Or via Odoo: ./odoo-bin test -d mydb --test-tags ${s}
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def setUpClass(cls):
|
|
450
|
+
super().setUpClass()
|
|
451
|
+
cls.Model = cls.env['${M}']
|
|
452
|
+
cls.user = cls.env.ref('base.user_admin')
|
|
453
|
+
|
|
454
|
+
def _create_record(self, name='Test Record', **kwargs):
|
|
455
|
+
return self.Model.create({'name': name, **kwargs})
|
|
456
|
+
|
|
457
|
+
# ── Creation ─────────────────────────────────────────────────────────────
|
|
458
|
+
def test_01_create_basic(self):
|
|
459
|
+
rec = self._create_record()
|
|
460
|
+
self.assertTrue(rec.id, 'Record should have an ID after creation')
|
|
461
|
+
self.assertEqual(rec.name, 'Test Record')
|
|
462
|
+
self.assertEqual(rec.state, 'draft', 'Default state should be draft')
|
|
463
|
+
|
|
464
|
+
def test_02_sequence_assigned(self):
|
|
465
|
+
rec = self._create_record('Seq Test')
|
|
466
|
+
self.assertNotEqual(rec.reference, 'New', 'Sequence should be assigned on create')
|
|
467
|
+
|
|
468
|
+
# ── State machine ─────────────────────────────────────────────────────────
|
|
469
|
+
def test_03_confirm(self):
|
|
470
|
+
rec = self._create_record('Confirm Test')
|
|
471
|
+
rec.action_confirm()
|
|
472
|
+
self.assertEqual(rec.state, 'confirmed')
|
|
473
|
+
|
|
474
|
+
def test_04_cannot_confirm_twice(self):
|
|
475
|
+
rec = self._create_record('Double Confirm')
|
|
476
|
+
rec.action_confirm()
|
|
477
|
+
with self.assertRaises(Exception):
|
|
478
|
+
rec.action_confirm()
|
|
479
|
+
|
|
480
|
+
def test_05_done(self):
|
|
481
|
+
rec = self._create_record('Done Test')
|
|
482
|
+
rec.action_confirm()
|
|
483
|
+
rec.action_start()
|
|
484
|
+
rec.action_done()
|
|
485
|
+
self.assertEqual(rec.state, 'done')
|
|
486
|
+
|
|
487
|
+
def test_06_cancel(self):
|
|
488
|
+
rec = self._create_record('Cancel Test')
|
|
489
|
+
rec.action_cancel()
|
|
490
|
+
self.assertEqual(rec.state, 'cancelled')
|
|
491
|
+
|
|
492
|
+
def test_07_reset_draft(self):
|
|
493
|
+
rec = self._create_record('Reset Test')
|
|
494
|
+
rec.action_cancel()
|
|
495
|
+
rec.action_reset_draft()
|
|
496
|
+
self.assertEqual(rec.state, 'draft')
|
|
497
|
+
|
|
498
|
+
# ── Date constraint ───────────────────────────────────────────────────────
|
|
499
|
+
def test_08_date_constraint(self):
|
|
500
|
+
from odoo.exceptions import ValidationError
|
|
501
|
+
import datetime
|
|
502
|
+
with self.assertRaises(ValidationError):
|
|
503
|
+
self._create_record(
|
|
504
|
+
'Bad Dates',
|
|
505
|
+
date_start=datetime.date(2025, 12, 31),
|
|
506
|
+
date_end=datetime.date(2025, 1, 1),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# ── Computed fields ───────────────────────────────────────────────────────
|
|
510
|
+
def test_09_display_name(self):
|
|
511
|
+
rec = self._create_record('Display Test')
|
|
512
|
+
self.assertIn('draft', rec.display_name.lower())
|
|
513
|
+
self.assertIn('display test', rec.display_name.lower())
|
|
514
|
+
|
|
515
|
+
def test_10_duration(self):
|
|
516
|
+
import datetime
|
|
517
|
+
rec = self._create_record(
|
|
518
|
+
'Duration Test',
|
|
519
|
+
date_start=datetime.date(2025, 1, 1),
|
|
520
|
+
date_end=datetime.date(2025, 1, 11),
|
|
521
|
+
)
|
|
522
|
+
self.assertEqual(rec.duration_days, 10)
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function genWizard(s, P, M, L) {
|
|
527
|
+
return `# -*- coding: utf-8 -*-
|
|
528
|
+
from odoo import api, fields, models, _
|
|
529
|
+
from odoo.exceptions import UserError
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class ${P}Wizard(models.TransientModel):
|
|
533
|
+
"""Wizard for bulk actions on ${L}."""
|
|
534
|
+
_name = '${M}.wizard'
|
|
535
|
+
_description = '${L} Wizard'
|
|
536
|
+
|
|
537
|
+
record_ids = fields.Many2many('${M}', string='Records')
|
|
538
|
+
action = fields.Selection([
|
|
539
|
+
('confirm', 'Confirm All'),
|
|
540
|
+
('cancel', 'Cancel All'),
|
|
541
|
+
], string='Action', required=True, default='confirm')
|
|
542
|
+
note = fields.Text(string='Note / Reason')
|
|
543
|
+
|
|
544
|
+
def action_execute(self):
|
|
545
|
+
self.ensure_one()
|
|
546
|
+
records = self.record_ids or self.env['${M}'].browse(self.env.context.get('active_ids', []))
|
|
547
|
+
if not records:
|
|
548
|
+
raise UserError(_('No records selected.'))
|
|
549
|
+
|
|
550
|
+
if self.action == 'confirm':
|
|
551
|
+
records.action_confirm()
|
|
552
|
+
elif self.action == 'cancel':
|
|
553
|
+
records.action_cancel()
|
|
554
|
+
|
|
555
|
+
return {'type': 'ir.actions.act_window_close'}
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function genWizardView(s, P, L) {
|
|
560
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
561
|
+
<odoo>
|
|
562
|
+
<record id="view_${s}_wizard_form" model="ir.ui.view">
|
|
563
|
+
<field name="name">${s}.wizard.form</field>
|
|
564
|
+
<field name="model">${s.replace(/_/g, '.')}.wizard</field>
|
|
565
|
+
<field name="arch" type="xml">
|
|
566
|
+
<form string="${L} — Bulk Action">
|
|
567
|
+
<group>
|
|
568
|
+
<field name="record_ids" widget="many2many_tags"/>
|
|
569
|
+
<field name="action"/>
|
|
570
|
+
<field name="note" placeholder="Optional reason..."/>
|
|
571
|
+
</group>
|
|
572
|
+
<footer>
|
|
573
|
+
<button name="action_execute" string="Execute" type="object" class="btn-primary"/>
|
|
574
|
+
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
|
575
|
+
</footer>
|
|
576
|
+
</form>
|
|
577
|
+
</field>
|
|
578
|
+
</record>
|
|
579
|
+
|
|
580
|
+
<record id="action_${s}_wizard" model="ir.actions.act_window">
|
|
581
|
+
<field name="name">${L} — Bulk Action</field>
|
|
582
|
+
<field name="res_model">${s.replace(/_/g, '.')}.wizard</field>
|
|
583
|
+
<field name="view_mode">form</field>
|
|
584
|
+
<field name="target">new</field>
|
|
585
|
+
<field name="binding_model_id" ref="${s}.model_${s}"/>
|
|
586
|
+
<field name="binding_view_types">list</field>
|
|
587
|
+
</record>
|
|
588
|
+
</odoo>
|
|
589
|
+
`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function genReport(s, P, M, L) {
|
|
593
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
594
|
+
<odoo>
|
|
595
|
+
<record id="action_report_${s}" model="ir.actions.report">
|
|
596
|
+
<field name="name">${L} Report</field>
|
|
597
|
+
<field name="model">${M}</field>
|
|
598
|
+
<field name="report_type">qweb-pdf</field>
|
|
599
|
+
<field name="report_name">${s}.report_${s}_template</field>
|
|
600
|
+
<field name="report_file">${s}/report/${s}_report_template</field>
|
|
601
|
+
<field name="print_report_name">'${L} - ' + object.name</field>
|
|
602
|
+
<field name="binding_model_id" ref="${s}.model_${s}"/>
|
|
603
|
+
<field name="binding_type">report</field>
|
|
604
|
+
<field name="paperformat_id" ref="base.paperformat_euro"/>
|
|
605
|
+
</record>
|
|
606
|
+
</odoo>
|
|
607
|
+
`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function genReportTemplate(s, P, M, L) {
|
|
611
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
612
|
+
<odoo>
|
|
613
|
+
<template id="report_${s}_template">
|
|
614
|
+
<t t-call="web.html_container">
|
|
615
|
+
<t t-foreach="docs" t-as="doc">
|
|
616
|
+
<t t-call="web.external_layout">
|
|
617
|
+
<div class="page">
|
|
618
|
+
<!-- Header -->
|
|
619
|
+
<div class="row">
|
|
620
|
+
<div class="col-6">
|
|
621
|
+
<h2><t t-esc="doc.name"/></h2>
|
|
622
|
+
<p class="text-muted">Ref: <t t-esc="doc.reference"/></p>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="col-6 text-end">
|
|
625
|
+
<span t-attf-class="badge bg-{{ 'success' if doc.state == 'done' else 'warning' }}">
|
|
626
|
+
<t t-esc="doc.state.upper()"/>
|
|
627
|
+
</span>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
<hr/>
|
|
631
|
+
<!-- Body -->
|
|
632
|
+
<div class="row mt-3">
|
|
633
|
+
<div class="col-6">
|
|
634
|
+
<strong>Responsible:</strong>
|
|
635
|
+
<t t-esc="doc.user_id.name"/>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="col-6">
|
|
638
|
+
<strong>Period:</strong>
|
|
639
|
+
<t t-esc="doc.date_start"/> — <t t-esc="doc.date_end"/>
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
<t t-if="doc.description">
|
|
643
|
+
<div class="row mt-3">
|
|
644
|
+
<div class="col-12">
|
|
645
|
+
<strong>Description:</strong>
|
|
646
|
+
<p><t t-esc="doc.description"/></p>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</t>
|
|
650
|
+
<!-- Footer -->
|
|
651
|
+
<div class="row mt-5">
|
|
652
|
+
<div class="col-12 text-muted text-center">
|
|
653
|
+
<small>Generated by create-odoo-module — https://create-odoo-module.dev</small>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
</t>
|
|
658
|
+
</t>
|
|
659
|
+
</t>
|
|
660
|
+
</template>
|
|
661
|
+
</odoo>
|
|
662
|
+
`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function genPackageJson(s, L, config) {
|
|
666
|
+
return JSON.stringify({
|
|
667
|
+
name: s.replace(/_/g, '-'),
|
|
668
|
+
version: '1.0.0',
|
|
669
|
+
description: `${L} Odoo module`,
|
|
670
|
+
private: true,
|
|
671
|
+
scripts: {
|
|
672
|
+
deploy: 'bash scripts/deploy.sh',
|
|
673
|
+
'deploy:win': 'powershell scripts/deploy.ps1',
|
|
674
|
+
dev: 'docker-compose -f scripts/docker-compose.yml up -d',
|
|
675
|
+
'dev:stop': 'docker-compose -f scripts/docker-compose.yml down',
|
|
676
|
+
'dev:logs': 'docker-compose -f scripts/docker-compose.yml logs -f odoo',
|
|
677
|
+
...(config.withUi ? {
|
|
678
|
+
'flutter:run': 'cd flutter_app && flutter run',
|
|
679
|
+
'flutter:build:android': 'cd flutter_app && flutter build apk --release',
|
|
680
|
+
'flutter:build:ios': 'cd flutter_app && flutter build ios --release',
|
|
681
|
+
'flutter:test': 'cd flutter_app && flutter test',
|
|
682
|
+
'flutter:analyze': 'cd flutter_app && flutter analyze',
|
|
683
|
+
} : {}),
|
|
684
|
+
'odoo:test': `cd odoo_module && python -m pytest tests/ -v`,
|
|
685
|
+
'odoo:lint': `cd odoo_module && python -m flake8 . --max-line-length=120 --exclude=__pycache__`,
|
|
686
|
+
'odoo:shell': 'docker exec -it odoo_dev odoo shell -d odoo',
|
|
687
|
+
},
|
|
688
|
+
}, null, 2);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function genEnvExample(s, config) {
|
|
692
|
+
return `# ── Odoo Server ──────────────────────────────────────────────────
|
|
693
|
+
ODOO_URL=${(config.odooUrl || 'http://localhost:8069')}
|
|
694
|
+
ODOO_DB=${(config.odooDb || 'odoo')}
|
|
695
|
+
ODOO_USER=admin
|
|
696
|
+
ODOO_PASSWORD=admin
|
|
697
|
+
ODOO_ADDONS_PATH=/opt/odoo/custom-addons
|
|
698
|
+
ODOO_SERVICE=odoo
|
|
699
|
+
|
|
700
|
+
# ── Flutter App ───────────────────────────────────────────────────
|
|
701
|
+
FLUTTER_ODOO_BASE_URL=${(config.odooUrl || 'http://localhost:8069')}
|
|
702
|
+
FLUTTER_ODOO_DB=${(config.odooDb || 'odoo')}
|
|
703
|
+
|
|
704
|
+
# ── Pro License (optional) ────────────────────────────────────────
|
|
705
|
+
CREATE_ODOO_MODULE_PRO_KEY=
|
|
706
|
+
`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function genGitignore() {
|
|
710
|
+
return `# Python
|
|
711
|
+
__pycache__/
|
|
712
|
+
*.py[cod]
|
|
713
|
+
*.pyo
|
|
714
|
+
*.egg-info/
|
|
715
|
+
.eggs/
|
|
716
|
+
dist/
|
|
717
|
+
build/
|
|
718
|
+
*.egg
|
|
719
|
+
.pytest_cache/
|
|
720
|
+
.mypy_cache/
|
|
721
|
+
|
|
722
|
+
# Odoo
|
|
723
|
+
*.pot~
|
|
724
|
+
|
|
725
|
+
# Flutter/Dart
|
|
726
|
+
.dart_tool/
|
|
727
|
+
.flutter-plugins
|
|
728
|
+
.flutter-plugins-dependencies
|
|
729
|
+
flutter_app/build/
|
|
730
|
+
flutter_app/.dart_tool/
|
|
731
|
+
flutter_app/pubspec.lock
|
|
732
|
+
|
|
733
|
+
# Node
|
|
734
|
+
node_modules/
|
|
735
|
+
npm-debug.log*
|
|
736
|
+
|
|
737
|
+
# Docker
|
|
738
|
+
.docker/
|
|
739
|
+
|
|
740
|
+
# Environment
|
|
741
|
+
.env
|
|
742
|
+
*.env.local
|
|
743
|
+
|
|
744
|
+
# IDE
|
|
745
|
+
.vscode/
|
|
746
|
+
.idea/
|
|
747
|
+
*.swp
|
|
748
|
+
*.swo
|
|
749
|
+
.DS_Store
|
|
750
|
+
|
|
751
|
+
# Wrangler
|
|
752
|
+
.wrangler/
|
|
753
|
+
`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function genReadme(s, P, L, M, config) {
|
|
757
|
+
return `# ${L}
|
|
758
|
+
|
|
759
|
+
> Generated by [create-odoo-module](https://create-odoo-module.dev) — the Next.js for Odoo.
|
|
760
|
+
|
|
761
|
+
## Quick Start
|
|
762
|
+
|
|
763
|
+
\`\`\`bash
|
|
764
|
+
cp .env.example .env # Fill in your Odoo credentials
|
|
765
|
+
${config.withDocker ? 'npm run dev # Start local Odoo via Docker\n' : ''}\
|
|
766
|
+
npm run deploy # Upload module to Odoo
|
|
767
|
+
${config.withUi ? 'npm run flutter:run # Start Flutter app\n' : ''}\
|
|
768
|
+
\`\`\`
|
|
769
|
+
|
|
770
|
+
## What's Inside
|
|
771
|
+
|
|
772
|
+
| Path | Description |
|
|
773
|
+
|------|-------------|
|
|
774
|
+
| \`odoo_module/\` | Full Odoo Python module (${M}) |
|
|
775
|
+
${config.withApi ? '| `controllers/` | REST API GET/POST/PUT/DELETE |\n' : ''}\
|
|
776
|
+
${config.withUi ? '| `flutter_app/` | Flutter mobile app |\n' : ''}\
|
|
777
|
+
${config.withReports ? '| `report/` | QWeb PDF reports |\n' : ''}\
|
|
778
|
+
| \`scripts/\` | Deploy + Docker Compose |
|
|
779
|
+
|
|
780
|
+
## Odoo Module Info
|
|
781
|
+
|
|
782
|
+
- **Technical name**: \`${M}\`
|
|
783
|
+
- **Version**: \`${config.odooVersion}.0.1.0.0\`
|
|
784
|
+
- **Depends**: base, mail, web${config.extraDepends.length ? ', ' + config.extraDepends.join(', ') : ''}
|
|
785
|
+
|
|
786
|
+
${config.withApi ? `## REST API\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | \`/api/${s}\` | List records |\n| GET | \`/api/${s}/:id\` | Get record |\n| POST | \`/api/${s}\` | Create record |\n| PUT | \`/api/${s}/:id\` | Update record |\n| DELETE | \`/api/${s}/:id\` | Delete record |\n` : ''}
|
|
787
|
+
|
|
788
|
+
## License
|
|
789
|
+
|
|
790
|
+
LGPL-3 — see [LICENSE](LICENSE)
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
module.exports = { generateOdooModule };
|