Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions specifyweb/backend/context/app_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,141 @@ def merge_formatters(custom_xml, default_xml):

# Convert the merged XML tree back to a string
return ElementTree.tostring(custom_root, encoding='utf-8').decode('utf-8')


def merge_ui_formatters_by_name(*xml_documents):
"""Merge UIFormatters XML documents by format ``name`` (first wins).

Used when listing catalog-number formats for a target discipline so that
collection/discipline/common/backstop definitions combine without the
login-collection session scope (#8678).
"""
merged_root = None
seen_names = set()
for xml in xml_documents:
if not xml:
continue
root = ElementTree.fromstring(xml)
if merged_root is None:
merged_root = ElementTree.Element(root.tag, root.attrib)
for format_elem in root.findall('format'):
name = format_elem.get('name')
if not name or name in seen_names:
continue
merged_root.append(format_elem)
seen_names.add(name)
if merged_root is None:
return None
return ElementTree.tostring(merged_root, encoding='utf-8').decode('utf-8')


def _uiformatters_xml_from_dirs(dirs):
resources = Spappresourcedata.objects.filter(
spappresource__name='UIFormatters',
spappresource__spappresourcedir__in=dirs,
)
return [resource.data for resource in resources if resource.data]


def _catalog_number_uiformatters_only(xml):
"""Return a UIFormatters document containing only catalogNumber formats.

Filtering before name-dedupe avoids a non-catalog ``<format name="X">``
blocking a later catalogNumber format that reuses the same name.
"""
root = ElementTree.fromstring(xml)
filtered = ElementTree.Element(root.tag, root.attrib)
for format_elem in root.findall('format'):
if format_elem.get('fieldname') != 'catalogNumber':
continue
if not format_elem.get('name'):
continue
filtered.append(format_elem)
if len(filtered) == 0:
return None
return ElementTree.tostring(filtered, encoding='utf-8').decode('utf-8')


def _append_catalog_number_uiformatters(xml_documents, xml):
filtered = _catalog_number_uiformatters_only(xml) if xml else None
if filtered:
xml_documents.append(filtered)


def _append_fs_catalog_number_uiformatters(xml_documents, collection, level):
"""Append catalogNumber UIFormatters from the filesystem at *level*, if present."""
resource = load_resource_at_level(collection, None, level, 'UIFormatters')
if resource is not None:
_append_catalog_number_uiformatters(xml_documents, resource[0])


def get_catalog_number_format_names_for_discipline(discipline):
"""Catalog-number UIFormatter names visible for creating a collection under *discipline*.

Includes formats from (most specific first; first name wins):
- collections already in that discipline (DB),
- the discipline itself (DB, then filesystem),
- common/global (DB, then filesystem),
- backstop defaults (filesystem).

Does **not** use the logged-in collection's unrelated UIFormatters.
Only ``fieldname="catalogNumber"`` formats are considered before name dedupe.
"""
from types import SimpleNamespace

from specifyweb.specify.models import Collection

xml_documents = []

sibling_collections = list(Collection.objects.filter(discipline=discipline))

# Sibling collection-level UIFormatters (most specific within the discipline).
for collection in sibling_collections:
dirs = Spappresourcedir.objects.filter(
ispersonal=False,
usertype__isnull=True,
collection=collection,
discipline=discipline,
)
for data in _uiformatters_xml_from_dirs(dirs):
_append_catalog_number_uiformatters(xml_documents, data)

# Discipline-level (DB, then filesystem).
discipline_dirs = Spappresourcedir.objects.filter(
ispersonal=False,
usertype__isnull=True,
collection__isnull=True,
discipline=discipline,
)
for data in _uiformatters_xml_from_dirs(discipline_dirs):
_append_catalog_number_uiformatters(xml_documents, data)
# load_resource_at_level needs a collection for discipline.type → FS path.
# When creating the first collection, synthesize a minimal stand-in.
fs_collection = sibling_collections[0] if sibling_collections else SimpleNamespace(
discipline=discipline
)
_append_fs_catalog_number_uiformatters(xml_documents, fs_collection, 'Discipline')

# Common / global (DB, then filesystem).
common_dirs = Spappresourcedir.objects.filter(
ispersonal=False,
usertype__isnull=True,
collection__isnull=True,
discipline__isnull=True,
)
for data in _uiformatters_xml_from_dirs(common_dirs):
_append_catalog_number_uiformatters(xml_documents, data)
_append_fs_catalog_number_uiformatters(xml_documents, None, 'Common')

