diff --git a/CHANGELOG b/CHANGELOG index 35dedafe22..e6ba410275 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add Erase Party wizard * Search party also by contact mechanisms * Add contact_mechanism_get * Add name to contact mechanism diff --git a/__init__.py b/__init__.py index 9363038243..1597859ac3 100644 --- a/__init__.py +++ b/__init__.py @@ -2,30 +2,32 @@ # this repository contains the full copyright notices and license terms. from trytond.pool import Pool -from .category import * -from .party import * -from .address import * -from .contact_mechanism import * -from .configuration import * +from . import category +from . import party +from . import address +from . import contact_mechanism +from . import configuration def register(): Pool.register( - Category, - Party, - PartyLang, - PartyCategory, - PartyIdentifier, - CheckVIESResult, - PartyReplaceAsk, - Address, - AddressFormat, - ContactMechanism, - Configuration, - ConfigurationSequence, - ConfigurationLang, + category.Category, + party.Party, + party.PartyLang, + party.PartyCategory, + party.PartyIdentifier, + party.CheckVIESResult, + party.PartyReplaceAsk, + party.PartyEraseAsk, + address.Address, + address.AddressFormat, + contact_mechanism.ContactMechanism, + configuration.Configuration, + configuration.ConfigurationSequence, + configuration.ConfigurationLang, module='party', type_='model') Pool.register( - CheckVIES, - PartyReplace, + party.CheckVIES, + party.PartyReplace, + party.PartyErase, module='party', type_='wizard') diff --git a/doc/index.rst b/doc/index.rst index b6d86007b7..672bcb8483 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -29,6 +29,9 @@ parties with the VIES web service. The *Replace* wizard allows to replace duplicate record by the original and relink all the related documents. +The *Erase* wizard allows to erase all personal data of a party from the system +included the historized data and the resources attached for all the parties +which were replaced by this one. Address ******* diff --git a/party.py b/party.py index 1ed5ed1f34..c667e49bb5 100644 --- a/party.py +++ b/party.py @@ -2,8 +2,8 @@ # this repository contains the full copyright notices and license terms. import stdnum.eu.vat as vat import stdnum.exceptions -from sql import Null, Column -from sql.functions import CharLength +from sql import Null, Column, Cast, Literal +from sql.functions import CharLength, Substring, Position from trytond.model import (ModelView, ModelSQL, MultiValueMixin, ValueMixin, fields, Unique, sequence_ordered) @@ -16,7 +16,8 @@ from trytond.tools.multivalue import migrate_property __all__ = ['Party', 'PartyLang', 'PartyCategory', 'PartyIdentifier', 'CheckVIESResult', 'CheckVIES', - 'PartyReplace', 'PartyReplaceAsk'] + 'PartyReplace', 'PartyReplaceAsk', + 'PartyErase', 'PartyEraseAsk'] VAT_COUNTRIES = [('', '')] STATES = { @@ -576,3 +577,139 @@ class PartyReplaceAsk(ModelView): def on_change_source(self): if self.source and self.source.replaced_by: self.destination = self.source.replaced_by + + +class PartyErase(Wizard): + "Erase Party" + __name__ = 'party.erase' + start_state = 'ask' + ask = StateView('party.erase.ask', 'party.erase_ask_view_form', [ + Button("Cancel", 'end', 'tryton-cancel'), + Button("Erase", 'erase', 'tryton-clear', default=True), + ]) + erase = StateTransition() + + @classmethod + def __setup__(cls): + super(PartyErase, cls).__setup__() + cls._error_messages.update({ + 'active_party': ( + 'The party "%(party)s" can not be erased ' + 'because he is still active.'), + }) + + def transition_erase(self): + pool = Pool() + Party = pool.get('party.party') + cursor = Transaction().connection.cursor() + + def convert_from(table, tables): + right, condition = tables[None] + if table: + table = table.join(right, condition=condition) + else: + table = right + for k, sub_tables in tables.iteritems(): + if k is None: + continue + table = convert_from(table, sub_tables) + return table + + resources = self.get_resources() + parties = replacing = [self.ask.party] + with Transaction().set_context(active_test=False): + while replacing: + replacing = Party.search([ + ('replaced_by', 'in', map(int, replacing)), + ]) + parties += replacing + for party in parties: + self.check_erase(party) + to_erase = self.to_erase(party.id) + for Model, domain, resource, columns, values in to_erase: + assert issubclass(Model, ModelSQL) + assert len(columns) == len(values) + if 'active' in Model._fields: + records = Model.search(domain) + Model.write(records, {'active': False}) + + tables, where = Model.search_domain(domain, active_test=False) + from_ = convert_from(None, tables) + table, _ = tables[None] + query = from_.select(table.id, where=where) + + model_tables = [Model.__table__()] + if Model._history: + model_tables.append(Model.__table_history__()) + for table in model_tables: + sql_columns, sql_values = [], [] + for column, value in zip(columns, values): + column = Column(table, column) + sql_columns.append(column) + sql_values.append( + value(column) if callable(value) else value) + cursor.execute(*table.update( + sql_columns, sql_values, + where=table.id.in_(query))) + if not resource: + continue + for Resource in resources: + model_tables = [Resource.__table__()] + if Resource._history: + model_tables.append(Resource.__table_history__()) + for table in model_tables: + cursor.execute(*table.delete( + where=table.resource.like( + Model.__name__ + ',%') + & Cast(Substring(table.resource, + Position(',', table.resource) + + Literal(1)), + Model.id.sql_type().base).in_(query))) + return 'end' + + def check_erase(self, party): + if party.active: + self.raise_user_error('active_party', { + 'party': party.rec_name, + }) + + def to_erase(self, party_id): + pool = Pool() + Party = pool.get('party.party') + Identifier = pool.get('party.identifier') + Address = pool.get('party.address') + ContactMechanism = pool.get('party.contact_mechanism') + return [ + (Party, [('id', '=', party_id)], True, + ['name'], + [None]), + (Identifier, [('party', '=', party_id)], True, + ['type', 'code'], + [None, '****']), + (Address, [('party', '=', party_id)], True, + ['name', 'street', 'zip', 'city', 'country', 'subdivision'], + [None, None, None, None, None, None]), + (ContactMechanism, [('party', '=', party_id)], True, + ['value', 'name', 'comment'], + [None, None, None]), + ] + + @classmethod + def get_resources(cls): + pool = Pool() + Attachment = pool.get('ir.attachment') + Note = pool.get('ir.note') + return [Attachment, Note] + + +class PartyEraseAsk(ModelView): + "Erase Party" + __name__ = 'party.erase.ask' + party = fields.Many2One('party.party', "Party", required=True, + help="The party to be erased.") + + @classmethod + def default_party(cls): + context = Transaction().context + if context.get('active_model') == 'party.party': + return context.get('active_id') diff --git a/party.xml b/party.xml index 957b2af754..615ee3900c 100644 --- a/party.xml +++ b/party.xml @@ -161,5 +161,26 @@ this repository contains the full copyright notices and license terms. --> form replace_ask_form + + + Erase + party.erase + party.party + + + + + + + form_action + party.party,-1 + + + + + party.erase.ask + form + erase_ask_form + diff --git a/tests/scenario_party_erase.rst b/tests/scenario_party_erase.rst new file mode 100644 index 0000000000..2e0ab81871 --- /dev/null +++ b/tests/scenario_party_erase.rst @@ -0,0 +1,65 @@ +==================== +Party Erase Scenario +==================== + +Imports:: + + >>> from proteus import Model, Wizard + >>> from trytond.tests.tools import activate_modules + +Install party:: + + >>> config = activate_modules('party') + +Create a party:: + + >>> Party = Model.get('party.party') + >>> Attachment = Model.get('ir.attachment') + >>> party = Party(name='Pam') + >>> _ = party.identifiers.new(code="Identifier") + >>> _ = party.contact_mechanisms.new(type='other', value="mechanism") + >>> party.save() + >>> address, = party.addresses + >>> address.street = "St sample, 15" + >>> address.city = "City" + >>> address.save() + >>> identifier, = party.identifiers + >>> contact_mechanism, = party.contact_mechanisms + >>> attachment = Attachment() + >>> attachment.resource = party + >>> attachment.name = "Attachment" + >>> attachment.save() + +Try erase active party:: + + >>> erase = Wizard('party.erase', models=[party]) + >>> erase.form.party == party + True + >>> erase.execute('erase') # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + ... + UserError: ... + +Erase inactive party:: + + >>> party.active = False + >>> party.save() + + >>> erase = Wizard('party.erase', models=[party]) + >>> erase.form.party == party + True + >>> erase.execute('erase') + +Check fields have been erased:: + + >>> party.name + >>> identifier.reload() + >>> identifier.code + u'****' + >>> address.reload() + >>> address.street + >>> address.city + >>> contact_mechanism.reload() + >>> contact_mechanism.value + >>> Attachment.find() + [] diff --git a/tests/test_party.py b/tests/test_party.py index 5de253b920..3771655571 100644 --- a/tests/test_party.py +++ b/tests/test_party.py @@ -423,4 +423,9 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', checker=doctest_checker, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite( + 'scenario_party_erase.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite diff --git a/view/erase_ask_form.xml b/view/erase_ask_form.xml new file mode 100644 index 0000000000..89f6b56bc8 --- /dev/null +++ b/view/erase_ask_form.xml @@ -0,0 +1,7 @@ + + +
+