Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a848e67
Sp6 to Sp7 add missing constraints and tables
acwhite211 Jan 12, 2026
0a88e90
datamodel additions
acwhite211 Jan 13, 2026
6714b4f
add table model ids
acwhite211 Jan 13, 2026
ad10717
allow multi-field primary keys in the datamodel
acwhite211 Jan 13, 2026
f2981d5
modeling naming fiexes
acwhite211 Jan 13, 2026
6aa8762
add Sgrbatchmatchresultset and Sgrmatchconfiguration
acwhite211 Jan 13, 2026
863e1aa
fix field index issue
acwhite211 Jan 14, 2026
19f92ae
Merge branch 'main' into issue-7551
acwhite211 Jan 14, 2026
c9593de
add missing migration commands
acwhite211 Jan 14, 2026
20a6ebe
migration error fix in components
acwhite211 Jan 14, 2026
cf692fe
discline id check
acwhite211 Jan 14, 2026
dc5f505
init fix of unit tests
acwhite211 Jan 14, 2026
c4b7a65
fix base predicates
acwhite211 Jan 14, 2026
974251d
temp
acwhite211 Jan 14, 2026
4038313
patch path fix
acwhite211 Jan 15, 2026
8e9b25d
another patch path fix
acwhite211 Jan 15, 2026
927557f
sqlalchemy build models with multi primary key fields
acwhite211 Jan 16, 2026
9da909b
predicates safe filtering to fix unit test issues
acwhite211 Jan 16, 2026
46ba371
update_locality uiformatter fix
acwhite211 Jan 16, 2026
17da256
Merge branch 'main' into issue-7551
acwhite211 Jan 16, 2026
4b20be6
Lint code with ESLint and Prettier
acwhite211 Jan 16, 2026
766f2e9
add a skip option in the datamodel for sqlalchemy
acwhite211 Jan 23, 2026
b3cdd53
datamodel Table, add skip field
acwhite211 Jan 23, 2026
ec63f06
filter out skipped tables
acwhite211 Jan 23, 2026
9fe5f4a
back populate test fixes
acwhite211 Jan 23, 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
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_localityupdate_not_exist(self):
self._assertStatusCodeEqual(response, http.HttpResponseNotFound.status_code)
self.assertEqual(response.content.decode(), f"The localityupdate with task id '{task_id}' was not found")

@patch("specifyweb.specify.views.update_locality_task.AsyncResult")
@patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult")
def test_failed(self, AsyncResult: Mock):
mock_result = Mock()
mock_result.state = CELERY_TASK_STATE.FAILURE
Expand Down Expand Up @@ -70,7 +70,7 @@ def test_failed(self, AsyncResult: Mock):
}
)

@patch("specifyweb.specify.views.update_locality_task.AsyncResult")
@patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult")
def test_parse_failed(self, AsyncResult: Mock):
mock_result = Mock()
mock_result.state = CELERY_TASK_STATE.SUCCESS
Expand Down Expand Up @@ -98,7 +98,7 @@ def test_parse_failed(self, AsyncResult: Mock):
}
)

@patch("specifyweb.specify.views.update_locality_task.AsyncResult")
@patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult")
def test_parsed(self, AsyncResult: Mock):
mock_result = Mock()
mock_result.state = CELERY_TASK_STATE.SUCCESS
Expand Down Expand Up @@ -149,7 +149,7 @@ def test_parsed(self, AsyncResult: Mock):
}
)

@patch("specifyweb.specify.views.update_locality_task.AsyncResult")
@patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult")
def test_succeeded(self, AsyncResult: Mock):
mock_result = Mock()
mock_result.state = LocalityUpdateStatus.SUCCEEDED
Expand Down Expand Up @@ -181,7 +181,7 @@ def test_succeeded(self, AsyncResult: Mock):
}
)

@patch("specifyweb.specify.views.update_locality_task.AsyncResult")
@patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult")
def test_succeeded_locality_rows(self, AsyncResult: Mock):
mock_result = Mock()
mock_result.state = LocalityUpdateStatus.SUCCEEDED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_no_ui_formatter(self):

self.assertEqual(parsed_with_value, parsed_with_value_result)

@patch("specifyweb.specify.update_locality.get_uiformatter")
@patch("specifyweb.backend.locality_update_tool.update_locality.get_uiformatter")
def test_cnn_formatter(self, get_uiformatter: Mock):

