Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
534e726
Merge branch 'main' into v7_12_0_5
grantfitzsimmons May 19, 2026
fbe2cf1
Merge branch 'main' into v7_12_0_5
grantfitzsimmons May 20, 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 @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ export const getDerivedPermissions = () => derivedPermissions;
const sortPolicies = (policy: typeof operationPolicies) =>
JSON.stringify(
Object.fromEntries(
Object.entries(policy).sort(sortFunction(([key]) => key))
Object.entries(policy)
.sort(sortFunction(([key]) => key))
.map(([key, actions]) => [
key,
Array.from(actions).sort(sortFunction((action) => action)),
])
)
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,38 +59,50 @@ def apply_schema_overrides_for_all_disciplines(_apps):
)
apply_schema_defaults_task.apply(args=[discipline.id])

# PERF: The vast majority of these can be collapsed to a single call to
# update_table_schema_config_with_defaults
funcs = [
# usc.update_all_table_schema_config_with_defaults,
usc.create_geo_table_schema_config_with_defaults, # specify 0002
usc.create_cotype_splocalecontaineritem, # specify 0003
usc.create_strat_table_schema_config_with_defaults, # specify 0004 - getting skip warnings
usc.create_agetype_picklist, # specify 0004
usc.update_cog_type_fields, # specify 0007
# BUG: This should really only be run in the context of the migration,
# and not on startup. See the below BUG comment above usc.update_hidden_prop
# usc.update_cog_type_fields, # specify 0007
usc.create_cogtype_picklist, # specify 0007
usc.update_cogtype_splocalecontaineritem, # specify 0007
usc.update_systemcogtypes_picklist, # specify 0007
usc.update_cogtype_type_splocalecontaineritem, # specify 0007
# BUG: These also shouldn't be run with this suite. These are one way
# data migrations in the contect of migrations meant to resolve
# eariler migrations.
# The functions can be destructive as we can't really discern whether
# or not these functions should be applied
# usc.update_cogtype_splocalecontaineritem, # specify 0007
# usc.update_systemcogtypes_picklist, # specify 0007
# usc.update_cogtype_type_splocalecontaineritem, # specify 0007
usc.update_relative_age_fields, # specify 0008
usc.add_cojo_to_schema_config, # specify 0012
usc.update_cog_schema_config, # specify 0013
usc.update_age_schema_config, # specify 0015
usc.schemaconfig_fixes, # specify 0017
usc.add_cot_catnum_to_schema, # specify 0018
# usc.schemaconfig_fixes, # specify 0017
# usc.add_cot_catnum_to_schema, # specify 0018
usc.add_tectonicunit_to_pc_in_schema_config, # specify 0020
usc.fix_hidden_geo_prop, # specify 0021
usc.update_schema_config_field_desc, # specify 0023
usc.update_hidden_prop, # specify 0023
# usc.fix_hidden_geo_prop, # specify 0021
# usc.update_schema_config_field_desc, # specify 0023
# BUG: We can't reliably run this function at startup, as there is no
# easy way to differentiate Schema Config tables/fields that should or
# should not be updated for already existing Disciplines.
# usc.update_hidden_prop, # specify 0023
usc.update_storage_unique_id_fields, # specify 0024
usc.update_co_children_fields, # specify 0027
usc.remove_collectionobject_parentco, # specify 0029
usc.add_quantities_gift, # specify 0032
usc.update_paleo_desc, # specify 0033
usc.update_accession_date_fields, # specify 0034
# usc.update_co_children_fields, # specify 0027
# usc.remove_collectionobject_parentco, # specify 0029
# usc.add_quantities_gift, # specify 0032
# usc.update_paleo_desc, # specify 0033
# usc.update_accession_date_fields, # specify 0034
usc.update_loan_and_gift_agent_fields, # specify 0039
usc.update_loan_and_gift_agents, # specify 0039
usc.componets_schema_config_migrations, # specify 0040
usc.remove_componentparent_item, # specify 0040
usc.create_table_schema_config_with_defaults, # specify 0040
usc.create_discipline_type_picklist, # specify 0042
usc.update_discipline_type_splocalecontaineritem, # specify 0042
# usc.update_discipline_type_splocalecontaineritem, # specify 0042
apply_schema_overrides_for_all_disciplines,
usc.deduplicate_schema_config_orm,
]
Expand Down
15 changes: 8 additions & 7 deletions specifyweb/specify/migration_utils/default_cots.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def create_cogtype_type_picklist(apps, using='default'):
Picklistitem = apps.get_model('specify', 'Picklistitem')

