Skip to content
Merged
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
35 changes: 34 additions & 1 deletion specifyweb/backend/trees/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import logging
logger = logging.getLogger(__name__)

def get_tree_stats(treedef, tree, parentid, specify_collection, session_context, using_cte):
def get_tree_stats(treedef, tree, parentid, specify_collection, session_context, using_cte, include_synonym_count=False):
tree_table = datamodel.get_table(tree)
tree_def_item = getattr(models, tree_table.name + 'TreeDefItem')
parentid = None if parentid == 'null' else int(parentid)
Expand Down Expand Up @@ -112,9 +112,42 @@ def wrap_cte_query(cte_query, query):
query = make_joins(query)
results = list(query)

if include_synonym_count:
synonym_counts = get_synonym_counts(
tree_node, treedef_col, treedef, parentid, specify_collection,
session)
results = [
(*row, synonym_counts.get(row[0], 0)) for row in results
]

logger.debug(str(query))
return results


def get_synonym_counts(tree_node, treedef_col, treedef, parentid, specify_collection, session):
"""Count current determinations that still point directly at a node which has
since been synonymized into a different (preferred) node, aggregated over each
child's whole subtree so parents reflect their synonymized descendants."""
child = aliased(tree_node)
descendant = aliased(tree_node)
det = aliased(models.Determination)

query = session.query(child._id, sql.func.count(det._id)) \
.outerjoin(descendant, sql.and_(
descendant.nodeNumber.between(
child.nodeNumber, child.highestChildNodeNumber),
getattr(descendant, treedef_col) == int(treedef))) \
.outerjoin(det, sql.and_(
det.isCurrent,
det.collectionMemberId == specify_collection.id,
det.TaxonID == descendant._id,
det.PreferredTaxonID != descendant._id)) \
.filter(child.ParentID == parentid) \
.filter(getattr(child, treedef_col) == int(treedef)) \
.group_by(child._id)

return dict(query)

class StatsQuerySpecialization(
namedtuple('StatsQuerySpecialization', 'collection')):

Expand Down
7 changes: 6 additions & 1 deletion specifyweb/backend/trees/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,13 @@ def tree_stats(request, treedef, tree, parentid):
"Returns tree stats (collection object count) for tree nodes parented by <parentid>."

using_cte = (tree in ['geography', 'taxon', 'storage'])
include_synonym_count = (
tree == 'taxon'
and request.GET.get('includeSynonymCount', 'false') == 'true'
)
results = get_tree_stats(
treedef, tree, parentid, request.specify_collection, sqlmodels.session_context, using_cte)
treedef, tree, parentid, request.specify_collection, sqlmodels.session_context, using_cte,
include_synonym_count)

