summaryrefslogtreecommitdiff
path: root/ishtar_common
diff options
context:
space:
mode:
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
commit1cc8d6156a79d8eff8d9f6ee82797df6e464408d (patch)
treefc3cb300358ed90b4d08dac9a274bcc124dcec01 /ishtar_common
parent1094b07f381b658f8325c2723afa2e26b8909ebb (diff)
downloadIshtar-1cc8d6156a79d8eff8d9f6ee82797df6e464408d.tar.bz2
Ishtar-1cc8d6156a79d8eff8d9f6ee82797df6e464408d.zip
Merge action implementation (person and organization)
Diffstat (limited to 'ishtar_common')
-rw-r--r--ishtar_common/forms_common.py117
-rw-r--r--ishtar_common/ishtar_menu.py6
-rw-r--r--ishtar_common/management/commands/generate_merge_candidates.py40
-rw-r--r--ishtar_common/migrations/0013_auto__add_field_organization_merge_key__add_field_historicalorganizati.py271
-rw-r--r--ishtar_common/model_merging.py128
-rw-r--r--ishtar_common/models.py74
-rw-r--r--ishtar_common/static/media/style.css19
-rw-r--r--ishtar_common/templates/ishtar/merge.html34
-rw-r--r--ishtar_common/templates/ishtar/merge_organization.html23
-rw-r--r--ishtar_common/templates/ishtar/merge_person.html22
-rw-r--r--ishtar_common/tests.py57
-rw-r--r--ishtar_common/urls.py6
-rw-r--r--ishtar_common/views.py46
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 %}'>&Larr;</a><a href='{% url current_url previous_page %}'>&larr;</a>{% else %}&nbsp;{% endif %}
+ {% trans "Page " %}{{current_page}}/{{max_page}}
+{% if next_page %}<a href='{% url current_url next_page %}'>&rarr;</a><a href='{% url current_url max_page %}'>&Rarr;</a>{% else %}&nbsp;{% 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)