rocketdoo 3.2.1__tar.gz → 3.3.0__tar.gz

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 (119) hide show
  1. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/LICENSE +2 -2
  2. {rocketdoo-3.2.1/rocketdoo.egg-info → rocketdoo-3.3.0}/PKG-INFO +8 -4
  3. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/README.md +5 -1
  4. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/pyproject.toml +1 -1
  5. rocketdoo-3.3.0/rocketdoo/core/odoo_db.py +191 -0
  6. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/server.py +10 -3
  7. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/mail_cli.py +268 -21
  8. {rocketdoo-3.2.1 → rocketdoo-3.3.0/rocketdoo.egg-info}/PKG-INFO +8 -4
  9. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo.egg-info/SOURCES.txt +1 -0
  10. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_gui_api.py +41 -0
  11. rocketdoo-3.3.0/tests/test_mail_cli.py +617 -0
  12. rocketdoo-3.3.0/tests/test_odoo_db.py +325 -0
  13. rocketdoo-3.2.1/rocketdoo/core/odoo_db.py +0 -118
  14. rocketdoo-3.2.1/tests/test_odoo_db.py +0 -112
  15. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/MANIFEST.in +0 -0
  16. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/__init__.py +0 -0
  17. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/cli.py +0 -0
  18. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/__init__.py +0 -0
  19. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/compose.py +0 -0
  20. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/deploy/__init__.py +0 -0
  21. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/deploy/base.py +0 -0
  22. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/deploy/config_manager.py +0 -0
  23. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/deploy/module_packager.py +0 -0
  24. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/deploy/odoo_sh.py +0 -0
  25. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/deploy/vps.py +0 -0
  26. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/edition_setup.py +0 -0
  27. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/generator.py +0 -0
  28. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/gitignore_manager.py +0 -0
  29. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/gitman_config.py +0 -0
  30. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/instance/__init__.py +0 -0
  31. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/instance/config_manager.py +0 -0
  32. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/instance/deployer_docker.py +0 -0
  33. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/instance/deployer_native.py +0 -0
  34. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/instance/secrets_store.py +0 -0
  35. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/instance/ssh_utils.py +0 -0
  36. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/models/__init__.py +0 -0
  37. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/models/deploy.py +0 -0
  38. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/models/instance.py +0 -0
  39. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/models/profiles.py +0 -0
  40. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/models/project.py +0 -0
  41. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/module_scanner.py +0 -0
  42. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/port_validation.py +0 -0
  43. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/ssh_manager.py +0 -0
  44. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/core/utils.py +0 -0
  45. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/deploy_cli.py +0 -0
  46. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/docker_cli.py +0 -0
  47. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/__init__.py +0 -0
  48. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/__init__.py +0 -0
  49. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/deploy_ops.py +0 -0
  50. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/docker_ops.py +0 -0
  51. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/gitman.py +0 -0
  52. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/instances.py +0 -0
  53. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/mail.py +0 -0
  54. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/modules.py +0 -0
  55. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/odoo.py +0 -0
  56. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/pack_ops.py +0 -0
  57. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/project.py +0 -0
  58. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/setup.py +0 -0
  59. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/traefik_ops.py +0 -0
  60. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/api/workspace.py +0 -0
  61. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui/static/index.html +0 -0
  62. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/gui_cli.py +0 -0
  63. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/init_project.py +0 -0
  64. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/instance_cli.py +0 -0
  65. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/pack_environment.py +0 -0
  66. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/project_info.py +0 -0
  67. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/scaffold.py +0 -0
  68. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/.devcontainer/devcontainer.json +0 -0
  69. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/.devcontainer/docker-compose.yaml +0 -0
  70. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/.gitignore.jinja +0 -0
  71. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/.vscode/launch.json +0 -0
  72. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/.vscode/launch.json.jinja +0 -0
  73. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/.vscode/settings.json +0 -0
  74. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/Dockerfile.jinja +0 -0
  75. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/addons/.gitkeep +0 -0
  76. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/config/odoo.conf +0 -0
  77. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/config/odoo.conf.jinja +0 -0
  78. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/deploy/.deployignore +0 -0
  79. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/deploy/deploy.yaml +0 -0
  80. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/deploy/deploy.yaml.jinja +0 -0
  81. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/docker-compose.yaml.jinja +0 -0
  82. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/install_dependencies.sh +0 -0
  83. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/instance/Dockerfile.jinja +0 -0
  84. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/instance/docker-compose.jinja +0 -0
  85. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/instance/odoo.conf.jinja +0 -0
  86. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/odoo_pg_pass +0 -0
  87. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/README.md +0 -0
  88. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo15-ce.yaml +0 -0
  89. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo15-ee.yaml +0 -0
  90. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo16-ce.yaml +0 -0
  91. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo16-ee.yaml +0 -0
  92. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo17-ce.yaml +0 -0
  93. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo17-ee.yaml +0 -0
  94. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo18-ce.yaml +0 -0
  95. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo18-ee.yaml +0 -0
  96. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo19-ce.yaml +0 -0
  97. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo19-ee.yaml +0 -0
  98. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/traefik_cli.py +0 -0
  99. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/unpack_environment.py +0 -0
  100. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo/welcome.py +0 -0
  101. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo.egg-info/dependency_links.txt +0 -0
  102. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo.egg-info/entry_points.txt +0 -0
  103. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo.egg-info/requires.txt +0 -0
  104. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/rocketdoo.egg-info/top_level.txt +0 -0
  105. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/setup.cfg +0 -0
  106. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_compose.py +0 -0
  107. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_deploy_config_manager.py +0 -0
  108. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_e2e_scaffold.py +0 -0
  109. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_edition_setup.py +0 -0
  110. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_gitignore_manager.py +0 -0
  111. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_imports.py +0 -0
  112. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_models.py +0 -0
  113. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_module_scanner.py +0 -0
  114. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_pack_environment.py +0 -0
  115. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_port_validation.py +0 -0
  116. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_profiles.py +0 -0
  117. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_secrets_hardening.py +0 -0
  118. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_ssh_manager.py +0 -0
  119. {rocketdoo-3.2.1 → rocketdoo-3.3.0}/tests/test_templates.py +0 -0
