Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend Record Merging to multiple records #3474

Merged
merged 11 commits into from
May 19, 2023
2 changes: 1 addition & 1 deletion specifyweb/frontend/js_src/lib/components/Forms/Save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { Submit } from '../Atoms/Submit';
import { LoadingContext } from '../Core/Contexts';
import type { AnySchema } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import { resourceOn } from '../DataModel/resource';
import type { Tables } from '../DataModel/types';
import { error } from '../Errors/assert';
import { Dialog } from '../Molecules/Dialog';
Expand All @@ -25,6 +24,7 @@ import { userPreferences } from '../Preferences/userPreferences';
import { smoothScroll } from '../QueryBuilder/helpers';
import { FormContext } from './BaseResourceView';
import { FORBID_ADDING, NO_CLONE } from './ResourceView';
import { resourceOn } from '../DataModel/resource';

export const saveFormUnloadProtect = formsText.unsavedFormUnloadProtect();

Expand Down
64 changes: 32 additions & 32 deletions specifyweb/frontend/js_src/lib/components/Merging/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import { commonText } from '../../localization/common';
import { mergingText } from '../../localization/merging';
import { treeText } from '../../localization/tree';
import { ajax } from '../../utils/ajax';
import { hijackBackboneAjax } from '../../utils/ajax/backboneAjax';
import { Http } from '../../utils/ajax/definitions';
import { f } from '../../utils/functools';
import type { RA } from '../../utils/types';
import { filterArray } from '../../utils/types';
import { removeKey, sortFunction } from '../../utils/utils';
import { multiSortFunction, removeKey } from '../../utils/utils';
import { ErrorMessage } from '../Atoms';
import { Button } from '../Atoms/Button';
import { Input, Label } from '../Atoms/Form';
Expand Down Expand Up @@ -208,45 +207,46 @@ function Merging({
* and, presumably the longest auditing history
*/
const resources = Array.from(rawResources).sort(
sortFunction((resource) => resource.get('timestampCreated'))
multiSortFunction(
(resource) => resource.get('specifyUser'),
true,
(resource) => resource.get('timestampCreated')
)
);
const target = resources[0];
target.bulkSet(removeKey(merged.toJSON(), 'version'));

const clones = resources.slice(1);
loading(
hijackBackboneAjax(
[],
async () => target.save(),
undefined,
'dismissible'
).then(async () => {
/*
* Make requests sequentially as they are expected to fail
* (due to business rules). If we do them sequentially, we
* can leave the UI in a state consistent with the back-end
*/
// eslint-disable-next-line functional/no-loop-statement
/*
melton-jason marked this conversation as resolved.
Show resolved Hide resolved
* Make requests sequentially as they are expected to fail
* (due to business rules). If we do them sequentially, we
* can leave the UI in a state consistent with the back-end
*/
// eslint-disable-next-line functional/no-loop-statement
ajax(
`/api/specify/${model.name.toLowerCase()}/replace/${target.id}/`,
{
method: 'POST',
headers: {
Accept: 'text/plain',
},
body: {
old_record_ids: clones.map((clone) => clone.id),
new_record_data: merged.toJSON(),
},
expectedErrors: [Http.NOT_ALLOWED],
errorMode: 'dismissible',
}
).then((response) => {
if (response.status === Http.NOT_ALLOWED) {
setError(response.data);
return;
}
for (const clone of clones) {
const response = await ajax(
`/api/specify/${model.name.toLowerCase()}/replace/${
clone.id
}/${target.id}/`,
{
method: 'POST',
headers: {
Accept: 'text/plain',
},
expectedErrors: [Http.NOT_ALLOWED],
errorMode: 'dismissible',
}
);
if (response.status === Http.NOT_ALLOWED) {
setError(response.data);
return;
}
resourceEvents.trigger('deleted', clone);
}

setError(undefined);
handleClose();
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ test('multiSortFunction', () => {
].sort(
multiSortFunction(
({ type }) => type,
false,
({ priority }) => priority,
true
)
Expand Down
2 changes: 1 addition & 1 deletion specifyweb/frontend/js_src/lib/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const sortFunction =
export const multiSortFunction =
<ORIGINAL_TYPE>(
...payload: readonly (
| boolean
| true
| ((value: ORIGINAL_TYPE) => Date | boolean | number | string)
)[]
): ((left: ORIGINAL_TYPE, right: ORIGINAL_TYPE) => -1 | 0 | 1) =>
Expand Down
142 changes: 123 additions & 19 deletions specifyweb/specify/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Tests for api.py
"""

from datetime import datetime
import json
from unittest import skip

Expand Down Expand Up @@ -576,9 +577,12 @@ def test_replace_agents(self):

# Assert that the api request ran successfully
response = c.post(
f'/api/specify/agent/replace/{agent_1.id}/{agent_2.id}/',
data=[],
content_type='text/plain')
f'/api/specify/agent/replace/{agent_2.id}/',
data=json.dumps({
'old_record_ids': [agent_1.id]
}),
content_type='application/json'
)
self.assertEqual(response.status_code, 204)

# Assert that the collector relationship was updated correctly to the new agent
Expand All @@ -591,9 +595,12 @@ def test_replace_agents(self):

# Assert that a new api request will not find the old agent
response = c.post(
f'/api/specify/agent/replace/{agent_1.id}/{agent_2.id}/',
data=[],
content_type='text/plain')
f'/api/specify/agent/replace/{agent_2.id}/',
data=json.dumps({
'old_record_ids': [agent_1.id]
}),
content_type='application/json'
)
self.assertEqual(response.status_code, 404)

def test_record_recursive_merge(self):
Expand All @@ -616,7 +623,7 @@ def test_record_recursive_merge(self):
insitution_1 = models.Institution.objects.get(name='Test Institution')
reference_work_1 = models.Referencework.objects.create(
id=875,
timestampcreated="2022-11-30 14:36:56.000",
timestampcreated=datetime.strptime("2022-11-30 14:36:56.000", '%Y-%m-%d %H:%M:%S.%f'),
referenceworktype=2,
institution=insitution_1
)
Expand All @@ -627,23 +634,27 @@ def test_record_recursive_merge(self):
ordernumber=7,
agent=agent_1,
referencework=reference_work_1,
timestampcreated="2022-11-30 14:34:51.000",
timestampmodified="2022-11-30 14:33:30.000"
timestampcreated=datetime.strptime("2022-11-30 14:34:51.000", '%Y-%m-%d %H:%M:%S.%f'),
timestampmodified=datetime.strptime("2022-11-30 14:33:30.000", '%Y-%m-%d %H:%M:%S.%f')
)
models.Author.objects.create(
id=2554,
ordernumber=2,
agent=agent_2,
referencework=reference_work_1,
timestampcreated="2022-11-30 14:33:30.000",
timestampmodified="2022-11-30 14:36:56.000"
timestampcreated=datetime.strptime("2022-11-30 14:33:30.000", '%Y-%m-%d %H:%M:%S.%f'),
timestampmodified=datetime.strptime("2022-11-30 14:36:56.000", '%Y-%m-%d %H:%M:%S.%f')
)

# Assert that the api request ran successfully
response = c.post(
f'/api/specify/agent/replace/{agent_1.id}/{agent_2.id}/',
data=[],
content_type='text/plain')
f'/api/specify/agent/replace/{agent_2.id}/',
data=json.dumps({
'old_record_ids': [agent_1.id],
'new_record_data': None
}),
content_type='application/json'
)
self.assertEqual(response.status_code, 204)

# Assert that only one of the Authors remains
Expand Down Expand Up @@ -675,24 +686,117 @@ def test_agent_address_replacement(self):
# Create mock addresses
models.Address.objects.create(
id=1,
timestampcreated="22022-11-30 14:34:51.000",
timestampcreated=datetime.strptime("2022-11-30 14:34:51.000", '%Y-%m-%d %H:%M:%S.%f'),
address="1234 Main St.",
agent=agent_1
)
models.Address.objects.create(
id=2,
timestampcreated="2022-11-30 14:33:30.000",
timestampcreated=datetime.strptime("2022-11-30 14:33:30.000", '%Y-%m-%d %H:%M:%S.%f'),
address="5678 Rainbow Rd.",
agent=agent_2
)

# Assert that the api request ran successfully
response = c.post(
f'/api/specify/agent/replace/{agent_2.id}/{agent_1.id}/',
data=[],
content_type='text/plain')
f'/api/specify/agent/replace/{agent_1.id}/',
data=json.dumps({
'old_record_ids': [agent_2.id],
'new_record_data': None
}),
content_type='application/json'
)
self.assertEqual(response.status_code, 204)

# Assert there is only one address the points to agent_1
self.assertEqual(models.Address.objects.filter(agent_id=7).count(), 1)
self.assertEqual(models.Address.objects.filter(agent_id=6).exists(), False)

def test_agent_address_multiple_replacement(self):
c = Client()
c.force_login(self.specifyuser)

# Create agents and a collector relationship
agent_1 = models.Agent.objects.create(
id=7,
agenttype=0,
firstname="agent",
lastname="007",
specifyuser=None)
agent_2 = models.Agent.objects.create(
id=6,
agenttype=0,
firstname="agent",
lastname="006",
specifyuser=None)
agent_3 = models.Agent.objects.create(
id=5,
agenttype=0,
firstname="agent",
lastname="005",
specifyuser=None)

# Create mock addresses
models.Address.objects.create(
id=1,
timestampcreated=datetime.strptime("2022-11-30 14:34:51.000", '%Y-%m-%d %H:%M:%S.%f'),
address="1234 Main St.",
agent=agent_1
)
models.Address.objects.create(
id=2,
timestampcreated=datetime.strptime("2022-11-30 14:33:30.000", '%Y-%m-%d %H:%M:%S.%f'),
address="5678 Rainbow Rd.",
agent=agent_2
)
models.Address.objects.create(
id=3,
timestampcreated=datetime.strptime("2022-11-30 14:32:30.000", '%Y-%m-%d %H:%M:%S.%f'),
address="2468 Mass St.",
agent=agent_3
)

# Assert that the api request ran successfully
response = c.post(
f'/api/specify/agent/replace/{agent_1.id}/',
data=json.dumps({
'old_record_ids': [
agent_2.id,
agent_3.id
],
'new_record_data': {
'addresses': [
{
'address': '1234 Main St.',
'timestampcreated': '22022-11-30 14:34:51.000',
'agent': agent_1.id
},
{
'address': '5678 Rainbow Rd.',
'timestampcreated': '2022-11-30 14:33:30.000',
'agent': agent_1.id
},
{
'address': '2468 Mass St.',
'timestampcreated': '2022-11-30 14:32:30.000',
'agent': agent_1.id
},
{
'address': '1345 Jayhawk Blvd.',
'timestampcreated': '22022-11-30 14:34:51.000',
'agent': agent_1.id
}
],
'jobtitle': 'shardbearer'
}
}),
content_type='application/json')
self.assertEqual(response.status_code, 204)

# Assert there is only one address the points to agent_1
self.assertEqual(models.Address.objects.filter(agent_id=7).count(), 4)
self.assertEqual(models.Address.objects.filter(agent_id=6).exists(), False)
self.assertEqual(models.Address.objects.filter(agent_id=5).exists(), False)

# Assert that the new_record_data was updated in the db
self.assertEqual(models.Agent.objects.get(id=7).jobtitle, 'shardbearer')
2 changes: 1 addition & 1 deletion specifyweb/specify/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

urlpatterns = [
# replace record
url(r'^specify/(?P<model_name>\w+)/replace/(?P<old_model_id>\d+)/(?P<new_model_id>\d+)/$', views.record_merge),
url(r'^specify/(?P<model_name>\w+)/replace/(?P<new_model_id>\d+)/$', views.record_merge),

# the main business data API
url(r'^specify_schema/openapi.json$', schema.openapi),
Expand Down
Loading