Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b3320ec
Fix cascaded Discipline delete blockers for Division deletes
acwhite211 Apr 20, 2026
f26b61f
mypy fix
acwhite211 Apr 20, 2026
324d90f
Create unit tests for cascade deletes
acwhite211 Apr 20, 2026
fa14bb5
Implement get_delete_cascade_discipline_guard_blockers
acwhite211 Apr 21, 2026
9a47326
Add more division delete blocker unit tests
acwhite211 Apr 21, 2026
84d344a
Update test_delete_blockers.py
acwhite211 Apr 22, 2026
23d2e8d
Simplify delete blockers
acwhite211 Apr 22, 2026
efd0ef2
Reformat TestDeleteBlockersCascade
acwhite211 Apr 22, 2026
bdb3ef4
Revert crud.py changes
acwhite211 Apr 22, 2026
c13feb9
Update crud.py
acwhite211 Apr 22, 2026
3b57b8b
Fix policy action order differences between front-end and back-end
acwhite211 Apr 22, 2026
45fd151
Lint code with ESLint and Prettier
acwhite211 Apr 22, 2026
214f53f
fix: don't create duplicate SpLocaleContainerItem records
melton-jason Apr 23, 2026
a198a4c
fix: order duplicate containers and items by ID
melton-jason Apr 24, 2026
d3b5a52
Merge pull request #7999 from specify/issue-7998
acwhite211 Apr 24, 2026
05ef410
Fix form column definition precedence
acwhite211 Apr 27, 2026
8615e2b
Fix column unit test
acwhite211 Apr 27, 2026
885350a
Merge branch 'v7_12_0_5' into issue-7988-2
melton-jason Apr 28, 2026
a6a08e0
fix: prevent overwriting Loan and Gift Schema items
melton-jason Apr 28, 2026
08caee7
fix: stop updating SpLocaleItemStr records in place when managing def…
melton-jason Apr 28, 2026
0809a1b
fix: stop creating SchemaConfig records for ID fields
melton-jason Apr 28, 2026
ed3619c
fix: respect user changes to cot relationship for create_cotype_sploc…
melton-jason Apr 29, 2026
aeda486
refactor: use defaults kwarg in get_or_create for readability
melton-jason Apr 29, 2026
344a139
fix: stop running update_cog_type_fields with fix_schema_config suite
melton-jason Apr 29, 2026
5bcc251
fix: remove one-way data changes from fix_schema_config pipeline
melton-jason Apr 29, 2026
9864d17
fix: correctly unpack get_or_create tuple
melton-jason Apr 29, 2026
822d07e
chore: correct types in TypedDict
melton-jason Apr 29, 2026
e4bc672
fix: assume duplicates may exist for CO -> COT in Schema Config
melton-jason Apr 29, 2026
4a1246f
fix: only create default TectonicUnit ranks if root doesn't exist
melton-jason Apr 29, 2026
1b10f43
fix: change filters to prevent duplicates when matching TectonicUnit …
melton-jason Apr 29, 2026
e8a9608
Merge pull request #8028 from specify/issue-8022
acwhite211 Apr 29, 2026
04788f9
Merge branch 'v7_12_0_5' into issue-7988-2
acwhite211 Apr 29, 2026
0571dc9
fix: prevent duplicate PickListItems being created for SystemCOGTypes
melton-jason Apr 29, 2026
5921ba3
Merge branch 'issue-7988-2' of github.com:specify/specify7 into issue…
melton-jason Apr 29, 2026
888aca8
Merge pull request #8039 from specify/issue-7988-2
melton-jason Apr 29, 2026
52eaa9d
fix: tighten filters for Tectonic key migration functions
melton-jason Apr 29, 2026
c10446e
Merge remote-tracking branch 'origin/main' into v7_12_0_5
melton-jason May 1, 2026
f4690f7
Lint code with ESLint and Prettier
melton-jason May 1, 2026
ddbf76c
Merge branch 'main' into v7_12_0_5
melton-jason May 5, 2026
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
64 changes: 59 additions & 5 deletions specifyweb/backend/delete_blockers/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django import http
from django.db import router, transaction
from django.db.models.deletion import Collector
from django.db.models import ForeignKey