@@ -57,7 +57,7 @@ If the Library as you received it specifies that a proxy can decide whether futu
57
57
  <div RKD as ROCKETDOO V3=""></div>
58
58
 
59
59
  Licencia: LGPL-3.0+
60
- Versión: "3.2.1"
60
+ Versión: "3.3.0"
61
61
  Autor: Horacio Montaño
62
- Fecha: 13/09/2026
62
+ Fecha: 14/09/2026
63
63
  Descripción: Framework to development Odoo
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rocketdoo
3
- Version: 3.2.1
3
+ Version: 3.3.0
4
4
  Summary: Framework for creating Odoo development environments with Docker and custom templates.
5
5
  Author-email: Horacio Montaño <horaciomontano@hdmsoft.com.ar>
6
6
  License: GNU LESSER GENERAL PUBLIC LICENSE
@@ -62,9 +62,9 @@ License: GNU LESSER GENERAL PUBLIC LICENSE
62
62
  <div RKD as ROCKETDOO V3=""></div>
63
63
 
64
64
  Licencia: LGPL-3.0+
65
- Versión: "3.2.1"
65
+ Versión: "3.3.0"
66
66
  Autor: Horacio Montaño
67
- Fecha: 13/09/2026
67
+ Fecha: 14/09/2026
68
68
  Descripción: Framework to development Odoo
69
69
  Requires-Python: >=3.10
70
70
  Description-Content-Type: text/markdown
@@ -97,7 +97,7 @@ Odoo Development Framework
97
97
  - "Horacio Montaño"
98
98
 
99
99
  ## Version:
100
- - "3.2.1"
100
+ - "3.3.0"
101
101
 
102
102
  ----------------------------------------------------------------------------------------------------------------------------------------------------------
103
103
 
@@ -472,6 +472,10 @@ rkd mail open # Open http://localhost:8025 in your browser
472
472
 
473
473
  Mailpit is toggled by commenting/uncommenting its service block in `docker-compose.yaml` using special markers (`# rkd:mailpit`). When enabled, Odoo's SMTP configuration is automatically updated to route emails through Mailpit.
474
474
 
