From ca81b4f8110d925812a511ffddd1e73ecfa0aaa4 Mon Sep 17 00:00:00 2001 From: Torrie Fischer Date: Tue, 11 Sep 2018 23:41:28 -0700 Subject: [PATCH 1/4] crm: api_views: implement a metadata class that supports field aliases --- crm/api_views.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crm/api_views.py b/crm/api_views.py index cdf235e8..e98f18db 100644 --- a/crm/api_views.py +++ b/crm/api_views.py @@ -6,6 +6,8 @@ from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly, AllowAny from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.metadata import SimpleMetadata +from rest_framework.serializers import HiddenField from django.template import loader, engines from django.db.models import Q, Count, Subquery, OuterRef from django.contrib.auth import logout @@ -76,10 +78,20 @@ def get_object_or_none(self): # return a 404 response. raise +class AliasableMetadata(SimpleMetadata): + def get_serializer_info(self, serializer): + aliases = getattr(serializer.Meta, 'field_aliases', {}) + ret = super(AliasableMetadata, self).get_serializer_info(serializer) + for field_name, field in serializer.fields.items(): + if not isinstance(field, HiddenField): + ret[field_name]['aliases'] = aliases.get(field_name, []) + return ret + class PersonViewSet(AllowPUTAsCreateMixin, IntrospectiveViewSet): queryset = models.Person.objects.all().order_by('email') serializer_class = serializers.PersonSerializer permission_classes = (IsAuthenticated,) + metadata_class = AliasableMetadata lookup_field = 'email' lookup_value_regex = '[^/]+' From 5799c9df6462c8f0cda7fc14f0274ea171e57afa Mon Sep 17 00:00:00 2001 From: Torrie Fischer Date: Tue, 11 Sep 2018 23:43:20 -0700 Subject: [PATCH 2/4] crm: serializers: add aliases for fields --- crm/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crm/serializers.py b/crm/serializers.py index e2eab87f..0dbcd383 100644 --- a/crm/serializers.py +++ b/crm/serializers.py @@ -105,3 +105,10 @@ class Meta: 'geo': {'read_only': True}, 'phone': {'write_only': True}, } + + field_aliases = { + 'address': ['street address', 'zipcode', 'city', 'mailing address'], + 'name': ['full name'], + 'email': ['e-mail', 'e-mail address', 'email address'], + 'phone': ['phone number'] + } From 4c3292bd688401b1ae213122a560d3fc58efa92f Mon Sep 17 00:00:00 2001 From: Torrie Fischer Date: Tue, 11 Sep 2018 23:43:42 -0700 Subject: [PATCH 3/4] components: importdialog: load available headers from server --- assets/js/components/ImportDialog.js | 61 ++++++++++++++++------------ 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/assets/js/components/ImportDialog.js b/assets/js/components/ImportDialog.js index 7402ac38..d6471f9f 100644 --- a/assets/js/components/ImportDialog.js +++ b/assets/js/components/ImportDialog.js @@ -22,18 +22,20 @@ import SheetClip from 'sheetclip' const sheetclip = new SheetClip() -const columnNames = { - name: ['name', 'full name'], - email: ['email', 'e-mail', 'email address'], - phone: ['phone', 'phone number'], - address: ['address', 'street address'], - state: ['state'], +function fetchValidFields() { + return fetch('/api/people/', {method: 'OPTIONS'}) + .then(resp => resp.json()) + .then(resp => Object.entries(resp.actions.POST) + .filter(([_fieldName, fieldProps]) => fieldProps.read_only == false) + .map(([fieldName, fieldProps]) => [fieldName, [fieldName, ...fieldProps.aliases]]) + .reduce((prev, next) => ({...prev, [next[0]]: next[1]}), {}) + ) } -function guessHeaderMap(columns) { +function guessHeaderMap(columns, knownColumns) { return _.fromPairs(_.map(columns, c => { const normalized = c.toLowerCase() - const commonName = _.findKey(columnNames, alternatives => alternatives.indexOf(normalized) >= 0) + const commonName = _.findKey(knownColumns, alternatives => alternatives.indexOf(normalized) >= 0) if (commonName) { return [c, commonName] } else { @@ -48,7 +50,7 @@ function mapRows(rows, headerMap, tags) { return _.map(compactRows, row => { - const mappedObject = _.mapKeys(row, (_v, k) => _.get(headerMap, k, k)) + const mappedObject = _.pickBy(_.mapKeys(row, (_v, k) => _.get(headerMap, k, k)), v => v != undefined) return { ...mappedObject, @@ -59,17 +61,17 @@ function mapRows(rows, headerMap, tags) { } -function parsePaste({input}) { +function parsePaste({input}, knownHeaders) { const sheet = sheetclip.parse(input) const headers = _.head(sheet) - const rows = _.tail(sheet) + const rows = _.tail(sheet).map(row => row.map(column => column == '' ? undefined : column)) const tagKeys = _.filter(headers, key => key.startsWith('tag:')) const tags = _.map(tagKeys, key => { return key.substr(4) }) - const headerMap = guessHeaderMap(_.difference(headers, tagKeys)) + const headerMap = guessHeaderMap(_.difference(headers, tagKeys), knownHeaders) const jsonRows = _.map(rows, row => _.zipObject(headers, row)) @@ -86,12 +88,18 @@ class ImportDialog extends React.Component { this.state = { stage: 0, parsed: {headerMap: {}}, - mapped: [] + mapped: [], + validFields: {} } this.next = this.next.bind(this) this.previous = this.previous.bind(this) } + componentDidMount() { + fetchValidFields() + .then(fields => this.setState({validFields: fields})) + } + reset() { this.setState({stage: 0}) } @@ -99,7 +107,7 @@ class ImportDialog extends React.Component { next() { switch(this.state.stage) { case 0: - this.setState({parsed: parsePaste(this.pasteForm.formContext.formState.values), stage: 1}) + this.setState({parsed: parsePaste(this.pasteForm.formContext.formState.values, this.state.validFields), stage: 1}) break case 1: this.setState({stage: 2, mapped: mapRows(this.state.parsed.rows, this.state.parsed.headerMap, this.state.parsed.tags)}) @@ -134,11 +142,13 @@ class ImportDialog extends React.Component { What headers can I use?

