diff --git a/specifyweb/backend/permissions/initialize.py b/specifyweb/backend/permissions/initialize.py
index dc047388065..a9cf2c62d5b 100644
--- a/specifyweb/backend/permissions/initialize.py
+++ b/specifyweb/backend/permissions/initialize.py
@@ -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')
diff --git a/specifyweb/backend/setup_tool/app_resource_defaults.py b/specifyweb/backend/setup_tool/app_resource_defaults.py
index 2d9b6a9be45..04f72fd3488 100644
--- a/specifyweb/backend/setup_tool/app_resource_defaults.py
+++ b/specifyweb/backend/setup_tool/app_resource_defaults.py
@@ -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
)
@@ -100,7 +99,12 @@ 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,
@@ -108,8 +112,8 @@ def _ensure_discipline_resource_dir(
)
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
@@ -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:
diff --git a/specifyweb/backend/setup_tool/schema_defaults.py b/specifyweb/backend/setup_tool/schema_defaults.py
index 791ac736ac5..da580abccfd 100644
--- a/specifyweb/backend/setup_tool/schema_defaults.py
+++ b/specifyweb/backend/setup_tool/schema_defaults.py
@@ -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:
@@ -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)
\ No newline at end of file
diff --git a/specifyweb/backend/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py
index c3998084018..dd6ab28501c 100644
--- a/specifyweb/backend/stored_queries/format.py
+++ b/specifyweb/backend/stored_queries/format.py
@@ -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"):
diff --git a/specifyweb/backend/stored_queries/query_construct.py b/specifyweb/backend/stored_queries/query_construct.py
index 328f18055a6..614ee449d43 100644
--- a/specifyweb/backend/stored_queries/query_construct.py
+++ b/specifyweb/backend/stored_queries/query_construct.py
@@ -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
@@ -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]
@@ -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)
@@ -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)
\ No newline at end of file
+ add_proxy_method(name)
diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py
index ceec7f6c8bf..4a8facdd45b 100644
--- a/specifyweb/backend/stored_queries/queryfieldspec.py
+++ b/specifyweb/backend/stored_queries/queryfieldspec.py
@@ -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:
@@ -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)
diff --git a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py
index cca0c1fe657..db37156d3b6 100644
--- a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py
+++ b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py
@@ -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):
@@ -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"]]
diff --git a/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py b/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py
index 8135a72c503..c1a1de15d23 100644
--- a/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py
+++ b/specifyweb/backend/stored_queries/tests/test_execution/test_run_ephemeral_query.py
@@ -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
@@ -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),
+ ],
+ )
\ No newline at end of file
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx
index 25b28459e39..c4dd763a37c 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import * as Router from 'react-router-dom';
+import { hasPermission } from '../../Permissions/helpers';
import { requireContext } from '../../../tests/helpers';
import { mount } from '../../../tests/reactUtils';
import type { RA } from '../../../utils/types';
@@ -11,10 +12,12 @@ import { testAppResources } from './testAppResources';
requireContext();
jest.mock('../../Permissions/helpers', () => ({
- hasPermission: jest.fn(),
+ hasPermission: jest.fn(() => true),
hasToolPermission: jest.fn(() => true),
}));
+const mockedHasPermission = hasPermission as jest.Mock;
+
describe('AppResourcesAside (simple no conformation case)', () => {
test('simple no conformation case', () => {
const onOpen = jest.fn();
@@ -35,6 +38,36 @@ describe('AppResourcesAside (simple no conformation case)', () => {
});
});
+/*
+ * TEST: This should really (also) be a test for filterAppResources, but
+ * including this here just cause
+ */
+describe('Missing Collection Preferences Permission', () => {
+ beforeAll(() => {
+ mockedHasPermission.mockReturnValue(false);
+ });
+ afterAll(() => {
+ mockedHasPermission.mockReturnValue(true);
+ });
+ test('simple no conformation test', () => {
+ const onOpen = jest.fn();
+ const setConformations = jest.fn();
+
+ const { asFragment, unmount } = mount(
+