475
+ `rkd mail on`/`off` also write a `Mailpit (rkd)` record straight into the project's `ir.mail_server` table (Odoo's technical settings, *Outgoing Mail Servers*), so emails are captured with no extra manual setup. `on` creates or reactivates that record with `sequence = 1`; `off` archives it (never deletes it), leaving any of your own mail servers untouched. With a single database this is automatic; with more than one, pass `--db NAME` to `on`, `off`, or `status` — with several databases and no `--db`, nothing is written or read and the command tells you to re-run with it. If the `db` container isn't reachable (project stopped, Docker not running), the rest of the command still runs and it reports the reason; re-running `rkd mail on` once the project is up fixes it.
476
+
477
+ `rkd mail status` shows this record's state (active, archived, not created yet, or not checked, with the reason) plus any other active `ir.mail_server` in the database. **A `sequence = 1` on the Mailpit record does not guarantee it captures every email**: Odoo's server selection filters by `from_filter` before it sorts by sequence, so a server you (or a restored production dump) already have, with a `from_filter` matching the sender, can still win even with a higher sequence number. `mail status` warns loudly when another active server has `sequence <= 1` (it can win the sequence tie-break outright), and with a softer note whenever any other server is active at all, since `from_filter` can override sequence regardless.
478
+
475
479
  ---
476
480
 
477
481
  #### `rkd traefik` — Traefik Reverse Proxy
@@ -15,7 +15,7 @@ Odoo Development Framework
15
15
  - "Horacio Montaño"
16
16
 
17
17
  ## Version:
18
- - "3.2.1"
18
+ - "3.3.0"
19
19
 
20
20
  ----------------------------------------------------------------------------------------------------------------------------------------------------------
21
21
 
@@ -390,6 +390,10 @@ rkd mail open # Open http://localhost:8025 in your browser
390
390
 
391
391
  Mailpit is toggled by commenting/uncommenting its service block in `docker-compose.yaml` using special markers (`# rkd:mailpit`). When enabled, Odoo's SMTP configuration is automatically updated to route emails through Mailpit.
392
392
 
393
+ `rkd mail on`/`off` also write a `Mailpit (rkd)` record straight into the project's `ir.mail_server` table (Odoo's technical settings, *Outgoing Mail Servers*), so emails are captured with no extra manual setup. `on` creates or reactivates that record with `sequence = 1`; `off` archives it (never deletes it), leaving any of your own mail servers untouched. With a single database this is automatic; with more than one, pass `--db NAME` to `on`, `off`, or `status` — with several databases and no `--db`, nothing is written or read and the command tells you to re-run with it. If the `db` container isn't reachable (project stopped, Docker not running), the rest of the command still runs and it reports the reason; re-running `rkd mail on` once the project is up fixes it.
394
+
395
+ `rkd mail status` shows this record's state (active, archived, not created yet, or not checked, with the reason) plus any other active `ir.mail_server` in the database. **A `sequence = 1` on the Mailpit record does not guarantee it captures every email**: Odoo's server selection filters by `from_filter` before it sorts by sequence, so a server you (or a restored production dump) already have, with a `from_filter` matching the sender, can still win even with a higher sequence number. `mail status` warns loudly when another active server has `sequence <= 1` (it can win the sequence tie-break outright), and with a softer note whenever any other server is active at all, since `from_filter` can override sequence regardless.
396
+
393
397
  ---
394
398
 
395
399
  #### `rkd traefik` — Traefik Reverse Proxy
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rocketdoo"
3
- version = "3.2.1"
3
+ version = "3.3.0"
4
4
  description = "Framework for creating Odoo development environments with Docker and custom templates."