for collection in Collection.objects.using(using).all():
cog_type_picklist, _ = Picklist.objects.using(using).get_or_create(
cog_type_picklist, picklist_created = Picklist.objects.using(using).get_or_create(
name='SystemCOGTypes', # Default Collection Object Group Types
type=0,
collection=collection,
Expand All @@ -77,12 +77,13 @@ def create_cogtype_type_picklist(apps, using='default'):
"readonly": False,
}
)
for cog_type in DEFAULT_COG_TYPES:
Picklistitem.objects.using(using).get_or_create(
title=cog_type,
value=cog_type,
picklist=cog_type_picklist
)
if picklist_created:
for cog_type in DEFAULT_COG_TYPES:
Picklistitem.objects.using(using).get_or_create(
title=cog_type,
value=cog_type,
picklist=cog_type_picklist
)

COTYPE_PICKLIST_NAME = 'CollectionObjectType'
FIELD_NAME = 'collectionObjectType'
Expand Down
17 changes: 0 additions & 17 deletions specifyweb/specify/migration_utils/sp7_schemaconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,23 +411,6 @@
'Gift': ['agent1', 'agent2', 'agent3', 'agent4', 'agent5'],
}

MIGRATION_0038_UPDATE_FIELDS = {
'Loan': [
('agent1','Agent 1','Agent 1'),
('agent2','Agent 2','Agent 2'),
('agent3','Agent 3','Agent 3'),
('agent4','Agent 4','Agent 4'),
('agent5','Agent 5','Agent 5'),
],
'Gift': [
('agent1','Agent 1','Agent 1'),
('agent2','Agent 2','Agent 2'),
('agent3','Agent 3','Agent 3'),
('agent4','Agent 4','Agent 4'),
('agent5','Agent 5','Agent 5'),
]
}

MIGRATION_0040_TABLES = [
('Component', None),
]
Expand Down
32 changes: 24 additions & 8 deletions specifyweb/specify/migration_utils/tectonic_ranks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,28 @@ def create_default_tectonic_ranks(apps):
if not tectonic_tree_def:
tectonic_tree_def, _ = TectonicTreeDef.objects.get_or_create(name="Tectonic Unit", discipline=discipline)

root, _ = TectonicUnitTreeDefItem.objects.get_or_create(
name="Root",
title="Root",
root, root_created = TectonicUnitTreeDefItem.objects.get_or_create(
rankid=0,
parent=None,
treedef=tectonic_tree_def,
isenforced=True
defaults={
"name": "Root",
"title": "Root",
"isenforced": True
}
)
# The root rank already exists in some capacity in the Discipline
# We can assume the user has made modifications to the tree at this
# point, so shouldn't go further with checking/creating lower ranks
if not root_created:
# BUG?: handle setting the tectonicunittreedef on the Discipline
# here? We can probably practically assume it's already set if the
# root node exists.
continue

# At this point, these get_or_create calls should always be the
# equivalent of create (as we know the root node didn't exist).
# But keeping the get_or_create here just because
superstructure, _ = TectonicUnitTreeDefItem.objects.get_or_create(
name="Superstructure",
title="Superstructure",
Expand Down Expand Up @@ -91,23 +105,25 @@ def create_root_tectonic_node(apps):

for discipline in Discipline.objects.all():

tectonic_tree_def = TectonicUnitTreeDef.objects.filter(name="Tectonic Unit", discipline=discipline).first()
tectonic_tree_def = TectonicUnitTreeDef.objects.filter(discipline=discipline).first()
if not tectonic_tree_def:
tectonic_tree_def, is_created = TectonicUnitTreeDef.objects.get_or_create(
name="Tectonic Unit",
discipline=discipline
)

tectonic_tree_def_item = TectonicUnitTreeDefItem.objects.filter(treedef=tectonic_tree_def, name="Root").first()
tectonic_tree_def_item = TectonicUnitTreeDefItem.objects.filter(treedef=tectonic_tree_def, rankid=0, parent=None).first()
if not tectonic_tree_def_item:
tectonic_tree_def_item, is_created = TectonicUnitTreeDefItem.objects.get_or_create(
name="Root",
title="Root",
treedef=tectonic_tree_def,
rankid=0,
parent=None,
isenforced=True
)

root = TectonicUnit.objects.filter(name="Root", definition=tectonic_tree_def).first()
root = TectonicUnit.objects.filter(definition=tectonic_tree_def, definitionitem=tectonic_tree_def_item, rankid=0, parent=None).first()
if not root:
root, is_created = TectonicUnit.objects.get_or_create(
name="Root",
Expand All @@ -123,7 +139,7 @@ def create_root_tectonic_node(apps):
if is_created:
logger.info(f"Created root tectonic unit for discipline {discipline.name}")

TectonicUnitTreeDefItem.objects.filter(rankid=0, isenforced__isnull=True).update(isenforced=True)
TectonicUnitTreeDefItem.objects.filter(parent=None,rankid=0, isenforced__isnull=True).update(isenforced=True)

def revert_create_root_tectonic_node(apps, schema_editor=None):
TectonicUnit = apps.get_model('specify', 'TectonicUnit')
Expand Down
Loading
Loading