diff options
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) |