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( + + ); + + expect(asFragment()).toMatchSnapshot(); + unmount(); + }); +}); + describe('AppResourcesAside (expanded case)', () => { test('expanded case', async () => { const onOpen = jest.fn(); @@ -111,13 +144,13 @@ describe('AppResourcesAside (expanded case)', () => { const laterFragment = asFragmentLater().textContent; expect(initialFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Expand AllCollapse All' + 'Global Resources (2)Discipline Resources (4)Expand AllCollapse All' ); expect(intermediateFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Botany (1)Expand AllCollapse All' + 'Global Resources (2)Discipline Resources (4)Botany (4)Expand AllCollapse All' ); expect(laterFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Expand AllCollapse All' + 'Global Resources (2)Discipline Resources (4)Expand AllCollapse All' ); const expandAllButton = getFinal('button')[2]; @@ -144,7 +177,7 @@ describe('AppResourcesAside (expanded case)', () => { const expandedAllFragment = asFragmentAllExpanded().textContent; expect(expandedAllFragment).toBe( - 'Global Resources (0)Discipline Resources (1)Botany (1)Add Resourcec (1)Collection PreferencesAdd ResourceUser Accounts (0)testiiif (0)User Types (0)FullAccess (0)Guest (0)LimitedAccess (0)Manager (0)Expand AllCollapse All' + 'Global Resources (2)Global PreferencesRemote PreferencesAdd ResourceDiscipline Resources (4)Botany (4)Add Resourcec (4)Collection PreferencesAdd ResourceUser Accounts (3)testiiif (3)User PreferencesQueryExtraListQueryFreqListAdd ResourceUser Types (0)FullAccess (0)Guest (0)LimitedAccess (0)Manager (0)Expand AllCollapse All' ); expect(asFragmentAllExpanded()).toMatchSnapshot(); unmountExpandedll(); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap index 89c54ef66bc..37586285968 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap @@ -15,43 +15,43 @@ exports[`AppResourcesAside (expanded case) expanded case 1`] = ` >