odoo-addon-odoo-repository 16.0.1.3.0.13__py3-none-any.whl

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.
Files changed (77) hide show
  1. odoo/addons/odoo_repository/README.rst +81 -0
  2. odoo/addons/odoo_repository/__init__.py +2 -0
  3. odoo/addons/odoo_repository/__manifest__.py +58 -0
  4. odoo/addons/odoo_repository/controllers/__init__.py +1 -0
  5. odoo/addons/odoo_repository/controllers/main.py +32 -0
  6. odoo/addons/odoo_repository/data/ir_cron.xml +38 -0
  7. odoo/addons/odoo_repository/data/odoo.repository.csv +216 -0
  8. odoo/addons/odoo_repository/data/odoo_branch.xml +82 -0
  9. odoo/addons/odoo_repository/data/odoo_module.xml +16 -0
  10. odoo/addons/odoo_repository/data/odoo_repository.xml +71 -0
  11. odoo/addons/odoo_repository/data/odoo_repository_addons_path.xml +59 -0
  12. odoo/addons/odoo_repository/data/odoo_repository_org.xml +14 -0
  13. odoo/addons/odoo_repository/data/queue_job.xml +56 -0
  14. odoo/addons/odoo_repository/lib/__init__.py +0 -0
  15. odoo/addons/odoo_repository/lib/scanner.py +1302 -0
  16. odoo/addons/odoo_repository/migrations/16.0.1.1.0/post-migration.py +26 -0
  17. odoo/addons/odoo_repository/migrations/16.0.1.2.0/pre-migration.py +43 -0
  18. odoo/addons/odoo_repository/migrations/16.0.1.3.0/post-migration.py +19 -0
  19. odoo/addons/odoo_repository/models/__init__.py +18 -0
  20. odoo/addons/odoo_repository/models/authentication_token.py +12 -0
  21. odoo/addons/odoo_repository/models/odoo_author.py +16 -0
  22. odoo/addons/odoo_repository/models/odoo_branch.py +111 -0
  23. odoo/addons/odoo_repository/models/odoo_license.py +16 -0
  24. odoo/addons/odoo_repository/models/odoo_maintainer.py +31 -0
  25. odoo/addons/odoo_repository/models/odoo_module.py +24 -0
  26. odoo/addons/odoo_repository/models/odoo_module_branch.py +873 -0
  27. odoo/addons/odoo_repository/models/odoo_module_branch_version.py +123 -0
  28. odoo/addons/odoo_repository/models/odoo_module_category.py +15 -0
  29. odoo/addons/odoo_repository/models/odoo_module_dev_status.py +15 -0
  30. odoo/addons/odoo_repository/models/odoo_python_dependency.py +16 -0
  31. odoo/addons/odoo_repository/models/odoo_repository.py +664 -0
  32. odoo/addons/odoo_repository/models/odoo_repository_addons_path.py +40 -0
  33. odoo/addons/odoo_repository/models/odoo_repository_branch.py +98 -0
  34. odoo/addons/odoo_repository/models/odoo_repository_org.py +23 -0
  35. odoo/addons/odoo_repository/models/res_company.py +23 -0
  36. odoo/addons/odoo_repository/models/res_config_settings.py +23 -0
  37. odoo/addons/odoo_repository/models/ssh_key.py +12 -0
  38. odoo/addons/odoo_repository/readme/CONTRIBUTORS.rst +2 -0
  39. odoo/addons/odoo_repository/readme/DESCRIPTION.rst +1 -0
  40. odoo/addons/odoo_repository/security/ir.model.access.csv +27 -0
  41. odoo/addons/odoo_repository/security/res_groups.xml +25 -0
  42. odoo/addons/odoo_repository/static/description/README +4 -0
  43. odoo/addons/odoo_repository/static/description/icon.png +0 -0
  44. odoo/addons/odoo_repository/static/description/index.html +430 -0
  45. odoo/addons/odoo_repository/tests/__init__.py +6 -0
  46. odoo/addons/odoo_repository/tests/common.py +162 -0
  47. odoo/addons/odoo_repository/tests/test_base_scanner.py +214 -0
  48. odoo/addons/odoo_repository/tests/test_odoo_module_branch.py +97 -0
  49. odoo/addons/odoo_repository/tests/test_odoo_repository_scan.py +242 -0
  50. odoo/addons/odoo_repository/tests/test_repository_scanner.py +215 -0
  51. odoo/addons/odoo_repository/tests/test_sync_node.py +55 -0
  52. odoo/addons/odoo_repository/tests/test_utils.py +25 -0
  53. odoo/addons/odoo_repository/utils/__init__.py +0 -0
  54. odoo/addons/odoo_repository/utils/github.py +30 -0
  55. odoo/addons/odoo_repository/utils/module.py +25 -0
  56. odoo/addons/odoo_repository/utils/scanner.py +90 -0
  57. odoo/addons/odoo_repository/views/authentication_token.xml +63 -0
  58. odoo/addons/odoo_repository/views/menu.xml +38 -0
  59. odoo/addons/odoo_repository/views/odoo_author.xml +54 -0
  60. odoo/addons/odoo_repository/views/odoo_branch.xml +84 -0
  61. odoo/addons/odoo_repository/views/odoo_license.xml +40 -0
  62. odoo/addons/odoo_repository/views/odoo_maintainer.xml +69 -0
  63. odoo/addons/odoo_repository/views/odoo_module.xml +90 -0
  64. odoo/addons/odoo_repository/views/odoo_module_branch.xml +353 -0
  65. odoo/addons/odoo_repository/views/odoo_module_category.xml +40 -0
  66. odoo/addons/odoo_repository/views/odoo_module_dev_status.xml +40 -0
  67. odoo/addons/odoo_repository/views/odoo_python_dependency.xml +40 -0
  68. odoo/addons/odoo_repository/views/odoo_repository.xml +165 -0
  69. odoo/addons/odoo_repository/views/odoo_repository_addons_path.xml +49 -0
  70. odoo/addons/odoo_repository/views/odoo_repository_branch.xml +60 -0
  71. odoo/addons/odoo_repository/views/odoo_repository_org.xml +54 -0
  72. odoo/addons/odoo_repository/views/res_config_settings.xml +123 -0
  73. odoo/addons/odoo_repository/views/ssh_key.xml +63 -0
  74. odoo_addon_odoo_repository-16.0.1.3.0.13.dist-info/METADATA +100 -0
  75. odoo_addon_odoo_repository-16.0.1.3.0.13.dist-info/RECORD +77 -0
  76. odoo_addon_odoo_repository-16.0.1.3.0.13.dist-info/WHEEL +5 -0
  77. odoo_addon_odoo_repository-16.0.1.3.0.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,873 @@