from specifyweb.middleware.general import require_http_methods
from specifyweb.specify.api.crud import (
Expand Down Expand Up @@ -43,13 +44,66 @@ def _collect_delete_blockers(obj, using) -> list[dict]:
collector.collect([obj])
return flatten([
[
{
'table': sub_objs[0].__class__.__name__,
'field': field.name,
'ids': [sub_obj.id for sub_obj in sub_objs]
}
_serialize_delete_blocker(field, sub_objs)
] for field, sub_objs in collector.delete_blockers
])

def _serialize_delete_blocker(field, sub_objs) -> dict:
normalized = _normalize_many_to_many_blocker(field, sub_objs)
if normalized is not None:
return normalized

return {
'table': sub_objs[0].__class__.__name__,
'field': field.name,
'ids': [sub_obj.id for sub_obj in sub_objs]
}

def _normalize_many_to_many_blocker(field, sub_objs) -> dict | None:
through_model = sub_objs[0].__class__
if hasattr(through_model, 'specify_model'):
return None

foreign_keys = [
model_field
for model_field in through_model._meta.fields
if isinstance(model_field, ForeignKey)
]
if len(foreign_keys) != 2:
return None

other_field = next(
(
model_field
for model_field in foreign_keys
if model_field.name != field.name
),
None,
)
if other_field is None:
return None

other_model = other_field.related_model
if not hasattr(other_model, 'specify_model'):
return None

relationship = next(
(
relationship
for relationship in other_model.specify_model.relationships
if getattr(relationship, 'through_model', None) == through_model.__name__
and getattr(relationship, 'through_field', None) == other_field.name
),
None,
)
if relationship is None:
return None

return {
'table': other_model.specify_model.name,
'field': relationship.name,
'ids': [getattr(sub_obj, other_field.attname) for sub_obj in sub_objs],
}

def flatten(l):
return [item for sublist in l for item in sublist]
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
return undefined;
},
catalogNumber: async (resource): Promise<undefined> => {
const preferences = await import(
'../Preferences/collectionPreferences'
).then(({ collectionPreferences }) => collectionPreferences);
const preferences =
await import('../Preferences/collectionPreferences').then(
({ collectionPreferences }) => collectionPreferences
);

const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get(
'uniqueCatalogNumberAccrossComponentAndCO',
Expand Down Expand Up @@ -429,9 +430,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = {
return undefined;
},
catalogNumber: async (resource): Promise<undefined> => {
const preferences = await import(
'../Preferences/collectionPreferences'
).then(({ collectionPreferences }) => collectionPreferences);
const preferences =
await import('../Preferences/collectionPreferences').then(
({ collectionPreferences }) => collectionPreferences
);

const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get(
'uniqueCatalogNumberAccrossComponentAndCO',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type ExpressSearchConfigDialogProps = {
readonly isOpen: boolean;
readonly onClose: () => void;
readonly onSave?: () => void;
}
};

export function ExpressSearchConfigDialog({
isOpen,
Expand Down Expand Up @@ -76,7 +76,7 @@ export function ExpressSearchConfigDialog({
isOpen={isOpen}
onClose={onClose}
>
<ExpressSearchConfigEditor
<ExpressSearchConfigEditor
key={String(isOpen)}
onChangeJSON={handleChangeJSON}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ import { genericTables } from '../DataModel/tables';

function tableLabel(tableName: string): string {
return (
(genericTables[tableName as keyof typeof genericTables]?.label as string | undefined) ??
camelToHuman(tableName)
(genericTables[tableName as keyof typeof genericTables]?.label as
| string
| undefined) ?? camelToHuman(tableName)
);
}

export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onChangeConfig }: any) {
export function ResultsOrderingTab({
config,
relatedQueriesDefinitions = [],
onChangeConfig,
}: any) {
const baseTables = config.tables
.filter((t: any) => t.searchFields.some((sf: any) => sf.inUse !== false))
.map((t: any) => ({
Expand All @@ -29,8 +34,12 @@ export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onC
const activeQueries = config.relatedQueries
.filter((rq: any) => rq.isActive)
.map((rq: any) => {
const def = relatedQueriesDefinitions.find((def: any) => def.id === rq.id);
const title = def?.name ? getExpressSearchQueryTitle(def.name) : undefined;
const def = relatedQueriesDefinitions.find(
(def: any) => def.id === rq.id
);
const title = def?.name
? getExpressSearchQueryTitle(def.name)
: undefined;

if (!def || !title || title === String(def.name)) {
return undefined;
Expand Down Expand Up @@ -86,7 +95,9 @@ export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onC

return (
<div className="flex flex-col gap-2 h-full min-h-[400px]">
<h3 className="font-bold mb-2">{expressSearchConfigText.configureResultsOrdering()}</h3>
<h3 className="font-bold mb-2">
{expressSearchConfigText.configureResultsOrdering()}
</h3>
<p className="text-sm text-gray-500 mb-4">
{expressSearchConfigText.reorderResultsOrderingDescription()}
</p>
Expand All @@ -99,7 +110,10 @@ export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onC
>
<span className="font-medium">{item.label}</span>
<div className="flex gap-2">
<Button.BorderedGray disabled={index === 0} onClick={() => moveItem(index, 'up')}>
<Button.BorderedGray
disabled={index === 0}
onClick={() => moveItem(index, 'up')}
>
{icons.chevronUp}
</Button.BorderedGray>
<Button.BorderedGray
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ const mockConfigResponse = {
tableName: 'CollectionObject',
displayOrder: 0,
searchFields: [],
displayFields: []
}
displayFields: [],
},
],
relatedQueries: []
relatedQueries: [],
},
related_queries_definitions: [],
schema_metadata: [
{
name: 'CollectionObject',
title: 'Collection Object',
fields: []
}
]
fields: [],
},
],
};

describe('ExpressSearchConfigEditor', () => {
Expand All @@ -65,42 +65,43 @@ describe('ExpressSearchConfigEditor', () => {
});

expect(onChangeJSON).toHaveBeenCalled();
const latestConfig = onChangeJSON.mock.calls[onChangeJSON.mock.calls.length - 1][0];
const latestConfig =
onChangeJSON.mock.calls[onChangeJSON.mock.calls.length - 1][0];
expect(latestConfig.tables[0].tableName).toBe('Agent');
expect(latestConfig.tables[0].searchFields[0].fieldName).toBe('firstName');
});

test('renders loading state initially', async () => {
const { getByText } = mount(
<ExpressSearchConfigEditor
onChange={jest.fn()}
onSetCleanup={jest.fn()}
<ExpressSearchConfigEditor
onChange={jest.fn()}
onSetCleanup={jest.fn()}
/>
);
expect(getByText('Loading...')).toBeInTheDocument();

// Wait for it to finish loading to avoid act warnings
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
});

test('renders tabs after data load', async () => {
const { findByRole } = mount(
<ExpressSearchConfigEditor
onChange={jest.fn()}
onSetCleanup={jest.fn()}
<ExpressSearchConfigEditor
onChange={jest.fn()}
onSetCleanup={jest.fn()}
/>
);

expect(await findByRole('tablist')).toBeInTheDocument();
});

test('switches tabs correctly', async () => {
const { findByText, getByRole, user } = mount(
<ExpressSearchConfigEditor
onChange={jest.fn()}
onSetCleanup={jest.fn()}
<ExpressSearchConfigEditor
onChange={jest.fn()}
onSetCleanup={jest.fn()}
/>
);

Expand All @@ -112,7 +113,7 @@ describe('ExpressSearchConfigEditor', () => {
await act(async () => {
await user.click(relatedTab);
});

expect(await findByText('Related Tables Tab')).toBeInTheDocument();

// Click Results Ordering
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ describe('RelatedTablesTab', () => {

expect(onChangeConfig).toHaveBeenCalledTimes(1);
const newConfig = onChangeConfig.mock.calls[0][0];
expect(newConfig.relatedQueries.find((rq: any) => rq.id === '2').isActive).toBe(true);
expect(
newConfig.relatedQueries.find((rq: any) => rq.id === '2').isActive
).toBe(true);

const activeRow = rows[0];
const activeCheckbox = activeRow.querySelector('input[type="checkbox"]');
Expand All @@ -54,6 +56,8 @@ describe('RelatedTablesTab', () => {

expect(onChangeConfig).toHaveBeenCalledTimes(2);
const secondConfig = onChangeConfig.mock.calls[1][0];
expect(secondConfig.relatedQueries.find((rq: any) => rq.id === '1').isActive).toBe(false);
expect(
secondConfig.relatedQueries.find((rq: any) => rq.id === '1').isActive
).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ describe('ResultsOrderingTab', () => {
displayFields: [],
},
],
relatedQueries: [
{ id: '8', isActive: true, displayOrder: 1 },
],
relatedQueries: [{ id: '8', isActive: true, displayOrder: 1 }],
};

const onChangeConfig = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ export function FormTable<SCHEMA extends AnySchema>({
resource.cid,
Boolean(
resource.specifyTable.name === 'Preparation' &&
collectionPreparationPref &&
resource.isNew()
collectionPreparationPref &&
resource.isNew()
),
])
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,30 @@ describe('parseFormDefinition', () => {
describe('getColumnDefinitions', () => {
requireContext();

test('prefers linux definition over generic definition', () =>
expect(
getColumnDefinitions(
xml(
`<viewdef>
<columnDef>Generic</columnDef>
<columnDef os="lnx">Linux</columnDef>
</viewdef>`
)
)
).toBe('Linux'));

test('uses generic definition if linux definition is not available', () =>
expect(
getColumnDefinitions(
xml(
`<viewdef>
<columnDef os="mac">Mac</columnDef>
<columnDef>Generic</columnDef>
</viewdef>`
)
)
).toBe('Generic'));

test('fall back to first definition available', () =>
expect(
getColumnDefinitions(
Expand Down Expand Up @@ -510,7 +534,7 @@ theories(getColumnDefinition, [
),
undefined,
],
out: 'B',
out: 'A',
},
]);

Expand Down
25 changes: 21 additions & 4 deletions specifyweb/frontend/js_src/lib/components/FormParse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const formTypes = ['form', 'formTable'] as const;
export type FormType = (typeof formTypes)[number];
export type FormMode = 'edit' | 'search' | 'view';

const defaultColumnDefinitionOs = 'lnx';

let views: R<ViewDefinition | undefined> = {};

export const getViewSetApiUrl = (viewName: string): string =>
Expand Down Expand Up @@ -540,10 +542,25 @@ function getColumnDefinitions(viewDefinition: SimpleXmlNode): string {
const getColumnDefinition = (
viewDefinition: SimpleXmlNode,
os: string | undefined
): string | undefined =>
viewDefinition.children.columnDef?.find((child) =>
typeof os === 'string' ? getParsedAttribute(child, 'os') === os : true
)?.text;
): string | undefined => {
const columnDefinitions = viewDefinition.children.columnDef;
if (columnDefinitions === undefined) return undefined;

if (typeof os === 'string')
return columnDefinitions.find(
(child) => getParsedAttribute(child, 'os') === os
)?.text;

return (
columnDefinitions.find(
(child) => getParsedAttribute(child, 'os') === defaultColumnDefinitionOs
)?.text ??
columnDefinitions.find(
(child) => getParsedAttribute(child, 'os') === undefined
)?.text ??
columnDefinitions[0]?.text
);
};

const parseRows = async (
rawRows: RA<SimpleXmlNode>,
Expand Down
Loading
Loading