From 47823090fb12ee3df46a8122af8393dde65abbbb Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 5 Feb 2026 19:22:37 -0600 Subject: [PATCH 01/29] fix: use app and label name instead of object reference to determine scope model (cherry picked from commit cdf0bd16226a36593ff5981c036abcff7a745b70) --- specifyweb/specify/utils/scoping.py | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/specifyweb/specify/utils/scoping.py b/specifyweb/specify/utils/scoping.py index 458e8217a79..71eebc5c709 100644 --- a/specifyweb/specify/utils/scoping.py +++ b/specifyweb/specify/utils/scoping.py @@ -16,17 +16,25 @@ class ScopeType(Enum): @staticmethod def from_model(obj) -> "ScopeType": - clazz = obj.__class__ - - if clazz is models.Institution: - return ScopeType.INSTITUTION - if clazz is models.Division: - return ScopeType.DIVISION - if clazz is models.Discipline: - return ScopeType.DISCIPLINE - if clazz is models.Collection: - return ScopeType.COLLECTION - raise TypeError(f"{clazz.__name__} is not a hierarchy table") + app_and_model_name = obj._meta.label_lower + + # We can't directly use `obj.__class__ is SomeScopeModel` here because + # that will break historical fake models during migrations + # Using the app and model name means this will work in both migration + # and normal runtimes + # See https://docs.djangoproject.com/en/6.0/topics/migrations/#historical-models + # for more information about Django's Histroical models + mapping = { + 'specify.institution': ScopeType.INSTITUTION, + 'specify.division': ScopeType.DIVISION, + 'specify.discipline': ScopeType.DISCIPLINE, + 'specify.collection': ScopeType.COLLECTION + } + + scope_type = mapping.get(app_and_model_name, None) + if scope_type is None: + raise TypeError(f"{app_and_model_name} is not a hierarchy table") + return scope_type def __gt__(self, other): if not isinstance(other, ScopeType): From 6aa744bdb15b47bec7b6609c0a049510ba465ffb Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:49:33 -0600 Subject: [PATCH 02/29] Update schema_localization.py --- .../backend/context/schema_localization.py | 258 ++++++++++-------- 1 file changed, 142 insertions(+), 116 deletions(-) diff --git a/specifyweb/backend/context/schema_localization.py b/specifyweb/backend/context/schema_localization.py index 2509a0ceba5..57cbfb7e8ca 100644 --- a/specifyweb/backend/context/schema_localization.py +++ b/specifyweb/backend/context/schema_localization.py @@ -5,29 +5,60 @@ import logging logger = logging.getLogger(__name__) -from django.conf import settings -from django.db import connection +from django.db.models import F, OuterRef, Q, Subquery +from django.db.models.functions import Coalesce, Lower + +from specifyweb.specify.models import ( + Splocalecontainer, + Splocalecontaineritem, + Splocaleitemstr, +) + +def _is_null_or_blank(field_name: str) -> Q: + return Q(**{f'{field_name}__isnull': True}) | Q(**{field_name: ''}) + +def _localized_text_subquery(fk_field_name: str, language: str, country: str | None) -> Subquery: + filters = { + fk_field_name: OuterRef('id'), + 'language': language, + } + query = Splocaleitemstr.objects.filter(**filters).filter(_is_null_or_blank('variant')) + if country is None: + query = query.filter(_is_null_or_blank('country')) + else: + query = query.filter(country=country) -def get_schema_languages(): - cursor = connection.cursor() + return Subquery(query.order_by('id').values('text')[:1]) + +def _localized_text_annotation(fk_field_name: str, language: str, country: str | None, fallback_field_name: str): + candidates = [] + + if country is not None: + candidates.append(_localized_text_subquery(fk_field_name, language, country)) + + candidates.append(_localized_text_subquery(fk_field_name, language, None)) - cursor.execute(""" - SELECT DISTINCT - lower(`language`), - lower(`country`), - lower(`variant`) - FROM splocaleitemstr; - """) + if language != 'en': + candidates.append(_localized_text_subquery(fk_field_name, 'en', None)) - return list(cursor.fetchall()) + return Coalesce(*candidates, F(fallback_field_name)) + +def get_schema_languages(): + return list( + Splocaleitemstr.objects.annotate( + language_lower=Lower('language'), + country_lower=Lower('country'), + variant_lower=Lower('variant'), + ) + .values_list('language_lower', 'country_lower', 'variant_lower') + .distinct() + ) def get_schema_localization(collection, schematype, lang): disc = collection.discipline language, country = lang.lower().split('-') if '-' in lang else (lang, None) - cursor = connection.cursor() - # # It's possible to generate the json in the DB as follows: # cursor.execute(""" @@ -70,109 +101,104 @@ def get_schema_localization(collection, schematype, lang): # return cursor.fetchone()[0] - cursor.execute(f""" - select name, format, ishidden, isuiformatter, picklistname, type, aggregator, defaultui, - coalesce({'n1.text, ' if country is not None else ''} n2.text, n3.text, name), - coalesce({'d1.text, ' if country is not None else ''} d2.text, d3.text, name) - from splocalecontainer - - {''' - left outer join splocaleitemstr n1 on n1.splocalecontainernameid = splocalecontainerid - and n1.language = %(language)s - and n1.country = %(country)s - and (n1.variant is null or n1.variant = '') - ''' if country is not None else ''} - - left outer join splocaleitemstr n2 on n2.splocalecontainernameid = splocalecontainerid - and n2.language = %(language)s - and (n2.country is null or n2.country = '') - and (n2.variant is null or n2.variant = '') - - left outer join splocaleitemstr n3 on n3.splocalecontainernameid = splocalecontainerid - and n3.language = 'en' - and (n3.country is null or n3.country = '') - and (n3.variant is null or n3.variant = '') - - {''' - left outer join splocaleitemstr d1 on d1.splocalecontainerdescid = splocalecontainerid - and d1.language = %(language)s - and d1.country = %(country)s - and (d1.variant is null or d1.variant = '') - ''' if country is not None else ''} - - left outer join splocaleitemstr d2 on d2.splocalecontainerdescid = splocalecontainerid - and d2.language = %(language)s - and (d2.country is null or d2.country = '') - and (d2.variant is null or d2.variant = '') - - left outer join splocaleitemstr d3 on d3.splocalecontainerdescid = splocalecontainerid - and d3.language = 'en' - and (d3.country is null or d3.country = '') - and (d3.variant is null or d3.variant = '') - - where schematype = %(schematype)s and disciplineid = %(disciplineid)s - order by name - """, {'language': language, 'country': country, 'schematype': schematype, 'disciplineid': disc.id}) - - cfields = ('format', 'ishidden', 'isuiformatter', 'picklistname', 'type', 'aggregator', 'defaultui', 'name', 'desc') + container_rows = ( + Splocalecontainer.objects.filter(schematype=schematype, discipline_id=disc.id) + .annotate( + localized_name=_localized_text_annotation( + 'containername_id', + language, + country, + 'name', + ), + localized_desc=_localized_text_annotation( + 'containerdesc_id', + language, + country, + 'name', + ), + ) + .order_by('name') + .values( + 'name', + 'format', + 'ishidden', + 'isuiformatter', + 'picklistname', + 'type', + 'aggregator', + 'defaultui', + 'localized_name', + 'localized_desc', + ) + ) containers = { - row[0].lower(): dict(items={}, **{field: row[i+1] for i, field in enumerate(cfields)}) - for row in cursor.fetchall() + row['name'].lower(): dict( + items={}, + format=row['format'], + ishidden=row['ishidden'], + isuiformatter=row['isuiformatter'], + picklistname=row['picklistname'], + type=row['type'], + aggregator=row['aggregator'], + defaultui=row['defaultui'], + name=row['localized_name'], + desc=row['localized_desc'], + ) + for row in container_rows } - cursor.execute(f""" - select container.name, item.name, - item.format, item.ishidden, item.isuiformatter, item.picklistname, - item.type, item.isrequired, item.weblinkname, - coalesce({'n1.text, ' if country is not None else ''} n2.text, n3.text, item.name), - coalesce({'d1.text, ' if country is not None else ''} d2.text, d3.text, item.name) - from splocalecontainer container - inner join splocalecontaineritem item on item.splocalecontainerid = container.splocalecontainerid - - {''' - left outer join splocaleitemstr n1 on n1.splocalecontaineritemnameid = splocalecontaineritemid - and n1.language = %(language)s - and n1.country = %(country)s - and (n1.variant is null or n1.variant = '') - ''' if country is not None else ''} - - left outer join splocaleitemstr n2 on n2.splocalecontaineritemnameid = splocalecontaineritemid - and n2.language = %(language)s - and (n2.country is null or n2.country = '') - and (n2.variant is null or n2.variant = '') - - left outer join splocaleitemstr n3 on n3.splocalecontaineritemnameid = splocalecontaineritemid - and n3.language = 'en' - and (n3.country is null or n3.country = '') - and (n3.variant is null or n3.variant = '') - - {''' - left outer join splocaleitemstr d1 on d1.splocalecontaineritemdescid = splocalecontaineritemid - and d1.language = %(language)s - and d1.country = %(country)s - and (d1.variant is null or d1.variant = '') - ''' if country is not None else ''} - - left outer join splocaleitemstr d2 on d2.splocalecontaineritemdescid = splocalecontaineritemid - and d2.language = %(language)s - and (d2.country is null or d2.country = '') - and (d2.variant is null or d2.variant = '') - - left outer join splocaleitemstr d3 on d3.splocalecontaineritemdescid = splocalecontaineritemid - and d3.language = 'en' - and (d3.country is null or d3.country = '') - and (d3.variant is null or d3.variant = '') - - where schematype = %(schematype)s and disciplineid = %(disciplineid)s - order by item.name - """, {'language': language, 'country': country, 'schematype': schematype, 'disciplineid': disc.id}) - - - ifields = ('format', 'ishidden', 'isuiformatter', 'picklistname', 'type', 'isrequired', 'weblinkname', 'name', 'desc') - - for row in cursor.fetchall(): - containers[row[0].lower()]['items'][row[1].lower()] = {field: row[i+2] for i, field in enumerate(ifields)} - - return containers - + item_rows = ( + Splocalecontaineritem.objects.filter( + container__schematype=schematype, + container__discipline_id=disc.id, + ) + .annotate( + container_name=F('container__name'), + localized_name=_localized_text_annotation( + 'itemname_id', + language, + country, + 'name', + ), + localized_desc=_localized_text_annotation( + 'itemdesc_id', + language, + country, + 'name', + ), + ) + .order_by('name') + .values( + 'container_name', + 'name', + 'format', + 'ishidden', + 'isuiformatter', + 'picklistname', + 'type', + 'isrequired', + 'weblinkname', + 'localized_name', + 'localized_desc', + ) + ) + + for row in item_rows: + container_key = row['container_name'].lower() + if container_key not in containers: + continue + + containers[container_key]['items'][row['name'].lower()] = { + 'format': row['format'], + 'ishidden': row['ishidden'], + 'isuiformatter': row['isuiformatter'], + 'picklistname': row['picklistname'], + 'type': row['type'], + 'isrequired': row['isrequired'], + 'weblinkname': row['weblinkname'], + 'name': row['localized_name'], + 'desc': row['localized_desc'], + } + + return containers \ No newline at end of file From ca1846f1b49db9383c8b8847177642f688e93fb5 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 5 Mar 2026 11:55:19 -0600 Subject: [PATCH 03/29] fix: don't limit fetched agents for security and accounts --- specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx b/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx index c6706fd7727..a10154f0fee 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx @@ -150,7 +150,6 @@ export function useUserAgents( ? fetchCollection( 'Agent', { - limit: 1, specifyUser: userId, domainFilter: false, }, From 4414c303371539f49f46929a5bdab5ee06876912 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 11 Mar 2026 09:16:24 -0500 Subject: [PATCH 04/29] fix: set correct division id when creating agents for user --- .../lib/components/QueryComboBox/index.tsx | 21 +++++++++++-------- .../Security/MissingAgentsDialog.tsx | 7 +++++++ .../components/Security/UserCollections.tsx | 18 +++++++++++++--- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx index 46faf3112d8..f6d9ebc8edc 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx @@ -76,6 +76,7 @@ export function QueryComboBox({ searchView, defaultRecord, relatedTable: initialRelatedTable, + onSavingNewRecord: handleSavingNewRecord, }: { readonly id: string | undefined; readonly resource: SpecifyResource | undefined; @@ -92,6 +93,9 @@ export function QueryComboBox({ readonly searchView?: string; readonly defaultRecord?: string | undefined; readonly relatedTable?: SpecifyTable | undefined; + readonly onSavingNewRecord?: + | ((resource: SpecifyResource) => void) + | undefined; }): JSX.Element { React.useEffect(() => { useQueryComboBoxDefaults({ resource, field, defaultRecord }); @@ -621,15 +625,14 @@ export function QueryComboBox({ resource?.set(field.name, state.resource as never); setState({ type: 'MainState' }); }} - onSaving={ - field.isDependent() - ? (): false => { - resource?.set(field.name, state.resource as never); - setState({ type: 'MainState' }); - return false; - } - : undefined - } + onSaving={() => { + handleSavingNewRecord?.(state.resource); + if (field.isDependent()) { + resource?.set(field.name, state.resource as never); + setState({ type: 'MainState' }); + return false; + } + }} /> ) : undefined} {state.type === 'SearchState' ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Security/MissingAgentsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Security/MissingAgentsDialog.tsx index d4b385d15e7..0993312c4d9 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/MissingAgentsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/MissingAgentsDialog.tsx @@ -15,6 +15,7 @@ import { Button } from '../Atoms/Button'; import { Form, Label } from '../Atoms/Form'; import { Submit } from '../Atoms/Submit'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; +import { toTable } from '../DataModel/helpers'; import { fetchResource, idFromUrl } from '../DataModel/resource'; import { tables } from '../DataModel/tables'; import { Dialog } from '../Molecules/Dialog'; @@ -144,6 +145,12 @@ export function MissingAgentsDialog({ */ resource={address} typeSearch={undefined} + onSavingNewRecord={(resource) => { + toTable(resource, 'Agent')?.set( + 'division', + division.resource_uri + ); + }} /> {f.includes( response.AgentInUseException ?? [], diff --git a/specifyweb/frontend/js_src/lib/components/Security/UserCollections.tsx b/specifyweb/frontend/js_src/lib/components/Security/UserCollections.tsx index cda027fe177..7d36e75b006 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/UserCollections.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/UserCollections.tsx @@ -21,9 +21,10 @@ import { Form, Input, Label, Select } from '../Atoms/Form'; import { Submit } from '../Atoms/Submit'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; +import { toTable } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { resourceOn } from '../DataModel/resource'; +import { getResourceApiUrl, resourceOn } from '../DataModel/resource'; import { tables } from '../DataModel/tables'; import type { Collection, SpecifyUser } from '../DataModel/types'; import { Dialog } from '../Molecules/Dialog'; @@ -185,9 +186,11 @@ export function CollectionAccess({ ({ resource, actions }) => resource === collectionAccessResource && actions.includes('access') ) ?? false; - const collectionAddress = userAgents?.find(({ collections }) => + + const scopedAgent = userAgents?.find(({ collections }) => collections.includes(collectionId) - )?.address; + ); + const collectionAddress = scopedAgent?.address; const hasAgent = (collectionAddress?.get('agent')?.length ?? 0) > 0; React.useEffect( @@ -272,6 +275,15 @@ export function CollectionAccess({ isRequired={hasCollectionAccess || isSuperAdmin} resource={collectionAddress} typeSearch={undefined} + onSavingNewRecord={(resource) => { + const divisionId = scopedAgent?.divisionId; + + if (divisionId !== undefined) + toTable(resource, 'Agent')?.set( + 'division', + getResourceApiUrl('Division', divisionId) + ); + }} /> ) : ( From 75adbddd1f4df0c828ef12459d63d967cb793164 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 15 Apr 2026 15:05:18 -0500 Subject: [PATCH 05/29] Revert Back-end changes from Handle Disambiguation Case for Host Taxon PR --- .../backend/stored_queries/query_construct.py | 62 ++++++++----------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/specifyweb/backend/stored_queries/query_construct.py b/specifyweb/backend/stored_queries/query_construct.py index 230e7b788a4..56e042fc6e0 100644 --- a/specifyweb/backend/stored_queries/query_construct.py +++ b/specifyweb/backend/stored_queries/query_construct.py @@ -2,9 +2,6 @@ from collections import namedtuple, deque from sqlalchemy import orm, sql, or_ -from sqlalchemy import inspect -from sqlalchemy.orm.util import AliasedClass -from sqlalchemy.inspection import inspect as sa_inspect import specifyweb.specify.models as spmodels from specifyweb.backend.trees.utils import get_treedefs @@ -32,55 +29,47 @@ def __new__(cls, *args, **kwargs): def handle_tree_field(self, node, table, tree_rank: TreeRankQuery, next_join_path, current_field_spec: QueryFieldSpec): query = self - if query.collection is None: - raise AssertionError( - f"No Collection found in Query for {table}", - {"table": table, "localizationKey": "noCollectionInQuery"}, - ) - logger.info("handling treefield %s rank: %s field: %s", table, tree_rank.name, next_join_path) + if query.collection is None: raise AssertionError( # Not sure it makes sense to query across collections + f"No Collection found in Query for {table}", + {"table" : table, + "localizationKey" : "noCollectionInQuery"}) + logger.info('handling treefield %s rank: %s field: %s', table, tree_rank.name, next_join_path) treedefitem_column = table.name + 'TreeDefItemID' treedef_column = table.name + 'TreeDefID' - # Determine starting anchor correctly: - # If node is already an alias (from a relationship path), use that alias. - # If node is the mapped class (base table), don't alias it, start from the base table. - is_alias = isinstance(node, AliasedClass) - start_alias = node # keep as-is - mapped_cls = sa_inspect(node).mapper.class_ if is_alias else node - - # Use the specific start anchor in the cache key, so each branch has its own chain - cache_key = (start_alias, "TreeRanks") - - if cache_key in query.join_cache: - logger.debug("using join cache for %r tree ranks.", start_alias) - ancestors, treedefs = query.join_cache[cache_key] + if (table, 'TreeRanks') in query.join_cache: + logger.debug("using join cache for %r tree ranks.", table) + ancestors, treedefs = query.join_cache[(table, 'TreeRanks')] else: + treedefs = get_treedefs(query.collection, table.name) - max_depth = max(depth for _, depth in treedefs) - # Start ancestry from the provided alias (e.g., HostTaxon alias) - ancestors = [start_alias] - - # Build parent chain using aliases of the mapped class - for _ in range(max_depth - 1): - ancestor = orm.aliased(mapped_cls) + # We need to take the max here. Otherwise, it is possible that the same rank + # name may not occur at the same level across tree defs. + max_depth = max(depth for _, depth in treedefs) + + ancestors = [node] + for _ in range(max_depth-1): + ancestor = orm.aliased(node) query = query.outerjoin(ancestor, ancestors[-1].ParentID == ancestor._id) ancestors.append(ancestor) + - logger.debug("adding to join cache for %r tree ranks.", start_alias) + logger.debug("adding to join cache for %r tree ranks.", table) query = query._replace(join_cache=query.join_cache.copy()) - query.join_cache[cache_key] = (ancestors, treedefs) + query.join_cache[(table, 'TreeRanks')] = (ancestors, treedefs) item_model = getattr(spmodels, table.django_name + "treedefitem") # TODO: optimize out the ranks that appear? cache them - treedefs_with_ranks: list[tuple[int, int]] = [ - (treedef_id, _safe_filter(item_model.objects.filter(treedef_id=treedef_id, name=tree_rank.name).values_list("id", flat=True))) + treedefs_with_ranks: list[tuple[int, int]] = [tup for tup in [ + (treedef_id, _safe_filter(item_model.objects.filter(treedef_id=treedef_id, name=tree_rank.name).values_list('id', flat=True))) for treedef_id, _ in treedefs # For constructing tree queries for batch edit if (tree_rank.treedef_id is None or tree_rank.treedef_id == treedef_id) - ] + ] if tup[1] is not None] + assert len(treedefs_with_ranks) >= 1, "Didn't find the tree rank across any tree" treedefitem_params = [treedefitem_id for (_, treedefitem_id) in treedefs_with_ranks] @@ -107,8 +96,7 @@ def make_tree_field_spec(tree_node): # We don't want to include treedef if the rank is not present. new_filters = [ *query.internal_filters, - or_(getattr(start_alias, treedef_column).in_(defs_to_filter_on), getattr(start_alias, treedef_column) == None), - ] + or_(getattr(node, treedef_column).in_(defs_to_filter_on), getattr(node, treedef_column) == None)] query = query._replace(internal_filters=new_filters) return query, column, field, table @@ -167,4 +155,4 @@ def proxy(self, *args, **kwargs): setattr(QueryConstruct, name, proxy) for name in 'filter join outerjoin add_columns reset_joinpoint group_by'.split(): - add_proxy_method(name) \ No newline at end of file + add_proxy_method(name) From 117c2f6295b5477acad556c69fd58514d3a1fb7a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 15 Apr 2026 15:08:49 -0500 Subject: [PATCH 06/29] Revert queryfieldspec changes from Handle Disambiguation Case for Host Taxon --- .../backend/stored_queries/queryfieldspec.py | 51 +++---------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py index 104af499359..c5a242c7a79 100644 --- a/specifyweb/backend/stored_queries/queryfieldspec.py +++ b/specifyweb/backend/stored_queries/queryfieldspec.py @@ -161,28 +161,11 @@ def get_workbench_name(self): # Treedef id included to make it easier to pass it to batch edit return f"{self.treedef_name}{RANK_KEY_DELIMITER}{self.name}{RANK_KEY_DELIMITER}{self.treedef_id}" -def null_safe_not(field_expr, predicate): - - """Return a NOT clause that still matches NULL values on the target field. - - SQL's ``NOT IN`` and similar predicates exclude rows where the filtered column - is ``NULL``. Historical Specify 6 behaviour (and user expectation) is to keep - those "empty" rows when a negated filter is applied. This helper wraps the - negated predicate in an OR that explicitly re-includes NULL rows for the - relevant field expression. - - """ - if predicate is None or isinstance(predicate, Query): - return predicate - target = field_expr if field_expr is not None else getattr(predicate, "left", None) - if target is None: - return sql.not_(predicate) - return sql.or_(target.is_(None), sql.not_(predicate)) - QueryNode = Field | Relationship | TreeRankQuery FieldSpecJoinPath = tuple[QueryNode] + class QueryFieldSpec( namedtuple( "QueryFieldSpec", @@ -400,7 +383,6 @@ def apply_filter( query_op = QueryOps(uiformatter) op = query_op.by_op_num(op_num) - mod_orm_field = orm_field if query_op.is_precalculated(op_num): f = op( orm_field, value, query, is_strict=strict @@ -417,15 +399,7 @@ def apply_filter( op, mod_orm_field, value = apply_special_filter_cases(orm_field, field, table, value, op, op_num, uiformatter, collection, user) f = op(mod_orm_field, value) - NULL_SAFE_NEGATE_OPS = {1, 10, 11} - - if negate: - if op_num in NULL_SAFE_NEGATE_OPS: - predicate = null_safe_not(mod_orm_field or orm_field, f) - else: - predicate = sql.not_(f) - else: - predicate = f + predicate = sql.not_(f) if negate else f else: predicate = None @@ -485,26 +459,17 @@ def add_spec_to_query( cycle_detector, ) else: - tree_rank_idxs = [i for i, n in enumerate(self.join_path) if isinstance(n, TreeRankQuery)] - if tree_rank_idxs: - tree_rank_idx = tree_rank_idxs[0] - prefix = self.join_path[:tree_rank_idx] # up to (but not including) the tree-rank node - tree_rank_node = self.join_path[tree_rank_idx] - suffix = self.join_path[tree_rank_idx + 1 :] # field after the rank, e.g., "Name" - - # Join only the prefix to obtain the correct starting alias (e.g., HostTaxon) - query, orm_model, table, _ = self.build_join(query, prefix) - - # Build the CASE/joins for the tree rank starting at that alias + query, orm_model, table, field = self.build_join(query, self.join_path) + if isinstance(field, TreeRankQuery): + tree_rank_idx = self.join_path.index(field) query, orm_field, field, table = query.handle_tree_field( orm_model, table, - tree_rank_node, - suffix, + field, + self.join_path[tree_rank_idx + 1 :], self, ) else: - query, orm_model, table, field = self.build_join(query, self.join_path) try: field_name = self.get_field().name orm_field = getattr(orm_model, field_name) @@ -688,4 +653,4 @@ def co_components_ids(cat_num, collection): all_ids.update(str(i) for i in ids_with_cat_num) all_ids.update(str(i) for i in ids_with_no_cat_num) - return list(all_ids) \ No newline at end of file + return list(all_ids) From 4678a0d14ca90492dba2835325051c1fe4609bcd Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 15 Apr 2026 15:30:33 -0500 Subject: [PATCH 07/29] Fix queryfieldspec.py revert --- .../backend/stored_queries/queryfieldspec.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py index c5a242c7a79..02782fc0036 100644 --- a/specifyweb/backend/stored_queries/queryfieldspec.py +++ b/specifyweb/backend/stored_queries/queryfieldspec.py @@ -161,11 +161,28 @@ def get_workbench_name(self): # Treedef id included to make it easier to pass it to batch edit return f"{self.treedef_name}{RANK_KEY_DELIMITER}{self.name}{RANK_KEY_DELIMITER}{self.treedef_id}" +def null_safe_not(field_expr, predicate): + + """Return a NOT clause that still matches NULL values on the target field. + + SQL's ``NOT IN`` and similar predicates exclude rows where the filtered column + is ``NULL``. Historical Specify 6 behaviour (and user expectation) is to keep + those "empty" rows when a negated filter is applied. This helper wraps the + negated predicate in an OR that explicitly re-includes NULL rows for the + relevant field expression. + + """ + if predicate is None or isinstance(predicate, Query): + return predicate + target = field_expr if field_expr is not None else getattr(predicate, "left", None) + if target is None: + return sql.not_(predicate) + return sql.or_(target.is_(None), sql.not_(predicate)) + QueryNode = Field | Relationship | TreeRankQuery FieldSpecJoinPath = tuple[QueryNode] - class QueryFieldSpec( namedtuple( "QueryFieldSpec", @@ -383,6 +400,7 @@ def apply_filter( query_op = QueryOps(uiformatter) op = query_op.by_op_num(op_num) + mod_orm_field = orm_field if query_op.is_precalculated(op_num): f = op( orm_field, value, query, is_strict=strict @@ -399,7 +417,15 @@ def apply_filter( op, mod_orm_field, value = apply_special_filter_cases(orm_field, field, table, value, op, op_num, uiformatter, collection, user) f = op(mod_orm_field, value) - predicate = sql.not_(f) if negate else f + NULL_SAFE_NEGATE_OPS = {1, 10, 11} + + if negate: + if op_num in NULL_SAFE_NEGATE_OPS: + predicate = null_safe_not(mod_orm_field or orm_field, f) + else: + predicate = sql.not_(f) + else: + predicate = f else: predicate = None From da6c6ff39ffba22b7127c168c712920a65c2e8cc Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 16 Apr 2026 11:56:34 -0500 Subject: [PATCH 08/29] Fix crash for negated tree rank field query filters --- specifyweb/backend/stored_queries/queryfieldspec.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py index 02782fc0036..e295b0778f0 100644 --- a/specifyweb/backend/stored_queries/queryfieldspec.py +++ b/specifyweb/backend/stored_queries/queryfieldspec.py @@ -421,7 +421,8 @@ def apply_filter( if negate: if op_num in NULL_SAFE_NEGATE_OPS: - predicate = null_safe_not(mod_orm_field or orm_field, f) + field_expr = mod_orm_field if mod_orm_field is not None else orm_field + predicate = null_safe_not(field_expr, f) else: predicate = sql.not_(f) else: From c520e6659603b34c9826ef20d3ef72eda034f421 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 16 Apr 2026 11:59:03 -0500 Subject: [PATCH 09/29] Add negated filter query unit test --- .../test_run_ephemeral_query.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py b/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py index b44e3fe168d..406dd4e5f1c 100644 --- a/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py +++ b/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py @@ -1,6 +1,8 @@ from specifyweb.backend.stored_queries.execution import run_ephemeral_query from specifyweb.backend.stored_queries.tests.test_execution.simple_query import simple_query from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup +from specifyweb.backend.trees.tests.test_trees import SqlTreeSetup +from specifyweb.specify import models from unittest.mock import patch, Mock @@ -14,3 +16,55 @@ def test_query(self, context: Mock): {"results": [(co.id, co.catalognumber) for co in self.collectionobjects]}, result, ) + + +class TestRunEphemeralQueryByRank(SqlTreeSetup): + + @patch("specifyweb.backend.stored_queries.execution.models.session_context") + def test_negated_contains_on_tree_rank_field(self, context: Mock): + context.return_value = TestRunEphemeralQueryByRank.test_session_context() + + life = self.make_taxontree("Life", "Taxonomy Root") + animalia = self.make_taxontree("Animalia", "Kingdom", parent=life) + phylum = self.make_taxontree("TestPhylum", "Phylum", parent=animalia) + + models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + taxon=phylum, + iscurrent=True, + ) + + query = { + "contexttableid": 1, + "countonly": False, + "formatauditrecids": False, + "selectdistinct": False, + "fields": [ + { + "fieldname": "Phylum", + "formatname": None, + "isdisplay": True, + "isnot": True, + "isrelfld": False, + "operstart": 11, + "position": 0, + "sorttype": 0, + "startvalue": "Ooof", + "stringid": "1,9-determinations,4.taxon.Phylum", + "isstrict": False, + } + ], + } + + result = run_ephemeral_query(self.collection, self.specifyuser, query) + + self.assertCountEqual( + result["results"], + [ + (self.collectionobjects[0].id, "TestPhylum"), + (self.collectionobjects[1].id, None), + (self.collectionobjects[2].id, None), + (self.collectionobjects[3].id, None), + (self.collectionobjects[4].id, None), + ], + ) From df9ddc757adb7e50f37c3cc871118ddfb166bc79 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 16 Apr 2026 12:44:08 -0500 Subject: [PATCH 10/29] Fix blank partial date values in QB results --- specifyweb/backend/stored_queries/format.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py index 947d48b9a87..50b5bb7213d 100644 --- a/specifyweb/backend/stored_queries/format.py +++ b/specifyweb/backend/stored_queries/format.py @@ -434,7 +434,11 @@ def _dateformat(self, specify_field, field): def _fieldformat(self, table: Table, specify_field: Field, field: InstrumentedAttribute | Extract): - if self.format_types and specify_field.is_temporal(): + if ( + self.format_types + and specify_field.is_temporal() + and isinstance(field, InstrumentedAttribute) + ): return self._dateformat(specify_field, field) if self.format_agent_type and specify_field is Agent_model.get_field("agenttype"): From d0aee0f6e82ca3aef1c538e7cba1ade90804a1c0 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 16 Apr 2026 12:55:23 -0500 Subject: [PATCH 11/29] Add a a unit test for partial date queries --- .../tests/test_execution/test_execute.py | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py index 6c3806f4588..59ff51e4657 100644 --- a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py +++ b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py @@ -1,7 +1,10 @@ +from datetime import datetime + from specifyweb.specify.models import Collectionobject, Recordset, Recordsetitem from specifyweb.backend.stored_queries.execution import execute +from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup -from specifyweb.backend.stored_queries.tests.utils import make_query_fields_test +from specifyweb.backend.stored_queries.tests.utils import make_query_fields_test, make_query_test class TestExecute(SQLAlchemySetup): @@ -198,6 +201,60 @@ def test_simple_query_recordset_limit(self): self.assertEqual(result_count_only, dict(count=2)) + def test_related_date_part_query_fields(self): + self._update( + self.collectingevent, + { + "startdate": datetime(1999, 3, 15), + "startdateprecision": 1, + }, + ) + for collectionobject in self.collectionobjects: + self._update(collectionobject, {"collectingevent": self.collectingevent}) + + table, query_fields = make_query_fields_test( + "Collectionobject", + [["collectingEvent", "startDate"]], + ) + year_field = QueryFieldSpec.from_path( + ("Collectionobject", "collectingEvent", "startDate") + )._replace(date_part="Year") + month_field = QueryFieldSpec.from_path( + ("Collectionobject", "collectingEvent", "startDate") + )._replace(date_part="Month") + day_field = QueryFieldSpec.from_path( + ("Collectionobject", "collectingEvent", "startDate") + )._replace(date_part="Day") + query_fields = [ + make_query_test(year_field), + make_query_test(month_field), + make_query_test(day_field), + ] + + with TestExecute.test_session_context() as session: + result = execute( + session, + self.collection, + self.specifyuser, + table.tableId, + distinct=False, + series=False, + count_only=False, + field_specs=query_fields, + limit=0, + offset=0, + ) + + self.assertEqual( + { + "results": [ + (collectionobject.id, 1999, 3, 15) + for collectionobject in self.collectionobjects + ] + }, + result, + ) + def test_simple_query_series(self): table, query_fields = make_query_fields_test( "Collectionobject", [["catalognumber"]] From 46556f3668f8d943afc8d960a7ee7fd91e52ca7b Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 16 Apr 2026 14:42:38 -0500 Subject: [PATCH 12/29] Add data_part fix from #7970 --- specifyweb/backend/stored_queries/format.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py index 50b5bb7213d..61bd5709a3d 100644 --- a/specifyweb/backend/stored_queries/format.py +++ b/specifyweb/backend/stored_queries/format.py @@ -394,12 +394,14 @@ def fieldformat(self, query_field: QueryField, if field_spec.get_field() is not None: if field_spec.is_temporal() and field_spec.date_part == "Full Date": field = self._dateformat(field_spec.get_field(), field) - + elif field_spec.is_temporal() and field_spec.date_part is not None: + # Numeric date_part fields are already derived SQL expressions + pass elif field_spec.is_relationship(): pass - else: field = self._fieldformat(field_spec.table, field_spec.get_field(), field) + return blank_nulls(field) if self.replace_nulls else field def _dateformat(self, specify_field, field): From 0f059cdbbcf0d9f844b80be86fd7128d4096cb99 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 16 Apr 2026 20:47:11 -0500 Subject: [PATCH 13/29] fix: prevent duplicate splocalecontainer records when running migrations Fixes #7988 --- .../migration_utils/update_schema_config.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index c72cfdef69b..430cb1adfa4 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -305,14 +305,23 @@ def update_table_schema_config_with_defaults( language="en", ) - sp_local_container, is_new = Splocalecontainer.objects.get_or_create( - name=table_config.name.lower(), - discipline_id=discipline_id, - schematype=table_config.schema_type, - ishidden=False, - issystem=table.system, - version=0, - ) + container_attrs = { + "name": table_config.name.lower(), + "discipline_id": discipline_id, + "schematype": table_config.schema_type + } + + fetched_sp_locale_container = Splocalecontainer.objects.filter(**container_attrs).first() + + if fetched_sp_locale_container is None: + sp_local_container = Splocalecontainer.objects.create(**{ + **container_attrs, + "ishidden": False, + "issystem": table.system, + "version": 0, + }) + else: + sp_local_container = fetched_sp_locale_container if Splocalecontaineritem.objects.filter( container=sp_local_container, @@ -1831,9 +1840,7 @@ def revert_0034_schema_config_field_desc(apps): container=container, name=field_name.lower() ) - for item in items: - # If needed, reset ishidden or revert text - pass + # If needed, reset ishidden or revert text revert_0034_fields(apps) revert_0034_schema_config_field_desc(apps) @@ -1950,9 +1957,7 @@ def revert_loan_and_gift_agents(apps): container=container, name=field_name.lower() ) - for item in items: - # If needed, reset ishidden or revert text - pass + # If needed, reset ishidden or revert text # ########################################## # Used in 0040_components.py From 93c4b52d4342c51a49b6aeafac746937b57e0749 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 16 Apr 2026 21:49:20 -0500 Subject: [PATCH 14/29] fix: deduplicate existing container records See #7988 --- .../migration_utils/update_schema_config.py | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 430cb1adfa4..6f8b3b915ad 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -8,7 +8,7 @@ from pathlib import Path -from django.db.models import Q, Count, Window, F +from django.db.models import Q, Count, Window, F, Exists, OuterRef from django.conf import settings from django.apps import apps as global_apps from django.core.exceptions import MultipleObjectsReturned @@ -702,11 +702,48 @@ def deduplicate_schema_config_sql(apps=None): cursor.close() def deduplicate_schema_config_orm(apps, schema_editor=None): + Container = apps.get_model('specify', 'SpLocaleContainer') ContainerItem = apps.get_model('specify', 'SpLocaleContainerItem') ItemStr = apps.get_model('specify', 'SpLocaleItemStr') with transaction.atomic(): - # Identify duplicates using a Window function. + # Find duplicate SpLocaleContainers + # A duplicate should be in the same discipline and have the same name + # and schematype + # For this query we consider the oldest SpLocaleContainer as the + # "cannonical" record, and all later records as the duplicates + # We could be a little smarter about this and also check the associated + # container items and strings, but this should be minimally sufficient + # without sacrificing complexity and speed + # See #7988 + duplicate_containers = Container.objects.filter(schematype=0).annotate( + earlier_exists=Exists( + Container.objects.filter( + discipline_id=OuterRef('discipline_id'), + schematype=0, + name=OuterRef('name'), + timestampcreated__lt=OuterRef('timestampcreated') + ) + ) + ).filter(earlier_exists=True) + + # Remove the items and strings shouldn't be strictly neccesary as they + # should both cascade if we call duplicate_containers.delete() + # But this is the safer option for any edge cases with historical + # models in migrations and if we ever decide to change the delete + # behavior later down the line + # Plus, I don't think the performance impact should be **that** + # significantly different... + duplicate_items = ContainerItem.objects.filter(container__in=duplicate_containers) + ItemStr.objects.filter(itemname__in=duplicate_items).delete() + ItemStr.objects.filter(itemdesc__in=duplicate_items).delete() + duplicate_items.delete() + + ItemStr.objects.filter(containername__in=duplicate_containers).delete() + ItemStr.objects.filter(containerdesc__in=duplicate_containers).delete() + duplicate_containers.delete() + + # Identify duplicate container items using a Window function. # Partition by container_id + item name only. # Only schema type 0 containers (standard schema) are eligible for this cleanup. # The schema type 1 refers to the WorkBench Schema from Specify 6, which has From edb7e72a6f0947cd059a350d7ceb8ae7567dbb9c Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 17 Apr 2026 11:37:36 -0500 Subject: [PATCH 15/29] fix: hide collection preferences when user doesn't have permission See #7984 Introduced by f27aa03 in #7557 --- .../js_src/lib/components/AppResources/filtersHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts index 42210ecc84e..e5a51a0617a 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts @@ -58,7 +58,7 @@ export const filterAppResources = ( ); const finalAppResources = baseAppResources.filter((resource) => { - if (resource.name !== 'CollectionPreferences') { + if (resource.name === 'CollectionPreferences') { return hasEditPermission; } return true; From 9d1ba9185c9153355c6e82f3b0c2e75c5108dcc4 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 17 Apr 2026 12:38:46 -0500 Subject: [PATCH 16/29] fix: correct app resource tests --- .../__tests__/AppResourcesAside.test.tsx | 46 +- .../AppResourcesAside.test.tsx.snap | 410 +++++++++++++++--- 2 files changed, 382 insertions(+), 74 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx index 1b51e0b0bfa..e09aaed26fd 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx @@ -11,7 +11,7 @@ import { testAppResources } from './testAppResources'; requireContext(); jest.mock('../../Permissions/helpers', () => ({ - hasPermission: jest.fn(), + hasPermission: jest.fn(() => true), hasToolPermission: jest.fn(() => true), })); @@ -35,6 +35,42 @@ describe('AppResourcesAside (simple no conformation case)', () => { }); }); +// TEST: This should really (also) be a test for filterAppResources, but +// including this here just cause +describe('Missing Collection Preferences Permission', () => { + beforeAll(() => { + jest.clearAllMocks(); + jest.mock('../../Permissions/helpers', () => ({ + hasPermission: jest.fn(() => false), + hasToolPermission: jest.fn(() => true), + })); + }); + afterAll(() => { + jest.clearAllMocks(); + jest.mock('../../Permissions/helpers', () => ({ + hasPermission: jest.fn(() => true), + hasToolPermission: jest.fn(() => true), + })); + }); + test('simple no conformation test', () => { + const onOpen = jest.fn(); + const setConformations = jest.fn(); + + const { asFragment, unmount } = mount( + + ); + + expect(asFragment()).toMatchSnapshot(); + unmount(); + }); +}); + describe('AppResourcesAside (expanded case)', () => { test('expanded case', async () => { const onOpen = jest.fn(); @@ -111,13 +147,13 @@ describe('AppResourcesAside (expanded case)', () => { const laterFragment = asFragmentLater().textContent; expect(initialFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Expand AllCollapse All' + 'Global Resources (2)Discipline Resources (4)Expand AllCollapse All' ); expect(intermediateFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Botany (1)Expand AllCollapse All' + 'Global Resources (2)Discipline Resources (4)Botany (4)Expand AllCollapse All' ); expect(laterFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Expand AllCollapse All' + 'Global Resources (2)Discipline Resources (4)Expand AllCollapse All' ); const expandAllButton = getFinal('button')[2]; @@ -141,7 +177,7 @@ describe('AppResourcesAside (expanded case)', () => { const expandedAllFragment = asFragmentAllExpanded().textContent; expect(expandedAllFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Botany (1)Add Resourcec (1)Collection PreferencesAdd ResourceUser Accounts (0)testiiif (0)User Types (0)FullAccess (0)Guest (0)LimitedAccess (0)Manager (0)Expand AllCollapse All' + 'Global Resources (2)Global PreferencesRemote PreferencesAdd ResourceDiscipline Resources (4)Botany (4)Add Resourcec (4)Collection PreferencesAdd ResourceUser Accounts (3)testiiif (3)User PreferencesQueryExtraListQueryFreqListAdd ResourceUser Types (0)FullAccess (0)Guest (0)LimitedAccess (0)Manager (0)Expand AllCollapse All' ); expect(asFragmentAllExpanded()).toMatchSnapshot(); unmountExpandedll(); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap index 3d16068e4d4..04acc08c687 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap @@ -15,43 +15,43 @@ exports[`AppResourcesAside (expanded case) expanded case 1`] = ` >
    • @@ -121,44 +121,127 @@ exports[`AppResourcesAside (expanded case) expanded case 2`] = ` role="tree" >
          • @@ -545,7 +737,87 @@ exports[`AppResourcesAside (simple no conformation case) simple no conformation - (1) + (4) + + + +
          +
          + + +
          + + +`; + +exports[`Missing Collection Preferences Permission simple no conformation test 1`] = ` + +