5
5
  authors = [
6
6
  { name = "Horacio Montaño", email = "horaciomontano@hdmsoft.com.ar" }
@@ -0,0 +1,191 @@
1
+ """Access to the project's PostgreSQL container: reads and Mailpit writes.
2
+
3
+ The `pg_database` query and the container lookup used to live only in
4
+ `pack_environment`; the GUI module-update feature needs the same data, so
5
+ this consolidates them into a single place (like `core/compose.py` did for
6
+ `docker compose` in #137) instead of growing a third copy. `rkd mail on/off`
7
+ also writes the Rocketdoo `ir.mail_server` row here (#176), so this module is
8
+ no longer read-only.
9
+ """
10
+
11
+ import subprocess
12
+
13
+ from rocketdoo.project_info import read_docker_compose
14
+
15
+ PSQL_USER = "root" # POSTGRES_USER set by the docker-compose template
16
+
17
+ MAILPIT_SERVER_NAME = "Mailpit (rkd)"
18
+ MAILPIT_SMTP_HOST = "mailpit"
19
+ MAILPIT_SMTP_PORT = 1025
20
+ MAILPIT_SEQUENCE = 1 # Odoo's default is 10: Mailpit must win the tie
21
+
22
+
23
+ def db_container(compose_data: dict | None = None) -> str | None:
24
+ """Return the `db` service's container name, or None if it can't be read."""
25
+ if compose_data is None:
26
+ compose_data = read_docker_compose()
27
+ try:
28
+ return compose_data["services"]["db"]["container_name"]
29
+ except (KeyError, TypeError):
30
+ return None
31
+
32
+
33
+ def _psql(db: str, sql: str, *flags: str, compose_data: dict | None = None) -> tuple[str, str]:
34
+ """Run `sql` against `db` via `docker exec ... psql`. Returns (stdout, error).
35
+
36
+ A non-empty error means the query did not run: stdout is not meaningful.
37
+ Shared by every query/write in this module so the argv and the error
38
+ translation (missing docker, unreachable container, psql failure) live in
39
+ one place.
40
+ """
41
+ container = db_container(compose_data)
42
+ if not container:
43
+ return "", "no database container configured"
44
+
45
+ try:
46
+ result = subprocess.run(
47
+ ["docker", "exec", container, "psql", "-U", PSQL_USER, "-d", db, *flags, "-c", sql],
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=15,
51
+ )
52
+ except FileNotFoundError:
53
+ return "", "docker not found"
54
+ except (OSError, subprocess.SubprocessError) as exc:
55
+ return "", str(exc)
56
+
57
+ if result.returncode != 0:
58
+ return "", result.stderr.strip()
59
+
60
+ return result.stdout, ""
61
+
62
+
63
+ def databases_result(compose_data: dict | None = None) -> tuple[list[str], str]:
64
+ """List of user databases plus the reason it came back empty, if any.
65
+
66
+ Excludes `postgres` and the templates, matching what `rkd pack` already
67
+ queried. Any failure to reach the container yields an empty list.
68
+ """
69
+ stdout, error = _psql(
70
+ "postgres",
71
+ "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';",
72
+ "-t",
73
+ compose_data=compose_data,
74
+ )
75
+ if error:
76
+ return [], error
77
+
78
+ return [line.strip() for line in stdout.splitlines() if line.strip()], ""
79
+
80
+
81
+ def list_databases(compose_data: dict | None = None) -> list[str]:
82
+ """List of user databases. Use `databases_result` when the reason matters."""
83
+ return databases_result(compose_data)[0]
84
+
85
+
86
+ def module_states(db: str) -> tuple[dict[str, str], str]:
87
+ """Map `{module_name: state}` read from `ir_module_module` in `db`.
88
+
89
+ `db` is not validated here: that is the trust boundary's job (the GUI
90
+ layer), and keeping it in one place avoids the validation being
91
+ duplicated with different criteria. If `db` isn't an Odoo database,
92
+ psql fails and ({}, stderr) is returned.
93
+ """
94
+ stdout, error = _psql(db, "SELECT name, state FROM ir_module_module;", "-t", "-A", "-F", "\t")
95
+ if error:
96
+ return {}, error
97
+
98
+ states = {}
99
+ for line in stdout.splitlines():
100
+ name, _, state = line.strip().partition("\t")
101
+ if name and state:
102
+ states[name] = state
103
+ return states, ""
104
+
105
+
106
+ def mail_servers(db: str) -> tuple[list[dict], str]:
107
+ """Every ir.mail_server row in `db`, ordered the way Odoo orders them.
108
+
109
+ All rows are returned, active or not, so `rkd mail status` can tell
110
+ "archived" from "never created" with a single query.
111
+ """
112
+ stdout, error = _psql(
113
+ db,
114
+ "SELECT id, name, sequence, smtp_host, active FROM ir_mail_server ORDER BY sequence, id;",
115
+ "-t",
116
+ "-A",
117
+ "-F",
118
+ "\t",
119
+ )
120
+ if error:
121
+ return [], error
122
+
123
+ servers = []
124
+ for line in stdout.splitlines():
125
+ fields = line.split("\t")
126
+ if len(fields) != 5:
127
+ continue
128
+ raw_id, name, raw_sequence, smtp_host, raw_active = fields
129
+ try:
130
+ server_id = int(raw_id)
131
+ sequence = int(raw_sequence)
132
+ except ValueError:
133
+ continue
134
+ servers.append(
135
+ {
136
+ "id": server_id,
137
+ "name": name,
138
+ "sequence": sequence,
139
+ "smtp_host": smtp_host,
140
+ "active": raw_active == "t",
141
+ }
142
+ )
143
+ return servers, ""
144
+
145
+
146
+ def enable_mailpit_server(db: str) -> str:
147
+ """Create or reactivate the Rocketdoo mail server. Returns '' on success.
148
+
149
+ Matched by name + smtp_host, not by id: there is no UNIQUE constraint on
150
+ name to key an UPSERT off. Idempotent by construction, so callers can run
151
+ it unconditionally instead of tracking local state.
152
+ """
153
+ sql = (
154
+ "WITH updated AS ("
155
+ "UPDATE ir_mail_server "
156
+ f"SET active = true, smtp_port = {MAILPIT_SMTP_PORT}, sequence = {MAILPIT_SEQUENCE}, write_date = now() "
157
+ f"WHERE name = '{MAILPIT_SERVER_NAME}' AND smtp_host = '{MAILPIT_SMTP_HOST}' "
158
+ "RETURNING id"
159
+ ") "
160
+ "INSERT INTO ir_mail_server "
161
+ "(name, smtp_host, smtp_port, smtp_encryption, smtp_authentication, sequence, active, create_date, write_date) "
162
+ f"SELECT '{MAILPIT_SERVER_NAME}', '{MAILPIT_SMTP_HOST}', {MAILPIT_SMTP_PORT}, 'none', 'login', "
163
+ f"{MAILPIT_SEQUENCE}, true, now(), now() "
164
+ "WHERE NOT EXISTS (SELECT 1 FROM updated);"
165
+ )
166
+ _, error = _psql(db, sql)
167
+ return error
168
+
169
+
170
+ def disable_mailpit_server(db: str) -> tuple[int, str]:
171
+ """Archive the Rocketdoo mail server. Returns (rows archived, error).
172
+
173
+ Only rows matching both name and smtp_host are touched, and only
174
+ archived: never DELETE, so a record a user renamed or repurposed is
175
+ never at risk even if it collides on one of the two conditions.
176
+ """
177
+ sql = (
178
+ "UPDATE ir_mail_server SET active = false, write_date = now() "
179
+ f"WHERE name = '{MAILPIT_SERVER_NAME}' AND smtp_host = '{MAILPIT_SMTP_HOST}' "
180
+ "RETURNING id;"
181
+ )
182
+ # -q suppresses psql's status line: without it, `RETURNING id` with zero
183
+ # rows still prints "UPDATE 0", which counts as a row and makes the command
184
+ # report an archive that never happened.
185
+ stdout, error = _psql(db, sql, "-q", "-t", "-A")
186
+ if error:
187
+ return 0, error
188
+
189
+ # Count returned ids, not non-empty lines: psql's status line ("UPDATE 0")
190
+ # is not a row, and counting it reports an archive that never happened.
191
+ return len([line for line in stdout.splitlines() if line.strip().isdigit()]), ""
@@ -176,13 +176,20 @@ def create_app(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> FastAPI:
176
176
  except Exception:
177
177
  pass
178
178
 
179
+ # The SPA is one file with a fixed name, so without this the browser keeps
180
+ # serving its stored copy across upgrades — a user on a new version saw the
181
+ # previous interface until a forced refresh. "no-cache" does not disable
182
+ # caching, it requires revalidation: with the etag already sent, an
183
+ # unchanged file still answers 304.
184
+ _SPA_HEADERS = {"Cache-Control": "no-cache"}
185
+
179
186
  @app.get("/", response_class=FileResponse)
180
187
  async def root():
181
- return FileResponse(STATIC_DIR / "index.html")
188
+ return FileResponse(STATIC_DIR / "index.html", headers=_SPA_HEADERS)
182
189
 
183
190
  @app.get("/health")
184
191
  async def health():
185
- return {"status": "ok", "version": "3.0.0"}
192
+ return {"status": "ok", "version": __version__}
186
193
 
187
194
  @app.get("/{path:path}", response_class=FileResponse)
188
195
  async def spa_fallback(path: str):
@@ -190,7 +197,7 @@ def create_app(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> FastAPI:
190
197
  return JSONResponse({"error": "Not found"}, status_code=404)
191
198
  index = STATIC_DIR / "index.html"
192
199
  if index.exists():
193
- return FileResponse(index)
200
+ return FileResponse(index, headers=_SPA_HEADERS)
194
201
  return JSONResponse({"error": "GUI not found"}, status_code=404)
195
202
 
196
203
  return app