get_uiformatter.return_value = UIFormatter(
Expand Down
16 changes: 12 additions & 4 deletions specifyweb/backend/locality_update_tool/update_locality.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,11 +379,19 @@ def parse_locality_set(collection, raw_headers: list[str], data: list[list[str]]
locality_id: int | None = None if len(
locality_query) != 1 else locality_query[0].id

parsed_locality_fields = [parse_field(
collection, 'Locality', dict['field'], dict['value'], locality_id, row_number) for dict in locality_values if dict['value'].strip() != ""]
parsed_locality_fields = [
parse_field(
collection, 'Locality', d['field'], d['value'], locality_id, row_number
)
for d in locality_values
]

parsed_geocoorddetail_fields = [parse_field(
collection, 'Geocoorddetail', dict["field"], dict['value'], locality_id, row_number) for dict in geocoorddetail_values if dict['value'].strip() != ""]
parsed_geocoorddetail_fields = [
parse_field(
collection, 'Geocoorddetail', d['field'], d['value'], locality_id, row_number
)
for d in geocoorddetail_values
]

parsed_row, parsed_errors = merge_parse_results(
[*parsed_locality_fields, *parsed_geocoorddetail_fields], locality_id, row_number)
Expand Down
3 changes: 2 additions & 1 deletion specifyweb/backend/merge/record_merging.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ def record_merge_fx(model_name: str, old_model_ids: list[int], new_model_id: int

# Get all of the columns in all of the tables of specify the are foreign keys referencing model ID
foreign_key_cols = []
for table in spmodels.datamodel.tables:
# for table in spmodels.datamodel.tables:
for table in (t for t in spmodels.datamodel.tables if not getattr(t, "skip", False)):
for relationship in table.relationships:
if relationship.relatedModelName.lower() == model_name.lower() and not relationship.type.endswith('to-many'):
foreign_key_cols.append((table.name, relationship.name))
Expand Down
94 changes: 76 additions & 18 deletions specifyweb/backend/stored_queries/build_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def make_table(datamodel: Datamodel, tabledef: Table):

def make_foreign_key(datamodel: Datamodel, reldef: Relationship):
remote_tabledef = datamodel.get_table(reldef.relatedModelName) # TODO: this could be a method of relationship
if remote_tabledef is None:
if remote_tabledef is None or getattr(remote_tabledef, "skip", False):
return

fk_target = '.'.join((remote_tabledef.table, remote_tabledef.idColumn))
Expand Down Expand Up @@ -84,7 +84,7 @@ def make_column(flddef: Field):
}

def make_tables(datamodel: Datamodel):
return {td.table: make_table(datamodel, td) for td in datamodel.tables}
return {td.table: make_table(datamodel, td) for td in iter_included_tables(datamodel)}

def make_classes(datamodel: Datamodel):
def make_class(tabledef):
Expand All @@ -97,7 +97,7 @@ def make_class(tabledef):
},
)

return {td.name: make_class(td) for td in datamodel.tables}
return {td.name: make_class(td) for td in iter_included_tables(datamodel)}

def map_classes(datamodel: Datamodel, tables: list[Table], classes):

Expand All @@ -106,22 +106,75 @@ def map_class(tabledef):
table = tables[ tabledef.table ]

def make_relationship(reldef):
if not hasattr(reldef, 'column') or not reldef.column or reldef.relatedModelName not in classes:
has_back_populates = False
if reldef.relatedModelName not in classes:
return

remote_class = classes[ reldef.relatedModelName ]
column = getattr(table.c, reldef.column)

relationship_args = {'foreign_keys': column}
if remote_class is cls:
relationship_args['remote_side'] = table.c[ tabledef.idColumn ]

if hasattr(reldef, 'otherSideName') and reldef.otherSideName:
backref_args = {'uselist': reldef.type != 'one-to-one'}

relationship_args['backref'] = orm.backref(reldef.otherSideName, **backref_args)

return reldef.name, orm.relationship(remote_class, **relationship_args)
remote_class = classes[reldef.relatedModelName]
remote_tabledef = datamodel.get_table(reldef.relatedModelName)
remote_table = tables.get(remote_tabledef.table) if remote_tabledef else None

# Handle standard to-one relationships with an explicit column.
if getattr(reldef, "column", None):
column = getattr(table.c, reldef.column)
relationship_args = {"foreign_keys": column}

if remote_class is cls:
relationship_args["remote_side"] = table.c[tabledef.idColumn]

# If the remote side declares a one-to-many back-link without a column,
# wire the two sides together.
reverse_one_to_many = None
if remote_tabledef:
reverse_one_to_many = next(
(
r
for r in remote_tabledef.relationships
if r.relatedModelName == tabledef.name
and r.type == "one-to-many"
),
None,
)
if reverse_one_to_many is not None:
relationship_args["back_populates"] = reverse_one_to_many.name
has_back_populates = True

if (not has_back_populates) and getattr(reldef, "otherSideName", None):
backref_args = {"uselist": reldef.type != "one-to-one"}
relationship_args["backref"] = orm.backref(
reldef.otherSideName, **backref_args
)

return reldef.name, orm.relationship(remote_class, **relationship_args)

# Handle one-to-many relationships defined on the parent side (no column specified).
if reldef.type == "one-to-many" and remote_table is not None:
# Find a reverse many-to-one pointing back to this table with a FK column.
reverse_rel = next(
(
r
for r in remote_tabledef.relationships
if r.relatedModelName == tabledef.name
and getattr(r, "column", None)
and r.type in ("many-to-one", "one-to-one")
),
None,
)
if reverse_rel is None:
return

fk_column = remote_table.c[reverse_rel.column]
relationship_args = {
"foreign_keys": fk_column,
"primaryjoin": table.c[tabledef.idColumn] == fk_column,
}

