diff options
| author | Étienne Loks <etienne.loks@proxience.com> | 2014-11-28 11:30:02 +0100 | 
|---|---|---|
| committer | Étienne Loks <etienne.loks@proxience.com> | 2014-11-28 11:30:02 +0100 | 
| commit | 1cc8d6156a79d8eff8d9f6ee82797df6e464408d (patch) | |
| tree | fc3cb300358ed90b4d08dac9a274bcc124dcec01 /ishtar_common | |
| parent | 1094b07f381b658f8325c2723afa2e26b8909ebb (diff) | |
| download | Ishtar-1cc8d6156a79d8eff8d9f6ee82797df6e464408d.tar.bz2 Ishtar-1cc8d6156a79d8eff8d9f6ee82797df6e464408d.zip  | |
Merge action implementation (person and organization)
Diffstat (limited to 'ishtar_common')
| -rw-r--r-- | ishtar_common/forms_common.py | 117 | ||||
| -rw-r--r-- | ishtar_common/ishtar_menu.py | 6 | ||||
| -rw-r--r-- | ishtar_common/management/commands/generate_merge_candidates.py | 40 | ||||
| -rw-r--r-- | ishtar_common/migrations/0013_auto__add_field_organization_merge_key__add_field_historicalorganizati.py | 271 | ||||
| -rw-r--r-- | ishtar_common/model_merging.py | 128 | ||||
| -rw-r--r-- | ishtar_common/models.py | 74 | ||||
| -rw-r--r-- | ishtar_common/static/media/style.css | 19 | ||||
| -rw-r--r-- | ishtar_common/templates/ishtar/merge.html | 34 | ||||
| -rw-r--r-- | ishtar_common/templates/ishtar/merge_organization.html | 23 | ||||
| -rw-r--r-- | ishtar_common/templates/ishtar/merge_person.html | 22 | ||||
| -rw-r--r-- | ishtar_common/tests.py | 57 | ||||
| -rw-r--r-- | ishtar_common/urls.py | 6 | ||||
| -rw-r--r-- | ishtar_common/views.py | 46 | 
13 files changed, 836 insertions, 7 deletions
diff --git a/ishtar_common/forms_common.py b/ishtar_common/forms_common.py index 8964ae57b..a229fe319 100644 --- a/ishtar_common/forms_common.py +++ b/ishtar_common/forms_common.py @@ -30,6 +30,7 @@ from django.core import validators  from django.core.mail import send_mail  from django.core.exceptions import ObjectDoesNotExist  from django.forms.formsets import formset_factory, DELETION_FIELD_NAME +from django.forms.models import BaseModelFormSet  from django.template import Context, RequestContext, loader  from django.shortcuts import render_to_response  from django.utils.safestring import mark_safe @@ -302,6 +303,122 @@ class TownFormSet(FormSet):  TownFormset = formset_factory(TownForm, can_delete=True, formset=TownFormSet)  TownFormset.form_label = _("Towns") +class MergeFormSet(BaseModelFormSet): +    from_key = '' +    to_key = '' +    def __init__(self, *args, **kwargs): +        self._cached_list = [] +        super(MergeFormSet, self).__init__(*args, **kwargs) + +    def merge(self): +        for form in self.initial_forms: +            form.merge() + +    def initial_form_count(self): +        """ +        Recopied from django source only get_queryset is changed +        """ +        if not (self.data or self.files): +            return len(self.get_restricted_queryset()) +        return super(MergeFormSet, self).initial_form_count() + +    def _construct_form(self, i, **kwargs): +        """ +        Recopied from django source only get_queryset is changed +        """ +        if self.is_bound and i < self.initial_form_count(): +            # Import goes here instead of module-level because importing +            # django.db has side effects. +            from django.db import connections +            pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name) +            pk = self.data[pk_key] +            pk_field = self.model._meta.pk +            """pk = pk_field.get_db_prep_lookup('exact', pk, +                connection=connections[self.get_queryset().db])""" +            pk = self.get_restricted_queryset()[i].pk +            if isinstance(pk, list): +                pk = pk[0] +            kwargs['instance'] = self._existing_object(pk) +        if i < self.initial_form_count() and not kwargs.get('instance'): +            kwargs['instance'] = self.get_restricted_queryset()[i] +        if i >= self.initial_form_count() and self.initial_extra: +            # Set initial values for extra forms +            try: +                kwargs['initial'] = self.initial_extra[i-self.initial_form_count()] +            except IndexError: +                pass +        return super(BaseModelFormSet, self)._construct_form(i, **kwargs) + +    def get_restricted_queryset(self): +        ''' +        Filter (from, to) when (to, from) is already here +        ''' +        q = self.queryset +        if self._cached_list: +            return self._cached_list +        existing, res = [], [] +        # only get one version of each couple +        for item in q.all(): +            tpl = [getattr(item, self.from_key).pk, +                   getattr(item, self.to_key).pk] +            if tpl not in existing: +                res.append(item) +                existing.append(list(reversed(tpl))) +        self._cached_list = res +        return res + +class MergeForm(forms.ModelForm): +    id = forms.IntegerField(label=u"", widget=forms.HiddenInput, required=False) +    a_is_duplicate_b = forms.BooleanField(required=False) +    b_is_duplicate_a = forms.BooleanField(required=False) +    not_duplicate = forms.BooleanField(required=False) + +    def clean(self): +        checked = [True for k in ['a_is_duplicate_b', 'b_is_duplicate_a', +                                  'not_duplicate'] if self.cleaned_data.get(k)] +        if len(checked) > 1: +            raise forms.ValidationError(_(u"Only one choice can be checked.")) +        return self.cleaned_data + +    def merge(self, *args, **kwargs): +        try: +            to_item = getattr(self.instance, self.TO_KEY) +            from_item = getattr(self.instance, self.FROM_KEY) +        except ObjectDoesNotExist: +            return +        if self.cleaned_data.get('a_is_duplicate_b'): +            to_item.merge(from_item) +        elif self.cleaned_data.get('b_is_duplicate_a'): +            from_item.merge(to_item) +        elif self.cleaned_data.get('not_duplicate'): +            from_item.merge_exclusion.add(to_item) +        else: +            return +        try: +            reverse = self.instance.__class__.objects.get( +                **{self.TO_KEY:from_item, +                   self.FROM_KEY:to_item}).delete() +        except ObjectDoesNotExist: +            pass +        self.instance.delete() + +class MergePersonForm(MergeForm): +    class Meta: +        model = models.Person +        fields = [] + +    FROM_KEY = 'from_person' +    TO_KEY = 'to_person' + +class MergeOrganizationForm(MergeForm): +    class Meta: +        model = models.Organization +        fields = [] + +    FROM_KEY = 'from_organization' +    TO_KEY = 'to_organization' + +  ######################  # Sources management #  ###################### diff --git a/ishtar_common/ishtar_menu.py b/ishtar_common/ishtar_menu.py index 4303b707e..1dd22bd8a 100644 --- a/ishtar_common/ishtar_menu.py +++ b/ishtar_common/ishtar_menu.py @@ -45,6 +45,9 @@ MENU_SECTIONS = [                      MenuItem('person_modification', _(u"Modification"),                          model=models.Person,                          access_controls=['change_person', 'change_own_person']), +                    MenuItem('person_merge', _(u"Merge"), +                        model=models.Person, +                        access_controls=['merge_person',]),                      MenuItem('person_deletion', _(u"Delete"),                          model=models.Person,                          access_controls=['change_person', 'change_own_person']), @@ -59,6 +62,9 @@ MENU_SECTIONS = [                          model=models.Organization,                          access_controls=['change_organization',                                           'change_own_organization']), +                    MenuItem('organization_merge', _(u"Merge"), +                        model=models.Organization, +                        access_controls=['merge_organization',]),                      MenuItem('organization_deletion', _(u"Delete"),                          model=models.Organization,                          access_controls=['change_organization', diff --git a/ishtar_common/management/commands/generate_merge_candidates.py b/ishtar_common/management/commands/generate_merge_candidates.py new file mode 100644 index 000000000..a4aa87f38 --- /dev/null +++ b/ishtar_common/management/commands/generate_merge_candidates.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (C) 2014  Étienne Loks  <etienne.loks_AT_peacefrogsDOTnet> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# See the file COPYING for details. + +import sys + +from django.core.management.base import BaseCommand, CommandError +from django.core.exceptions import ObjectDoesNotExist + +import ishtar_common.models as models + +class Command(BaseCommand): +    args = '' +    help = 'Regenerate merge candidates' + +    def handle(self, *args, **options): +        for model in [models.Person, models.Organization]: +            sys.stdout.write('\n* %s treatment\n' % unicode(model)) +            q = model.objects +            total = q.count() +            for idx, item in enumerate(q.all()): +                sys.stdout.write('\r\t %d/%d' % (idx, total)) +                sys.stdout.flush() +                item.generate_merge_candidate() +        sys.stdout.write('\nSuccessfully generation of merge candidates\n') diff --git a/ishtar_common/migrations/0013_auto__add_field_organization_merge_key__add_field_historicalorganizati.py b/ishtar_common/migrations/0013_auto__add_field_organization_merge_key__add_field_historicalorganizati.py new file mode 100644 index 000000000..b0934572d --- /dev/null +++ b/ishtar_common/migrations/0013_auto__add_field_organization_merge_key__add_field_historicalorganizati.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + +    def forwards(self, orm): +        # Adding field 'Organization.merge_key' +        db.add_column('ishtar_common_organization', 'merge_key', +                      self.gf('django.db.models.fields.CharField')(max_length=300, null=True, blank=True), +                      keep_default=False) + +        # Adding M2M table for field merge_candidate on 'Organization' +        db.create_table('ishtar_common_organization_merge_candidate', ( +            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), +            ('from_organization', models.ForeignKey(orm['ishtar_common.organization'], null=False)), +            ('to_organization', models.ForeignKey(orm['ishtar_common.organization'], null=False)) +        )) +        db.create_unique('ishtar_common_organization_merge_candidate', ['from_organization_id', 'to_organization_id']) + +        # Adding M2M table for field merge_exclusion on 'Organization' +        db.create_table('ishtar_common_organization_merge_exclusion', ( +            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), +            ('from_organization', models.ForeignKey(orm['ishtar_common.organization'], null=False)), +            ('to_organization', models.ForeignKey(orm['ishtar_common.organization'], null=False)) +        )) +        db.create_unique('ishtar_common_organization_merge_exclusion', ['from_organization_id', 'to_organization_id']) + +        # Adding field 'HistoricalOrganization.merge_key' +        db.add_column('ishtar_common_historicalorganization', 'merge_key', +                      self.gf('django.db.models.fields.CharField')(max_length=300, null=True, blank=True), +                      keep_default=False) + +        # Adding field 'Person.merge_key' +        db.add_column('ishtar_common_person', 'merge_key', +                      self.gf('django.db.models.fields.CharField')(max_length=300, null=True, blank=True), +                      keep_default=False) + +        # Adding M2M table for field merge_candidate on 'Person' +        db.create_table('ishtar_common_person_merge_candidate', ( +            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), +            ('from_person', models.ForeignKey(orm['ishtar_common.person'], null=False)), +            ('to_person', models.ForeignKey(orm['ishtar_common.person'], null=False)) +        )) +        db.create_unique('ishtar_common_person_merge_candidate', ['from_person_id', 'to_person_id']) + +        # Adding M2M table for field merge_exclusion on 'Person' +        db.create_table('ishtar_common_person_merge_exclusion', ( +            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), +            ('from_person', models.ForeignKey(orm['ishtar_common.person'], null=False)), +            ('to_person', models.ForeignKey(orm['ishtar_common.person'], null=False)) +        )) +        db.create_unique('ishtar_common_person_merge_exclusion', ['from_person_id', 'to_person_id']) + + +    def backwards(self, orm): +        # Deleting field 'Organization.merge_key' +        db.delete_column('ishtar_common_organization', 'merge_key') + +        # Removing M2M table for field merge_candidate on 'Organization' +        db.delete_table('ishtar_common_organization_merge_candidate') + +        # Removing M2M table for field merge_exclusion on 'Organization' +        db.delete_table('ishtar_common_organization_merge_exclusion') + +        # Deleting field 'HistoricalOrganization.merge_key' +        db.delete_column('ishtar_common_historicalorganization', 'merge_key') + +        # Deleting field 'Person.merge_key' +        db.delete_column('ishtar_common_person', 'merge_key') + +        # Removing M2M table for field merge_candidate on 'Person' +        db.delete_table('ishtar_common_person_merge_candidate') + +        # Removing M2M table for field merge_exclusion on 'Person' +        db.delete_table('ishtar_common_person_merge_exclusion') + + +    models = { +        'auth.group': { +            'Meta': {'object_name': 'Group'}, +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), +            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) +        }, +        'auth.permission': { +            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, +            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) +        }, +        'auth.user': { +            'Meta': {'object_name': 'User'}, +            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), +            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), +            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), +            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), +            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), +            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), +            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), +            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), +            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), +            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), +            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) +        }, +        'contenttypes.contenttype': { +            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, +            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) +        }, +        'ishtar_common.arrondissement': { +            'Meta': {'object_name': 'Arrondissement'}, +            'department': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ishtar_common.Department']"}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '30'}) +        }, +        'ishtar_common.author': { +            'Meta': {'object_name': 'Author'}, +            'author_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ishtar_common.AuthorType']"}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'person': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'author'", 'to': "orm['ishtar_common.Person']"}) +        }, +        'ishtar_common.authortype': { +            'Meta': {'object_name': 'AuthorType'}, +            'available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), +            'comment': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'txt_idx': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) +        }, +        'ishtar_common.canton': { +            'Meta': {'object_name': 'Canton'}, +            'arrondissement': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ishtar_common.Arrondissement']"}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '30'}) +        }, +        'ishtar_common.department': { +            'Meta': {'ordering': "['number']", 'object_name': 'Department'}, +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'label': ('django.db.models.fields.CharField', [], {'max_length': '30'}), +            'number': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '3'}) +        }, +        'ishtar_common.documenttemplate': { +            'Meta': {'ordering': "['associated_object_name']", 'object_name': 'DocumentTemplate'}, +            'associated_object_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'available': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'template': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}) +        }, +        'ishtar_common.globalvar': { +            'Meta': {'ordering': "['slug']", 'object_name': 'GlobalVar'}, +            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '50'}), +            'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) +        }, +        'ishtar_common.historicalorganization': { +            'Meta': {'ordering': "('-history_date', '-history_id')", 'object_name': 'HistoricalOrganization'}, +            'address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'address_complement': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'country': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), +            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), +            'history_creator_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), +            'history_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), +            'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'history_modifier_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), +            'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), +            'history_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}), +            'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), +            'merge_key': ('django.db.models.fields.CharField', [], {'max_length': '300', 'null': 'True', 'blank': 'True'}), +            'mobile_phone': ('django.db.models.fields.CharField', [], {'max_length': '18', 'null': 'True', 'blank': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '300'}), +            'organization_type_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), +            'phone': ('django.db.models.fields.CharField', [], {'max_length': '18', 'null': 'True', 'blank': 'True'}), +            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}), +            'town': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}) +        }, +        'ishtar_common.ishtaruser': { +            'Meta': {'object_name': 'IshtarUser', '_ormbases': ['auth.User']}, +            'person': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'ishtaruser'", 'unique': 'True', 'to': "orm['ishtar_common.Person']"}), +            'user_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'primary_key': 'True'}) +        }, +        'ishtar_common.organization': { +            'Meta': {'object_name': 'Organization'}, +            'address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'address_complement': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'country': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), +            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), +            'history_creator': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}), +            'history_modifier': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'merge_candidate': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'merge_candidate_rel_+'", 'null': 'True', 'to': "orm['ishtar_common.Organization']"}), +            'merge_exclusion': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'merge_exclusion_rel_+'", 'null': 'True', 'to': "orm['ishtar_common.Organization']"}), +            'merge_key': ('django.db.models.fields.CharField', [], {'max_length': '300', 'null': 'True', 'blank': 'True'}), +            'mobile_phone': ('django.db.models.fields.CharField', [], {'max_length': '18', 'null': 'True', 'blank': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '300'}), +            'organization_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ishtar_common.OrganizationType']"}), +            'phone': ('django.db.models.fields.CharField', [], {'max_length': '18', 'null': 'True', 'blank': 'True'}), +            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}), +            'town': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}) +        }, +        'ishtar_common.organizationtype': { +            'Meta': {'ordering': "('label',)", 'object_name': 'OrganizationType'}, +            'available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), +            'comment': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'txt_idx': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) +        }, +        'ishtar_common.person': { +            'Meta': {'object_name': 'Person'}, +            'address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'address_complement': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'attached_to': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'members'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['ishtar_common.Organization']"}), +            'country': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), +            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'null': 'True', 'blank': 'True'}), +            'history_creator': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}), +            'history_modifier': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'merge_candidate': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'merge_candidate_rel_+'", 'null': 'True', 'to': "orm['ishtar_common.Person']"}), +            'merge_exclusion': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'merge_exclusion_rel_+'", 'null': 'True', 'to': "orm['ishtar_common.Person']"}), +            'merge_key': ('django.db.models.fields.CharField', [], {'max_length': '300', 'null': 'True', 'blank': 'True'}), +            'mobile_phone': ('django.db.models.fields.CharField', [], {'max_length': '18', 'null': 'True', 'blank': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}), +            'person_types': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['ishtar_common.PersonType']", 'symmetrical': 'False'}), +            'phone': ('django.db.models.fields.CharField', [], {'max_length': '18', 'null': 'True', 'blank': 'True'}), +            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}), +            'raw_name': ('django.db.models.fields.CharField', [], {'max_length': '300', 'null': 'True', 'blank': 'True'}), +            'surname': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'blank': 'True'}), +            'title': ('django.db.models.fields.CharField', [], {'max_length': '2'}), +            'town': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}) +        }, +        'ishtar_common.persontype': { +            'Meta': {'ordering': "('label',)", 'object_name': 'PersonType'}, +            'available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), +            'comment': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Group']", 'null': 'True', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'txt_idx': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) +        }, +        'ishtar_common.sourcetype': { +            'Meta': {'object_name': 'SourceType'}, +            'available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), +            'comment': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'txt_idx': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) +        }, +        'ishtar_common.town': { +            'Meta': {'ordering': "['numero_insee']", 'object_name': 'Town'}, +            'canton': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ishtar_common.Canton']", 'null': 'True', 'blank': 'True'}), +            'center': ('django.contrib.gis.db.models.fields.PointField', [], {'srid': '27572', 'null': 'True', 'blank': 'True'}), +            'departement': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ishtar_common.Department']", 'null': 'True', 'blank': 'True'}), +            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), +            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), +            'numero_insee': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '6'}), +            'surface': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}) +        } +    } + +    complete_apps = ['ishtar_common']
\ No newline at end of file diff --git a/ishtar_common/model_merging.py b/ishtar_common/model_merging.py new file mode 100644 index 000000000..b8c145fcb --- /dev/null +++ b/ishtar_common/model_merging.py @@ -0,0 +1,128 @@ +# from https://djangosnippets.org/snippets/2283/ + +from django.db import transaction +from django.db.models import get_models, Model +from django.contrib.contenttypes.generic import GenericForeignKey + +@transaction.commit_on_success +def merge_model_objects(primary_object, alias_objects=[], keep_old=False): +    """ +    Use this function to merge model objects (i.e. Users, Organizations, Polls, +    etc.) and migrate all of the related fields from the alias objects to the +    primary object. + +    Usage: +    from django.contrib.auth.models import User +    primary_user = User.objects.get(email='good_email@example.com') +    duplicate_user = User.objects.get(email='good_email+duplicate@example.com') +    merge_model_objects(primary_user, duplicate_user) +    """ +    MERGE_FIELDS = ('merge_candidate', 'merge_exclusion') + +    if not isinstance(alias_objects, list): +        alias_objects = [alias_objects] + +    # check that all aliases are the same class as primary one and that +    # they are subclass of model +    primary_class = primary_object.__class__ + +    if not issubclass(primary_class, Model): +        raise TypeError('Only django.db.models.Model subclasses can be merged') + +    for alias_object in alias_objects: +        if not isinstance(alias_object, primary_class): +            raise TypeError('Only models of same class can be merged') + +    # Get a list of all GenericForeignKeys in all models +    # TODO: this is a bit of a hack, since the generics framework should provide +    # a similar +    # method to the ForeignKey field for accessing the generic related fields. +    generic_fields = [] +    for model in get_models(): +        for field_name, field in filter(lambda x: isinstance(x[1], +                                                             GenericForeignKey), +                                        model.__dict__.iteritems()): +            generic_fields.append(field) + +    blank_local_fields = set() +    for field in primary_object._meta.local_fields: +        value = getattr(primary_object, field.attname) +        # string fields with only spaces are empty fields +        if isinstance(value, unicode) or isinstance(value, str): +            value = value.strip() +        if value in [None, '']: +            blank_local_fields.add(field.attname) + +    # Loop through all alias objects and migrate their data to the primary object. +    for alias_object in alias_objects: +        # Migrate all foreign key references from alias object to primary object. +        for related_object in alias_object._meta.get_all_related_objects(): +            # The variable name on the alias_object model. +            alias_varname = related_object.get_accessor_name() +            # The variable name on the related model. +            obj_varname = related_object.field.name +            related_objects = getattr(alias_object, alias_varname) +            for obj in related_objects.all(): +                setattr(obj, obj_varname, primary_object) +                obj.save() + +        # Migrate all many to many references from alias object to primary object. +        related_many_objects = \ +                       alias_object._meta.get_all_related_many_to_many_objects() +        related_many_object_names = set() +        for related_many_object in related_many_objects: +            alias_varname = related_many_object.get_accessor_name() +            obj_varname = related_many_object.field.name +            if alias_varname in MERGE_FIELDS or obj_varname in MERGE_FIELDS: +                continue + +            if alias_varname is not None: +                # standard case +                related_many_objects = getattr(alias_object, alias_varname).all() +                related_many_object_names.add(alias_varname) +            else: +                # special case, symmetrical relation, no reverse accessor +                related_many_objects = getattr(alias_object, obj_varname).all() +                related_many_object_names.add(obj_varname) +            for obj in related_many_objects.all(): +                getattr(obj, obj_varname).remove(alias_object) +                getattr(obj, obj_varname).add(primary_object) + +        # Migrate local many to many references from alias object to primary object. +        for many_to_many_object in alias_object._meta.many_to_many: +            alias_varname = many_to_many_object.get_attname() +            if alias_varname in related_many_object_names or \ +               alias_varname in MERGE_FIELDS: +                continue + +            many_to_many_objects = getattr(alias_object, alias_varname).all() +            if alias_varname in blank_local_fields: +                blank_local_fields.pop(alias_varname) +            for obj in many_to_many_objects.all(): +                getattr(alias_object, alias_varname).remove(obj) +                getattr(primary_object, alias_varname).add(obj) + +        # Migrate all generic foreign key references from alias object to +        # primary object. +        for field in generic_fields: +            filter_kwargs = {} +            filter_kwargs[field.fk_field] = alias_object._get_pk_val() +            filter_kwargs[field.ct_field] = field.get_content_type(alias_object) +            for generic_related_object in field.model.objects.filter( +                                                               **filter_kwargs): +                setattr(generic_related_object, field.name, primary_object) +                generic_related_object.save() + +        # Try to fill all missing values in primary object by values of duplicates +        filled_up = set() +        for field_name in blank_local_fields: +            val = getattr(alias_object, field_name) +            if val not in [None, '']: +                setattr(primary_object, field_name, val) +                filled_up.add(field_name) +        blank_local_fields -= filled_up + +        if not keep_old: +            alias_object.delete() +    primary_object.save() +    return primary_object diff --git a/ishtar_common/models.py b/ishtar_common/models.py index a96b24840..1031df71e 100644 --- a/ishtar_common/models.py +++ b/ishtar_common/models.py @@ -47,6 +47,7 @@ from django.contrib import admin  from simple_history.models import HistoricalRecords as BaseHistoricalRecords  from ishtar_common.ooo_replace import ooo_replace +from ishtar_common.model_merging import merge_model_objects  from ishtar_common.utils import get_cache  def post_save_user(sender, **kwargs): @@ -214,7 +215,7 @@ class GeneralType(models.Model):      txt_idx = models.CharField(_(u"Textual ID"),                           validators=[validate_slug], max_length=30, unique=True)      comment = models.TextField(_(u"Comment"), blank=True, null=True) -    available = models.BooleanField(_(u"Available")) +    available = models.BooleanField(_(u"Available"), default=True)      HELP_TEXT = u""      class Meta: @@ -224,6 +225,10 @@ class GeneralType(models.Model):      def __unicode__(self):          return self.label +    @classmethod +    def create_default_for_test(cls): +        return [cls.objects.create(label='Test %d' % i) for i in range(5)] +      @property      def short_label(self):          return self.label @@ -334,6 +339,8 @@ class GeneralType(models.Model):          if not self.id and not self.label:              self.label = u" ".join(u" ".join(self.txt_idx.split('-')                                                          ).split('_')).title() +        if not self.txt_idx: +            self.txt_idx = slugify(self.label)          return super(GeneralType, self).save(*args, **kwargs)  class ImageModel(models.Model): @@ -820,13 +827,62 @@ class Address(BaseHistorizedItem):      class Meta:          abstract = True +class Merge(models.Model): +    merge_key = models.CharField(_("Merge key"), max_length=300, +                                    blank=True, null=True) +    merge_candidate = models.ManyToManyField("self", +                                    blank=True, null=True) +    merge_exclusion = models.ManyToManyField("self", +                                    blank=True, null=True) +    # 1 for one word similarity, 2 for two word similarity, etc. +    MERGE_CLEMENCY = None +    EMPTY_MERGE_KEY = '--' + +    class Meta: +        abstract = True + +    def generate_merge_key(self): +        self.merge_key = slugify(self.name if self.name else '') +        if not self.merge_key: +            self.merge_key = self.EMPTY_MERGE_KEY + +    def generate_merge_candidate(self): +        if not self.merge_key: +            self.generate_merge_key() +            self.save() +        if not self.pk or self.merge_key == self.EMPTY_MERGE_KEY: +            return +        q = self.__class__.objects.exclude(pk=self.pk +                                ).exclude(merge_exclusion=self +                                ).exclude(merge_candidate=self) +        if not self.MERGE_CLEMENCY: +            q = q.filter(merge_key=self.merge_key) +        else: +            subkeys_front = u"-".join( +                        self.merge_key.split('-')[:self.MERGE_CLEMENCY]) +            subkeys_back = u"-".join( +                        self.merge_key.split('-')[-self.MERGE_CLEMENCY:]) +            q = q.filter(Q(merge_key__istartswith=subkeys_front) | +                         Q(merge_key__iendswith=subkeys_back)) +        for item in q.all(): +            self.merge_candidate.add(item) + +    def save(self, *args, **kwargs): +        self.generate_merge_key() +        item = super(Merge, self).save(*args, **kwargs) +        self.generate_merge_candidate() + +    def merge(self, item): +        merge_model_objects(self, item) +        self.generate_merge_candidate() +  class OrganizationType(GeneralType):      class Meta:          verbose_name = _(u"Organization type")          verbose_name_plural = _(u"Organization types")          ordering = ('label',) -class Organization(Address, OwnPerms, ValueGetter): +class Organization(Address, Merge, OwnPerms, ValueGetter):      TABLE_COLS = ('name', 'organization_type',)      name = models.CharField(_(u"Name"), max_length=300)      organization_type = models.ForeignKey(OrganizationType, @@ -862,7 +918,7 @@ class PersonType(GeneralType):          verbose_name_plural = _(u"Person types")          ordering = ('label',) -class Person(Address, OwnPerms, ValueGetter) : +class Person(Address, Merge, OwnPerms, ValueGetter) :      _prefix = 'person_'      TYPE = (('Mr', _(u'Mr')),              ('Ms', _(u'Miss')), @@ -900,7 +956,7 @@ class Person(Address, OwnPerms, ValueGetter) :                     for attr in ('surname', 'name')                                 if getattr(self, attr)]          if not values: -            values = [self.raw_name] +            values = [self.raw_name or ""]          if self.attached_to:              values.append(u"- " + unicode(self.attached_to))          return u" ".join(values) @@ -921,6 +977,16 @@ class Person(Address, OwnPerms, ValueGetter) :      def person_types_list(self):          return u", ".join([unicode(pt) for pt in self.person_types.all()]) +    def generate_merge_key(self): +        if self.name and self.name.strip(): +            self.merge_key = slugify(self.name.strip()) + ( +                                (u'-' + slugify(self.surname.strip()))  +                                                    if self.surname else u'') +        elif self.raw_name and self.raw_name.strip(): +            self.merge_key = slugify(self.raw_name.strip()) +        else: +            self.merge_key = self.EMPTY_MERGE_KEY +      def has_right(self, right_name):          if '.' in right_name:              right_name = right_name.split('.')[-1] diff --git a/ishtar_common/static/media/style.css b/ishtar_common/static/media/style.css index 23bc28832..b19366dc1 100644 --- a/ishtar_common/static/media/style.css +++ b/ishtar_common/static/media/style.css @@ -885,6 +885,10 @@ a.remove{      text-align:center;  } +.form table td.check{ +    text-align:center; +} +  .inline-table input[type=text]{      width:60px;  } @@ -913,3 +917,18 @@ a.remove{      height:50px;      padding:10px;  } + +#merge-table{ +    background-color:#fff; +    width:700px; +} + +#merge-table thead th{ +    text-align:center; +    font-weight:bold; +    border-bottom:1px solid #ccc; +} + +#merge-table th.small{ +    width:80px; +} diff --git a/ishtar_common/templates/ishtar/merge.html b/ishtar_common/templates/ishtar/merge.html new file mode 100644 index 000000000..edd27a722 --- /dev/null +++ b/ishtar_common/templates/ishtar/merge.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load url from future %} +{% load i18n inline_formset %} +{% block content %} +<h2>{% trans "Merge" %}</h2> +<div class='form'> +<p class='alert'><label>{%trans "Every operation on this form is irreversible"%}</label></p> +  <p>{% if previous_page %}<a href='{% url current_url 1 %}'>↞</a><a href='{% url current_url previous_page %}'>←</a>{% else %} {% endif %} +    {% trans "Page " %}{{current_page}}/{{max_page}} +{% if next_page %}<a href='{% url current_url next_page %}'>→</a><a href='{% url current_url max_page %}'>↠</a>{% else %} {% endif %} </p> +  <form action="." method="post">{% csrf_token %} +  {{formset.management_form}} +  {% for form in formset %}{% for hidden_field in form.hidden_fields %}{{hidden_field}}{% endfor %}{% endfor %} +  <table id='merge-table'> +    <thead> +      <tr> +        <th colspan='2'>{% trans "Item A" %}</th> +        <th colspan='2'>{% trans "Item B" %}</th> +        <th class='small'>{% trans "B is a duplicate of A" %}</th> +        <th class='small'>{% trans "A is a duplicate of B" %}</th> +        <th class='small'>{% trans "Is not duplicate" %}</th> +      </tr> +    </thead> +    <tbody> +      {% for form in formset %} +{% block merge_field_row %} +{% endblock %} +      {% endfor %} +    <tbody> +</table> +<input type="submit" value="{% trans "Validate" %}"/> +</form> +</div> +{% endblock %} diff --git a/ishtar_common/templates/ishtar/merge_organization.html b/ishtar_common/templates/ishtar/merge_organization.html new file mode 100644 index 000000000..4118ee6d5 --- /dev/null +++ b/ishtar_common/templates/ishtar/merge_organization.html @@ -0,0 +1,23 @@ +{% extends "ishtar/merge.html" %} +{% load url from future %} +{% block merge_field_row %} +      {% if form.non_field_errors %}<tr><td colspan='4'></td><td colspan='3' class='errorlist'>{% for error in form.non_field_errors %}{{error}} {% endfor%}</tr>{% endif %} +      <tr> +        <td> +          <a href="#" onclick="load_window('{% url 'show-organization' form.instance.from_organization.pk '' %}', 'organization');" class="display_details">Détails</a> +        </td> +        <td> +          {{form.instance.from_organization}} ({{form.instance.from_organization.pk}}) +        </td> +        <td> +          <a href="#" onclick="load_window('{% url 'show-organization' form.instance.to_organization.pk '' %}', 'organization');" class="display_details">Détails</a> +        </td> +        <td> +          {{form.instance.to_organization}} ({{form.instance.to_organization.pk}}) +        </td> +        <td class='check'>{{form.b_is_duplicate_a}}</td> +        <td class='check'>{{form.a_is_duplicate_b}}</td> +        <td class='check'>{{form.not_duplicate}}</td> +      </tr> +{% endblock %} + diff --git a/ishtar_common/templates/ishtar/merge_person.html b/ishtar_common/templates/ishtar/merge_person.html new file mode 100644 index 000000000..6b67182b3 --- /dev/null +++ b/ishtar_common/templates/ishtar/merge_person.html @@ -0,0 +1,22 @@ +{% extends "ishtar/merge.html" %} +{% load url from future %} +{% block merge_field_row %} +      {% if form.non_field_errors %}<tr><td colspan='4'></td><td colspan='3' class='errorlist'>{% for error in form.non_field_errors %}{{error}} {% endfor%}</tr>{% endif %} +      <tr> +        <td> +          <a href="#" onclick="load_window('{% url 'show-person' form.instance.from_person.pk '' %}', 'person');" class="display_details">Détails</a> +        </td> +        <td> +          {{form.instance.from_person}} ({{form.instance.from_person.pk}}) +        </td> +        <td> +          <a href="#" onclick="load_window('{% url 'show-person' form.instance.to_person.pk '' %}', 'person');" class="display_details">Détails</a> +        </td> +        <td> +          {{form.instance.to_person}} ({{form.instance.to_person}}.pk) +        </td> +        <td class='check'>{{form.b_is_duplicate_a}}</td> +        <td class='check'>{{form.a_is_duplicate_b}}</td> +        <td class='check'>{{form.not_duplicate}}</td> +      </tr> +{% endblock %} diff --git a/ishtar_common/tests.py b/ishtar_common/tests.py index cdcb42103..000abc268 100644 --- a/ishtar_common/tests.py +++ b/ishtar_common/tests.py @@ -20,6 +20,7 @@  import tempfile  from zipfile import ZipFile, ZIP_DEFLATED +from django.contrib.auth.models import User  from django.test import TestCase  from ishtar_common import models, ooo_replace @@ -34,3 +35,59 @@ class OOOGenerationTest(TestCase):          value = inzip.read('content.xml')          self.assertTrue("Testé" in value)          self.assertTrue("testé 2" not in value) + +class MergeTest(TestCase): +    def setUp(self): +        self.user, created = User.objects.get_or_create(username='username') +        self.organisation_types = models.OrganizationType.create_default_for_test() + +        self.person_types = [models.PersonType.objects.create(label='Admin'), +                             models.PersonType.objects.create(label='User')] +        self.author_types = [models.AuthorType.objects.create(label='1'), +                             models.AuthorType.objects.create(label='2'),] + +        self.company_1 = models.Organization.objects.create( +                    history_modifier=self.user, name='Franquin Comp.', +                    organization_type=self.organisation_types[0]) +        self.person_1 = models.Person.objects.create(name='Boule', +                surname=' ', +                history_modifier=self.user, attached_to=self.company_1) +        self.person_1.person_types.add(self.person_types[0]) +        self.author_1_pk = models.Author.objects.create(person=self.person_1, +                                            author_type=self.author_types[0]).pk + +        self.company_2 = models.Organization.objects.create( +                    history_modifier=self.user, name='Goscinny Corp.', +                    organization_type=self.organisation_types[1]) +        self.person_2 = models.Person.objects.create(name='Bill', +                history_modifier=self.user, surname='Peyo', title='Mr', +                attached_to=self.company_2) +        self.person_2.person_types.add(self.person_types[1]) +        self.author_2_pk = models.Author.objects.create(person=self.person_2, +                                            author_type=self.author_types[1]).pk +        self.person_3 = models.Person.objects.create(name='George', +                history_modifier=self.user, attached_to=self.company_1) + +    def testPersonMerge(self): +        self.person_1.merge(self.person_2) +        # preserve existing fields +        self.assertEqual(self.person_1.name, 'Boule') +        # fill missing fields +        self.assertEqual(self.person_1.title, 'Mr') +        # string field with only spaces is an empty field +        self.assertEqual(self.person_1.surname, 'Peyo') +        # preserve existing foreign key +        self.assertEqual(self.person_1.attached_to, self.company_1) +        # preserve existing many to many +        self.assertTrue(self.person_types[0] in self.person_1.person_types.all()) +        # add new many to many +        self.assertTrue(self.person_types[1] in self.person_1.person_types.all()) +        # update reverse foreign key association and dont break the existing +        self.assertEqual(models.Author.objects.get(pk=self.author_1_pk).person, +                         self.person_1) +        self.assertEqual(models.Author.objects.get(pk=self.author_2_pk).person, +                         self.person_1) + +        self.person_3.merge(self.person_1) +        # manage well empty many to many fields +        self.assertTrue(self.person_types[1] in self.person_3.person_types.all()) diff --git a/ishtar_common/urls.py b/ishtar_common/urls.py index c361ef438..d1e88f68f 100644 --- a/ishtar_common/urls.py +++ b/ishtar_common/urls.py @@ -93,8 +93,10 @@ urlpatterns += patterns('ishtar_common.views',             'autocomplete_organization', name='autocomplete-organization'),       url(r'admin-globalvar/', views.GlobalVarEdit.as_view(),          name='admin-globalvar'), -     url(r'(?P<action_slug>' + actions + r')/$', 'action', -           name='action'), +     url(r'person_merge/(?:(?P<page>\d+)/)?$', 'person_merge', name='person_merge'), +     url(r'organization_merge/(?:(?P<page>\d+)/)?$', 'organization_merge', +         name='organization_merge'), +     url(r'(?P<action_slug>' + actions + r')/$', 'action', name='action'),  )  if settings.DEBUG: diff --git a/ishtar_common/views.py b/ishtar_common/views.py index c27aae74b..0f234c0a1 100644 --- a/ishtar_common/views.py +++ b/ishtar_common/views.py @@ -40,7 +40,7 @@ from django.contrib.auth.decorators import login_required  from django.core import serializers  from django.core.exceptions import ObjectDoesNotExist  from django.core.urlresolvers import reverse, NoReverseMatch -from django.db.models import Q, ImageField +from django.db.models import Q, F, ImageField  from django.http import HttpResponse, Http404  from django.shortcuts import render_to_response, redirect  from django.template import RequestContext, loader @@ -795,6 +795,50 @@ def dashboard_main_detail(request, item_name):      return render_to_response('ishtar/dashboards/dashboard_main_detail.html',                              dct, context_instance=RequestContext(request)) +from django.forms.models import model_to_dict + +from django.forms.models import modelformset_factory + +ITEM_PER_PAGE = 20 +def merge_action(model, form, key): +    def merge(request, page=1): +        current_url = key + '_merge' +        if not page: +            page = 1 +        page = int(page) +        FormSet = modelformset_factory(model.merge_candidate.through, +                                 form=form, formset=forms.MergeFormSet ,extra=0) +        q = model.merge_candidate.through.objects +        context = {'current_url':current_url, +                   'current_page':page, +                   'max_page':q.count()/ITEM_PER_PAGE} +        if page < context["max_page"]: +            context['next_page'] = page + 1 +        if page > 1: +            context['previous_page'] = page - 1 + +        item_nb = page*ITEM_PER_PAGE +        item_nb_1 = item_nb + ITEM_PER_PAGE +        from_key = 'from_' + key +        to_key = 'to_' + key +        queryset = q.all().order_by(from_key + '__name')[item_nb:item_nb_1] +        FormSet.from_key = from_key +        FormSet.to_key = to_key +        if request.method == 'POST': +            context['formset'] = FormSet(request.POST, queryset=queryset) +            if context['formset'].is_valid(): +                context['formset'].merge() +                return redirect(reverse(current_url, kwargs={'page':page})) +        else: +            context['formset'] = FormSet(queryset=queryset) +        return render_to_response('ishtar/merge_'+key+'.html', context, +                              context_instance=RequestContext(request)) + +    return merge + +person_merge = merge_action(models.Person, forms.MergePersonForm, 'person') +organization_merge = merge_action(models.Organization, forms.MergeOrganizationForm, +                            'organization')  class LoginRequiredMixin(object):      @method_decorator(login_required)  | 
