├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── docker-compose.yml ├── docs ├── do_import.png ├── match_columns.png └── start_import.png ├── manage.py ├── setup.py ├── simple_import ├── __init__.py ├── admin.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── odsreader.py ├── static │ ├── test_import.csv │ ├── test_import.ods │ └── test_import.xls ├── templates │ └── simple_import │ │ ├── base.html │ │ ├── do_import.html │ │ ├── import.html │ │ ├── match_columns.html │ │ └── match_relations.html ├── tests.py ├── urls.py ├── utils.py └── views.py ├── simple_import_demo ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | settings_local.py 3 | coverage.xml 4 | django_setuptest-*.egg/ 5 | django_simple_import.egg-info/ 6 | *.egg/ 7 | pep8.txt 8 | import_file/ 9 | .idea 10 | error_file 11 | simple_import/static 12 | build 13 | dist 14 | db.sqlite3 15 | .tox 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:3.7-slim 2 | 3 | test: 4 | script: 5 | - pip install tox 6 | - tox 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | ENV PYTHONUNBUFFERED 1 3 | 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | COPY setup.py /usr/src/app/ 8 | RUN pip install -e .[ods,xlsx,xls] 9 | 10 | COPY . /usr/src/app 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Burke Software and Consulting LLC 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. The name of the author may not be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 17 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 18 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 19 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 20 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 21 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 22 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include simple_import/templates * 2 | recursive-exclude simple_import/static * 3 | recursive-exclude simple_import_demo * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-simple-import 2 | ==================== 3 | 4 | An easy to use import tool for Django. django-simple-import aims to keep track of logs and user preferences in the database. 5 | 6 | Project is now stable and in maintenance only mode. If you'd like to add features please fork or take over ownership. 7 | 8 |  9 |  10 |  11 | 12 | # Changelog 13 | 14 | ## 2.1 15 | 16 | - Add support for Django 2.2 and 3.0 17 | - Move to gitlab and gitlab CI for freedom and consistency with other Burke Software maintained projects 18 | 19 | ## 2.0 20 | 21 | 2.0 adds support for Django 1.9 and 1.10. Support for 1.8 and under is dropped. Support for Python 2 is dropped. 22 | Removed support for django-custom-field 23 | Use 1.x for older environments. 24 | 25 | ## 1.17 26 | 27 | The most apparent changes are 1.7 compatibility and migration to Django's 28 | atomic transactions. Please report any issues. I test against mysql innodb, postgres, and sqlite. 29 | 30 | ## Features 31 | - Supports csv, xls, xlsx, and ods import file 32 | - Save user matches of column headers to fields 33 | - Guess matches 34 | - Create, update, or both 35 | - Allow programmers to define special import methods for custom handling 36 | - Set related objects by any unique field 37 | - Simulate imports before commiting to database 38 | - Undo (create only) imports 39 | - Security checks if user has correct permissions (partially implemented) 40 | 41 | ## Install 42 | 43 | 1. `pip install django-simple-import[ods,xls,xlsx]` for full install or specify which formats you need to support. CSV is supported out of box. 44 | 2. Add 'simple_import' to INSTALLED APPS 45 | 3. Add simple_import to urls.py like 46 | `url(r'^simple_import/', include('simple_import.urls')),` 47 | 4. migrate 48 | 49 | ## Optional Settings 50 | Define allowed methods to be "imported". Example: 51 | 52 | class Foo(models.Model): 53 | ... 54 | def set_bar(self, value): 55 | self.bar = value 56 | simple_import_methods = ('set_bar',) 57 | 58 | ### settings.py 59 | SIMPLE_IMPORT_LAZY_CHOICES: Default True. If enabled simple_import will look up choices when importing. Example: 60 | 61 | choices = ['M', 'Monday'] 62 | 63 | If the spreadsheet value is "Monday" it will set the database value to "M." 64 | 65 | SIMPLE_IMPORT_LAZY_CHOICES_STRIP: Default False. If enabled, simple_import will trip leading/trailing whitespace 66 | from the cell's value before checking for a match. Only relevant when SIMPLE_IMPORT_LAZY_CHOICES is also enabled. 67 | 68 | If you need any help, we do consulting and custom development. Just email us at david at burkesoftware.com. 69 | 70 | 71 | ## Usage 72 | 73 | Go to `/simple_import/start_import/` or use the admin interface. 74 | 75 | The screenshots have a django-grappelli like theme. The base templates have no style and are very basic. 76 | See an example of customization [here](https://github.com/burke-software/django-sis/tree/master/templates/simple_import). 77 | It is often sufficient to simply override `simple_import/templates/base.html`. 78 | 79 | There is also a log of import records. Check out `/admin/simple_import/`. 80 | 81 | ## Odd Things 82 | 83 | Added a special set password property on auth.User to set password. This sets the password instead of just 84 | saving a hash. 85 | 86 | User has some required fields that...aren't really required. Hardcoded to let them pass. 87 | 88 | ### Security 89 | 90 | I'm working on the assumption staff users are trusted. Only users with change permission 91 | to a field will see it as an option. I have not spent much time looking for ways users could 92 | manipulate URLs to run unauthorized imports. Feel free to contribute changes. 93 | All import views do require admin "is staff" permission. 94 | 95 | ## Testing 96 | 97 | If you have [docker-compose](https://docs.docker.com/compose/) and [Docker](https://www.docker.com/) 98 | installed, then just running `docker-compose run --rm app ./manage.py test` will do everything you need to test 99 | the packages. 100 | 101 | Otherwise look at the `.travis.yml` file for test dependencies. 102 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | app: 2 | build: . 3 | volumes: 4 | - .:/usr/src/app 5 | command: ./manage.py runserver 0.0.0.0:8000 6 | ports: 7 | - "8000:8000" -------------------------------------------------------------------------------- /docs/do_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/docs/do_import.png -------------------------------------------------------------------------------- /docs/match_columns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/docs/match_columns.png -------------------------------------------------------------------------------- /docs/start_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/docs/start_import.png -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "simple_import_demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name = "django-simple-import", 5 | version = "2.1.0", 6 | author = "David Burke", 7 | author_email = "david@burkesoftware.com", 8 | description = ("A Django import tool easy enough your users could use it"), 9 | license = "BSD", 10 | keywords = "django import", 11 | url = "https://gitlab.com/burke-software/django-simple-import", 12 | packages=find_packages(exclude=("simple_import/static","simple_import_demo")), 13 | include_package_data=True, 14 | classifiers=[ 15 | "Development Status :: 5 - Production/Stable", 16 | 'Environment :: Web Environment', 17 | 'Framework :: Django', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Intended Audience :: Developers', 21 | 'Intended Audience :: System Administrators', 22 | "License :: OSI Approved :: BSD License", 23 | ], 24 | install_requires=['django'], 25 | extras_require = { 26 | 'xlsx': ["openpyxl"], 27 | 'ods': ["odfpy"], 28 | 'xls': ["xlrd"], 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /simple_import/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/simple_import/__init__.py -------------------------------------------------------------------------------- /simple_import/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from simple_import.models import ImportLog, ColumnMatch 3 | 4 | class ImportLogAdmin(admin.ModelAdmin): 5 | list_display = ('name', 'user', 'date',) 6 | def has_add_permission(self, request): 7 | return False 8 | admin.site.register(ImportLog, ImportLogAdmin) 9 | -------------------------------------------------------------------------------- /simple_import/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.contenttypes.models import ContentType 3 | 4 | from simple_import.models import ImportLog, ColumnMatch, RelationalMatch 5 | 6 | 7 | class ImportForm(forms.ModelForm): 8 | class Meta: 9 | model = ImportLog 10 | fields = ('name', 'import_file', 'import_type') 11 | model = forms.ModelChoiceField(ContentType.objects.all()) 12 | 13 | 14 | class MatchForm(forms.ModelForm): 15 | class Meta: 16 | model = ColumnMatch 17 | exclude = ['header_position'] 18 | 19 | 20 | class MatchRelationForm(forms.ModelForm): 21 | class Meta: 22 | model = RelationalMatch 23 | widgets = { 24 | 'related_field_name': forms.Select(choices=(('', '---------'),)) 25 | } 26 | fields = "__all__" 27 | -------------------------------------------------------------------------------- /simple_import/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('contenttypes', '0001_initial'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='ColumnMatch', 18 | fields=[ 19 | ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), 20 | ('column_name', models.CharField(max_length=200)), 21 | ('field_name', models.CharField(blank=True, max_length=255)), 22 | ('default_value', models.CharField(blank=True, max_length=2000)), 23 | ('null_on_empty', models.BooleanField(default=False, help_text='If cell is blank, clear out the field setting it to blank.')), 24 | ('header_position', models.IntegerField(help_text='Annoying way to order the columns to match the header rows')), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='ImportedObject', 29 | fields=[ 30 | ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), 31 | ('object_id', models.IntegerField()), 32 | ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='ImportLog', 37 | fields=[ 38 | ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), 39 | ('name', models.CharField(max_length=255)), 40 | ('date', models.DateTimeField(auto_now_add=True, verbose_name='Date Created')), 41 | ('import_file', models.FileField(upload_to='import_file')), 42 | ('error_file', models.FileField(blank=True, upload_to='error_file')), 43 | ('import_type', models.CharField(choices=[('N', 'Create New Records'), ('U', 'Create and Update Records'), ('O', 'Only Update Records')], max_length=1)), 44 | ('update_key', models.CharField(blank=True, max_length=200)), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='ImportSetting', 49 | fields=[ 50 | ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), 51 | ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), 52 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 53 | ], 54 | ), 55 | migrations.CreateModel( 56 | name='RelationalMatch', 57 | fields=[ 58 | ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), 59 | ('field_name', models.CharField(max_length=255)), 60 | ('related_field_name', models.CharField(blank=True, max_length=255)), 61 | ('import_log', models.ForeignKey(to='simple_import.ImportLog', on_delete=models.CASCADE)), 62 | ], 63 | ), 64 | migrations.AddField( 65 | model_name='importlog', 66 | name='import_setting', 67 | field=models.ForeignKey(to='simple_import.ImportSetting', editable=False, on_delete=models.CASCADE), 68 | ), 69 | migrations.AddField( 70 | model_name='importlog', 71 | name='user', 72 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='simple_import_log', editable=False, on_delete=models.CASCADE), 73 | ), 74 | migrations.AddField( 75 | model_name='importedobject', 76 | name='import_log', 77 | field=models.ForeignKey(to='simple_import.ImportLog', on_delete=models.CASCADE), 78 | ), 79 | migrations.AddField( 80 | model_name='columnmatch', 81 | name='import_setting', 82 | field=models.ForeignKey(to='simple_import.ImportSetting', on_delete=models.CASCADE), 83 | ), 84 | migrations.AlterUniqueTogether( 85 | name='importsetting', 86 | unique_together={('user', 'content_type')}, 87 | ), 88 | migrations.AlterUniqueTogether( 89 | name='columnmatch', 90 | unique_together={('column_name', 'import_setting')}, 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /simple_import/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/simple_import/migrations/__init__.py -------------------------------------------------------------------------------- /simple_import/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.conf import settings 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.core.exceptions import ValidationError 5 | from django.db import models, transaction 6 | from django.utils.encoding import smart_text 7 | from .utils import get_all_field_names 8 | import csv 9 | import datetime 10 | from io import StringIO 11 | AUTH_USER_MODEL = settings.AUTH_USER_MODEL 12 | 13 | 14 | class ImportSetting(models.Model): 15 | """ Save some settings per user per content type """ 16 | user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE) 17 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 18 | 19 | class Meta(): 20 | unique_together = ('user', 'content_type',) 21 | 22 | 23 | class ColumnMatch(models.Model): 24 | """ Match column names from the user uploaded file to the database """ 25 | column_name = models.CharField(max_length=200) 26 | field_name = models.CharField(max_length=255, blank=True) 27 | import_setting = models.ForeignKey(ImportSetting, on_delete=models.CASCADE) 28 | default_value = models.CharField(max_length=2000, blank=True) 29 | null_on_empty = models.BooleanField( 30 | default=False, 31 | help_text="If cell is blank, clear out the field setting it to blank.") 32 | header_position = models.IntegerField( 33 | help_text="Annoying way to order the columns to match the header rows") 34 | 35 | class Meta: 36 | unique_together = ('column_name', 'import_setting') 37 | 38 | def __str__(self): 39 | return str('{0} {1}'.format(self.column_name, self.field_name)) 40 | 41 | def guess_field(self): 42 | """ Guess the match based on field names 43 | First look for an exact field name match 44 | then search defined alternative names 45 | then normalize the field name and check for match 46 | """ 47 | model = self.import_setting.content_type.model_class() 48 | field_names = get_all_field_names(model) 49 | if self.column_name in field_names: 50 | self.field_name = self.column_name 51 | return 52 | #TODO user defined alt names 53 | normalized_field_name = self.column_name.lower().replace(' ', '_') 54 | if normalized_field_name in field_names: 55 | self.field_name = normalized_field_name 56 | return 57 | # Try verbose name 58 | for field_name in field_names: 59 | field = model._meta.get_field(field_name) 60 | if hasattr(field, 'verbose_name'): 61 | if (field.verbose_name.lower().replace(' ', '_') == normalized_field_name): 62 | self.field_name = field_name 63 | 64 | 65 | class ImportLog(models.Model): 66 | """ A log of all import attempts """ 67 | name = models.CharField(max_length=255) 68 | user = models.ForeignKey( 69 | AUTH_USER_MODEL, editable=False, related_name="simple_import_log", on_delete=models.CASCADE) 70 | date = models.DateTimeField(auto_now_add=True, verbose_name="Date Created") 71 | import_file = models.FileField(upload_to="import_file") 72 | error_file = models.FileField(upload_to="error_file", blank=True) 73 | import_setting = models.ForeignKey(ImportSetting, editable=False, on_delete=models.CASCADE) 74 | import_type_choices = ( 75 | ("N", "Create New Records"), 76 | ("U", "Create and Update Records"), 77 | ("O", "Only Update Records"), 78 | ) 79 | import_type = models.CharField(max_length=1, choices=import_type_choices) 80 | update_key = models.CharField(max_length=200, blank=True) 81 | 82 | def __str__(self): 83 | return str(self.name) 84 | 85 | def clean(self): 86 | filename = str(self.import_file).lower() 87 | if not filename[-3:] in ('xls', 'ods', 'csv', 'lsx'): 88 | raise ValidationError( 89 | 'Invalid file type. Must be xls, xlsx, ods, or csv.') 90 | 91 | @transaction.atomic 92 | def undo(self): 93 | if self.import_type != "N": 94 | raise Exception("Cannot undo this type of import!") 95 | for obj in self.importedobject_set.all(): 96 | if obj.content_object: 97 | obj.content_object.delete() 98 | obj.delete() 99 | 100 | @staticmethod 101 | def is_empty(value): 102 | """ Check `value` for emptiness by first comparing with None and then 103 | by coercing to string, trimming, and testing for zero length """ 104 | return value is None or not len(smart_text(value).strip()) 105 | 106 | def get_matches(self): 107 | """ Get each matching header row to database match 108 | Returns a ColumnMatch queryset""" 109 | header_row = self.get_import_file_as_list(only_header=True) 110 | match_ids = [] 111 | 112 | for i, cell in enumerate(header_row): 113 | # Sometimes we get blank headers, ignore them. 114 | if self.is_empty(cell): 115 | continue 116 | 117 | try: 118 | match = ColumnMatch.objects.get( 119 | import_setting=self.import_setting, 120 | column_name=cell, 121 | ) 122 | except ColumnMatch.DoesNotExist: 123 | match = ColumnMatch( 124 | import_setting=self.import_setting, 125 | column_name=cell, 126 | ) 127 | match.guess_field() 128 | 129 | match.header_position = i 130 | match.save() 131 | 132 | match_ids += [match.id] 133 | 134 | return ColumnMatch.objects.filter( 135 | pk__in=match_ids).order_by('header_position') 136 | 137 | def get_import_file_as_list(self, only_header=False): 138 | file_ext = str(self.import_file).lower()[-3:] 139 | data = [] 140 | 141 | self.import_file.seek(0) 142 | 143 | if file_ext == "xls": 144 | import xlrd 145 | 146 | wb = xlrd.open_workbook(file_contents=self.import_file.read()) 147 | sh1 = wb.sheet_by_index(0) 148 | for rownum in range(sh1.nrows): 149 | row_values = [] 150 | for cell in sh1.row(rownum): 151 | # xlrd is too dumb to just check for dates. So we have to ourselves 152 | # 3 is date 153 | # http://www.lexicon.net/sjmachin/xlrd.html#xlrd.Cell-class 154 | if cell.ctype == 3: 155 | row_values += [datetime.datetime(*xlrd.xldate_as_tuple(cell.value, wb.datemode))] 156 | else: 157 | row_values += [cell.value] 158 | data += [row_values] 159 | if only_header: 160 | break 161 | elif file_ext == "csv": 162 | reader = csv.reader(StringIO(self.import_file.read().decode())) 163 | for row in reader: 164 | data += [row] 165 | if only_header: 166 | break 167 | elif file_ext == "lsx": 168 | from openpyxl.reader.excel import load_workbook 169 | # load_workbook actually accepts a file-like object for the filename param 170 | wb = load_workbook(filename=self.import_file, read_only=True) 171 | sheet = wb.get_active_sheet() 172 | for row in sheet.iter_rows(): 173 | data_row = [] 174 | for cell in row: 175 | data_row += [cell.value] 176 | data += [data_row] 177 | if only_header: 178 | break 179 | elif file_ext == "ods": 180 | from .odsreader import ODSReader 181 | doc = ODSReader(self.import_file) 182 | table = list(doc.SHEETS.items())[0] 183 | 184 | # Remove blank columns that ods files seems to have 185 | blank_columns = [] 186 | for i, header_cell in enumerate(table[1][0]): 187 | if self.is_empty(header_cell): 188 | blank_columns += [i] 189 | # just an overly complicated way to remove these 190 | # indexes from a list 191 | for offset, blank_column in enumerate(blank_columns): 192 | for row in table[1]: 193 | del row[blank_column - offset] 194 | 195 | if only_header: 196 | data += [table[1][0]] 197 | else: 198 | data += table[1] 199 | # Remove blank columns. We use the header row as a unique index. Can't handle blanks. 200 | columns_to_del = [] 201 | for i, header_cell in enumerate(data[0]): 202 | if self.is_empty(header_cell): 203 | columns_to_del += [i] 204 | num_deleted = 0 205 | for column_to_del in columns_to_del: 206 | for row in data: 207 | del row[column_to_del - num_deleted] 208 | num_deleted += 1 209 | if only_header: 210 | return data[0] 211 | return data 212 | 213 | 214 | class RelationalMatch(models.Model): 215 | """Store which unique field is being use to match. 216 | This can be used only to set a FK or one M2M relation 217 | on the import root model. It does not add them. 218 | With Multple rows set to the same field, you could set more 219 | than one per row. 220 | EX Lets say a student has an ID and username and both 221 | are marked as unique in Django orm. The user could reference 222 | that student by either.""" 223 | import_log = models.ForeignKey(ImportLog, on_delete=models.CASCADE) 224 | field_name = models.CharField(max_length=255) # Ex student_number_set 225 | related_field_name = models.CharField(max_length=255, blank=True) # Ex username 226 | 227 | 228 | class ImportedObject(models.Model): 229 | import_log = models.ForeignKey(ImportLog, on_delete=models.CASCADE) 230 | object_id = models.IntegerField() 231 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 232 | content_object = GenericForeignKey('content_type', 'object_id') 233 | -------------------------------------------------------------------------------- /simple_import/odsreader.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011 Marco Conti 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Thanks to grt for the fixes 16 | 17 | import odf.opendocument 18 | from odf.table import * 19 | from odf.text import P 20 | 21 | class ODSReader: 22 | 23 | # loads the file 24 | def __init__(self, file): 25 | self.doc = odf.opendocument.load(file) 26 | self.SHEETS = {} 27 | for sheet in self.doc.spreadsheet.getElementsByType(Table): 28 | self.readSheet(sheet) 29 | 30 | 31 | # reads a sheet in the sheet dictionary, storing each sheet as an array (rows) of arrays (columns) 32 | def readSheet(self, sheet): 33 | name = sheet.getAttribute("name") 34 | rows = sheet.getElementsByType(TableRow) 35 | arrRows = [] 36 | 37 | # for each row 38 | for row in rows: 39 | row_comment = "" 40 | arrCells = [] 41 | cells = row.getElementsByType(TableCell) 42 | 43 | # for each cell 44 | for cell in cells: 45 | # repeated value? 46 | repeat = cell.getAttribute("numbercolumnsrepeated") 47 | if(not repeat): 48 | repeat = 1 49 | 50 | ps = cell.getElementsByType(P) 51 | textContent = "" 52 | 53 | # for each text node 54 | for p in ps: 55 | for n in p.childNodes: 56 | if (n.nodeType == 3): 57 | textContent = textContent + str(n.data) 58 | 59 | if(textContent or textContent == ""): 60 | if(textContent == "" or textContent[0] != "#"): # ignore comments cells 61 | for rr in range(int(repeat)): # repeated? 62 | arrCells.append(textContent) 63 | 64 | else: 65 | row_comment = row_comment + textContent + " "; 66 | 67 | # if row contained something 68 | if(len(arrCells)): 69 | arrRows.append(arrCells) 70 | 71 | #else: 72 | # print "Empty or commented row (", row_comment, ")" 73 | 74 | self.SHEETS[name] = arrRows 75 | 76 | # returns a sheet as an array (rows) of arrays (columns) 77 | def getSheet(self, name): 78 | return self.SHEETS[name] -------------------------------------------------------------------------------- /simple_import/static/test_import.csv: -------------------------------------------------------------------------------- 1 | name,UseR,nothing,import file,import_setting,importtype 2 | Test Import,1,,/tmp/foo.xls,1,N 3 | Something that won't import,1,????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????,hello,1,ERROR 4 | -------------------------------------------------------------------------------- /simple_import/static/test_import.ods: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/simple_import/static/test_import.ods -------------------------------------------------------------------------------- /simple_import/static/test_import.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burke-software/django-simple-import/0b3d33c3a6949de22cf0141828bcbd44d0e2d9cb/simple_import/static/test_import.xls -------------------------------------------------------------------------------- /simple_import/templates/simple_import/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |10 | Import was undone. This is now a simulation, you can run the import again. 11 |
12 | {% endif %} 13 | 14 | {% if create_count %} 15 | Created: {{ create_count }}30 | It's possible to undo Create only reports. Click here to undo. 31 | If you imported properties that created other records, we can not guarentee those records will be removed. 32 |
33 | {% endif %} 34 | {% else %} 35 |36 | This was only a simulation. Click here to run the import. 37 |
38 | {% endif %} 39 | {% endblock %} -------------------------------------------------------------------------------- /simple_import/templates/simple_import/import.html: -------------------------------------------------------------------------------- 1 | {% extends "simple_import/base.html" %} 2 | 3 | {% block simple_import_page_title %}Import{% endblock %} 4 | 5 | {% block simple_import_title %}Import{% endblock %} 6 | 7 | {% block simple_import_form %} 8 | 14 | {% endblock %} -------------------------------------------------------------------------------- /simple_import/templates/simple_import/match_columns.html: -------------------------------------------------------------------------------- 1 | {% extends "simple_import/base.html" %} 2 | 3 | {% block simple_import_page_title %}Match Colums{% endblock %} 4 | 5 | {% block simple_import_title %}Match Columns{% endblock %} 6 | 7 | {% block simple_import_form %} 8 | 73 | {% endblock %} -------------------------------------------------------------------------------- /simple_import/templates/simple_import/match_relations.html: -------------------------------------------------------------------------------- 1 | {% extends "simple_import/base.html" %} 2 | 3 | {% block simple_import_page_title %}Match Relations and Prepare to Run Import{% endblock %} 4 | 5 | {% block simple_import_title %}Match Relations and Prepare to Run Import{% endblock %} 6 | 7 | {% block simple_import_form %} 8 | 48 | {% endblock %} -------------------------------------------------------------------------------- /simple_import/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.urls import reverse 5 | from django.test import TestCase 6 | from .models import * 7 | from django.core.files import File 8 | from django.contrib.auth import get_user_model 9 | User = get_user_model() 10 | 11 | 12 | class SimpleTest(TestCase): 13 | def setUp(self): 14 | user = User.objects.create_user( 15 | 'temporary', 'temporary@gmail.com', 'temporary') 16 | user.is_staff = True 17 | user.is_superuser = True 18 | user.save() 19 | self.user = user 20 | self.client.login(username='temporary', password='temporary') 21 | self.absolute_path = os.path.join( 22 | os.path.dirname(__file__), 'static', 'test_import.xls') 23 | self.import_setting = ImportSetting.objects.create( 24 | user=user, 25 | content_type=ContentType.objects.get_for_model(ImportLog) 26 | ) 27 | with open(self.absolute_path, 'rb') as fp: 28 | self.import_log = ImportLog.objects.create( 29 | name='test', 30 | user=user, 31 | import_file=File(fp), 32 | import_setting=self.import_setting, 33 | import_type='N', 34 | ) 35 | 36 | def test_csv(self): 37 | path = os.path.join( 38 | os.path.dirname(__file__), 'static', 'test_import.csv') 39 | with open(path, 'rb') as fp: 40 | import_log = ImportLog.objects.create( 41 | name="test", 42 | user=self.user, 43 | import_file=File(fp), 44 | import_setting=self.import_setting, 45 | import_type='N', 46 | ) 47 | file_data = import_log.get_import_file_as_list(only_header=True) 48 | self.assertIn('name', file_data) 49 | 50 | def test_ods(self): 51 | path = os.path.join( 52 | os.path.dirname(__file__), 'static', 'test_import.ods') 53 | with open(path, 'rb') as fp: 54 | import_log = ImportLog.objects.create( 55 | name="test", 56 | user=self.user, 57 | import_file=File(fp), 58 | import_setting=self.import_setting, 59 | import_type='N', 60 | ) 61 | file_data = import_log.get_import_file_as_list(only_header=True) 62 | self.assertIn('name', file_data) 63 | 64 | def test_import(self): 65 | """ Make sure we can upload the file and match columns """ 66 | import_log_ct_id = ContentType.objects.get_for_model(ImportLog).id 67 | 68 | self.assertEqual(ImportLog.objects.count(), 1) 69 | 70 | with open(self.absolute_path, 'rb') as fp: 71 | response = self.client.post(reverse('simple_import-start_import'), { 72 | 'name': 'This is a test', 73 | 'import_file': fp, 74 | 'import_type': "N", 75 | 'model': import_log_ct_id}, follow=True) 76 | 77 | self.assertEqual(ImportLog.objects.count(), 2) 78 | 79 | self.assertRedirects(response, reverse('simple_import-match_columns', kwargs={'import_log_id': ImportLog.objects.all()[1].id})) 80 | self.assertContains(response, '