Skip to content

Commit 42b488f

Browse files
committed
feat(dwc): add clone endpoint, list APIs, and Export Packages tool shell
Adds the first set of mapping/dataset CRUD endpoints and the Export Packages tool entry point: - list_mappings — returns all schema mappings as JSON - list_export_datasets — returns all export datasets as JSON - clone_mapping — deep-copies a SchemaMapping (new SpQuery, all SpQueryFields, new SchemaMapping pointing to the new query) - urls.py: register the three new endpoints - OverlayRoutes: register Export Packages route - ExportPackages/index.tsx: list view shell for Export Packages tool Field references adapted to Caroline's schemamapping schema (mapping_type, is_default), and clone_mapping sets specifyuser/createdbyagent (required by schema). Restored test_clone_mapping which depends on this layer's endpoint. Fixes #7712 (cloning of schema mapping queries), #7723 (Export Packages tool).
1 parent 7b784aa commit 42b488f

5 files changed

Lines changed: 259 additions & 0 deletions

File tree

specifyweb/backend/export/tests/test_models.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,70 @@ def test_export_dataset_extension(self):
172172
exportdataset=ds, schemamapping=ext_mapping, sortorder=2,
173173
)
174174

175+
def test_clone_mapping(self):
176+
"""Clone endpoint creates new SpQuery, SpQueryFields, and SchemaMapping."""
177+
original_mapping = self._make_mapping('Original')
178+
original_query = original_mapping.query
179+
180+
# Add query fields to the original query
181+
Spqueryfield.objects.create(
182+
query=original_query,
183+
fieldname='catalogNumber',
184+
operstart=0,
185+
sorttype=0,
186+
position=0,
187+
startvalue='',
188+
stringid='1.collectionobject.catalogNumber',
189+
tablelist='1',
190+
term='http://rs.tdwg.org/dwc/terms/catalogNumber',
191+
)
192+
Spqueryfield.objects.create(
193+
query=original_query,
194+
fieldname='locality',
195+
operstart=0,
196+
sorttype=0,
197+
position=1,
198+
startvalue='',
199+
stringid='1.collectionobject.locality',
200+
tablelist='1',
201+
term='http://rs.tdwg.org/dwc/terms/locality',
202+
isstatic=True,
203+
staticvalue='Some Place',
204+
)
205+
206+
from django.test import RequestFactory
207+
from specifyweb.backend.export.views import clone_mapping
208+
209+
factory = RequestFactory()
210+
request = factory.post(f'/export/clone_mapping/{original_mapping.id}/')
211+
request.user = self.specifyuser
212+
request.specify_user = self.specifyuser
213+
214+
# Mock permission check — in tests, permissions are not configured
215+
from unittest.mock import patch
216+
with patch('specifyweb.backend.export.views.check_permission_targets'):
217+
response = clone_mapping(request, original_mapping.id)
218+
219+
self.assertEqual(response.status_code, 200)
220+
import json
221+
data = json.loads(response.content)
222+
self.assertIn('id', data)
223+
self.assertEqual(data['name'], 'Copy of Original')
224+
self.assertFalse(data['isDefault'])
225+
self.assertNotEqual(data['queryId'], original_query.id)
226+
227+
# Verify new query has cloned fields
228+
new_query = Spquery.objects.get(id=data['queryId'])
229+
self.assertEqual(new_query.name, f'Copy of {original_query.name}')
230+
self.assertEqual(new_query.fields.count(), 2)
231+
232+
# Verify field data was cloned
233+
cloned_field = new_query.fields.get(position=0)
234+
self.assertEqual(cloned_field.term, 'http://rs.tdwg.org/dwc/terms/catalogNumber')
235+
cloned_static = new_query.fields.get(position=1)
236+
self.assertTrue(cloned_static.isstatic)
237+
self.assertEqual(cloned_static.staticvalue, 'Some Place')
238+
175239
def test_cache_table_meta(self):
176240
mapping = self._make_mapping()
177241
meta = CacheTableMeta.objects.create(

specifyweb/backend/export/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@
99
path('extract_query/<int:query_id>/', views.extract_query),
1010
path('force_update/', views.force_update),
1111
path('schema_terms/', views.get_schema_terms),
12+
path('list_mappings/', views.list_mappings),
13+
path('list_export_datasets/', views.list_export_datasets),
14+
path('clone_mapping/<int:mapping_id>/', views.clone_mapping),
1215
]

