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,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 };