Skip to content
Merged
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
4782309
fix: use app and label name instead of object reference to determine …
melton-jason Feb 6, 2026
6aa744b
Update schema_localization.py
grantfitzsimmons Mar 4, 2026
ca1846f
fix: don't limit fetched agents for security and accounts
melton-jason Mar 5, 2026
4414c30
fix: set correct division id when creating agents for user
melton-jason Mar 11, 2026
75adbdd
Revert Back-end changes from Handle Disambiguation Case for Host Taxo…
acwhite211 Apr 15, 2026
117c2f6
Revert queryfieldspec changes from Handle Disambiguation Case for Hos…
acwhite211 Apr 15, 2026
4678a0d
Fix queryfieldspec.py revert
acwhite211 Apr 15, 2026
67ea5b6
Merge pull request #7981 from specify/revert-disambiguation-v7.12.0.3
melton-jason Apr 15, 2026
3dddf07
Merge tag 'v7.12.0.3' into v7.11.4-paris
melton-jason Apr 15, 2026
da6c6ff
Fix crash for negated tree rank field query filters
acwhite211 Apr 16, 2026
c520e66
Add negated filter query unit test
acwhite211 Apr 16, 2026
df9ddc7
Fix blank partial date values in QB results
acwhite211 Apr 16, 2026
d0aee0f
Add a a unit test for partial date queries
acwhite211 Apr 16, 2026
46556f3
Add data_part fix from #7970
acwhite211 Apr 16, 2026
987ce2e
Merge pull request #7987 from specify/issue-7986
acwhite211 Apr 16, 2026
cb298fb
Merge pull request #7986 from specify/issue-7982
acwhite211 Apr 16, 2026
0f059cd
fix: prevent duplicate splocalecontainer records when running migrations
melton-jason Apr 17, 2026
93c4b52
fix: deduplicate existing container records
melton-jason Apr 17, 2026
7aee4d8
Merge branch 'v7_12_0_4' into issue-7988
grantfitzsimmons Apr 17, 2026
3e784eb
Merge pull request #7989 from specify/issue-7988
grantfitzsimmons Apr 17, 2026
edb7e72
fix: hide collection preferences when user doesn't have permission
melton-jason Apr 17, 2026
9d1ba91
fix: correct app resource tests
melton-jason Apr 17, 2026
5543b34
Lint code with ESLint and Prettier
melton-jason Apr 17, 2026
21fb356
fix: correct SpAppResourceDir filter and discipline type
melton-jason Apr 17, 2026
c488a1a
fix: deduplicate SpAppResourceDirs scoped to discipline
melton-jason Apr 17, 2026
545caf1
Merge pull request #7993 from specify/issue-7984-2
grantfitzsimmons Apr 18, 2026
87d9411
Merge pull request #7990 from specify/issue-7984
grantfitzsimmons Apr 18, 2026
5840503
avoid null task warning
acwhite211 Apr 17, 2026
2faec3d
Prevent startup permission initialization from re-granting legacy col…
acwhite211 Apr 17, 2026
6e15f7b
refactor: use apply over run when running celery task locally
melton-jason Apr 18, 2026
d20c17d
Merge pull request #7995 from specify/key-migration-fixes-2
melton-jason Apr 18, 2026
61a3ff2
Merge branch 'v7.12.0.3-paris' into v7.12.0.4-paris
melton-jason Apr 20, 2026
528950c
Merge branch 'main' into v7.12.0.4-paris
acwhite211 Apr 22, 2026
b79cc60
test fixes
acwhite211 Apr 22, 2026
8af9adc
Merge branch 'main' into v7.12.0.4-paris
acwhite211 Apr 24, 2026
20bb77b
fix(schema): skip idField
grantfitzsimmons Apr 28, 2026
03bf3df
Fix stored query tree rank join caching
acwhite211 Apr 28, 2026
665039a
Stabilize AppResources permission mocks
acwhite211 Apr 28, 2026
b9c797f
Clear unload protection after dependent QueryComboBox saves
acwhite211 Apr 28, 2026
e8e0980
Make app resource directory dedupe deterministic
acwhite211 Apr 28, 2026
8f15b93
Serialize schema config row creation and preserve children during dedupe
acwhite211 Apr 28, 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
16 changes: 11 additions & 5 deletions specifyweb/backend/permissions/initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,22 @@ def is_sp6_user_permissions_migrated(user, apps=apps) -> bool:
return UserRole.objects.filter(specifyuser=user).exists() or \
UserPolicy.objects.filter(specifyuser=user).exists()