return HttpResponse(toJson(results), content_type="application/json")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,14 @@ export const userPreferenceDefinitions = {
defaultValue: true,
type: 'java.lang.Boolean',
}),
showSynonymCounts: definePref<boolean>({
title: preferencesText.showSynonymCounts(),
description: preferencesText.showSynonymCountsDescription(),
requiresReload: true,
visible: true,
defaultValue: false,
type: 'java.lang.Boolean',
}),
queryField: definePref<'preferredTaxon' | 'taxon'>({
title: preferencesText.queryButtonTaxonField(),
description: preferencesText.queryButtonTaxonFieldDescription(),
Expand Down
4 changes: 3 additions & 1 deletion specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ export function TreeRow<SCHEMA extends AnyTree>({
descendantCount === 0;

const hasNoChildrenNodes =
nodeStats?.directCount === 0 && nodeStats.childCount === 0;
nodeStats?.directCount === 0 &&
nodeStats.childCount === 0 &&
(nodeStats.synonymCount ?? 0) === 0;

return (hideEmptyNodes && hasNoChildrenNodes) || isHiddenInFilter ? null : (
<li role="treeitem row">
Expand Down
15 changes: 13 additions & 2 deletions specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,23 @@ export function Tree<
'rankThreshold'
);

const [showSynonymCounts] = userPreferences.use(
'treeEditor',
'taxon',
'showSynonymCounts'
);
const includeSynonymCount = tableName === 'Taxon' && showSynonymCounts;

const getStats = React.useCallback(
async (nodeId: number | 'null', rankId: number): Promise<Stats> =>
rankId >= statsThreshold
? fetchStats(`${baseUrl}/${nodeId}/stats/`)
? fetchStats(
`${baseUrl}/${nodeId}/stats/${
includeSynonymCount ? '?includeSynonymCount=true' : ''
}`
)
: Promise.resolve({}),
[baseUrl, statsThreshold]
[baseUrl, statsThreshold, includeSynonymCount]
);

const treeDefinition = treeDefinitionItems[0].treeDef;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { requireContext } from '../../../tests/helpers';
import { formatTreeStats } from '../helpers';

requireContext();

describe('formatTreeStats', () => {
test('leaf node without synonym count', () => {
expect(
formatTreeStats(
{ directCount: 3, childCount: 0, synonymCount: undefined },
true
).text
).toBe('(3)');
});

test('internal node without synonym count', () => {
expect(
formatTreeStats(
{ directCount: 3, childCount: 5, synonymCount: undefined },
false
).text
).toBe('(3, 5)');
});

test('leaf node with synonym count', () => {
expect(
formatTreeStats({ directCount: 0, childCount: 0, synonymCount: 2 }, true)
.text
).toBe('(0, 2)');
});

test('internal node with synonym count', () => {
expect(
formatTreeStats({ directCount: 3, childCount: 5, synonymCount: 2 }, false)
.text
).toBe('(3, 5, 2)');
});

test('synonym count is included in the tooltip', () => {
const title = formatTreeStats(
{ directCount: 3, childCount: 5, synonymCount: 2 },
false
).title;
expect(title).toContain('2');
expect(title.split('\n')).toHaveLength(5);
});
});
74 changes: 50 additions & 24 deletions specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,24 +89,27 @@ export type Stats = RR<
{
readonly directCount: number;
readonly childCount: number;
// Only present when the "show counts for synonymized nodes" pref is on
readonly synonymCount: number | undefined;
}
>;

/**
* Fetch tree node usage stats
*/
export const fetchStats = async (url: string): Promise<Stats> =>
ajax<RA<readonly [number, number, number]>>(url, {
ajax<RA<readonly [number, number, number, number?]>>(url, {
headers: { Accept: 'application/json' },
errorMode: 'silent',
})
.then(({ data }) =>
Object.fromEntries(
data.map(([childId, directCount, allCount]) => [
data.map(([childId, directCount, allCount, synonymCount]) => [
childId,
{
directCount,
childCount: allCount - directCount,
synonymCount,
},
])
)
Expand Down Expand Up @@ -208,30 +211,53 @@ export const formatTreeStats = (
): {
readonly title: string;
readonly text: string;
} => ({
title: filterArray([
commonText.colonLine({
label: treeText.directCollectionObjectCount({
collectionObjectTable: tables.CollectionObject.label,
}),
value: nodeStats.directCount.toString(),
}),
isLeaf
? undefined
: commonText.colonLine({
label: treeText.indirectCollectionObjectCount({
collectionObjectTable: tables.CollectionObject.label,
}),
value: nodeStats.childCount.toString(),
} => {
const { synonymCount } = nodeStats;
return {
title: filterArray([
commonText.colonLine({
label: treeText.directCollectionObjectCount({
collectionObjectTable: tables.CollectionObject.label,
}),
]).join('\n'),
text: isLeaf
? treeText.leafNodeStats({ directCount: nodeStats.directCount })
: treeText.nodeStats({
directCount: nodeStats.directCount,
childCount: nodeStats.childCount,
value: nodeStats.directCount.toString(),
}),
});
isLeaf
? undefined
: commonText.colonLine({
label: treeText.indirectCollectionObjectCount({
collectionObjectTable: tables.CollectionObject.label,
}),
value: nodeStats.childCount.toString(),
}),
typeof synonymCount === 'number'
? commonText.colonLine({
label: treeText.synonymizedCollectionObjectCount({
collectionObjectTable: tables.CollectionObject.label,
}),
value: synonymCount.toString(),
})
: undefined,
]).join('\n\n'),
text:
typeof synonymCount === 'number'
? isLeaf
? treeText.leafNodeStatsWithSynonyms({
directCount: nodeStats.directCount,
synonymCount,
})
: treeText.nodeStatsWithSynonyms({
directCount: nodeStats.directCount,
childCount: nodeStats.childCount,
synonymCount,
})
: isLeaf
? treeText.leafNodeStats({ directCount: nodeStats.directCount })
: treeText.nodeStats({
directCount: nodeStats.directCount,
childCount: nodeStats.childCount,
}),
};
};

/**
* Check if there are any enforced ranks between current tree node parent
Expand Down
7 changes: 7 additions & 0 deletions specifyweb/frontend/js_src/lib/localization/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,13 @@ export const preferencesText = createDictionary({
'ru-ru': '',
'uk-ua': '',
},
showSynonymCounts: {
'en-us': 'Show counts for synonymized nodes',
},
showSynonymCountsDescription: {
'en-us':
'Display an additional count of records that were determined as a node, or any of its descendants, and are now synonyms of another node.',
},
welcomePage: {
'en-us': 'Home Page',
'ru-ru': 'Главная страница',
Expand Down
15 changes: 15 additions & 0 deletions specifyweb/frontend/js_src/lib/localization/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,21 @@ export const treeText = createDictionary({
'hr-hr': '({directCount:number|formatted})',
nb: '({directCount:number|formatted})',
},
nodeStatsWithSynonyms: {
comment: "Used to show tree node's direct, indirect and synonymized usages",
'en-us':
'({directCount:number|formatted}, {childCount:number|formatted}, {synonymCount:number|formatted})',
},
leafNodeStatsWithSynonyms: {
comment: "Used to show leaf tree node's direct and synonymized usages",
'en-us':
'({directCount:number|formatted}, {synonymCount:number|formatted})',
},
synonymizedCollectionObjectCount: {
comment:
'Count of records determined as this node or any of its descendants that have been synonymized into another node',
'en-us': 'Synonymized {collectionObjectTable:string} Count',
},
directCollectionObjectCount: {
comment: 'Example: Direct Collection Object count',
'en-us': 'Direct {collectionObjectTable:string} Count',
Expand Down
Loading