udata 13.0.1.dev12__py3-none-any.whl → 14.4.1.dev7__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.

Potentially problematic release.


This version of udata might be problematic. Click here for more details.

Files changed (177) hide show
  1. udata/api/__init__.py +2 -8
  2. udata/api_fields.py +35 -4
  3. udata/app.py +30 -50
  4. udata/auth/__init__.py +29 -6
  5. udata/auth/forms.py +8 -6
  6. udata/auth/views.py +6 -3
  7. udata/commands/__init__.py +2 -14
  8. udata/commands/db.py +13 -25
  9. udata/commands/info.py +0 -16
  10. udata/commands/serve.py +3 -11
  11. udata/commands/tests/test_fixtures.py +9 -9
  12. udata/core/access_type/api.py +1 -1
  13. udata/core/access_type/constants.py +12 -8
  14. udata/core/activity/api.py +5 -6
  15. udata/core/avatars/api.py +43 -0
  16. udata/core/avatars/test_avatar_api.py +30 -0
  17. udata/core/badges/tests/test_commands.py +6 -6
  18. udata/core/csv.py +5 -0
  19. udata/core/dataservices/models.py +15 -3
  20. udata/core/dataservices/tasks.py +7 -0
  21. udata/core/dataset/api.py +2 -0
  22. udata/core/dataset/models.py +2 -2
  23. udata/core/dataset/permissions.py +31 -0
  24. udata/core/dataset/tasks.py +50 -10
  25. udata/core/discussions/models.py +1 -0
  26. udata/core/metrics/__init__.py +0 -6
  27. udata/core/organization/api.py +8 -5
  28. udata/core/organization/mails.py +1 -1
  29. udata/core/organization/models.py +9 -1
  30. udata/core/organization/notifications.py +84 -0
  31. udata/core/organization/permissions.py +1 -1
  32. udata/core/organization/tasks.py +3 -0
  33. udata/core/pages/tests/test_api.py +32 -0
  34. udata/core/post/api.py +24 -69
  35. udata/core/post/models.py +84 -16
  36. udata/core/post/tests/test_api.py +24 -1
  37. udata/core/reports/api.py +18 -0
  38. udata/core/reports/models.py +42 -2
  39. udata/core/reuse/models.py +1 -1
  40. udata/core/reuse/tasks.py +7 -0
  41. udata/core/site/models.py +2 -6
  42. udata/core/spatial/commands.py +2 -4
  43. udata/core/spatial/forms.py +2 -2
  44. udata/core/spatial/models.py +0 -10
  45. udata/core/spatial/tests/test_api.py +1 -36
  46. udata/core/user/models.py +15 -2
  47. udata/cors.py +2 -5
  48. udata/db/migrations.py +279 -0
  49. udata/features/notifications/api.py +7 -18
  50. udata/features/notifications/models.py +56 -0
  51. udata/features/notifications/tasks.py +25 -0
  52. udata/flask_mongoengine/engine.py +0 -4
  53. udata/frontend/__init__.py +3 -122
  54. udata/frontend/markdown.py +2 -1
  55. udata/harvest/actions.py +24 -9
  56. udata/harvest/api.py +30 -22
  57. udata/harvest/backends/__init__.py +21 -9
  58. udata/harvest/backends/base.py +29 -3
  59. udata/harvest/backends/ckan/harvesters.py +13 -2
  60. udata/harvest/backends/dcat.py +3 -0
  61. udata/harvest/backends/maaf.py +1 -0
  62. udata/harvest/commands.py +39 -4
  63. udata/harvest/filters.py +17 -6
  64. udata/harvest/forms.py +9 -6
  65. udata/harvest/models.py +16 -0
  66. udata/harvest/permissions.py +27 -0
  67. udata/harvest/tasks.py +3 -5
  68. udata/harvest/tests/ckan/test_ckan_backend.py +35 -2
  69. udata/harvest/tests/ckan/test_ckan_backend_errors.py +1 -1
  70. udata/harvest/tests/ckan/test_ckan_backend_filters.py +1 -1
  71. udata/harvest/tests/ckan/test_dkan_backend.py +1 -1
  72. udata/harvest/tests/dcat/udata.xml +6 -6
  73. udata/harvest/tests/factories.py +1 -1
  74. udata/harvest/tests/test_actions.py +63 -8
  75. udata/harvest/tests/test_api.py +278 -123
  76. udata/harvest/tests/test_base_backend.py +88 -1
  77. udata/harvest/tests/test_dcat_backend.py +60 -13
  78. udata/harvest/tests/test_filters.py +6 -0
  79. udata/i18n.py +11 -273
  80. udata/mail.py +5 -1
  81. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  82. udata/migrations/2025-11-13-delete-user-email-index.py +25 -0
  83. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  84. udata/models/__init__.py +0 -8
  85. udata/mongo/slug_fields.py +1 -1
  86. udata/rdf.py +45 -6
  87. udata/routing.py +2 -10
  88. udata/sentry.py +4 -10
  89. udata/settings.py +23 -17
  90. udata/tasks.py +4 -3
  91. udata/templates/mail/message.html +5 -31
  92. udata/tests/__init__.py +28 -12
  93. udata/tests/api/__init__.py +108 -21
  94. udata/tests/api/test_activities_api.py +36 -0
  95. udata/tests/api/test_auth_api.py +121 -95
  96. udata/tests/api/test_base_api.py +7 -4
  97. udata/tests/api/test_dataservices_api.py +29 -1
  98. udata/tests/api/test_datasets_api.py +45 -21
  99. udata/tests/api/test_organizations_api.py +192 -197
  100. udata/tests/api/test_reports_api.py +157 -0
  101. udata/tests/api/test_reuses_api.py +147 -147
  102. udata/tests/api/test_security_api.py +12 -12
  103. udata/tests/api/test_swagger.py +4 -4
  104. udata/tests/api/test_tags_api.py +8 -8
  105. udata/tests/api/test_user_api.py +13 -1
  106. udata/tests/apiv2/test_swagger.py +4 -4
  107. udata/tests/apiv2/test_topics.py +1 -1
  108. udata/tests/cli/test_cli_base.py +8 -9
  109. udata/tests/dataset/test_dataset_commands.py +4 -4
  110. udata/tests/dataset/test_dataset_model.py +66 -26
  111. udata/tests/dataset/test_dataset_rdf.py +99 -5
  112. udata/tests/dataset/test_resource_preview.py +0 -1
  113. udata/tests/frontend/test_auth.py +24 -1
  114. udata/tests/frontend/test_csv.py +0 -3
  115. udata/tests/helpers.py +37 -27
  116. udata/tests/organization/test_notifications.py +67 -2
  117. udata/tests/plugin.py +6 -261
  118. udata/tests/site/test_site_csv_exports.py +22 -10
  119. udata/tests/test_activity.py +9 -9
  120. udata/tests/test_cors.py +1 -1
  121. udata/tests/test_dcat_commands.py +2 -2
  122. udata/tests/test_discussions.py +5 -5
  123. udata/tests/test_migrations.py +181 -481
  124. udata/tests/test_notifications.py +15 -57
  125. udata/tests/test_notifications_task.py +43 -0
  126. udata/tests/test_owned.py +81 -1
  127. udata/tests/test_storages.py +25 -19
  128. udata/tests/test_topics.py +77 -61
  129. udata/tests/test_uris.py +33 -0
  130. udata/tests/workers/test_jobs_commands.py +23 -23
  131. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  132. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  133. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  134. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  135. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  136. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  137. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  138. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  139. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  140. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  141. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  142. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  143. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  144. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  145. udata/translations/udata.pot +215 -106
  146. udata/uris.py +0 -2
  147. udata/utils.py +5 -0
  148. udata-14.4.1.dev7.dist-info/METADATA +109 -0
  149. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +153 -166
  150. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +3 -5
  151. udata/core/followers/views.py +0 -15
  152. udata/core/post/forms.py +0 -30
  153. udata/entrypoints.py +0 -93
  154. udata/features/identicon/__init__.py +0 -0
  155. udata/features/identicon/api.py +0 -13
  156. udata/features/identicon/backends.py +0 -131
  157. udata/features/identicon/tests/__init__.py +0 -0
  158. udata/features/identicon/tests/test_backends.py +0 -18
  159. udata/features/territories/__init__.py +0 -49
  160. udata/features/territories/api.py +0 -25
  161. udata/features/territories/models.py +0 -51
  162. udata/flask_mongoengine/json.py +0 -38
  163. udata/migrations/__init__.py +0 -367
  164. udata/templates/mail/base.html +0 -105
  165. udata/templates/mail/base.txt +0 -6
  166. udata/templates/mail/button.html +0 -3
  167. udata/templates/mail/layouts/1-column.html +0 -19
  168. udata/templates/mail/layouts/2-columns.html +0 -20
  169. udata/templates/mail/layouts/center-panel.html +0 -16
  170. udata/tests/cli/test_db_cli.py +0 -68
  171. udata/tests/features/territories/__init__.py +0 -20
  172. udata/tests/features/territories/test_territories_api.py +0 -185
  173. udata/tests/frontend/test_hooks.py +0 -149
  174. udata-13.0.1.dev12.dist-info/METADATA +0 -133
  175. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
  176. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
  177. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