# Backstop filesystem defaults.
_append_fs_catalog_number_uiformatters(xml_documents, None, 'Backstop')

merged = merge_ui_formatters_by_name(*xml_documents)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not merged:
return []

return [
format_elem.get('name')
for format_elem in ElementTree.fromstring(merged).findall('format')
if format_elem.get('name')
]
115 changes: 114 additions & 1 deletion specifyweb/backend/context/tests/test_app_resource.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
from xml.etree import ElementTree

from django.test import Client

from specifyweb.specify.tests.test_api import ApiTests
from unittest.mock import Mock, patch

from specifyweb.backend.context.app_resource import get_data_view_queries_resource
from specifyweb.backend.context.app_resource import (
_catalog_number_uiformatters_only,
get_catalog_number_format_names_for_discipline,
get_data_view_queries_resource,
merge_ui_formatters_by_name,
)


FOUND_RESOURCE = ('<resource><text>"Value"</text></resource>', "text/xml", 4)

DISCIPLINE_UI_FORMATTERS = """<?xml version="1.0" encoding="UTF-8"?>
<formats>
<format name="BirdsCatalog" class="edu.ku.brc.specify.datamodel.CollectionObject" fieldname="catalogNumber"/>
<format name="GridRefMGRS" class="edu.ku.brc.specify.datamodel.Locality" fieldname="text3"/>
</formats>
"""

BACKSTOP_UI_FORMATTERS = """<?xml version="1.0" encoding="UTF-8"?>
<formats>
<format name="CatalogNumber" class="edu.ku.brc.specify.datamodel.CollectionObject" fieldname="catalogNumber"/>
<format name="CatalogNumberNumeric" class="edu.ku.brc.specify.datamodel.CollectionObject" fieldname="catalogNumber"/>
</formats>
"""


class TestAppResource(ApiTests):

Expand Down Expand Up @@ -90,3 +111,95 @@ def test_not_found_quiet(self, get_app_resource: Mock):

response = c.get("/context/app.resource?name='simple'&quiet='true'")
self._assertStatusCodeEqual(response, 204)

def test_merge_ui_formatters_by_name_first_wins(self):
merged = merge_ui_formatters_by_name(
DISCIPLINE_UI_FORMATTERS, BACKSTOP_UI_FORMATTERS
)
root = ElementTree.fromstring(merged)
names = [fmt.get('name') for fmt in root.findall('format')]
self.assertEqual(
names, ['BirdsCatalog', 'GridRefMGRS', 'CatalogNumber', 'CatalogNumberNumeric']
)

def test_catalog_number_filter_before_name_dedupe(self):
"""A non-catalog format must not block a same-named catalogNumber format."""
non_catalog_first = """<?xml version="1.0" encoding="UTF-8"?>
<formats>
<format name="SharedName" class="edu.ku.brc.specify.datamodel.Locality" fieldname="text3"/>
</formats>
"""
catalog_later = """<?xml version="1.0" encoding="UTF-8"?>
<formats>
<format name="SharedName" class="edu.ku.brc.specify.datamodel.CollectionObject" fieldname="catalogNumber"/>
</formats>
"""
# Unfiltered merge would keep only the Locality format.
unfiltered = merge_ui_formatters_by_name(non_catalog_first, catalog_later)
self.assertEqual(
[
(f.get('name'), f.get('fieldname'))
for f in ElementTree.fromstring(unfiltered).findall('format')
],
[('SharedName', 'text3')],
)

filtered = merge_ui_formatters_by_name(
_catalog_number_uiformatters_only(non_catalog_first),
_catalog_number_uiformatters_only(catalog_later),
)
self.assertEqual(
[
(f.get('name'), f.get('fieldname'))
for f in ElementTree.fromstring(filtered).findall('format')
],
[('SharedName', 'catalogNumber')],
)

@patch("specifyweb.backend.context.app_resource.load_resource_at_level")
@patch("specifyweb.backend.context.app_resource._uiformatters_xml_from_dirs")
def test_catalog_number_format_names_for_discipline(
self, xml_from_dirs: Mock, load_from_filesystem: Mock
):
xml_from_dirs.return_value = [DISCIPLINE_UI_FORMATTERS]
load_from_filesystem.side_effect = lambda _collection, _user, level, _name: (
(BACKSTOP_UI_FORMATTERS, 'text/xml', None)
if level == 'Backstop'
else None
)