All headers are case insensitive. The following lists acceptable values:

-
    -
  • Name: Can be any variant of "Name", "Full Name"
  • -
  • Email: Can be any variant of "Email", "e-mail" "Email Address"
  • -
  • Tags: To tag people, include a column that starts with "tag_". eg "tag_Came to meeting" tags each person in the spreadsheet with "Came to meeting"
  • -
+ + + {Object.entries(this.state.validFields).map(([k, v]) => ( + + ))} + +
ColumnAliases
{k}{v.join(', ')}
TagsTo tag people, include a column that starts with "tag:". eg "tag:Came to meeting" tags each person in the spreadsheet with "Came to meeting"
) @@ -157,7 +167,7 @@ class ImportDialog extends React.Component { value={dst} onChange={evt => this.setState({parsed: {...this.state.parsed, headerMap: {...this.state.parsed.headerMap, [src]: evt.target.value}}})}> None - {_.map(_.keys(columnNames), name => {name})} + {_.map(_.keys(this.state.validFields), name => {name})} {_.join(samples[src], ', ')} @@ -187,13 +197,14 @@ class ImportDialog extends React.Component {

The following data will be imported:

- + + {Object.keys(this.state.validFields).map(header => )} + - {_.map(this.state.mapped, (row, idx) => ( - - - + {this.state.mapped.map((row, idx) => ( + + {Object.keys(this.state.validFields).map(header => )} ))} From f2aced253b5084f3cdb92936a57b43e9d67a84a7 Mon Sep 17 00:00:00 2001 From: Torrie Fischer Date: Wed, 12 Sep 2018 01:26:28 -0700 Subject: [PATCH 4/4] components: importdialog: mock up fetch for test --- assets/js/components/ImportDialog.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/js/components/ImportDialog.test.js b/assets/js/components/ImportDialog.test.js index 906adc20..f9852040 100644 --- a/assets/js/components/ImportDialog.test.js +++ b/assets/js/components/ImportDialog.test.js @@ -1,8 +1,10 @@ import React from 'react' import { shallow } from 'enzyme' import ImportDialog from './ImportDialog' +import fetchMock from 'fetch-mock' it('should render defaults safely', () => { + fetchMock.mock('/api/people/', {}) shallow() })
NameE-mailPhone numberStreet addressState
{header}
{row.name} {_.map(row.tags, t => )}{row.email}{row.phone}{row.address}{row.state}
{header == 'tags' ? row[header].map(tag => ) : row[header]}