@@ -1,520 +1,220 @@
1
- import importlib.util
2
1
  from datetime import datetime
2
+ from pathlib import Path
3
3
  from textwrap import dedent
4
4
 
5
5
  import pytest
6
6
  from mongoengine.connection import get_db
7
7
 
8
- from udata import migrations
8
+ from udata.db import migrations
9
9
  from udata.tests.api import PytestOnlyDBTestCase
10
- from udata.tests.helpers import assert_equal_dates
11
-
12
-
13
- class MigrationsMock:
14
- def __init__(self, root):
15
- self.root = root
16
- self.plugins = set()
17
- self.enabled = set()
18
- self.build_module("udata")
19
-
20
- def add_migration(self, plugin, filename, content="", enable=True):
21
- module = self.ensure_plugin(plugin, enabled=enable)
22
- module.ensure_dir("migrations")
23
- migration = module / "migrations" / filename
24
- migration.write(dedent(content))
25
-
26
- def build_module(self, name):
27
- root = self.root.ensure_dir(name)
28
- root.ensure("__init__.py")
29
-
30
- def ensure_plugin(self, plugin, enabled=True):
31
- if plugin not in self.plugins and plugin != "udata":
32
- self.build_module(plugin)
33
- self.plugins.add(plugin)
34
- if enabled and plugin != "udata":
35
- self.enabled.add(plugin)
36
- else:
37
- self.enabled.discard(plugin)
38
- return self.root / plugin
39
-
40
- def _load_module(self, name, path):
41
- # See: https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
42
- spec = importlib.util.spec_from_file_location(name, str(path / "__init__.py"))
43
- module = importlib.util.module_from_spec(spec)
44
- return module
45
-
46
- def _resource_path(self, name, path):
47
- return self.root / name / path
48
-
49
- def mock_resource_listdir(self, name, dirname):
50
- target = self._resource_path(name, dirname)
51
- return [f.relto(target) for f in target.listdir()]
52
-
53
- def mock_resource_string(self, name, filename):
54
- target = self._resource_path(name, filename)
55
- return target.read()
56
-
57
- def mock_resource_filename(self, name, filename):
58
- return str(self._resource_path(name, filename))
59
-
60
- def mock_resource_isdir(self, name, dirname):
61
- return self._resource_path(name, dirname).check(dir=1, exists=1)
62
-
63
- def mock_get_enabled_entrypoints(self, entrypoint, app):
64
- return {plugin: self._load_module(plugin, self.root / plugin) for plugin in self.enabled}
65
-
66
- def mock_get_plugin_module(self, entrypoint, app, plugin):
67
- return self._load_module(plugin, self.root / plugin)
68
-
69
-
70
- @pytest.fixture
71
- def mock(app, tmpdir, mocker):
72
- """
73
- Mock migrations files
74
- """
75
- m = MigrationsMock(tmpdir)
76
- mocker.patch("udata.migrations.resource_listdir", side_effect=m.mock_resource_listdir)
77
- mocker.patch("udata.migrations.resource_isdir", side_effect=m.mock_resource_isdir)
78
- mocker.patch("udata.migrations.resource_string", side_effect=m.mock_resource_string)
79
- mocker.patch("udata.migrations.resource_filename", side_effect=m.mock_resource_filename)
80
- mocker.patch("udata.entrypoints.get_enabled", side_effect=m.mock_get_enabled_entrypoints)
81
- mocker.patch("udata.entrypoints.get_plugin_module", side_effect=m.mock_get_plugin_module)
82
- yield m
83
-
84
-
85
- class MigrationsTest(PytestOnlyDBTestCase):
86
- @pytest.fixture
87
- def db(self):
88
- return get_db()
89
-
90
- @pytest.mark.parametrize(
91
- "args",
92
- [
93
- ("udata", "test.py"),
94
- ("udata", "test"),
95
- ("udata:test.py", None),
96
- ("udata:test.py", None),
97
- ],
98
- )
99
- def test_get_migration(self, args):
100
- migration = migrations.get(*args)
101
-
102
- assert isinstance(migration, migrations.Migration)
103
- assert migration.plugin == "udata"
104
- assert migration.filename == "test.py"
105
-
106
- def test_list_available_migrations(self, mock):
107
- mock.add_migration("udata", "01_core_migration.py")
108
- mock.add_migration("test", "02_test_migration.py")
109
- mock.add_migration("other", "03_other_migration.py")
110
- # Should not list `__*.py` files
111
- mock.add_migration("test", "__private.py")
112
- # Should not list migrations for disabled plugin
113
- mock.add_migration("disabled", "should_not_be_there.py", enable=False)
114
- # Should not fail on plugins without migrations dir
115
- mock.ensure_plugin("nomigrations")
116
-
117
- availables = migrations.list_available()
118
-
119
- assert len(availables) == 3
120
- assert availables == [
121
- migrations.Migration(p, f)
122
- for p, f in (
123
- ("udata", "01_core_migration.py"),
124
- ("test", "02_test_migration.py"),
125
- ("other", "03_other_migration.py"),
126
- )
127
- ]
128
-
129
- def test_get_record(self, db):
130
- inserted = {
131
- "plugin": "test",
132
- "filename": "filename.py",
133
- "ops": [
134
- {
135
- "date": datetime.utcnow(),
136
- "type": "migrate",
137
- "script": "script",
138
- "output": "output",
139
- "success": True,
140
- }
141
- ],
142
- }
143
- db.migrations.insert_one(inserted)
144
-
145
- record = migrations.get("test", "filename.py").record
146
-
147
- assert record["plugin"] == inserted["plugin"]
148
- assert record["filename"] == inserted["filename"]
149
-
150
- op = record["ops"][0]
151
- assert op["script"] == inserted["ops"][0]["script"]
152
- assert op["output"] == inserted["ops"][0]["output"]
153
- assert op["type"] == inserted["ops"][0]["type"]
154
- assert op["success"]
155
- assert_equal_dates(op["date"], inserted["ops"][0]["date"])
156
-
157
- def test_migration_execute(self, mock, db):
158
- mock.add_migration(
159
- "udata",
160
- "migration.py",
161
- """\
162
- import logging
163
10
 
164
- log = logging.getLogger(__name__)
165
-
166
- def migrate(db):
167
- db.test.insert_one({'key': 'value'})
168
- log.info('test')
169
- """,
170
- )
171
11
 