# Keep both sides linked when possible.
relationship_args["back_populates"] = reverse_rel.name

return reldef.name, orm.relationship(remote_class, **relationship_args)

return

id_column = table.c[tabledef.idColumn]
properties = {
Expand All @@ -139,6 +192,11 @@ def make_relationship(reldef):

orm.mapper(cls, table, properties=properties)

for tabledef in datamodel.tables:
for tabledef in iter_included_tables(datamodel):
map_class(tabledef)

def iter_included_tables(datamodel: Datamodel):
for td in datamodel.tables:
if getattr(td, "skip", False):
continue
yield td
7 changes: 6 additions & 1 deletion specifyweb/backend/stored_queries/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ def validate_sqlalchemy_model(datamodel_table):
known_fields = datamodel_table.all_fields

for field in known_fields:
if field.is_relationship:
remote_td = spmodels.datamodel.get_table(field.relatedModelName)
if remote_td is not None and getattr(remote_td, "skip", False):
continue

in_sql = getattr(orm_table, field.name, None) or getattr(
orm_table, field.name.lower(), None
Expand Down Expand Up @@ -244,7 +248,8 @@ def validate_sqlalchemy_model(datamodel_table):
return {key: value for key, value in table_errors.items() if len(value) > 0}

def test_sqlalchemy_model_errors(self):
for table in spmodels.datamodel.tables:
# for table in spmodels.datamodel.tables:
for table in (t for t in spmodels.datamodel.tables if not getattr(t, "skip", False)):
table_errors = SQLAlchemyModelTest.validate_sqlalchemy_model(table)
self.assertTrue(
len(table_errors) == 0 or table.name in expected_errors,
Expand Down
7 changes: 6 additions & 1 deletion specifyweb/backend/stored_queries/tests/tests_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ def validate_sqlalchemy_model(datamodel_table):
known_fields = datamodel_table.all_fields

for field in known_fields:
if field.is_relationship:
remote_td = spmodels.datamodel.get_table(field.relatedModelName)
if remote_td is not None and getattr(remote_td, "skip", False):
continue

in_sql = getattr(orm_table, field.name, None) or getattr(orm_table, field.name.lower(), None)

Expand Down Expand Up @@ -385,7 +389,8 @@ def validate_sqlalchemy_model(datamodel_table):

class SQLAlchemyModelTest(TestCase):
def test_sqlalchemy_model_errors(self):
for table in spmodels.datamodel.tables:
# for table in spmodels.datamodel.tables:
for table in (t for t in spmodels.datamodel.tables if not getattr(t, "skip", False)):
table_errors = validate_sqlalchemy_model(table)
self.assertTrue(len(table_errors) == 0 or table.name in expected_errors, f"Did not find {table.name}. Has errors: {table_errors}")
if 'not_found' in table_errors:
Expand Down
40 changes: 35 additions & 5 deletions specifyweb/backend/trees/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,18 +411,48 @@ def synonymize(node, into, agent, user=None, collection=None):

# This check can be disabled by a remote pref
import specifyweb.backend.context.app_resource as app_resource
collection_prefs_json, _, __ = app_resource.get_app_resource(collection, user, 'CollectionPreferences')
if collection_prefs_json is not None:
collection_prefs_dict = json.loads(collection_prefs_json)

treeManagement_pref = collection_prefs_dict.get('treeManagement', {})
collection_prefs_dict = {} # always defined

res = app_resource.get_app_resource(collection, user, 'CollectionPreferences')
force_checks = (collection is None or user is None)
if res is not None:
collection_prefs_json, _, __ = res
if collection_prefs_json:
try:
collection_prefs_dict = json.loads(collection_prefs_json) or {}
except Exception:
collection_prefs_dict = {}

import specifyweb.backend.context.app_resource as app_resource

treeManagement_pref = collection_prefs_dict.get('treeManagement', {})
if force_checks and target.children.exists():
raise TreeBusinessRuleException(
f'Synonymizing "{node.fullname}" to "{into.fullname}" which has children',
{"tree": "Taxon",
"localizationKey": "nodeSynonimizeWithChildren",
"node": {
"id": node.id,
"rankid": node.rankid,
"fullName": node.fullname,
"children": list(node.children.values('id', 'fullname'))
},
"parent": {
"id": into.id,
"rankid": into.rankid,
"fullName": into.fullname,
"parentid": into.parent.id,
"children": list(into.children.values('id', 'fullname'))
}}
)
force_checks = (collection is None or user is None)
synonymized = treeManagement_pref.get('synonymized', {}) \
if isinstance(treeManagement_pref, dict) else {}

add_synonym_enabled = synonymized.get(r'^sp7\.allow_adding_child_to_synonymized_parent\.' + node.specify_model.name + '=(.+)', False) if isinstance(synonymized, dict) else False

if node.children.count() > 0 and (add_synonym_enabled is True):
if node.children.count() > 0 and (force_checks or add_synonym_enabled is False):
raise TreeBusinessRuleException(
f'Synonymizing node "{node.fullname}" which has children',
{"tree" : "Taxon",
Expand Down
Loading
Loading