1
+ # Copyright 2023 Camptocamp SA
2
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3
+
4
+ import pathlib
5
+ import random
6
+ import time
7
+ from urllib.parse import urlparse
8
+
9
+ from odoo import _, api, fields, models, tools
10
+ from odoo.exceptions import ValidationError
11
+ from odoo.osv import expression
12
+
13
+ from odoo.addons.queue_job.exception import RetryableJobError
14
+
15
+ from ..utils import github
16
+ from ..utils.module import adapt_version
17
+
18
+
19
+ class OdooModuleBranch(models.Model):
20
+ _name = "odoo.module.branch"
21
+ _description = "Odoo Module Branch"
22
+ _order = "repository_sequence, module_name, branch_name"
23
+
24
+ module_id = fields.Many2one(
25
+ comodel_name="odoo.module",
26
+ ondelete="restrict",
27
+ string="Technical name",
28
+ required=True,
29
+ index=True,
30
+ )
31
+ module_name = fields.Char(
32
+ string="Module Technical Name", related="module_id.name", store=True, index=True
33
+ )
34
+ repository_branch_id = fields.Many2one(
35
+ comodel_name="odoo.repository.branch",
36
+ ondelete="set null",
37
+ string="Repository Branch",
38
+ index=True,
39
+ )
40
+ repository_id = fields.Many2one(
41
+ related="repository_branch_id.repository_id",
42
+ store=True,
43
+ precompute=True,
44
+ string="Repository",
45
+ )
46
+ repository_sequence = fields.Integer(
47
+ related="repository_id.sequence",
48
+ store=True,
49
+ index=True,
50
+ )
51
+ org_id = fields.Many2one(
52
+ related="repository_branch_id.repository_id.org_id",
53
+ store=True,
54
+ string="Organization",
55
+ )
56
+ branch_id = fields.Many2one(
57
+ # NOTE: not a related on 'repository_branch_id' as we need to create
58
+ # modules without knowing in advance what is their repo (orphaned modules).
59
+ comodel_name="odoo.branch",
60
+ ondelete="cascade",
61
+ string="Odoo Version",
62
+ required=True,
63
+ index=True,
64
+ )
65
+ branch_name = fields.Char(
66
+ string="Branch Name", related="branch_id.name", store=True, index=True
67
+ )
68
+ branch_sequence = fields.Integer(
69
+ string="Branch Sequence", related="branch_id.sequence", store=True, index=True
70
+ )
71
+ pr_url = fields.Char(string="PR URL")
72
+ is_standard = fields.Boolean(
73
+ string="Standard?",
74
+ help="Is this module part of Odoo standard?",
75
+ default=False,
76
+ )
77
+ is_enterprise = fields.Boolean(
78
+ string="Enterprise?",
79
+ help="Is this module designed for Odoo Enterprise only?",
80
+ default=False,
81
+ )
82
+ is_community = fields.Boolean(
83
+ string="Community?",
84
+ help="Is this module a contribution of the community?",
85
+ default=False,
86
+ )
87
+ title = fields.Char(index=True, help="Descriptive name")
88
+ name = fields.Char(
89
+ string="Techname",
90
+ compute="_compute_name",
91
+ store=True,
92
+ index=True,
93
+ )
94
+ summary = fields.Char(index=True)
95
+ category_id = fields.Many2one(
96
+ comodel_name="odoo.module.category",
97
+ ondelete="restrict",
98
+ string="Category",
99
+ index=True,
100
+ )
101
+ author_ids = fields.Many2many(
102
+ comodel_name="odoo.author",
103
+ string="Authors",
104
+ )
105
+ maintainer_ids = fields.Many2many(
106
+ comodel_name="odoo.maintainer",
107
+ relation="module_branch_maintainer_rel",
108
+ column1="module_branch_id",
109
+ column2="maintainer_id",
110
+ string="Maintainers",
111
+ )
112
+ dependency_ids = fields.Many2many(
113
+ comodel_name="odoo.module.branch",
114
+ relation="module_branch_dependency_rel",
115
+ column1="module_branch_id",
116
+ column2="dependency_id",
117
+ string="Dependencies",
118
+ )
119
+ reverse_dependency_ids = fields.Many2many(
120
+ comodel_name="odoo.module.branch",
121
+ relation="module_branch_dependency_rel",
122
+ column1="dependency_id",
123
+ column2="module_branch_id",
124
+ string="Reverse Dependencies",
125
+ )
126
+ global_dependency_level = fields.Integer(
127
+ compute="_compute_dependency_level",
128
+ recursive=True,
129
+ store=True,
130
+ string="Global Dep. Level",
131
+ help="Dependency level including all standard Odoo modules.",
132
+ )
133
+ non_std_dependency_level = fields.Integer(
134
+ compute="_compute_dependency_level",
135
+ recursive=True,
136
+ store=True,
137
+ string="Non-Std Dep. Level",
138
+ help="Dependency level excluding all standard Odoo modules.",
139
+ )
140
+ license_id = fields.Many2one(
141
+ comodel_name="odoo.license",
142
+ ondelete="restrict",
143
+ string="License",
144
+ index=True,
145
+ )
146
+ version = fields.Char("Last version")
147
+ version_ids = fields.One2many(
148
+ comodel_name="odoo.module.branch.version",
149
+ inverse_name="module_branch_id",
150
+ string="Versions",
151
+ )
152
+ development_status_id = fields.Many2one(
153
+ comodel_name="odoo.module.dev.status",
154
+ ondelete="restrict",
155
+ string="Develoment Status",
156
+ index=True,
157
+ )
158
+ external_dependencies = fields.Serialized()
159
+ python_dependency_ids = fields.Many2many(
160
+ comodel_name="odoo.python.dependency",
161
+ string="Python Dependencies",
162
+ )
163
+ application = fields.Boolean(default=False)
164
+ installable = fields.Boolean(default=True)
165
+ auto_install = fields.Boolean(
166
+ string="Auto-Install",
167
+ default=False,
168
+ )
169
+ sloc_python = fields.Integer("Python", help="Python source lines of code")
170
+ sloc_xml = fields.Integer("XML", help="XML source lines of code")
171
+ sloc_js = fields.Integer("JS", help="JavaScript source lines of code")
172
+ sloc_css = fields.Integer("CSS", help="CSS source lines of code")
173
+ last_scanned_commit = fields.Char()
174
+ removed = fields.Boolean()
175
+ addons_path = fields.Char(
176
+ help="Technical field. Where the module is located in the repository."
177
+ )
178
+ full_path = fields.Char(compute="_compute_full_path")
179
+ url = fields.Char("URL", compute="_compute_url")
180
+ specific = fields.Boolean(
181
+ help=(
182
+ "Module specific to a project repository."
183
+ "It cannot be used across different projects."
184
+ )
185
+ )
186
+
187
+ _sql_constraints = [
188
+ (
189
+ "module_id_branch_id_repository_id_uniq",
190
+ "UNIQUE (module_id, branch_id, repository_id)",
191
+ "This module already exists for this repository/branch.",
192
+ ),
193
+ ]
194
+
195
+ def init(self):
196
+ # Index to complete unique constraint 'module_id_branch_id_repository_id_uniq'.
197
+ # This is mandatory to support repository_id=NULL in the constraint,
198
+ # so we cannot create the same orphaned module twice.
199
+ indexes = [
200
+ # PostgreSQL < 15 (partial indexes)
201
+ """
202
+ CREATE UNIQUE INDEX IF NOT EXISTS odoo_module_branch_uniq_not_null
203
+ ON odoo_module_branch (module_id, branch_id, repository_id)
204
+ WHERE repository_id IS NOT NULL;
205
+ """,
206
+ """
207
+ CREATE UNIQUE INDEX IF NOT EXISTS odoo_module_branch_uniq_null
208
+ ON odoo_module_branch (module_id, branch_id)
209
+ WHERE repository_id IS NULL;
210
+ """
211
+ # PostgreSQL >= 15 (with NULLS NOT DISTINCT)
212
+ # """
213
+ # CREATE UNIQUE INDEX odoo_module_branch_uniq
214
+ # ON odoo_module_branch (module_id, branch_id, repository_id)
215
+ # NULLS NOT DISTINCT;
216
+ # """
217
+ ]
218
+ for index in indexes:
219
+ self._cr.execute(index)
220
+
221
+ @api.constrains("specific", "dependency_ids")
222
+ def _check_generic_depends_on_specific(self):
223
+ for rec in self:
224
+ if not rec.specific:
225
+ specific_deps = rec.dependency_ids.filtered("specific")
226
+ if specific_deps:
227
+ msg = _(
228
+ "Generic module %(generic_mod)s cannot depend "
229
+ "on specific module(s): %(specific_mods)s"
230
+ )
231
+ raise ValidationError(
232
+ msg
233
+ % {
234
+ "generic_mod": rec.module_name,
235
+ "specific_mods": ", ".join(
236
+ specific_deps.mapped("module_name")
237
+ ),
238
+ }
239
+ )
240
+
241
+ @api.depends("module_name", "addons_path")
242
+ def _compute_full_path(self):
243
+ for rec in self:
244
+ rec.full_path = pathlib.Path(rec.addons_path or ".").joinpath(
245
+ rec.module_name
246
+ )
247
+
248
+ @api.depends(
249
+ "repository_id.repo_url",
250
+ "branch_name",
251
+ "repository_branch_id.cloned_branch",
252
+ "addons_path",
253
+ "module_name",
254
+ )
255
+ def _compute_url(self):
256
+ for rec in self:
257
+ rec.url = False
258
+ if not rec.repository_id:
259
+ continue
260
+ branch = rec.branch_name
261
+ if rec.repository_branch_id.cloned_branch:
262
+ branch = rec.repository_branch_id.cloned_branch
263
+ module_path = "/".join([rec.addons_path or ".", rec.module_name])
264
+ rec.url = rec.repository_id._get_resource_url(branch, module_path)
265
+
266
+ @api.depends("repository_branch_id.name", "module_id.name")
267
+ def _compute_name(self):
268
+ for rec in self:
269
+ rec.name = f"{rec.repository_branch_id.name or '?'} - {rec.module_id.name}"
270
+
271
+ @api.depends(
272
+ "dependency_ids.global_dependency_level",
273
+ "dependency_ids.non_std_dependency_level",
274
+ "dependency_ids.is_standard",
275
+ )
276
+ def _compute_dependency_level(self):
277
+ for rec in self:
278
+ global_max_parent_level = max(
279
+ [dep.global_dependency_level for dep in rec.dependency_ids] + [0]
280
+ )
281
+ rec.global_dependency_level = global_max_parent_level + 1
282
+ non_std_max_parent_level = max(
283
+ [
284
+ dep.non_std_dependency_level
285
+ for dep in rec.dependency_ids
286
+ if not dep.is_standard
287
+ ]
288
+ + [0]
289
+ )
290
+ rec.non_std_dependency_level = (
291
+ # Set 0 on all std modules so they will always have a dependency
292
+ # level inferior to non-std modules
293
+ (non_std_max_parent_level + 1)
294
+ if not rec.is_standard
295
+ else 0
296
+ )
297
+
298
+ def _get_recursive_dependencies(self, domain=None):
299
+ """Return all dependencies recursively.
300
+
301
+ A domain can be applied to restrict the modules to return, e.g:
302
+
303
+ >>> mod._get_recursive_dependencies([("org_id", "=", "OCA")])
304
+
305
+ """
306
+ if not domain:
307
+ domain = []
308
+ dependencies = self.dependency_ids.filtered_domain(domain)
309
+ dep_ids = set(dependencies.ids)
310
+ for dep in dependencies:
311
+ dep_ids |= set(
312
+ dep._get_recursive_dependencies().filtered_domain(domain).ids
313
+ )
314
+ return self.browse(dep_ids)
315
+
316
+ def open_recursive_dependencies(self):
317
+ self.ensure_one()
318
+ xml_id = "odoo_repository.odoo_module_branch_action_recursive_dependencies"
319
+ action = self.env["ir.actions.actions"]._for_xml_id(xml_id)
320
+ action["name"] = "All dependencies"
321
+ action["domain"] = [("id", "in", self._get_recursive_dependencies().ids)]
322
+ return action
323
+
324
+ def action_find_pr_url(self):
325
+ """Find the PR on GitHub that adds this module."""
326
+ self.ensure_one()
327
+ if self.pr_url or self.repository_branch_id or self.specific:
328
+ return False
329
+ values = {"pr_url": False}
330
+ pr_urls = self._find_pr_urls_from_github(self.branch_id, self.module_id)
331
+ for pr_url in pr_urls:
332
+ values["pr_url"] = pr_url
333
+ # Get the relevant repository from PR URL if not yet defined
334
+ if not self.repository_branch_id:
335
+ repository = self._find_repository_from_pr_url(pr_url)
336
+ if not repository:
337
+ continue
338
+ repository_branch = self.env["odoo.repository.branch"].search(
339
+ [
340
+ ("repository_id", "=", repository.id),
341
+ ("branch_id", "=", self.branch_id.id),
342
+ ]
343
+ )
344
+ if repository_branch:
345
+ values["repository_branch_id"] = repository_branch.id
346
+ break
347
+ self.sudo().write(values)
348
+ return True
349
+
350
+ def _find_pr_urls_from_github(self, branch, module):
351
+ """Find the GitHub Pull Requests adding `module` on `branch`."""
352
+ # Look for an open PR first, then unmerged (which includes closed ones)
353
+ for pr_state in ("open", "unmerged"):
354
+ url = (
355
+ f"search/issues?q=is:pr+is:{pr_state}+base:{branch.name}"
356
+ f"+in:title+{module.name}"
357
+ )
358
+ try:
359
+ # Mitigate 'API rate limit exceeded' GitHub API error
360
+ # by adding a random waiting time of 1-4s
361
+ time.sleep(random.randrange(1, 5))
362
+ prs = github.request(self.env, url)
363
+ except RuntimeError as exc:
364
+ raise RetryableJobError("Error while looking for PR URL") from exc
365
+ for pr in prs.get("items", []):
366
+ yield pr["html_url"]
367
+
368
+ def _find_repository_from_pr_url(self, pr_url):
369
+ """Return the repository corresponding to `pr_url`."""
370
+ # Extract organization and repository name from PR url
371
+ path_parts = list(filter(None, urlparse(pr_url).path.split("/")))
372
+ org, repository = path_parts[:2]
373
+ repository_model = self.env["odoo.repository"].with_context(active_test=False)
374
+ return repository_model.search(
375
+ [
376
+ ("org_id", "=", org),
377
+ ("name", "=", repository),
378
+ ]
379
+ )
380
+
381
+ @api.model
382
+ @api.returns("odoo.module.branch")
383
+ def push_scanned_data(self, repo_branch_id, module, data):
384
+ """Entry point for the scanner to push its data."""
385
+ module = self._get_module(module)
386
+ repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id)
387
+ values = self._prepare_module_branch_values(repo_branch, module, data)
388
+ return self._create_or_update(repo_branch, module, values)
389
+
390
+ def _prepare_module_branch_values(self, repo_branch, module, data):
391
+ # Get existing module.branch (hosted in scanned repo) if any
392
+ module_branch = self._get_module_branch(
393
+ repo_branch.branch_id, module, repo=repo_branch.repository_id
394
+ )
395
+ # Prepare the 'odoo.module.branch' values
396
+ manifest = data.get("manifest", {})
397
+ values = {
398
+ "repository_branch_id": repo_branch.id,
399
+ "branch_id": repo_branch.branch_id.id,
400
+ "module_id": module.id,
401
+ "is_standard": data["is_standard"],
402
+ "is_enterprise": data["is_enterprise"],
403
+ "is_community": data["is_community"],
404
+ "last_scanned_commit": data.get("last_scanned_commit", False),
405
+ "addons_path": data["relative_path"],
406
+ "specific": repo_branch.repository_id.specific,
407
+ # Unset PR URL once the module is available in the repository.
408
+ "pr_url": False,
409
+ }
410
+ if manifest:
411
+ category_id = self._get_module_category_id(manifest.get("category", ""))
412
+ author_ids = self._get_author_ids(manifest.get("author", ""))
413
+ maintainer_ids = self._get_maintainer_ids(
414
+ tuple(manifest.get("maintainers", []))
415
+ )
416
+ dev_status_id = self._get_dev_status_id(
417
+ manifest.get("development_status", "")
418
+ )
419
+ dependency_ids = []
420
+ external_dependencies = {}
421
+ python_dependency_ids = []
422
+ if manifest.get("installable", True):
423
+ dependency_ids = self._get_dependency_ids(
424
+ repo_branch, manifest.get("depends", [])
425
+ )
426
+ external_dependencies = manifest.get("external_dependencies", {})
427
+ python_dependency_ids = self._get_python_dependency_ids(
428
+ tuple(external_dependencies.get("python", []))
429
+ )
430
+ license_id = self._get_license_id(manifest.get("license", ""))
431
+ values.update(
432
+ {
433
+ "title": manifest.get("name", False),
434
+ "summary": manifest.get(
435
+ "summary", manifest.get("description", False)
436
+ ),
437
+ "category_id": category_id,
438
+ "author_ids": [(6, 0, author_ids)],
439
+ "maintainer_ids": [(6, 0, maintainer_ids)],
440
+ "dependency_ids": [(6, 0, dependency_ids)],
441
+ "external_dependencies": external_dependencies,
442
+ "python_dependency_ids": [(6, 0, python_dependency_ids)],
443
+ "license_id": license_id,
444
+ "version": manifest.get("version", False),
445
+ "development_status_id": dev_status_id,
446
+ "application": manifest.get("application", False),
447
+ "installable": manifest.get("installable", True),
448
+ "auto_install": manifest.get("auto_install", False),
449
+ }
450
+ )
451
+ if data.get("last_scanned_commit"):
452
+ values.update(
453
+ {
454
+ "removed": False,
455
+ "sloc_python": data["code"]["Python"],
456
+ "sloc_xml": data["code"]["XML"],
457
+ "sloc_js": data["code"]["JavaScript"],
458
+ "sloc_css": data["code"]["CSS"],
459
+ }
460
+ )
461
+ # Handle module removal
462
+ elif module_branch:
463
+ values.update(
464
+ {
465
+ "installable": False,
466
+ "removed": True,
467
+ }
468
+ )
469
+ # Handle versions history
470
+ if values.get("installable"):
471
+ versions = self._prepare_module_branch_version_ids_values(
472
+ repo_branch,
473
+ module_branch,
474
+ module,
475
+ # If no history versions was scanned (could happen if versions are
476
+ # part of an unfetched branch), create one corresponding to the
477
+ # current manifest version if any but without commit.
478
+ versions=(
479
+ data.get("versions")
480
+ or (
481
+ {values["version"]: {"commit": None}}
482
+ if values.get("version")
483
+ else {}
484
+ )
485
+ ),
486
+ )
487
+ if versions:
488
+ values["version_ids"] = versions
489
+ return values
490
+
491
+ def _create_or_update(self, repo_branch, module, values):
492
+ """Create or update a `odoo.module.branch` record from scanned module.
493
+
494
+ This method will try to link/update an existing module in DB, that could be:
495
+ - already scanned in the current repository (simple update)
496
+ - orphaned (update the repository of such module)
497
+ - unmerged/pending (only if the scanned repository hosts generic modules)
498
+ """
499
+ branch = repo_branch.branch_id
500
+ module_branch = False
501
+ module_branch_in_repo = self._get_module_branch(
502
+ branch, module, repo=repo_branch.repository_id
503
+ )
504
+ # Module was already scanned in the current repository: update it
505
+ if module_branch_in_repo:
506
+ module_branch = module_branch_in_repo
507
+ # Module was never scanned in the current repository:
508
+ else:
509
+ # Check if an orphaned module exists
510
+ orphaned_module_branch = self._get_orphaned_module_branch(branch, module)
511
+ if orphaned_module_branch:
512
+ module_branch = orphaned_module_branch
513
+ # Check if an unmerged module exists if the scanned repo is generic
514
+ elif not repo_branch.repository_id.specific:
515
+ unmerged_module_branch = self._get_unmerged_module_branch(
516
+ branch, module
517
+ )
518
+ if unmerged_module_branch:
519
+ module_branch = unmerged_module_branch
520
+ module_branch = self._filter_module_to_update(repo_branch, module_branch)
521
+ if module_branch:
522
+ values["repository_branch_id"] = repo_branch.id
523
+ module_branch.sudo().write(values)
524
+ else:
525
+ module_branch = self.sudo().create(values)
526
+ return module_branch
527
+
528
+ def _filter_module_to_update(self, repo_branch, module_branch):
529
+ """Hook called by '_create_or_update'.
530
+
531
+ Can be overriden to return `False` to force the creation of a new
532
+ `odoo.module.branch` record linked to the scanned repository.
533
+ """
534
+ return module_branch
535
+
536
+ @api.model
537
+ def _get_existing_version(self, module, manifest_value, commit):
538
+ if not commit:
539
+ return self.env["odoo.module.branch.version"]
540
+ return self.env["odoo.module.branch.version"].search(
541
+ [
542
+ ("module_name", "=", module.name),
543
+ ("manifest_value", "=", manifest_value),
544
+ ("commit", "=", commit),
545
+ ],
546
+ limit=1,
547
+ )
548
+
549
+ def _prepare_module_branch_version_ids_values(
550
+ self, repo_branch, module_branch, module, versions
551
+ ):
552
+ # Insert new versions
553
+ version_ids = []
554
+ other_odoo_versions = (
555
+ self.env["odoo.branch"]._get_all_odoo_versions() - repo_branch.branch_id
556
+ )
557
+ for manifest_value, data in versions.items():
558
+ # Version scanned doesn't belong to the current branch, skipping
559
+ if any(
560
+ manifest_value.startswith(odoo_version.name + ".")
561
+ for odoo_version in other_odoo_versions
562
+ ):
563
+ continue
564
+ name = adapt_version(repo_branch.branch_id.name, manifest_value)
565
+ # As we could import versions history from previous Odoo releases
566
+ # (i.e. the branch has been started from a previous one), check if
567
+ # it hasn't been imported already thanks to the related commit SHA
568
+ version = self._get_existing_version(module, manifest_value, data["commit"])
569
+ if version and module_branch:
570
+ # Skip if the version has already been imported for a
571
+ # previous Odoo release
572
+ if version.branch_id.sequence < module_branch.branch_id.sequence:
573
+ continue
574
+ # Corner case: we scanned a version that was already imported
575
+ # through a newer Odoo branch. Downgrade the existing version
576
+ # to the current module branch.
577
+ if version.branch_id.sequence > module_branch.branch_id.sequence:
578
+ version.write(
579
+ {
580
+ "module_branch_id": module_branch.id,
581
+ "name": name,
582
+ }
583
+ )
584
+ continue
585
+ module_version = module_branch.version_ids.filtered(
586
+ lambda v: v.name == name and v.manifest_value == manifest_value
587
+ )
588
+ values = {
589
+ "name": name,
590
+ "manifest_value": manifest_value,
591
+ "commit": data["commit"],
592
+ "has_migration_script": data.get("migration_script", False),
593
+ }
594
+ if module_version:
595
+ version_ids.append(fields.Command.update(module_version.id, values))
596
+ else:
597
+ version_ids.append(fields.Command.create(values))
598
+ return version_ids
599
+
600
+ @tools.ormcache("category_name")
601
+ def _get_module_category_id(self, category_name):
602
+ if category_name:
603
+ rec = self.env["odoo.module.category"].search(
604
+ [("name", "=", category_name)], limit=1
605
+ )
606
+ if not rec:
607
+ rec = (
608
+ self.env["odoo.module.category"]
609
+ .sudo()
610
+ .create({"name": category_name})
611
+ )
612
+ return rec.id
613
+ return False
614
+
615
+ @tools.ormcache("names")
616
+ def _get_author_ids(self, names):
617
+ if names:
618
+ # Some Odoo std modules have a list instead of a string as 'author'
619
+ if isinstance(names, str):
620
+ names = [name.strip() for name in names.split(",")]
621
+ authors = self.env["odoo.author"].search([("name", "in", names)])
622
+ missing_author_names = set(names) - set(authors.mapped("name"))
623
+ missing_authors = self.env["odoo.author"]
624
+ if missing_author_names:
625
+ missing_authors = (
626
+ self.env["odoo.author"]
627
+ .sudo()
628
+ .create([{"name": name} for name in missing_author_names])
629
+ )
630
+ return (authors | missing_authors).ids
631
+ return []
632
+
633
+ @tools.ormcache("names")
634
+ def _get_maintainer_ids(self, names):
635
+ if names:
636
+ maintainers = self.env["odoo.maintainer"].search([("name", "in", names)])
637
+ missing_maintainer_names = set(names) - set(maintainers.mapped("name"))
638
+ created = self.env["odoo.maintainer"]
639
+ if missing_maintainer_names:
640
+ created = created.sudo().create(
641
+ [{"name": name} for name in missing_maintainer_names]
642
+ )
643
+ return (maintainers | created).ids
644
+ return []
645
+
646
+ @tools.ormcache("name")
647
+ def _get_dev_status_id(self, name):
648
+ if name:
649
+ rec = self.env["odoo.module.dev.status"].search(
650
+ [("name", "=", name)], limit=1
651
+ )
652
+ if not rec:
653
+ rec = self.env["odoo.module.dev.status"].sudo().create({"name": name})
654
+ return rec.id
655
+ return False
656
+
657
+ @api.model
658
+ def _find(self, branch, module, repo, domain=None):
659
+ """Find an `odoo.module.branch` record matching parameters.
660
+
661
+ The lookup of the module is in this order:
662
+ - search in `repo`
663
+ - search among generic modules in other repositories
664
+ - search among orphaned modules
665
+
666
+ Additional search criteria can be added with `domain`,
667
+ e.g. `domain=[('installable', '=', True)]`.
668
+ """
669
+ # Look for the module first in the current repository
670
+ module_branch = self.browse()
671
+ if repo:
672
+ module_branch = self._get_module_branch(
673
+ branch, module, repo=repo, domain=domain
674
+ )
675
+ # Then look among generic modules
676
+ if not module_branch:
677
+ modules_branch = self._get_module_branch(
678
+ branch,
679
+ module,
680
+ domain=expression.AND(
681
+ [
682
+ domain or [],
683
+ [("specific", "=", False), ("repository_id", "!=", False)],
684
+ ],
685
+ ),
686
+ )
687
+ module_branch = fields.first(modules_branch)
688
+ # Otherwise look for the module among orphaned modules
689
+ if not module_branch:
690
+ module_branch = self._get_orphaned_module_branch(
691
+ branch, module, domain=domain
692
+ )
693
+ return module_branch
694
+
695
+ @api.model
696
+ def _find_or_create(self, branch, module, repo, domain=None):
697
+ """Find an `odoo.module.branch` record (see `_find`), or create an orphaned one."""
698
+ module_branch = self._find(branch, module, repo, domain=domain)
699
+ # If still not found, create the module as an orphaned module
700
+ # (it will hopefully be bound to a repository later)
701
+ if not module_branch:
702
+ module_branch = self.sudo()._create_orphaned_module_branch(branch, module)
703
+ return module_branch
704
+
705
+ def _get_dependency_ids(self, repo_branch, depends: list):
706
+ dependency_ids = []
707
+ for depend in depends:
708
+ module = self._get_module(depend)
709
+ dependency = self._find_or_create(
710
+ repo_branch.branch_id, module, repo_branch.repository_id
711
+ )
712
+ dependency_ids.append(dependency.id)
713
+ return dependency_ids
714
+
715
+ @tools.ormcache("packages")
716
+ def _get_python_dependency_ids(self, packages):
717
+ if packages:
718
+ dependencies = self.env["odoo.python.dependency"].search(
719
+ [("name", "in", packages)]
720
+ )
721
+ missing_dependencies = set(packages) - set(dependencies.mapped("name"))
722
+ created = self.env["odoo.python.dependency"]
723
+ if missing_dependencies:
724
+ created = created.sudo().create(
725
+ [{"name": package} for package in missing_dependencies]
726
+ )
727
+ return (dependencies | created).ids
728
+ return []
729
+
730
+ @tools.ormcache("license_name")
731
+ def _get_license_id(self, license_name):
732
+ if license_name:
733
+ license_model = self.env["odoo.license"]
734
+ rec = license_model.search([("name", "=", license_name)], limit=1)
735
+ if not rec:
736
+ rec = license_model.sudo().create({"name": license_name})
737
+ return rec.id
738
+ return False
739
+
740
+ def _get_module(self, name):
741
+ module = self.env["odoo.module"].search([("name", "=", name)])
742
+ if not module:
743
+ module = self.env["odoo.module"].sudo().create({"name": name})
744
+ return module
745
+
746
+ @api.model
747
+ def _get_module_branch_domain(self, branch, module, repo=None, domain=None):
748
+ """Return the domain to identify an `odoo.module.branch` record."""
749
+ _domain = [
750
+ ("branch_id", "=", branch.id),
751
+ ("module_id", "=", module.id),
752
+ ]
753
+ if repo:
754
+ _domain.append(("repository_id", "=", repo.id))
755
+ elif repo is False:
756
+ _domain.append(("repository_id", "=", False))
757
+ if domain:
758
+ _domain.extend(domain)
759
+ return _domain
760
+
761
+ @api.model
762
+ def _get_module_branch(self, branch, module, repo=None, domain=None):
763
+ """Return the `odoo.module.branch` if it already exists. Do not create it."""
764
+ domain = self._get_module_branch_domain(
765
+ branch, module, repo=repo, domain=domain
766
+ )
767
+ return self.search(domain)
768
+
769
+ @api.model
770
+ def _get_orphaned_module_branch_domain(self, branch, module, domain=None):
771
+ """Return the domain to identify an orphaned module (without repo)."""
772
+ return self._get_module_branch_domain(branch, module, repo=False, domain=domain)
773
+
774
+ @api.model
775
+ def _get_orphaned_module_branch(self, branch, module, domain=None):
776
+ """Return an orphaned module matching `branch` and `module`."""
777
+ domain = self._get_orphaned_module_branch_domain(branch, module, domain=domain)
778
+ return self.search(domain)
779
+
780
+ @api.model
781
+ def _get_unmerged_module_branch_domain(self, branch, module):
782
+ """Return the domain to identify an unmerged module (coming from a PR)."""
783
+ domain = self._get_module_branch_domain(branch, module)
784
+ domain.extend(
785
+ [
786
+ ("specific", "=", False),
787
+ ("repository_id", "!=", False),
788
+ ("pr_url", "!=", False),
789
+ ]
790
+ )
791
+ return domain
792
+
793
+ @api.model
794
+ def _get_unmerged_module_branch(self, branch, module):
795
+ """Return an unmerged module matching `branch` and `module`."""
796
+ domain = self._get_unmerged_module_branch_domain(branch, module)
797
+ return self.search(domain)
798
+
799
+ def _create_orphaned_module_branch(self, branch, module):
800
+ """Create an orphaned module."""
801
+ values = {
802
+ "module_id": module.id,
803
+ "branch_id": branch.id,
804
+ }
805
+ return self.create(values)
806
+
807
+ # TODO adds ormcache
808
+ def _get_modules_data(self, orgs=None, repositories=None, branches=None):
809
+ """Returns modules data matching the criteria.
810
+
811
+ E.g.:
812
+
813
+ >>> self._get_modules_data(
814
+ ... orgs=['OCA'],
815
+ ... repositories=['server-env'],
816
+ ... branches=['15.0', '16.0'],
817
+ ... )
818
+
819
+ """
820
+ domain = self._get_modules_domain(orgs, repositories, branches)
821
+ modules = self.search(domain)
822
+ data = []
823
+ for module in modules:
824
+ data.append(module._to_dict())
825
+ return data
826
+
827
+ def _get_modules_domain(self, orgs=None, repositories=None, branches=None):
828
+ domain = [
829
+ # Do not return orphans modules
830
+ ("org_id", "!=", False),
831
+ ("repository_id", "!=", False),
832
+ ("branch_id", "!=", False),
833
+ ]
834
+ if orgs:
835
+ domain.append(("org_id", "in", orgs))
836
+ if repositories:
837
+ domain.append(("repository_id", "in", repositories))
838
+ if branches:
839
+ domain.append(("branch_id", "in", branches))
840
+ return domain
841
+
842
+ def _to_dict(self):
843
+ """Convert module data to a dictionary."""
844
+ self.ensure_one()
845
+ return {
846
+ "module": self.module_name,
847
+ "branch": self.branch_id.name,
848
+ "repository": self.repository_branch_id._to_dict(),
849
+ "title": self.title,
850
+ "summary": self.summary,
851
+ "authors": self.author_ids.mapped("name"),
852
+ "maintainers": self.maintainer_ids.mapped("name"),
853
+ "depends": self.dependency_ids.mapped("module_name"),
854
+ "category": self.category_id.name,
855
+ "license": self.license_id.name,
856
+ "version": self.version,
857
+ "versions": [version._to_dict() for version in self.version_ids],
858
+ "development_status": self.development_status_id.name,
859
+ "application": self.application,
860
+ "installable": self.installable,
861
+ "auto_install": self.auto_install,
862
+ "external_dependencies": self.external_dependencies,
863
+ "is_standard": self.is_standard,
864
+ "is_enterprise": self.is_enterprise,
865
+ "is_community": self.is_community,
866
+ "sloc_python": self.sloc_python,
867
+ "sloc_xml": self.sloc_xml,
868
+ "sloc_js": self.sloc_js,
869
+ "sloc_css": self.sloc_css,
870
+ "last_scanned_commit": self.last_scanned_commit,
871
+ "addons_path": self.addons_path,
872
+ "pr_url": self.pr_url,
873
+ }