specifyweb/backend/export/views.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,116 @@ def get_schema_terms(request):
211211
terms_path = os.path.join(os.path.dirname(__file__), 'schema_terms.json')
212212
with open(terms_path) as f:
213213
return HttpResponse(f.read(), content_type='application/json')
214+
215+
216+
@require_GET
217+
@login_maybe_required
218+
def list_mappings(request):
219+
"""List all schema mappings."""
220+
check_permission_targets(None, request.specify_user.id, [SchemaMappingPT.read])
221+
from specifyweb.backend.export.models import SchemaMapping
222+
mappings = SchemaMapping.objects.all().values('id', 'name', 'mapping_type', 'is_default', 'query_id')
223+
return HttpResponse(json.dumps([
224+
{
225+
'id': m['id'],
226+
'name': m['name'],
227+
'mappingType': m['mapping_type'],
228+
'isDefault': m['is_default'],
229+
'queryId': m['query_id'],
230+
}
231+
for m in mappings
232+
]), content_type='application/json')
233+
234+
235+
@require_GET
236+
@login_maybe_required
237+
def list_export_datasets(request):
238+
"""List all export datasets."""
239+
check_permission_targets(None, request.specify_user.id, [ExportPackagePT.read])
240+
from specifyweb.backend.export.models import ExportDataSet
241+
datasets = ExportDataSet.objects.all().values(
242+
'id', 'exportname', 'filename', 'rss', 'frequency',
243+
'coremapping_id', 'collection_id', 'lastexported'
244+
)
245+
return HttpResponse(json.dumps([
246+
{
247+
'id': d['id'],
248+
'exportName': d['exportname'],
249+
'fileName': d['filename'],
250+
'isRss': d['rss'],
251+
'frequency': d['frequency'],
252+
'coreMappingId': d['coremapping_id'],
253+
'collectionId': d['collection_id'],
254+
'lastExported': d['lastexported'].isoformat() if d['lastexported'] else None,
255+
}
256+
for d in datasets
257+
]), content_type='application/json')
258+
259+
260+
@require_POST
261+
@login_maybe_required
262+
def clone_mapping(request, mapping_id):
263+
"""Deep-copy a SchemaMapping: creates new SpQuery with all SpQueryFields,
264+
creates new SchemaMapping pointing to the new query."""
265+
check_permission_targets(None, request.specify_user.id, [SchemaMappingPT.create])
266+
267+
from specifyweb.backend.export.models import SchemaMapping
268+
from specifyweb.specify.models import Spqueryfield
269+
270+
try:
271+
original = SchemaMapping.objects.select_related('query').get(id=mapping_id)
272+
except SchemaMapping.DoesNotExist:
273+
raise Http404
274+
275+
# Clone the SpQuery
276+
old_query = original.query
277+
new_query = Spquery.objects.create(
278+
name=f'Copy of {old_query.name}',
279+
contextname=old_query.contextname,
280+
contexttableid=old_query.contexttableid,
281+
specifyuser=request.specify_user,
282+
isfavorite=False,
283+
ordinal=old_query.ordinal,
284+
searchsynonymy=old_query.searchsynonymy,
285+
selectdistinct=old_query.selectdistinct,
286+
smushed=old_query.smushed,
287+
countonly=old_query.countonly,
288+
)
289+
290+
# Clone all query fields
291+
for field in old_query.fields.all():
292+
Spqueryfield.objects.create(
293+
query=new_query,
294+
fieldname=field.fieldname,
295+
stringid=field.stringid,
296+
tablelist=field.tablelist,
297+
operstart=field.operstart,
298+
startvalue=field.startvalue,
299+
position=field.position,
300+
sorttype=field.sorttype,
301+
isdisplay=field.isdisplay,
302+
isnot=field.isnot,
303+
isrelfld=field.isrelfld,
304+
formatname=field.formatname,
305+
term=field.term,
306+
isstatic=field.isstatic,
307+
staticvalue=field.staticvalue,
308+
)
309+
310+
# Clone the SchemaMapping
311+
new_mapping = SchemaMapping.objects.create(
312+
query=new_query,
313+
mapping_type=original.mapping_type,
314+
name=f'Copy of {original.name}',
315+
is_default=False,
316+
specifyuser=request.specify_user,
317+
createdbyagent=request.specify_user.agents.first(),
318+
)
319+
320+
return HttpResponse(json.dumps({
321+
'id': new_mapping.id,
322+
'name': new_mapping.name,
323+
'mappingType': new_mapping.mapping_type,
324+
'isDefault': False,
325+
'queryId': new_query.id,
326+
}), content_type='application/json')

specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ export const overlayRoutes: RA<EnhancedRoute> = [
209209
({ SchemaMapperOverlay }) => SchemaMapperOverlay
210210
),
211211
},
212+
{
213+
path: 'export-packages',
214+
title: headerText.exportPackages(),
215+
element: () =>
216+
import('../SchemaMapper/ExportPackages/index').then(
217+
({ ExportPackagesOverlay }) => ExportPackagesOverlay
218+
),
219+
},
212220
{
213221
path: 'about',
214222
title: welcomeText.aboutSpecify(),
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React from 'react';
2+
3+
import { commonText } from '../../../localization/common';
4+
import { headerText } from '../../../localization/header';
5+
import { Button } from '../../Atoms/Button';
6+
import { icons } from '../../Atoms/Icons';
7+
import { Dialog } from '../../Molecules/Dialog';
8+
import { OverlayContext } from '../../Router/Router';
9+
10+
type ExportPackageRecord = {
11+
readonly id: number;
12+
readonly exportName: string;
13+
readonly fileName: string;
14+
readonly isRss: boolean;
15+
readonly lastExported: string | undefined;
16+
};
17+
18+
export function ExportPackagesOverlay(): JSX.Element {
19+
const handleClose = React.useContext(OverlayContext);
20+
const [packages, setPackages] = React.useState<
21+
ReadonlyArray<ExportPackageRecord>
22+
>([]);
23+
24+
React.useEffect(() => {
25+
fetch('/export/list_export_datasets/')
26+
.then(async (response) => response.json())
27+
.then(setPackages)
28+
.catch(() => {});
29+
}, []);
30+
31+
return (
32+
<Dialog
33+
buttons={<Button.DialogClose>{commonText.close()}</Button.DialogClose>}
34+
header={headerText.exportPackages()}
35+
icon={icons.archive}
36+
onClose={handleClose}
37+
>
38+
<div className="flex flex-col gap-4">
39+
{packages.length === 0 ? (
40+
<p className="text-gray-500">No export packages configured</p>
41+
) : (
42+
<ul className="flex flex-col gap-2">
43+
{packages.map((pkg) => (
44+
<li
45+
className="flex items-center justify-between rounded border p-2"
46+
key={pkg.id}
47+
>
48+
<div>
49+
<span className="font-medium">{pkg.exportName}</span>
50+
<span className="ml-2 text-sm text-gray-500">
51+
({pkg.fileName})
52+
</span>
53+
{pkg.isRss && (
54+
<span className="ml-2 rounded bg-blue-100 px-1 text-xs text-blue-800">
55+
RSS
56+
</span>
57+
)}
58+
</div>
59+
<div className="flex gap-2">
60+
<Button.Small>Edit</Button.Small>
61+
<Button.Small>Clone</Button.Small>
62+
</div>
63+
</li>
64+
))}
65+
</ul>
66+
)}
67+
<Button.Info>New Export Package</Button.Info>
68+
</div>
69+
</Dialog>
70+
);
71+
}

0 commit comments

Comments
 (0)