names = get_catalog_number_format_names_for_discipline(self.discipline)
self.assertEqual(
names,
['BirdsCatalog', 'CatalogNumber', 'CatalogNumberNumeric'],
)
self.assertNotIn('GridRefMGRS', names)
# Discipline + Common + Backstop filesystem levels are consulted.
self.assertEqual(
[call.args[2] for call in load_from_filesystem.call_args_list],
['Discipline', 'Common', 'Backstop'],
)

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

with patch(
'specifyweb.backend.context.views.get_catalog_number_format_names_for_discipline',
return_value=['CatalogNumber', 'BirdsCatalog'],
):
response = c.get(
f'/context/catalog_number_format_names.json?discipline={self.discipline.id}'
)

self._assertStatusCodeEqual(response, 200)
self.assertEqual(
response.json(),
{'names': ['CatalogNumber', 'BirdsCatalog']},
)

def test_catalog_number_format_names_endpoint_requires_discipline(self):
c = Client()
c.force_login(self.specifyuser)
response = c.get('/context/catalog_number_format_names.json')
self._assertStatusCodeEqual(response, 400)
1 change: 1 addition & 0 deletions specifyweb/backend/context/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
path('schema_localization.json', views.schema_localization),
path('schema_localization_import.json', views.schema_localization_import),
path('app.resource', views.app_resource),
path('catalog_number_format_names.json', views.catalog_number_format_names),
path('available_related_searches.json', views.available_related_searches),
path('remoteprefs.properties', views.remote_prefs),

Expand Down
65 changes: 64 additions & 1 deletion specifyweb/backend/context/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@
from specifyweb.specify.api.serializers import uri_for_model
from specifyweb.specify.utils.specify_jar import specify_jar
from specifyweb.specify.views import login_maybe_required, openapi
from .app_resource import get_app_resource, FORM_RESOURCE_EXCLUDED_LST
from .app_resource import (
FORM_RESOURCE_EXCLUDED_LST,
get_app_resource,
get_catalog_number_format_names_for_discipline,
)
from .schema_localization import get_schema_languages, get_schema_localization
from .viewsets import get_views
from specifyweb.backend.setup_tool.api import (
Expand Down Expand Up @@ -437,6 +441,65 @@ def app_resource(request):
return response


@openapi(schema={
"get": {
"parameters": [
{
"name": "discipline",
"in": "query",
"required": True,
"schema": {"type": "integer"},
"description": "Discipline id for which catalog-number format names should be listed.",
}
],
"responses": {
"200": {
"description": "Catalog-number UIFormatter names for the discipline (+ global/backstop).",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"names": {
"type": "array",
"items": {"type": "string"},
}
},
"required": ["names"],
}
}
},
},
"400": {"description": "Missing or invalid discipline id."},
"404": {"description": "Discipline not found."},
},
}
})
@login_maybe_required
@require_http_methods(['GET', 'HEAD'])
def catalog_number_format_names(request):
"""List catalog-number format names relevant to a target discipline.

Used by System Configuration when creating/editing a Collection so the
Catalog Num Format Name pick list is not limited to the login collection's
UIFormatters (#8678).
"""
raw = request.GET.get('discipline')
try:
discipline_id = int(raw)
except (TypeError, ValueError):
return HttpResponseBadRequest('discipline query parameter is required')

try:
discipline = Discipline.objects.get(id=discipline_id)
except Discipline.DoesNotExist:
raise Http404()

return JsonResponse({
'names': get_catalog_number_format_names_for_discipline(discipline),
})


@login_maybe_required
@require_http_methods(['GET', 'HEAD'])
@cache_control(max_age=86400, private=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { SpecifyResource } from '../DataModel/legacyTypes';
import type { LiteralField, Relationship } from '../DataModel/specifyField';
import type { PickList, Tables } from '../DataModel/types';
import { PickListComboBox } from '../PickLists';
import { CatalogNumberFormatPickList } from '../PickLists/CatalogNumberFormatPickList';
import { PickListTypes } from '../PickLists/definitions';
import { fetchPickList, getPickListItems } from '../PickLists/fetch';
import { FieldsPickList } from '../PickLists/FieldsPickList';
Expand Down Expand Up @@ -42,6 +43,8 @@ export const specialPickLists = {
_fieldsPickList: FieldsPickList,
_formattersPickList: FormattersPickList,
_tableNamePickList: TablesPickList,
// Overrides session-scoped front-end pick list with discipline-scoped names (#8678).
_CatalogNumberUIFormatters: CatalogNumberFormatPickList,
} as const;

export const specialPickListMapping: RR<
Expand Down
Loading