From 9cec9b5c72837a2953e6b5b9e331aee5c34e12df Mon Sep 17 00:00:00 2001 From: arcstur Date: Thu, 19 Mar 2026 17:30:31 -0300 Subject: [PATCH] feat: SWITCH_PROPERTY_AND_VALUE --- .../0043_alter_batchcommand_operation.py | 18 +++++ src/core/models.py | 27 +++++++- src/core/parsers/v1.py | 31 +++++++++ src/core/tests/test_batch_processing.py | 68 +++++++++++++++++++ src/core/tests/test_entity_patching.py | 51 ++++++++++++++ translations/en.json | 2 +- translations/qqq.json | 3 +- 7 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/core/migrations/0043_alter_batchcommand_operation.py diff --git a/src/core/migrations/0043_alter_batchcommand_operation.py b/src/core/migrations/0043_alter_batchcommand_operation.py new file mode 100644 index 0000000..88dfd74 --- /dev/null +++ b/src/core/migrations/0043_alter_batchcommand_operation.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.9 on 2026-03-19 20:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0042_alter_batchcommand_operation'), + ] + + operations = [ + migrations.AlterField( + model_name='batchcommand', + name='operation', + field=models.TextField(blank=True, choices=[('create_item', 'Create item'), ('create_property', 'Create property'), ('set_statement', 'Set statement'), ('create_statement', 'Create statement'), ('switch_statement_value', 'Switch statement value'), ('switch_statement_property', 'Switch statement property'), ('switch_statement_property_value', 'Switch statement property and value'), ('remove_statement_by_id', 'Remove statement by id'), ('remove_statement_by_value', 'Remove statement by value'), ('remove_qualifier', 'Remove qualifier'), ('remove_reference', 'Remove reference'), ('set_sitelink', 'Set sitelink'), ('set_label', 'Set label'), ('set_description', 'Set description'), ('remove_sitelink', 'Remove sitelink'), ('remove_label', 'Remove label'), ('remove_description', 'Remove description'), ('add_alias', 'Add alias'), ('remove_alias', 'Remove alias')], null=True), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index 58ba8e4..efdb00d 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -854,6 +854,10 @@ class Operation(models.TextChoices): "switch_statement_property", pgettext_lazy("batchcommand-py-operation-switch-statement-property", "Switch statement property"), ) + SWITCH_STATEMENT_PROPERTY_AND_VALUE = ( + "switch_statement_property_value", + pgettext_lazy("batchcommand-py-operation-switch-statement-property-and-value", "Switch statement property and value"), + ) REMOVE_STATEMENT_BY_ID = ( "remove_statement_by_id", pgettext_lazy( @@ -1366,7 +1370,11 @@ def is_add_statement(self): return self.is_add() and self.what == "STATEMENT" def is_switch(self): - return self.is_switch_value() or self.is_switch_property() + return self.operation in ( + self.Operation.SWITCH_STATEMENT_VALUE, + self.Operation.SWITCH_STATEMENT_PROPERTY, + self.Operation.SWITCH_STATEMENT_PROPERTY_AND_VALUE, + ) def is_switch_value(self): return self.operation == self.Operation.SWITCH_STATEMENT_VALUE @@ -1523,6 +1531,7 @@ def operation_is_combinable(self): self.Operation.CREATE_PROPERTY, self.Operation.SWITCH_STATEMENT_VALUE, self.Operation.SWITCH_STATEMENT_PROPERTY, + self.Operation.SWITCH_STATEMENT_PROPERTY_AND_VALUE, self.Operation.REMOVE_STATEMENT_BY_VALUE, self.Operation.REMOVE_QUALIFIER, self.Operation.REMOVE_REFERENCE, @@ -1670,6 +1679,8 @@ def update_entity_json(self, entity: dict): self._switch_statement_value(entity) elif self.operation == self.Operation.SWITCH_STATEMENT_PROPERTY: self._switch_statement_property(entity) + elif self.operation == self.Operation.SWITCH_STATEMENT_PROPERTY_AND_VALUE: + self._switch_statement_property_and_value(entity) elif self.operation in (self.Operation.ADD_ALIAS, self.Operation.REMOVE_ALIAS): self._update_entity_aliases(entity) elif self.operation in ( @@ -1807,6 +1818,20 @@ def _switch_statement_property(self, entity: dict): entity["statements"][new_prop].append(statement) logger.debug("post switch proeprty: ", entity) + def _switch_statement_property_and_value(self, entity: dict): + """ + Switches a statement property and value + """ + statement = self._remove_entity_statement(entity) + if "id" in statement: + statement.pop("id") # id is read-only and defined by wikibase + new_prop = self.json["property_switch"] + statement["property"] = {"id": new_prop} + statement["value"] = self.statement_api_value_switch + entity["statements"].setdefault(new_prop, []) + entity["statements"][new_prop].append(statement) + logger.debug("post switch proeprty: ", entity) + def _update_entity_aliases(self, entity: dict): """ Update the entity's aliases, adding or removing. diff --git a/src/core/parsers/v1.py b/src/core/parsers/v1.py index 7dc0451..f9ccdf7 100644 --- a/src/core/parsers/v1.py +++ b/src/core/parsers/v1.py @@ -162,6 +162,34 @@ def parse_switch_property(self, elements): data["operation"] = BatchCommand.Operation.SWITCH_STATEMENT_PROPERTY return data + def parse_switch_property_and_value(self, elements): + llen = len(elements) + if llen != 6: + raise ParserException("SWITCH_PROPERTY_AND_VALUE command must be QID|PID|value|new_PID|new_value") + elements.pop(0) + + # qid|pid|value|new_pid|new_value + property_switch = elements.pop(3) + if not self.is_valid_property_id(property_switch): + raise ParserException(f"Invalid property '{property_switch}'") + + # since value is only parsed inside `parse_statement`, + # we do this workaround to get it + # qid|pid|value|new_value + new = list(elements) + new.pop(2) + value_switch = self.parse_statement(new, new[0].upper())["value"] + + # qid|pid|value + elements.pop(3) + data = self.parse_statement(elements, elements[0].upper()) + data["action"] = "switch" + data["what"] = "property" + data["property_switch"] = property_switch + data["value_switch"] = value_switch + data["operation"] = BatchCommand.Operation.SWITCH_STATEMENT_PROPERTY_AND_VALUE + return data + def parse_statement(self, elements, first_command): llen = len(elements) if llen < 3: @@ -335,6 +363,9 @@ def parse_command(self, raw_command): elif first_command == "SWITCH_PROPERTY": logger.debug(f"parsing switch property: {elements}") data = self.parse_switch_property(elements) + elif first_command == "SWITCH_PROPERTY_AND_VALUE": + logger.debug(f"parsing switch property and value: {elements}") + data = self.parse_switch_property_and_value(elements) else: logger.debug(f"parsing statement: {elements}/{first_command}") data = self.parse_statement(elements, first_command) diff --git a/src/core/tests/test_batch_processing.py b/src/core/tests/test_batch_processing.py index a6c1a5b..1b37536 100644 --- a/src/core/tests/test_batch_processing.py +++ b/src/core/tests/test_batch_processing.py @@ -1000,3 +1000,71 @@ def test_property_switch_data_type(self, mocker): self.assertEqual(commands[1].status, BatchCommand.STATUS_DONE) self.assertIsNone(commands[1].message) self.assertIsNone(commands[1].error) + + @requests_mock.Mocker() + def test_property_and_value_switch_data_type(self, mocker): + self.api_mocker.is_autoconfirmed(mocker) + self.api_mocker.property_data_type(mocker, "P5", "wikibase-item") + self.api_mocker.property_data_type(mocker, "P6", "wikibase-item") + self.api_mocker.property_data_type(mocker, "P7", "string") + self.api_mocker.wikidata_property_data_types(mocker) + self.api_mocker.item( + mocker, + "Q1", + { + "statements": { + "P5": [ + { + "id": "Q1$abcdefgh-uijkl", + "value": { + "type": "value", + "content": "Q12", + }, + "qualifiers": [], + "references": [], + "property": {"id": "P5", "data_type": "wikibase-item"}, + }, + ], + }, + }, + ) + self.api_mocker.patch_item_successful(mocker, "Q1", {}) + raw = """ + SWITCH_PROPERTY_AND_VALUE|Q1|P5|Q12|P7|Q13 + SWITCH_PROPERTY_AND_VALUE|Q1|P5|Q12|P6|Q13 + SWITCH_PROPERTY_AND_VALUE|Q1|P5|Q12|P6|"https://kernel.org/" + SWITCH_PROPERTY_AND_VALUE|Q1|P5|Q12|P6|+2000-01-01T00:00:00Z/11 + SWITCH_PROPERTY_AND_VALUE|Q1|P5|Q12|P7|+2000-01-01T00:00:00Z/11 + """ + batch = self.parse(raw) + commands = batch.commands() + batch.run() + self.assertEqual(batch.status, Batch.STATUS_DONE) + self.assertEqual(commands[0].status, BatchCommand.STATUS_ERROR) + self.assertIsNone(commands[0].error) # TODO: we should have an error type, no? + self.assertEqual( + commands[0].message, + "Invalid value type for the property P7: 'wikibase-entityid' was provided but it needs 'string'.", + ) + self.assertEqual(commands[1].status, BatchCommand.STATUS_DONE) + self.assertIsNone(commands[1].message) + self.assertIsNone(commands[1].error) + self.assertEqual(batch.status, Batch.STATUS_DONE) + self.assertEqual(commands[2].status, BatchCommand.STATUS_ERROR) + self.assertIsNone(commands[2].error) # TODO: we should have an error type, no? + self.assertEqual( + commands[2].message, + "Invalid value type for the property P5: 'string' was provided but it needs 'wikibase-entityid'.", + ) + self.assertEqual(commands[3].status, BatchCommand.STATUS_ERROR) + self.assertIsNone(commands[3].error) + self.assertEqual( + commands[3].message, + "Invalid value type for the property P5: 'time' was provided but it needs 'wikibase-entityid'.", + ) + self.assertEqual(commands[4].status, BatchCommand.STATUS_ERROR) + self.assertIsNone(commands[4].error) + self.assertEqual( + commands[4].message, + "Invalid value type for the property P5: 'time' was provided but it needs 'wikibase-entityid'.", + ) diff --git a/src/core/tests/test_entity_patching.py b/src/core/tests/test_entity_patching.py index 4fe4221..bcf322c 100644 --- a/src/core/tests/test_entity_patching.py +++ b/src/core/tests/test_entity_patching.py @@ -611,3 +611,54 @@ def test_switch_property(self): entity["statements"]["P99"][0]["references"], self.INITIAL["statements"]["P65"][0]["references"] ) + + def test_switch_property_and_value(self): + text = """ + SWITCH_PROPERTY_AND_VALUE|Q12345678|P1|42|P99|1337 + SWITCH_PROPERTY_AND_VALUE|Q12345678|P65|1111|P99|1337 + SWITCH_PROPERTY_AND_VALUE|Q12345678|P65|42|P99|1337 + """ + batch = self.parse(text) + entity = copy.deepcopy(self.INITIAL) + self.assertStmtnCount(entity, "P65", 1) + self.assertStmtnCount(entity, "P99", 0) + self.assertEqual( + entity["statements"]["P65"][0]["value"]["content"]["amount"], "+42" + ) + self.assertQualCount(entity, "P65", 2) + self.assertRefCount(entity, "P65", 0) + # ----- + switch = batch.commands()[0] + with self.assertRaises(NoStatementsForThatProperty): + switch.update_entity_json(entity) + # ----- + switch = batch.commands()[1] + with self.assertRaises(NoStatementsWithThatValue): + switch.update_entity_json(entity) + # ----- + switch = batch.commands()[2] + switch.update_entity_json(entity) + self.assertStmtnCount(entity, "P65", 0) + self.assertStmtnCount(entity, "P99", 1) + self.assertQualCount(entity, "P99", 2) + self.assertRefCount(entity, "P99", 0) + self.assertEqual( + entity["statements"]["P99"][0]["property"]["id"], + "P99", + ) + self.assertIsNone(entity["statements"]["P99"][0].get("id")) + self.assertEqual( + entity["statements"]["P99"][0]["rank"], + self.INITIAL["statements"]["P65"][0]["rank"], + ) + self.assertEqual( + entity["statements"]["P99"][0]["value"]["content"]["amount"], "+1337" + ) + self.assertEqual( + entity["statements"]["P99"][0]["qualifiers"], + self.INITIAL["statements"]["P65"][0]["qualifiers"] + ) + self.assertEqual( + entity["statements"]["P99"][0]["references"], + self.INITIAL["statements"]["P65"][0]["references"] + ) diff --git a/translations/en.json b/translations/en.json index b890cb3..5ec115d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -215,5 +215,5 @@ "batch-button-rerun": "Rerun", "batch-button-download-report": "Download report", "login-token-expired": "Your Wikimedia authentication has expired. This is completely normal and it happens for security reasons. Please, log in again.", - "batchcommand-py-operation-switch-statement-property": "Switch statement property" + "batchcommand-py-operation-switch-statement-property-and-value": "Switch statement property and value" } diff --git a/translations/qqq.json b/translations/qqq.json index c490246..026ffaf 100644 --- a/translations/qqq.json +++ b/translations/qqq.json @@ -26,6 +26,7 @@ "batchcommand-py-operation-create-statement": "Individual command operation type displayed in command list", "batchcommand-py-operation-switch-statement-value": "Individual command operation type displayed in command list", "batchcommand-py-operation-switch-statement-property": "Individual command operation type displayed in command list", + "batchcommand-py-operation-switch-statement-property-value": "Individual command operation type displayed in command list", "batchcommand-py-operation-remove-statement-by-id": "Individual command operation type displayed in command list", "batchcommand-py-operation-remove-statement-by-value": "Individual command operation type displayed in command list", "batchcommand-py-operation-remove-qualifier": "Individual command operation type displayed in command list", @@ -201,4 +202,4 @@ "new-batch-file-upload": "Form title of file upload", "new-batch-file-upload-targeting": "File upload explanation", "login-token-expired": "Shown when the user is forcefully logged out and needs to log in again." -} +} \ No newline at end of file