def initialize(wipe: bool=False, apps=apps) -> None:
def initialize(
wipe: bool = False,
apps=apps,
*,
migrate_sp6_users: bool = True,
) -> None:
with transaction.atomic():
if wipe:
wipe_permissions(apps)
create_admins(apps)
create_roles(apps)
if 'test' in ''.join(sys.argv):
assign_users_to_roles_during_testing(apps)
else:
assign_users_to_roles(apps)
if migrate_sp6_users:
if 'test' in ''.join(sys.argv):
assign_users_to_roles_during_testing(apps)
else:
assign_users_to_roles(apps)

def create_admins(apps=apps) -> None:
UserPolicy = apps.get_model('permissions', 'UserPolicy')
Expand Down
14 changes: 9 additions & 5 deletions specifyweb/backend/setup_tool/app_resource_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ def _ensure_discipline_resource_dir(
Spappresourcedir.objects.filter(
discipline=discipline,
collection__isnull=True,
specifyuser__isnull=True,
usertype__isnull=True,
ispersonal=False
)
Expand All @@ -100,16 +99,21 @@ def _ensure_discipline_resource_dir(
return (
Spappresourcedir.objects.create(
discipline=discipline,
disciplinetype=discipline.type,
# This is intentional and not a typo.
# DisciplineType is actually the Discipline Name for
# SpAppResourceDir records...
# This is another weird behavior from Specify 6 :/
# See #7984
disciplinetype=discipline.name,
ispersonal=False,
),
True,
False,
)

was_updated = False
if existing_dir.disciplinetype != discipline.type:
existing_dir.disciplinetype = discipline.type
if existing_dir.disciplinetype != discipline.name:
existing_dir.disciplinetype = discipline.name
existing_dir.save(update_fields=['disciplinetype'])
was_updated = True

Expand All @@ -125,7 +129,7 @@ def ensure_all_discipline_resource_dirs() -> dict[str, int]:
created = 0
updated = 0

for discipline in Discipline.objects.only('id', 'type'):
for discipline in Discipline.objects.only('id', 'name'):
total += 1
_, was_created, was_updated = _ensure_discipline_resource_dir(discipline)
if was_created:
Expand Down
8 changes: 6 additions & 2 deletions specifyweb/backend/setup_tool/schema_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def enqueue() -> None:
@app.task(bind=True, max_retries=SCHEMA_DEFAULTS_MISSING_DISCIPLINE_MAX_RETRIES)
def apply_schema_defaults_task(self, discipline_id: int):
"""Run schema localization defaults for one discipline in a background worker."""
task_id = getattr(self.request, 'id', None)

try:
discipline = Discipline.objects.get(id=discipline_id)
except Discipline.DoesNotExist as exc:
Expand All @@ -98,9 +100,11 @@ def apply_schema_defaults_task(self, discipline_id: int):
discipline_id,
SCHEMA_DEFAULTS_MISSING_DISCIPLINE_MAX_RETRIES,
)
finish_discipline_background_task(discipline_id, self.request.id)
if task_id is not None:
finish_discipline_background_task(discipline_id, task_id)
return
try:
apply_schema_defaults(discipline)
finally:
finish_discipline_background_task(discipline_id, self.request.id)
if task_id is not None:
finish_discipline_background_task(discipline_id, task_id)
6 changes: 5 additions & 1 deletion specifyweb/backend/stored_queries/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,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"):
Expand Down
46 changes: 19 additions & 27 deletions specifyweb/backend/stored_queries/query_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
if query.collection is None: # Not sure it makes sense to query across collections
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)
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")

cache_key = (node, 'TreeRanks')
if cache_key in query.join_cache:
logger.debug("using join cache for %r tree ranks.", start_alias)
logger.debug("using join cache for %r tree ranks.", node)
ancestors, treedefs = query.join_cache[cache_key]
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]
# 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)

# Build parent chain using aliases of the mapped class
ancestors = [node]
for _ in range(max_depth - 1):
ancestor = orm.aliased(mapped_cls)
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.", node)
query = query._replace(join_cache=query.join_cache.copy())
query.join_cache[cache_key] = (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]
Expand All @@ -107,7 +96,10 @@ 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).is_(None),
),
]
query = query._replace(internal_filters=new_filters)

Expand Down Expand Up @@ -173,4 +165,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)
add_proxy_method(name)
22 changes: 7 additions & 15 deletions specifyweb/backend/stored_queries/queryfieldspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,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:
Expand Down Expand Up @@ -499,26 +500,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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from datetime import date
from datetime import date, 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):
Expand Down Expand Up @@ -236,6 +237,61 @@ 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,
search_synonymy=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"]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,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


Expand Down Expand Up @@ -94,3 +96,55 @@ def test_query_with_displayed_date_parts(self, context: Mock):
},
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),
],
)
Loading
Loading