172
- output = migrations.get("udata", "migration.py").execute()
12
+ class MigrationsCommandsTest(PytestOnlyDBTestCase):
13
+ """Test migration commands without mocking, using real migration files"""
173
14
 
174
- inserted = db.test.find_one()
175
- assert inserted is not None
176
- assert inserted["key"] == "value"
177
- assert output == [["info", "test"]]
15
+ @pytest.fixture
16
+ def db(self):
17
+ return get_db()
178
18
 
179
- assert db.migrations.count_documents({}) == 1
180
- record = db.migrations.find_one()
181
- assert record["plugin"] == "udata"
182
- assert record["filename"] == "migration.py"
183
- assert len(record["ops"]) == 1
19
+ @pytest.fixture
20
+ def migration_file(self, tmp_path):
21
+ """Create a temporary migration file in udata/migrations"""
22
+ migrations_dir = Path(__file__).parent.parent / "migrations"
23
+ migration_path = migrations_dir / "test_migration_temp.py"
184
24
 
185
- op = record["ops"][0]
186
- assert op["type"] == "migrate"
187
- assert op["output"] == [["info", "test"]]
188
- assert op["state"] == {}
189
- assert isinstance(op["date"], datetime)
190
- assert op["success"]
191
-
192
- def test_migration_add_record(self, mock, db):
193
- mock.add_migration(
194
- "test",
195
- "filename.py",
25
+ # Create a simple migration
26
+ migration_content = dedent(
196
27
  """\
197
- # whatever
28
+ '''Test migration for integration testing'''
29
+ import logging
198
30
 
199
- def migrate():
200
- pass
201
- """,
202
- )
203
-
204
- expected_output = [["info", "Recorded only"]]
205
-
206
- output = migrations.get("test", "filename.py").execute(recordonly=True)
207
-
208
- assert output == expected_output
209
-
210
- migration = db.migrations.find_one()
211
- assert migration["plugin"] == "test"
212
- assert migration["filename"] == "filename.py"
213
-
214
- op = migration["ops"][0]
215
- assert op["script"].startswith("# whatever\n")
216
- assert op["output"] == expected_output
217
- assert op["type"] == "migrate"
218
- assert op["success"]
219
-
220
- def test_record_migration(self, mock, db):
221
- mock.add_migration(
222
- "test",
223
- "filename.py",
224
- """\
225
- # whatever
31
+ log = logging.getLogger(__name__)
226
32
 
227
- def migrate():
228
- pass
229
- """,
33
+ def migrate(db):
34
+ db.test_collection.insert_one({'test': 'value'})
35
+ log.info('Migration executed successfully')
36
+ """
230
37
  )
38
+ migration_path.write_text(migration_content)
231
39
 
232
- expected_output = [["info", "Recorded only"]]
233
-
234
- output = migrations.get("test", "filename.py").execute(recordonly=True)
40
+ yield "test_migration_temp.py"
235
41
 
236
- assert output == expected_output
42
+ # Cleanup
43
+ if migration_path.exists():
44
+ migration_path.unlink()
237
45
 
238
- migration = db.migrations.find_one()
239
- assert migration["plugin"] == "test"
240
- assert migration["filename"] == "filename.py"
46
+ def test_list_available_migrations(self):
47
+ """Test that we can list available migrations"""
48
+ result = self.cli("db status")
49
+ assert result.exit_code == 0
50
+ # Should contain at least some output (may be empty if no migrations)
241
51
 
242
- op = migration["ops"][0]
243
- assert op["script"].startswith("# whatever\n")
244
- assert op["output"] == expected_output
245
- assert op["type"] == "migrate"
246
- assert op["success"]
52
+ def test_migration_workflow(self, db, migration_file):
53
+ """Test complete migration workflow: info, migrate, status, unrecord"""
247
54
 
248
- def test_execute_missing_plugin(self, db):
249
- with pytest.raises(migrations.MigrationError):
250
- migrations.get("test", "filename.py").execute()
251
- assert db.migrations.find_one() is None
55
+ # 1. Test info command on non-executed migration
56
+ result = self.cli(f"db info {migration_file}")
57
+ assert result.exit_code == 0
58
+ assert "Test migration for integration testing" in result.output
59
+ assert "Not applied" in result.output
252
60
 
253
- def test_execute_missing_migration(self, db, mock):
254
- mock.ensure_plugin("test")
255
- with pytest.raises(migrations.MigrationError):
256
- migrations.get("test", "filename.py").execute()
257
- assert db.migrations.find_one() is None
258
-
259
- def test_execute_migration_error(self, mock, db):
260
- mock.add_migration(
261
- "udata",
262
- "migration.py",
263
- """\
264
- import logging
265
-
266
- log = logging.getLogger(__name__)
267
-
268
- def migrate(db):
269
- db.test.insert_one({'key': 'value'})
270
- log.info('test')
271
- raise ValueError('error')
272
- """,
273
- )
61
+ # 2. Test migrate command
62
+ result = self.cli("db migrate")
63
+ assert result.exit_code == 0
274
64
 
275
- with pytest.raises(migrations.MigrationError) as excinfo:
276
- migrations.get("udata", "migration.py").execute()
277
-
278
- exc = excinfo.value
279
- assert isinstance(exc, migrations.MigrationError)
280
- assert isinstance(exc.exc, ValueError)
281
- assert exc.msg == "Error while executing migration"
282
- assert exc.output == [["info", "test"]]
283
-
284
- # Without rollback DB is left as it is
285
- assert db.test.count_documents({}) == 1
286
- inserted = db.test.find_one()
65
+ # Verify migration was executed
66
+ inserted = db.test_collection.find_one()
287
67
  assert inserted is not None
288
- assert inserted["key"] == "value"
68
+ assert inserted["test"] == "value"
289
69
 
290
- # Failed migration is recorded
291
- assert db.migrations.count_documents({}) == 1
292
- record = db.migrations.find_one()
293
- assert record["plugin"] == "udata"
294
- assert record["filename"] == "migration.py"
70
+ # Verify migration was recorded
71
+ record = db.migrations.find_one({"filename": migration_file})
72
+ assert record is not None
73
+ assert record["filename"] == migration_file
295
74
  assert len(record["ops"]) == 1
75
+ assert record["ops"][0]["type"] == "migrate"
76
+ assert record["ops"][0]["success"] is True
296
77
 
297
- op = record["ops"][0]
298
- assert op["type"] == "migrate"
299
- assert op["output"] == [["info", "test"]]
300
- assert op["state"] == {}
301
- assert isinstance(op["date"], datetime)
302
- assert not op["success"]
303
-
304
- def test_execute_migration_error_with_rollback(self, mock, db):
305
- mock.add_migration(
306
- "udata",
307
- "migration.py",
308
- """\
309
- def migrate(db):
310
- db.test.insert_one({'key': 'value'})
311
- raise ValueError('error')
312
-
313
- def rollback(db):
314
- db.rollback_test.insert_one({'key': 'value'})
315
- """,
316
- )
317
-
318
- with pytest.raises(migrations.MigrationError) as excinfo:
319
- migrations.get("udata", "migration.py").execute()
78
+ # 3. Test status command after migration
79
+ result = self.cli("db status")
80
+ assert result.exit_code == 0
81
+ assert migration_file.replace(".py", "") in result.output
320
82
 
321
- exc = excinfo.value
322
- assert isinstance(exc, migrations.RollbackError)
323
- assert exc.exc is None
324
- assert exc.msg == "Error while executing migration, rollback has been applied"
83
+ # 4. Test info command on executed migration
84
+ result = self.cli(f"db info {migration_file}")
85
+ assert result.exit_code == 0
86
+ assert "Test migration for integration testing" in result.output
325
87
 
326
- assert isinstance(exc.migrate_exc, migrations.MigrationError)
327
- assert isinstance(exc.migrate_exc.exc, ValueError)
328
- assert exc.migrate_exc.msg == "Error while executing migration"
88
+ # 5. Test unrecord command
89
+ result = self.cli(f"db unrecord {migration_file}")
90
+ assert result.exit_code == 0
329
91
 
330
- # Migrate value is inserted
331
- assert db.test.count_documents({}) == 1
332
- # Rollback should be executed
333
- assert db.rollback_test.count_documents({}) == 1
92
+ # Verify migration record was removed
93
+ record = db.migrations.find_one({"filename": migration_file})
94
+ assert record is None
334
95
 
335
- # DB is rollbacked if possible
336
- assert db.migrations.count_documents({}) == 1
337
- record = db.migrations.find_one()
338
- assert record["plugin"] == "udata"
339
- assert record["filename"] == "migration.py"
340
- # Both failed migration and rollback are recorded
341
- assert len(record["ops"]) == 2
342
-
343
- # First the migration
344
- op = record["ops"][0]
345
- assert op["type"] == "migrate"
346
- assert op["output"] == []
347
- assert op["state"] == {}
348
- assert isinstance(op["date"], datetime)
349
- assert not op["success"]
350
-
351
- # Then the rollback
352
- op = record["ops"][1]
353
- assert op["type"] == "rollback"
354
- assert op["output"] == []
355
- assert op["state"] == {}
356
- assert isinstance(op["date"], datetime)
357
- assert op["success"]
358
-
359
- def test_execute_migration_error_with_state_rollback(self, mock, db):
360
- mock.add_migration(
361
- "udata",
362
- "migration.py",
363
- """\
364
- def migrate(db):
365
- db.test.insert_one({'key': 'first'})
366
- db._state['first'] = True
367
- raise ValueError('error')
368
- db.test.insert_two({'key': 'second'})
369
- db._state['second'] = True
370
-
371
- def rollback(db):
372
- if db._state.get('first', False):
373
- db.rollback_test.insert_one({'key': 'first'})
374
- if db._state.get('second', False):
375
- db.rollback_test.insert_one({'key': 'second'})
376
- """,
96
+ # But data inserted by migration should still be there
97
+ inserted = db.test_collection.find_one()
98
+ assert inserted is not None
99
+ assert inserted["test"] == "value"
100
+
101
+ # Cleanup test data
102
+ db.test_collection.delete_many({})
103
+
104
+ def test_migrate_recordonly(self, db, migration_file):
105
+ """Test migrate with --record flag"""
106
+
107
+ result = self.cli("db migrate --record")
108
+ assert result.exit_code == 0
109
+
110
+ # Migration should be recorded
111
+ record = db.migrations.find_one({"filename": migration_file})
112
+ assert record is not None
113
+ assert record["ops"][0]["output"] == [["info", "Recorded only"]]
114
+
115
+ # But data should NOT be inserted
116
+ inserted = db.test_collection.find_one()
117
+ assert inserted is None
118
+
119
+ # Cleanup
120
+ db.migrations.delete_one({"filename": migration_file})
121
+
122
+ def test_migrate_dry_run(self, db, migration_file):
123
+ """Test migrate with --dry-run flag"""
124
+
125
+ result = self.cli("db migrate --dry-run")
126
+ assert result.exit_code == 0
127
+
128
+ # Migration should NOT be recorded
129
+ record = db.migrations.find_one({"filename": migration_file})
130
+ assert record is None
131
+
132
+ # Data should NOT be inserted
133
+ inserted = db.test_collection.find_one()
134
+ assert inserted is None
135
+
136
+ def test_migrate_already_applied(self, db, migration_file):
137
+ """Test that already applied migrations are skipped"""
138
+
139
+ # First migration
140
+ result = self.cli("db migrate")
141
+ assert result.exit_code == 0
142
+
143
+ # Count records
144
+ count_before = db.test_collection.count_documents({})
145
+
146
+ # Second migration attempt
147
+ result = self.cli("db migrate")
148
+ assert result.exit_code == 0
149
+ assert "Skipped" in result.output
150
+
151
+ # No new records should be inserted
152
+ count_after = db.test_collection.count_documents({})
153
+ assert count_before == count_after
154
+
155
+ # Cleanup
156
+ db.test_collection.delete_many({})
157
+ db.migrations.delete_one({"filename": migration_file})
158
+
159
+ def test_unrecord_with_complete_filename(self, db):
160
+ """Should unrecord migration with complete filename"""
161
+ db.migrations.insert_one(
162
+ {
163
+ "filename": "test.py",
164
+ "ops": [
165
+ {
166
+ "date": datetime.utcnow(),
167
+ "type": "migrate",
168
+ "script": 'print("ok")',
169
+ "output": "ok",
170
+ "success": True,
171
+ }
172
+ ],
173
+ }
377
174
  )
175
+ result = self.cli("db unrecord test.py")
176
+ assert result.exit_code == 0
177
+ assert db.migrations.count_documents({}) == 0
378
178
 
379
- with pytest.raises(migrations.MigrationError) as excinfo:
380
- migrations.get("udata", "migration.py").execute()
381
-
382
- exc = excinfo.value
383
- assert isinstance(exc, migrations.RollbackError)
384
- assert exc.exc is None
385
- assert exc.msg == "Error while executing migration, rollback has been applied"
386
-
387
- assert isinstance(exc.migrate_exc, migrations.MigrationError)
388
- assert isinstance(exc.migrate_exc.exc, ValueError)
389
- assert exc.migrate_exc.msg == "Error while executing migration"
390
-
391
- # Only the first value is inserted
392
- assert db.test.count_documents({}) == 1
393
- # Only the first rollback operation is executed
394
- assert db.rollback_test.count_documents({}) == 1
395
-
396
- # DB is rollbacked if possible
397
- assert db.migrations.count_documents({}) == 1
398
- record = db.migrations.find_one()
399
- assert record["plugin"] == "udata"
400
- assert record["filename"] == "migration.py"
401
- # Both failed migration and rollback are recorded
402
- assert len(record["ops"]) == 2
403
-
404
- # First the migration
405
- op = record["ops"][0]
406
- assert op["type"] == "migrate"
407
- assert op["output"] == []
408
- assert op["state"] == {"first": True}
409
- assert isinstance(op["date"], datetime)
410
- assert not op["success"]
411
-
412
- # Then the rollback
413
- op = record["ops"][1]
414
- assert op["type"] == "rollback"
415
- assert op["output"] == []
416
- assert op["state"] == {"first": True}
417
- assert isinstance(op["date"], datetime)
418
- assert op["success"]
419
-
420
- def test_execute_migration_error_with_rollback_error(self, mock, db):
421
- mock.add_migration(
422
- "udata",
423
- "migration.py",
424
- """\
425
- def migrate(db):
426
- db.test.insert_one({'key': 'value'})
427
- raise ValueError('error')
428
-
429
- def rollback(db):
430
- db.rollback_test.insert_one({'key': 'value'})
431
- raise ValueError('error')
432
- """,
179
+ def test_unrecord_without_parameters(self, db):
180
+ """Should fail when no filename is provided"""
181
+ db.migrations.insert_one(
182
+ {
183
+ "filename": "test.py",
184
+ "ops": [
185
+ {
186
+ "date": datetime.utcnow(),
187
+ "type": "migrate",
188
+ "script": 'print("ok")',
189
+ "output": "ok",
190
+ "success": True,
191
+ }
192
+ ],
193
+ }
433
194
  )
434
-
435
- with pytest.raises(migrations.MigrationError) as excinfo:
436
- migrations.get("udata", "migration.py").execute()
437
-
438
- exc = excinfo.value
439
- assert isinstance(exc, migrations.RollbackError)
440
- assert isinstance(exc.exc, ValueError)
441
- assert exc.msg == "Error while executing migration rollback"
442
-
443
- assert isinstance(exc.migrate_exc, migrations.MigrationError)
444
- assert isinstance(exc.migrate_exc.exc, ValueError)
445
- assert exc.migrate_exc.msg == "Error while executing migration"
446
-
447
- # Migrate value is inserted
448
- assert db.test.count_documents({}) == 1
449
- # Rollback should not be recorded
450
- assert db.rollback_test.count_documents({}) == 1
451
-
452
- # DB is rollbacked if possible
195
+ result = self.cli("db unrecord", expect_error=True)
196
+ assert result.exit_code != 0
453
197
  assert db.migrations.count_documents({}) == 1
454
- record = db.migrations.find_one()
455
- assert record["plugin"] == "udata"
456
- assert record["filename"] == "migration.py"
457
- # Both failed migration and rollback are recorded
458
- assert len(record["ops"]) == 2
459
-
460
- # First the migration
461
- op = record["ops"][0]
462
- assert op["type"] == "migrate"
463
- assert op["output"] == []
464
- assert op["state"] == {}
465
- assert isinstance(op["date"], datetime)
466
- assert not op["success"]
467
-
468
- # Then the rollback
469
- op = record["ops"][1]
470
- assert op["type"] == "rollback"
471
- assert op["output"] == []
472
- assert op["state"] == {}
473
- assert isinstance(op["date"], datetime)
474
- assert not op["success"]
475
-
476
- def test_execute_migration_dry_run(self, mock, db):
477
- mock.add_migration(
478
- "udata",
479
- "migration.py",
480
- """\
481
- import logging
482
198
 
483
- log = logging.getLogger(__name__)
199
+ def test_all_existing_migrations_can_run(self, db):
200
+ """Test that all existing migrations can be executed without errors on a clean database"""
201
+ # Get all available migrations
202
+ all_migrations = migrations.list_available()
484
203
 
485
- def migrate(db):
486
- db.test.insert_one({'key': 'value'})
487
- log.info('test')
488
- """,
489
- )
204
+ # Run migrations
205
+ result = self.cli("db migrate")
206
+ assert result.exit_code == 0
490
207
 
491
- output = migrations.get("udata", "migration.py").execute(dryrun=True)
208
+ # Verify all migrations were recorded successfully
209
+ for migration in all_migrations:
210
+ record = db.migrations.find_one({"filename": migration.filename})
211
+ assert record is not None, f"Migration {migration.filename} was not recorded"
212
+ assert len(record["ops"]) > 0, f"Migration {migration.filename} has no operations"
492
213
 
493
- assert output == []
494
- assert db.test.find_one() is None
495
- assert db.migrations.count_documents({}) == 0
496
-
497
- def test_unrecord_migration(self, db):
498
- inserted = {
499
- "plugin": "test",
500
- "filename": "filename.py",
501
- "ops": [
502
- {
503
- "date": datetime.utcnow(),
504
- "type": "migrate",
505
- "script": "script",
506
- "output": "output",
507
- "state": {},
508
- "success": True,
509
- }
510
- ],
511
- }
512
- db.migrations.insert_one(inserted)
513
-
514
- migration = migrations.get("test", "filename.py")
515
-
516
- # Remove the migration record, return True
517
- assert migration.unrecord()
518
- assert db.migrations.find_one() is None
519
- # Already removed, return False
520
- assert not migration.unrecord()
214
+ last_op = record["ops"][-1]
215
+ assert last_op["success"], (
216
+ f"Migration {migration.filename} failed: {last_op.get('output', 'No output')}"
217
+ )
218
+ assert last_op["type"] == "migrate", (
219
+ f"Migration {migration.filename} last op is not migrate"
220
+ )